Skip to content

perf(schema): drop hot-table foreign keys and redundant indexes, set-based invoke_batch - #110

Open
psteinroe wants to merge 1 commit into
mainfrom
perf/schema
Open

psteinroe wants to merge 1 commit into
mainfrom
perf/schema

Conversation

@psteinroe

Copy link
Copy Markdown
Owner

Removes per-insert and per-row work from the hot tables. This covers review findings P12, P13, P14/S3, P5 and P6, plus the unused event wait index that #98 flagged.

P12: no foreign keys into executions. Steps, signals and wait subscriptions no longer reference _private_executions; that RI check ran on every step save, signal and wait registration. Every path that deletes executions now calls the new _private_delete_execution_data(ids) after the delete. That covers retention, settlement with removeOnComplete/removeOnFail 0, cancel_execution, superseding in invoke(), _private_fail_waiting_parents, the three schedule-removal paths, and the queue drop, which deletes by queue and now drops the partition without detaching it. The helper is a volatile SQL function, so each of its statements takes a fresh snapshot. That means it also finds rows committed while the deleting statement waited for the row lock, which is the guarantee the cascade gave. Every writer of those rows already locks the execution row and writes nothing once it is gone. The one exception was clearWaitingState, which stored the timeout step without a lock; it now inserts the step only after its update has locked the parent. AGENTS.md documents the invariant.

P14/S3: dropped the per-partition (task_key) index and idx_custom_event_subscription_wait_match. I re-checked this against #91. Every query that filters executions by task_key uses another index: the PK, the schedule index, the partial dedupe index (all of those queries filter singleton_on is null) or the key index. On base, the task_key index was only picked on the internal queue, because of stale stats. With real stats, dispatch uses the PK. Dispatch joins subscriptions by id or (event_key, required_field_count), so idx_custom_event_subscription_wait_match had 0 scans.

P13: invoke_batch without dedupe runs as one insert. When no spec has dedupe_key or dedupe_seconds, the batch is one insert … select from unnest(specs) with ordinality. The ids are generated in a materialized CTE and returned in input order, and _private_check_metadata runs per spec first. Batches with dedupe keep the loop.

P5 / P6. On completion, settlement now compares the locked row's signal_count with an unlocked read of the same row (its snapshot value) instead of counting the execution's entire signal history. Each signal insert increments the counter in the same transaction, so the two are equivalent. Prefix matching in dispatch probes only the lengths between the field's shortest and longest prefix term (two index probes for the bounds) instead of collecting distinct lengths over all of its terms.

S2 (O2-04, the redundant parent check in inserted_children) was already removed by #103.

Measurements

Postgres 17 database with 1M executions in 6 queues, 200k steps, 300k signals, 50k event waits and 102k subscriptions, including 100k prefix terms on one field. The numbers are mean execution ms from pg_stat_statements, as the median of 3 base/new runs interleaved on a shared, noisy machine. The harness is outside the repo, in the task state dir.

statement base this PR
invoke_batch of 100, no dedupe 17.4 3.1
step save 0.22 0.10
signal_execution 1.06 0.90
event wait registration 0.82 0.67
completing an execution with 10k delivered signals 1.97 0.36
dispatch to a field with 100k prefix terms 21.6 0.55
dispatch to a field with 5 prefix terms (56-char value) 0.40 0.45
dispatch to 200 subscribers 10.8 10.1
plain settlement 0.57 0.49
retention of 1000 executions with steps/signals 193 173
single invoke 0.36 0.37

The (task_key) index drop did not reproduce the review's −38% invoke insert: a 100k-row bulk insert took about 2.5 s with and without it, dominated by the column defaults. It still removes one btree per queue from every insert and delete. A sparse prefix field pays for one probe per length in the range, so it is slightly slower; a dense one no longer scans its whole term population.

Tests

execution-data.test.ts covers every deletion path: the deleted execution's steps, signals and waits are gone and another execution's rows are kept. Two concurrency tests use deterministic lock barriers, and both fail against naive implementations (verified by swapping them in). One commits a step while settlement waits for the row lock, and the step must still be deleted. The other has clearWaitingState run against an execution being superseded, and it must store no step. New invoke_batch tests cover field mapping, id order and per-spec metadata checks. just lint, just format, pnpm typecheck and the full pnpm test (631 tests) pass.

…s, set-based invoke_batch

- steps, signals and wait subscriptions no longer have foreign keys into executions;
  every path that deletes executions deletes them with _private_delete_execution_data()
- drop the per-partition (task_key) index and the unused event wait match index
- invoke_batch inserts a batch without dedupe in one statement
- completion compares signal_count with its snapshot value instead of counting all signals
- dispatch probes prefix lengths between the field's shortest and longest prefix term

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant