Post-mortems · Lawrence · case-context

Case-context saturation on a normal Monday

lawrence-engine ran at 100% CPU for three hours under ordinary Monday traffic. Chat, case summaries, the admin, and a form upload all timed out against it. Nothing alerted; the incident was found because two people watched a form not appear.

Date · 24 August 2026 Status · Mitigated, follow-ups open Severity · Major Author · Adolfo Tamayo Times · all UTC Deploy · deploy/prd-2026-08-21T15-05-59 Commits · ai#699, 700, 705 Channel · #errors-prod

Summary

For weeks, a record-keeping remediation (Project Hoover) has been filing old emails onto old cases. By 10 August, 444 cases carried 200 or more emails, and the statement lists on the biggest cases had outgrown every model's context window. Those cases failed case-context regeneration instantly and silently: an over-limit request is rejected before any work happens, so the failure cost nothing and produced nothing. Sixteen cases had been stuck this way since April.

PR #699 fixed that correctly: statements now fold into the case-context object (CCO) in token-bounded batches, so the stuck cases can finally complete. The cost is that a case which used to fail in zero seconds now runs a sequence of LLM passes inside one long HTTP request. The PR predicted 10 to 30 minutes for the worst case. It shipped on Friday 21 August at 15:05, in the first prod deploy since 31 July, into a quiet weekend that never exercised it.

Monday's ordinary traffic was the first real load. Every filing or update that touched a fat case started a multi-pass fold, and each fold occupied one of the service's two 1-vCPU asyncio event loops for tens of minutes. Health checks and interactive endpoints starve behind a busy loop, so the ALB returned 502s, Inngest retried, and the retries kept the service pinned from 07:30 until mitigation. The longest single request ran 95 minutes.

Mitigation was capacity and slot recovery: the service went from 2 tasks to 4 at 10:44, and the two CPU-pinned tasks were recycled at 11:39 and 12:04 so their stuck Inngest concurrency slots could retry onto fresh tasks. The backlog of roughly 1,700 queued runs drained to zero by 14:03. The service is holding at 4 tasks; the follow-ups below decide what happens after that.

3h 10m
CPU pinned at 100%, 07:30 to 10:40
95 min
longest single HTTP request (prior max 30)
~1,700
queued Inngest runs at the peak
0 alerts
found by a human, 2.5h after onset

The evidence: traffic was normal, the engine was not

Every input to the engine ran at its usual Monday level. Email filings through the Outlook add-in, statement extractions, and ingestion volume all sat inside their two-week weekday band. The two anomalies are on the output side: the number of case-context requests running over two minutes, and CPU. In the two weeks before this incident the busiest hour of any day never exceeded 56% CPU; on the 24th it hit 98%, with three hours above 80%. Monday's bars are partial, covering 00:00 to about 15:00.

Emails filed via the Outlook add-in filings / daynormal range
deploy · Fri 15:05Mon 10795Sat 15Mon 17Sat 22488*Mon 24
Statement extractions run by lawrence-engine assets / daynormal range
Mon 10179kSat 15Mon 17Sat 2261k*Mon 24
case_context requests running over 2 minutes requests / dayanomalous
1,054Mon 10Sat 15Mon 17Sat 221,488*Mon 24
Engine CPU, busiest hour of the day % of 2×1 vCPUanomalous
80% saturationMon 10Sat 15Mon 1756%Sat 2298%*Mon 24

The per-request cost tells the same story. The case-context endpoint averaged 3 to 4 seconds per request every day for a week; on the 24th the average was 14.7 seconds at a normal request count. Pre-saturation hours (00:00 to 07:00) averaged a normal 2.97 seconds, so the code did not get uniformly slower. What changed is the tail: 1,488 requests ran over two minutes (weekday range before: 270 to 1,054), and the longest ran 5,732 seconds against a prior two-week max of 1,805.

Before and after the fix, in Inngest's own numbers

Comparing every run of the two case-context functions across the deploy boundary (before: Mon 17 to Fri 21 15:05, on the old code; after: Fri 21 15:06 to Mon 24 15:30) confirms where #699's cost actually landed. Runs that complete take the same time as before. What changed is that failure collapsed into work.

generate-cco-from-statementsBefore deployAfter deploy
Completed runs3,743948
p50 / p90 / p99 duration81s / 232s / 780s73s / 226s / 844s
Longest completed run24.1 min29.9 min
Failed runs131
process-single-asset-statementsBefore deployAfter deploy
Failed runs1,616 (~350/day)86 (~29/day)
Completed runs, matched Monday 09:00 to 11:00 slicen=4,016 · p50 6.7 min · p90 21 min · max 36 minn=2,924 · p50 81 min · p90 2.4h · max 3.7h

Three things follow. First, the fix behaved as its PR claimed: typical folds are unchanged (p50 within 10%), and the context-window failures stopped (13 CCO failures before, 1 after). Second, the before world was quietly discarding roughly 350 asset extractions a day as failures; the after world does that work instead, which is the unfrozen backlog in one number. Third, the Monday-slice duration explosion is the incident measuring itself: an Inngest run's duration includes retry backoff and queuing against starved tasks, so runs alive during the saturation stretched by construction. The pre-saturation hours, at a normal 2.97s per request, are the cleaner evidence that the code's intrinsic cost did not jump.

One more number for the capacity decision: on the old code, the previous Monday's 09:00 to 11:00 slice already ran at a p50 of 6.7 minutes per asset run with a p90 of 21 minutes. The service was near its Monday-morning edge at 2 tasks before the deploy; #699 added the unfrozen failures on top rather than pushing a comfortable system off a cliff.

The remaining hump, measured in the case-context database at 16:30. Of 15,306 cases with 300 or more statements, 12,042 are fully folded and only 3 have no CCO at all. What is left folds lazily, one case at a time, whenever a case is next touched:

CohortCasesWhat it means
No CCO at all3The unfrozen-backlog work is essentially complete
Stale CCO, ≤50 statements pending5,859One cheap pass each on next touch
Stale CCO, 51–500 pending475A few passes each, minutes of work
Stale CCO, >500 pending (worst 7,027)51One-off 10 to 30 minute folds, the last mini-marathons
CCO at 300–600k characters63Heavy but bounded folds today; the cohort drifting toward the many-pass zone
CCO at ≥600k characters (many-pass zone)0Nobody pays the permanent multi-pass tax yet (largest 514k, p99 190k)

187,789 statements are pending in total across 6,385 stale cases. The tail is finite, lazy, and absorbable at 4 tasks; the consolidation follow-up is preventive, not firefighting.

What happened

Background · 5 to 21 August
5–7 Aug
Email back-fill fattens old cases

The Project Hoover remediation files missing emails onto historic cases. .eml ingestion jumps from roughly 300 a day to 7,379, 11,690 and 5,262 on the 5th, 6th and 7th. One case gains 6,716 statements in 22 minutes from 773 imported emails and attachments.

444 cases now carry 200+ emails 16 cases stuck failing CCO regeneration, oldest since 6 April 734k tokens on the worst case's reconciliation prompt
10–20 Aug
The fix is written

#699 folds statements into the CCO in token-bounded batches so no prompt can exceed the context window. Its own text predicts the cost: the worst case's CCO is ~176k tokens by itself, leaving ~54k per batch, "roughly 11 sequential passes… that fold is 10–30 minutes inside a single Inngest step." #700 adds CCO validation after Chain of Key operations, and #705 stops an oversized asset stalling the event loop.

Fri 15:05
First prod deploy since 31 July

deploy/prd-2026-08-21T15-05-59 ships all three case_context changes at once. Friday afternoon processes a backlog at 35 to 56% CPU and settles. Saturday and Sunday are quiet, at the usual weekend 1 to 28%, so the new fold path never sees weekday load.

Monday 24 August
05:00
The extraction ramp begins

Asset extraction climbs from 568 an hour at 05:00 to 4,111 at 07:00 and peaks at 4,757 at 08:00, driven by ordinary morning email sync and filing landing on cases whose backlogged work is now runnable.

07:30
Both tasks pin at 100% CPU

Long folds occupy the two single-threaded event loops. Health checks and interactive endpoints queue behind them, the ALB starts returning 502 on /case_context/api/inngest, and Inngest retries add load. The saturation now sustains itself.

08:09
User-facing timeouts, no alert

The legacy admin's case-context calls start failing with headers timeouts (PLATFORM-ADMIN-SJ, 80 events by mid-morning). lawrence-api logs matching fetch failures (V3-LAWRENCE-3). Neither pages anyone: the 502 signature was already a 28-day-old known issue (LAWRENCE-ENGINE-5B, 1,059 events), so new noise looked like old noise.

09:36
ECS replaces one task as unhealthy

The replacement pins again within minutes. CPU stays at effectively 100% across the service.

~10:05
A form upload silently fails, and becomes the detection

Sedona and Adolfo upload a form PDF through the admin. The first hop, lawrence-api asking the engine for a presigned S3 URL, times out against the starved event loop. No file reaches S3, no Inngest event is ever sent, and the form never appears. Investigating that is what surfaces the incident, two and a half hours after onset.

10:44
Scaled 2 → 4 tasks

The autoscaling target (pinned min=max=2) is raised to 4/4 and desired count bumped. Average CPU falls, but the fresh tasks sit near idle: the 50 Inngest concurrency slots are held by slow in-flight steps parked on the two pinned tasks, so little work reaches the new capacity.

11:39
Pinned tasks recycled

Both original tasks are stopped (11:39 and 12:04). Their in-flight steps fail fast, Inngest retries them across the four fresh tasks, and throughput recovers. The already-processed check keeps retried work cheap.

14:03
Backlog drained

Queued case-context runs reach zero and stay there. Work now starts within a second of being queued. CPU still bursts to 43 to 71% service-average when a fat case folds, with individual tasks briefly at 100%; at 4 tasks the service absorbs it, at 2 it would not.

0 queued at 14:03 and 14:40 25/50 concurrency slots in use at 14:40 11 min a live CCO fold observed mid-run, working as designed

Root cause

No single change was wrong. The incident is the composition of four things, each reasonable alone.

Before Friday's deploy

A statement list too big for the context window failed in zero seconds. The failure was silent, unbilled, and free. The stuck cases produced no load and no case context.

After Friday's deploy

The same case runs to completion through up to 11 sequential LLM passes inside one HTTP request. Correctness went up. The cost of touching a fat case went from zero to tens of minutes of a single vCPU.

The amplifiers, in order of contribution:

1. One event loop serves everything. Each task is one Python asyncio process on 1 vCPU, and it serves health checks, chat retrieval, form endpoints, and Inngest step execution together. Any long CPU-holding step makes the whole task unresponsive, which is why a background fold took down a form upload.

2. Failure manufactures load. A starved loop fails its ALB health check and drops outbound LLM connections. The 502s make Inngest retry whole steps, and a failed asset is deliberately left unprocessed for the next event to retry. Saturation therefore feeds itself until capacity changes.

3. Concurrency slots pin to sick tasks. Inngest's 50-slot cap is the throttle protecting the service, but a slot is only freed when its step finishes. Slow steps on pinned tasks held the slots, so adding fresh tasks changed little until the pinned tasks were recycled. Capacity and slot recovery had to happen together.

4. The fat cases were grown upstream. The email back-fill is legitimate remediation work, but it concentrated hundreds of assets and thousands of statements onto cases whose CCOs nothing ever consolidates. The worst CCO's events array grew from 426k to 498k characters with no pruning, and CCO size directly sets fold cost.

Impact

From roughly 07:30 to 12:00, anything that called lawrence-engine synchronously degraded: admin case-context views timed out, lawrence-api logged fetch failures, chat and case summaries were slow or failed, and the forms admin upload was unusable. Background case-context processing was delayed by up to several hours. No data was lost: Inngest retries durably, the failed form upload left no partial state, and the same upload succeeds now. The clearest user-visible cost was silent, which is its own finding: the form upload appeared to do nothing, and only a human noticing that started the response.

What the investigation got wrong first

Three confident theories died on baselines, and the record should keep them because each sounded right at the time.

A weekend filing blitz. Mid-investigation the day was mistaken for Sunday, which made 481 filings look like ten times a normal weekend and implied a coordinated push by a dozen people. The 24th is a Monday. Its filing volume was ordinary, and the "dozen people on home IPs" were lawyers doing their jobs.

A bulk-import pipeline firing ingestion. The Hoover data pipeline writes records directly and deliberately skips AI ingestion, and it was not running on the 24th. Ingestion volume for the day was normal for a weekday.

A traffic surge at all. Every input series, compared against its own two-week history, was in range. The only anomalous series were request duration and CPU. The lesson is mechanical: pull the two-week baseline for a metric before treating its current value as a surge, and check what day it is.

Follow-ups

ActionWhyStatus
Land Martin's follow-up stack (resumable folds, 4MB step-output cap, tiktoken crash on repeated characters, 77k swallowed retrieval errors)Turns the single-step marathon folds into resumable work and removes the permanently-failing assets that feed retriesIn review
Codify 4 tasks in the ai-platform terraformThe 4/4 scalable target is a console override; the next tofu apply silently reverts it to 2Open
Alert on engine CPU and on 502 rate for the Inngest endpointsThe service ran three hours at 100% on a weekday morning and the incident was found by a failed form uploadOpen
Consolidate the CCO events arrayCCO size sets fold cost and only ever grows. Preventive rather than urgent: no CCO is in the many-pass zone yet, but 63 cases sit at 300 to 600k characters and are drifting toward it. Pair with an alert when any CCO crosses 600k charactersOpen
Stop returning document bodies as Inngest step output; pass referencesFull document text is re-sent, re-parsed and re-validated on every subsequent step of every assetOpen
Serve Inngest execution separately from interactive trafficA background fold should never be able to starve chat, summaries, and forms; this is the structural fix for amplifier 1Open
Tokenize each statement once and store the countFolding currently re-encodes the same statements with tiktoken on every pass of every foldOpen
Profile a fold with py-spy before further micro-optimisation (orjson, validation tuning)Settles how the CPU actually splits between tiktoken, pydantic, JSON and logging instead of guessingOpen
Decide the post-incident task count with a full week of dataBursts at 4 tasks currently reach 71% service-average; scaling back to 2 would saturate on every burstOpen