Fix O(n) shape removal from the subquery index#4594
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4594 +/- ##
===========================================
+ Coverage 34.77% 58.97% +24.19%
===========================================
Files 233 385 +152
Lines 19839 42653 +22814
Branches 7094 12211 +5117
===========================================
+ Hits 6900 25154 +18254
- Misses 12905 17424 +4519
- Partials 34 75 +41
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Add the design for redesigning Electric.Shapes.Filter.Indexes.SubqueryIndex from a :bag table (O(n) removal via partial-key match_delete scans) to a single :ordered_set with discriminating fields lifted into keys, so removal becomes a prefix-bounded select_delete: O(V_shape · log n), independent of total shapes, shapes-on-node, and shapes-per-value. Also includes the in-progress failing :performance tests that reproduce the O(n) removal (96k / 80k reductions vs the 1300 budget). These will be reworked to seed memberships and assert flatness per the spec's test plan. Spec reviewed via two adversarial review rounds (approved). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the real correctness guarantee surfaced by review: derivation is safe because the consumer (sole writer of membership/node_member rows) is stopped synchronously before Filter.remove_shape runs — not merely because of the in-function remove-before-unregister order. add_value writes node_member before membership, so a concurrent in-flight write would orphan a node_member row; the synchronous Consumer.stop in ShapeCleaner is what rules that out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TDD plan: Chunk 1 lands the seeded + flatness perf tests (RED) proving removal independence from total shapes, shapes-on-node, and shapes-per-value, plus a view-size positive control and a no-orphan drain guard. Chunk 2 rewrites subquery_index.ex from :bag to :ordered_set with complete code (verified match specs), then runs the full suite green. Reviewed via plan-document-reviewer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…4279) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ddf3bbf to
367f582
Compare
✅ Deploy Preview for electric-next ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Claude Code ReviewSummaryThis PR restores O(1) shape removal from the where-clause subquery index (regressed to O(shapes × values) in v1.6, causing production WAL lag — #4279). The fix swaps the backing ETS table from What's Working Well
Issues FoundCritical (Must Fix)None. Important (Should Fix)None blocking. Suggestions (Nice to Have)1. Resolved — removal-safety invariant now inlined. Commit
The comment also correctly names the "no orphan rows" test as the guard and warns that reordering cleanup would reintroduce orphans. This is exactly the right place for the invariant to live now that the design doc was removed. Nicely done. 2. Keys like 3. Stale comment in the shapes-per-value perf test. (still open)
4. Perf tests are
Issue ConformanceDirectly fixes #4279. The issue is well-specified (production Honeycomb evidence, microbenchmark confirmation, root-cause analysis). The PR meets the primary ask — restoring O(1) add/remove. The issue also speculated about a memory redesign to "reduce memory footprint"; this PR explicitly and reasonably scopes memory out (documented in the PR body), and instead ships a memory-regression guard ( Previous Review StatusIteration 3. One new commit since iteration 2 ( Review iteration: 3 | 2026-06-16 |
3468044 to
367f582
Compare
…_node_members The comment referenced the design spec, which was removed in this PR. Inline the invariant the approach depends on: node-member rows are a subset of membership values, membership must outlive node removal, and the consumer (sole writer) is stopped before removal so no remove_value races it.
4ad5cb0 to
fa6c72f
Compare
Now that the index is :ordered_set, setup is no longer O(n^2), so the shapes-per-value flatness test can use the same @perf_small/@perf_large scale as the other dimensions. Replace the historical :bag comment with what the test checks.
|
This PR has been released! 🚀 The following packages include changes from this PR:
Thanks for contributing to Electric! |
Summary
Removing a shape from the subquery index in the where-clause filter had become O(n) in the number of shapes — and O(shapes × values) once subqueries were seeded. Shape removal runs on the replication path and blocks it, so slow removal caused WAL lag in production (the "Materializer shape invalidated" path). This restores O(1) removal (as in v1.5).
Fixes #4279
Problem
The subquery index is a single ETS
:bagtable. Several deletions used:ets.match_delete/2with a wildcard in the key position (e.g.{:membership, handle, :_, :_},{:node_positive_member, node, :_}). A:bag(hash) table can only use its key index when the key is fully bound, so a partially-bound key forces a full-table scan that grows with the number of shapes. The worst offender wasdelete_node_members, which scanned every shape's per-(node, value)routing row on each removal — O(shapes × values).Solution
Switch the table from
:bagto:ordered_setand lift the discriminating fields (value,shape,branch,next_cond) out of the value tuples into the keys. In an:ordered_set, a delete whose key has a bound prefix is range-limited (not a full scan), and a point delete by a fully-bound key is O(log n). Concretely:unregister_shape/2replaces its full-tablematch_deletescans with prefix-boundedselect_deletes.remove_shape/5point-deletes the shape's node rows, and derives itsnode_*_memberdeletions from the removed shape's own membership rows (scoped bysubquery_ref), applying exact point-deletes. This touches only the removed shape's rows — never the other shapes sharing a value.Removal is now O(V_shape · log n) — proportional to the removed shape's own view size, independent of the total number of shapes, the number of shapes on a node, and the number of shapes sharing a value.
This is a pure internal storage change: every public function and all externally-observable behavior is preserved. The per-change routing hot path (
affected_shapes) and the exact-evaluationmember?path are equal-or-faster (the:ordered_setprefixselectprojects in C); table memory is roughly flat (measured slightly lower in a microbenchmark).Test plan
:performanceflatness tests prove removal is independent of each dimension — total shapes, shapes-on-node, and shapes-per-value — by measuring removal at two well-separated sizes and asserting the delta stays within noise (observed delta 0 at 1k vs 20k).Filter.remove_shapeof a seeded shape, and draining a shared node one-by-one, leaves the subquery index byte-identical to its pre-add state.subquery_index/filter/consumer/shape_log_collectorsuites green; fullmix testgreen.🤖 Generated with Claude Code