Building an Off-Season Kill Switch and News Pipeline Viewer for AWS

Paul Pounder
April 10, 2026
6
 min read
Building an Off-Season Kill Switch for AWS

The English football season is winding down. In a few weeks, there won't be a match to poll, a goal to commentate on, or a pressure index to chart. But our AWS bill doesn't know that. Every minute, the live delta poller pings SportMonks. Every minute, the showrunner director calls Claude Sonnet. Every night, Glue ETL jobs crunch data that hasn't changed. Every four hours, the news ingestor crawls sources with nothing new to find.

So before the season ends, I wanted a clean way to shut it all down — and bring it all back up when August rolls around.

What We Built

A new admin page at /admin/services that gives us a dashboard view of every scheduled AWS resource in the platform. Twenty-six services in total, organised into four categories:

  • Live Pipeline (8 EventBridge rules) — the poller, showrunner, news ingestor, briefing generator, shorts generator, schedule analyser (x2 for timezone coverage), and standings serving
  • Data Ingestion (14 EventBridge rules) — all the SportMonks entity pipelines from the Medallion stack (fixtures, teams, players, standings, etc.)
  • ETL Processing (4 resources) — three Glue triggers (daily, weekly, monthly) plus the Silver data crawler
  • AI Services (1 ECS Fargate service) — the multi-agent hub running LangGraph correspondent and anchor agents

Each service shows its name, schedule frequency, a cost tier badge (high/medium/low), and a toggle switch. You can flip individual services on or off, select multiple with checkboxes for bulk operations, or use the "Season Mode" banner to do it all at once.

Key Decisions

Real state, not shadow state. The biggest decision was whether to store service states in DynamoDB (a "shadow" of what should be enabled) or read directly from AWS APIs. We went with real state. The API route calls ListRules on EventBridge, GetTrigger on Glue, and DescribeServices on ECS every time you load the page. This means you always see what's actually running — no drift, no stale data. The trade-off is slightly slower page loads (about 5 API calls to AWS), but for an admin page you visit occasionally, that's fine.

CDK construct ID matching. AWS CDK generates resource names with hash suffixes (e.g., AigentiqueLivePipelineStack-PollerScheduleRule4A7B2C3D). Rather than hardcoding these or tagging resources in CDK (which would need a redeploy), we use substring matching. A static registry maps each service to its CDK construct ID, and the API route matches ListRules results by checking if the rule name contains that substring. Simple, and it survives CDK redeploys without changes.

Season mode with essential services. Not everything should shut down in the off-season. Monthly data ingestion (venues, leagues, seasons) and the monthly ETL trigger are marked as "essential" — they keep the data lake fresh for the new season. When you hit "Switch to Off-Season", it disables everything except these essentials. One click to save, one click to restore.

No CDK changes needed. All toggles use runtime AWS APIs — EnableRule/DisableRule for EventBridge, StartTrigger/StopTrigger for Glue, UpdateService with desiredCount: 0 for ECS. CDK state will drift from actual state, but that's intentional. The next cdk deploy re-enables everything per CDK definitions, which is exactly what you want when returning to in-season.

How It Works

The implementation follows the same admin pattern as every other section in Ball Watching. A Next.js API route at /api/admin/services handles two methods:

  • GET — fetches real state from three AWS services in parallel, matches results against the static registry, and returns an array of service states
  • PATCH — accepts an array of toggle requests, executes each one independently (so partial failures don't abort the batch), and returns per-service success/failure results

On the frontend, each service row has a Switch component. Clicking it triggers a confirmation dialog, then fires a PATCH request. The page refetches all states after every toggle to ensure the UI matches reality. No optimistic updates — for infrastructure changes, I'd rather wait 500ms and know it worked.

Three new AWS SDK packages were added: @aws-sdk/client-eventbridge, @aws-sdk/client-glue, and @aws-sdk/client-ecs. These join the existing DynamoDB, S3, and API Gateway Management clients.

Phase 2.6: Curated News Viewer

With the service manager done, I turned to the content sources admin page. We have 219+ news scraper configurations, but no way to see what they've actually produced. Are the scrapers working? Is the LLM enrichment producing useful summaries? You'd have to dig through DynamoDB to find out.

So I added a "Curated News" action to the three-dot menu on every content source row. Click it, and a side panel opens showing all the enriched (SILVER-tier) articles for that team — title linked to the original article, LLM summary, a category badge (authoritative, reporting, or gossip), confidence score, signal tags like "injury" or "transfer", and a relative timestamp.

The implementation is a new API route at /api/admin/news that queries DynamoDB for NEWS#SILVER# items by team_id, with an optional source_id filter. A CuratedNewsSheet component renders the results in a Sheet (right-side panel), following the same pattern as the league override editor.

The Bugs Hiding in the Pipeline

Building the viewer immediately exposed two problems that had been invisible.

Bug 1: Every article had source_id: "unknown". The batch news ingestor was calling source.get('source_id', 'unknown'), but the content source records don't have a field called source_id. The UUID lives in the DynamoDB sort key (SOURCE#a7713372-...). The fix was one line — extract the UUID from the SK before writing the bronze record. The enricher already spreads all bronze fields into silver via **article, so the correct source_id propagates automatically.

Bug 2: Static-URL sources only ever produced one article. The BBC team page (bbc.co.uk/sport/football/teams/derby-county) has the same URL and title every day. The dedup hash was SHA-256(url + title), which meant it produced the same hash forever. After the first ingestion, every subsequent run hit the dedup check and skipped. The fix: include the UTC date (ddmmyy) in the hash input, so the same source produces at most one record per day. For RSS sources with unique article URLs, the date is redundant but harmless.

Bug 3: Team logos showing Access Denied. While checking the content sources table, I noticed custom team logos weren't rendering. The TeamLogo and LeagueLogo components were using raw S3 URLs from DynamoDB overrides, but the S3 bucket blocks all public access — assets must be served via CloudFront. The fix was adding assetUrl() to both components, which rewrites S3 URLs to CloudFront URLs. A two-line change that fixed logos across the entire platform.

What I Learned

The TypeScript target in this project doesn't support --downlevelIteration, which means you can't spread Set or iterate Map directly. I hit this twice during the build — once spreading a Set into an array ([...prev, ...ids]), once iterating a Map with for...of. The fix is simple (Array.from() and Object.keys() respectively), but it's the kind of thing that compiles locally and only fails in the production build.

The bigger lesson: building admin visibility tools isn't just about convenience — it's a diagnostic tool. The curated news viewer existed for about thirty seconds before it told me the pipeline was broken in two different ways. If I'd built it earlier, those bugs would have been caught weeks ago.

What's Next

This was a pragmatic detour before Phase 3 (WebGL Digital Humans). With the season ending, having the kill switch means we can confidently shut down the high-cost services — the poller and showrunner alone run every minute — and stop paying for compute that isn't doing anything useful. The news viewer means we can actually verify the pipeline is working before we rely on it for digital human scripts.

When pre-season kicks off, one click on "Activate In-Season" brings everything back. No console diving, no CDK deploys, no Slack messages asking "did anyone remember to turn the poller back on?"

Next up: Phase 3 — getting a virtual presenter talking on screen with react-three-fiber and WebGL avatars.

Sign up for our newsletter

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

By clicking Sign Up you're confirming that you agree with our Terms and Conditions.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.