## Summary
Incident (2026-10-02, SURTR-1590, related SURTR-1518). quickbooks-core-tables fanned out to its three marts at 06:46:25 UTC. mart-school-performance-unit-economics-refresh (Table 2) and mart-aerie-education-financials-refresh (AE) both died with deadlock detected, but not against each other. All three QuickBooks marts take quickbooks_financial_refresh_lock first, so they queue behind one another (AE got it at 06:46:29; QB waited 423 s and Table 2 436 s on it). The inversions were against the core-education-ontology-refresh producer, which rhodes-staging-sync triggered at 06:46:58 UTC and which does not take that lock, and against a plain reader. Relation 15058250 is core_education.dim_program and 15058264 is core_education.bridge_school_link.
Evidence from sys_query_history (CQL_download_OM):
| Time (UTC) | Statement | Result |
|---|---|---|
| 06:46:29 | AE sp_refresh_agg_school_pl_breakdown: LOCK bridge_school_link | waited 422 s on a holder not visible to this user, granted 06:53:32.085 |
| 06:47:02 | reader WITH alpha_school_years ... (session 1073826357; reads dim_school, bridge_school_link, dim_program) | 390 s lock wait, ends 06:53:36 |
| 06:53:32 | AE LOCK dim_program | blocked by a reader that holds dim_program and waits for bridge_school_link, which AE now holds: deadlock, AE aborted 06:53:33 (process 4582 / 5144 in the message). The reader above fits (its wait ends 06:53:36), but the message does not name it. |
| 06:53:40 | ontology retry sp_refresh_aerie_ontology: one LOCK TABLE rhodes..., bridge_school_link, dim_program, dim_school, dim_site, xref_school_source (the first attempt was a deadlock victim at 06:53:37) | takes bridge_school_link, then waits for dim_program |
| 06:58:17 | Table 2 sp_refresh_agg_school_performance_unit_economics_qtd: gets dim_program (it had dim_school already), asks for bridge_school_link | deadlock, Table 2 aborted 06:58:18 (process 4592 / 9564). The ontology LOCK completes 06:58:19.165, one second later, which identifies it as the other process. |
The ontology producer locks bridge_school_link, dim_program, dim_school, dim_site, xref_school_source. Table 2 and the Guide QTD procedure locked dim_school, dim_program, bridge_school_link: a textbook inversion. The AE procedure already follows the producer order, so the AE-versus-reader cycle is a queue pile-up behind the 422 s holder rather than an ordering bug in that procedure; it is not changed (the file is 165 KB, above the 100 KB Data API statement limit, and the live version is already newer than main).
Review follow-up (this push). Mercy's blocking finding on the first revision was that the contract left out the QuickBooks core writers. They lock xref_school_source before dim_school, the reverse of the producer, and they are not gated against it. Fixed here: the audit below covers every procedure that touches the ontology, sp_refresh_quickbooks_profit_and_loss_posting and sp_refresh_quickbooks_budget_detail now follow the canonical order, and the contract test discovers participants from the repo so a new procedure cannot silently escape it.
Lock-order map (ontology and shared relations, in acquisition order). Every participant takes these at the start of its body, before it reads them (a publication target may be locked just before its DELETE).
| Procedure | Before | After |
|---|---|---|
| sp_refresh_aerie_ontology (producer, unchanged) | bridge, program, school, site, xref_school_source | same |
| sp_refresh_agg_school_pl_breakdown (AE, unchanged) | posting fact, bridge, program, school, xref_school_source | same |
| sp_refresh_agg_school_performance_unit_economics_qtd (Table 2) | school, program, bridge, xref_school_source | bridge, program, school, xref_school_source |
| sp_refresh_agg_school_qtd_guide_staffing_program_spend | school, program, bridge, xref_school_source; posting fact after them | posting inputs first, then bridge, program, school, xref_school_source |
| sp_refresh_agg_school_performance_quickbooks_budget_qtd | xref_school_source, then school | school, then xref_school_source |
| sp_refresh_qtd_hc_posting_classification | school, xref_school_source, posting fact | posting fact, school, xref_school_source |
| sp_refresh_agg_school_qtd_all_other_headcount | Guide mart, then classification | classification, then Guide mart |
| sp_refresh_quickbooks_profit_and_loss_posting | xref_school_source, class_school, ue_model, school; posting fact ~900 lines later | posting fact, school, xref_school_source, class_school, ue_model |
| sp_refresh_quickbooks_budget_detail | xref_school_source, class_school, ue_model, school | school, xref_school_source, class_school, ue_model |
| sp_refresh_school_quickbooks_pl_reconciliation, ..._facilities_capex_campus_spend, ..._unit_economics_per_student_qtd, sp_load_q94_site_finance_entity_xref (unchanged) | already consistent | same |
sp_refresh_quickbooks_financial_contracts runs vendor identity, posting, budget, attribution, school P&L and reconciliation as children of one transaction (the handler calls only it), so its effective order is the children's locks concatenated; the first acquisition is posting fact, school, xref_school_source, which the contract checks.
Audit of every procedure that references bridge_school_link, dim_program, dim_school, dim_site, xref_school_source or a view over them (34 under pipelines/, 30 in the live catalog): the producer, 10 locking participants (the table above plus classification and q94), 4 one-off migrations that drop themselves (sp_replace_legacy_dim_school, sp_drop_retired_dim_school_next, the two sp_migrate_quickbooks_*), and 19 that take no explicit lock on these tables and are listed in UNLOCKED_READERS with a test that fails if one starts locking them: hubspot fct_admissions_deal/_event/hubspot_core_foundation, fct_enrollment, the two student-snapshot appenders, q48 publish, capex, finalsite_billing, school_calendar, aerie_admissions_program/_directory and the seven forecast procedures. The contract's participant list also holds All Other and Facilities, which lock shared marts without naming an ontology table. Readers are not changed (see below).
Canonical order. CANONICAL_LOCK_ORDER in mart-aerie-education-financials-refresh/tests/test_sibling_lock_order_contract.py: QuickBooks gate and posting inputs, then bridge_school_link, dim_program, dim_school, dim_site, xref_school_source, then the remaining inputs, then publication targets. Plain alphabetical was rejected: it would put the QuickBooks gate after dim_*. A procedure is *gated* when its first lock is the QuickBooks gate; gated procedures serialize on it and cannot deadlock each other, so the contract requires every pair that includes an *ungated* procedure (the producer, classification, All Other, Facilities, Table 3, q94) to agree on relation order, and ungated procedures to ascend through the canonical list.
Changes. Seven procedures re-ordered (lock statements and comments only; no lock mode, logic or output changes). The posting writer now locks the posting fact first instead of ~900 lines later, just before its DELETE: no lock is added or removed, it is taken earlier within a coordinator transaction that already holds it until commit (at most the writer's own ~25 s runtime earlier, per the 10:52 run). Table 2 and QB budget QTD version markers are bumped to 2026-10-02.1; the QuickBooks core markers are left alone because their tests pin them. The contract test (61 tests) now discovers participants, models gating and the coordinator transaction, and fails against the old files (11 failures) for the QuickBooks writers, the coordinator and the marts changed earlier.
Not changed, found on the way:
- Unlocked readers can stall writers for minutes. At 10:57:42 hubspot sp_refresh_fct_admissions_event opened an 11.7 minute transaction; it reads bridge_school_link, dim_program, dim_school and dim_site, so it holds AccessShare on them until commit. QB budget QTD's LOCK dim_school waited 777 s and was granted 0.6 s after that transaction's last statement; Table 2 waited 796 s behind QB; AE's classification step stalled behind QB's posting-fact lock and timed out (the 10:56 AE failure). Not a deadlock, not caused by lock order, and not fixed here; options are an up-front LOCK ... IN ACCESS SHARE MODE in canonical order or copying the inputs to a temp table first, as pl_breakdown does for fct_admissions_deal.
- Live procedures from unmerged branches differ from main: pl_breakdown 2026-10-02.1 (codex/alpha-enrollment-denominator, order already canonical), Guide and Facilities (#2124, applied after the first apply here; Guide keeps the canonical order), retention (#2049) and classification (older than main: lacks #1459).
- Live Facilities from #2124 locks xref_school_source before the posting fact, the reverse of classification. They run back to back in one AE run, so this only matters if two AE runs overlap; #2124 will fail this contract until it takes the posting fact first.
- The AE, Table 1 and Table 2 handlers do not retry a deadlock victim; the Table 3 client does. Classification, All Other, Facilities and Table 3 take no QuickBooks gate.
Live apply 1 (marts), 2026-10-02 07:54:27 to 07:54:50 UTC. Quiet window: nothing in the QuickBooks chain RUNNING or started in the last 10 min, no CALL of the five procedures, no DDL on the involved schemas in the previous 30 min (as visible to the pipeline DB user). CREATE OR REPLACE PROCEDURE as admin, one statement per procedure back to back (the Data API 100 KB statement limit rules out one combined statement): classification, All Other, Guide, QB budget QTD, Table 2. Owner, ACL, OID, SECURITY INVOKER and arguments identical before and after (owner CQL_download_OM; ACL CQL_download_OM=X/CQL_download_OM, Surtr_Service_User=X/CQL_download_OM). For the four whose live body matched main byte for byte the live body is byte-identical to this PR's file; classification live is older than main (#1459), so only the lock-block change was applied on top of the live body.
Live apply 2 (QuickBooks core writers), 2026-10-02 17:56:55 to 17:57:03 UTC. Last quickbooks-core-tables run ended 12:10; at 17:56:40 no pipeline in the QuickBooks chain was RUNNING or had started in the previous 15 min, no CALL of the chain was running, and no DDL had touched the involved schemas in the previous 30 min. quickbooks-raw-sync is scheduled once a day at 06:00 UTC (next 06:00 tomorrow); its other runs, and core-tables since #2123, are on demand, so I re-checked immediately before applying. Both writers were byte-identical to main in the live catalog before the apply. CREATE OR REPLACE PROCEDURE as admin, posting then budget detail back to back. Owner CQL_download_OM, ACL CQL_download_OM=X/CQL_download_OM ; Surtr_Service_User=X/CQL_download_OM, OIDs 16074835 and 17282951, SECURITY INVOKER and 9 arguments identical before and after; live body md5 now equals the PR file body for both (667ec6a7..., 1f3eaa44...). Catalog read of the live bodies: the coordinator's effective order is posting fact, dim_school, xref_school_source, and every pair involving an ungated live procedure agrees except the live Facilities pair noted above.
Since the first apply: no deadlock detected in pipeline_runs_prod. The 12:10 on-demand fan-out completed on all three marts and Table 3. The 10:56 fan-out's QB and Table 2 runs succeeded after the reader stall above; AE timed out on it and succeeded on re-run. The posting and budget writers have not yet run in their new order.
## Business Value
The School Performance reports (Tables 1, 2 and 3) and the Aerie school P&L marts are the finance team's view of school economics, and they all refresh off the same QuickBooks publication. Whenever that fan-out overlapped the school ontology refresh, a mart could abort on a lock deadlock, leaving its table stale until someone noticed the alert and re-ran it (on 2026-10-02 Table 2 and the AE P&L marts failed, and Table 3, which triggers off Table 2, never ran). This change puts every concurrent writer of the ontology tables, including the upstream QuickBooks core writers that feed all of those marts, on one lock order, and adds a CI check that discovers new procedures touching those tables so a future edit cannot reintroduce an inversion. That cuts alert noise, on-call triage time and the window in which leadership dashboards show stale numbers. It also documents, with evidence, the separate multi-minute stall caused by long-running readers, which is the next largest source of failed refreshes.
## Manual Effort Estimate
About 23 focused hours (roughly three working days) to do this by hand. The first revision was about 14 hours: reconstructing the deadlock timeline from the Redshift system history and mapping OIDs (3 h), reading the nine involved stored procedures for lock order and unlocked reads (3 h), working out a canonical order consistent with the producer, the QuickBooks writers and the live-versus-main drift (2 h), the five edits (1 h), the parser-based contract test (3 h), and the live apply with owner/ACL checks (2 h). The review follow-up adds about 9 hours: auditing the 34 procedures in the repo and 30 in the live catalog and classifying them (2 h), analysing the coordinator's single-transaction semantics and re-editing the two QuickBooks writers (2 h), reworking the contract test with discovery, gating and the coordinator's effective sequence (4 h), and the second live apply and verification (1 h). Proposed by Claude, Keval to confirm or adjust.
## Test plan
- [x] quickbooks-core-tables: uv run pytest 95 passed
- [x] mart-aerie-education-financials-refresh: uv run pytest 208 passed (61 are the contract tests)
- [x] mart-school-performance-quickbooks-refresh: uv run pytest 78 passed
- [x] mart-school-performance-unit-economics-refresh: uv run pytest 42 passed
- [x] mart-school-performance-unit-economics-per-student-refresh: uv run pytest 16 passed
- [x] ruff@0.15.22 check and ruff format --check clean on the touched Python file (no other Python changed)
- [x] Contract test run against the previous DDL (origin/main files in a throwaway worktree): 11 failures (50 pass), covering the QuickBooks core writers, the coordinator transaction and the marts changed earlier
- [x] Live apply 1: five marts, 07:54:27 to 07:54:50 UTC; owner/ACL/OID identical, body md5 equals the expected body
- [x] Live apply 2: two QuickBooks core writers, 17:56:55 to 17:57:03 UTC; owner/ACL/OID identical, body md5 equals the PR file body
- [x] Catalog read of live bodies: coordinator order and all ungated pairs agree, except the live Facilities version from #2124
- [x] No deadlock detected in pipeline_runs_prod since the first apply; 12:10 fan-out succeeded on all marts and Table 3
- [ ] First quickbooks-core-tables run after apply 2 completes with the new writer order
Linear: SURTR-1590
🤖 Generated with [Claude Code](https://claude.com/claude-code)