|
| 1 | +# RFC: Move-in/Move-out Handling for Shapes with Subqueries |
| 2 | + |
| 3 | +_Written with assistance from Claude Opus 4.1_ |
| 4 | + |
| 5 | +## Background |
| 6 | + |
| 7 | +Shapes now support subqueries in WHERE clauses (e.g. `SELECT * FROM tasks WHERE project_id IN (SELECT id FROM projects WHERE ...)`). This introduces move-ins (project now satisfies filter → pull in all its tasks) and move-outs (project no longer satisfies filter → remove its tasks). Currently we invalidate shapes on any move, forcing complete resync. |
| 8 | + |
| 9 | +## Core Algorithm: Move-in Query Positioning |
| 10 | + |
| 11 | +When a move-in is detected: |
| 12 | + |
| 13 | +1. Execute query with `REPEATABLE READ READ ONLY` transaction, capture xmin/xmax/xip snapshot info, but don't block processing until results are available |
| 14 | +1. Continue processing replication stream, writing to shape log until either: |
| 15 | + - A transaction outside the snapshot is encountered → start buffering |
| 16 | + - Query sends first result → pause processing, start buffering (query takes write lock on shape log, because we need to write results in the correct place without interleaving ongoing transactions or storing entire query result set in memory) |
| 17 | +1. Ongoing transaction processing: |
| 18 | + - **Before** query results written: write anything that's considered part of the snapshot until first transaction that's not part of the snapshot based on the info, then buffer |
| 19 | + - **While writing** buffer every transaction for a limited time or memory budget |
| 20 | + - **After** query results written: apply if transaction is not part of the snapshot |
| 21 | +1. Resume processing buffered transactions after query completes |
| 22 | + |
| 23 | +This ensures causal consistency - the query results are positioned correctly in the replication stream without duplicates and without shadowing things that |
| 24 | + |
| 25 | +## Two Approaches for Move-out Tracking |
| 26 | + |
| 27 | +### Approach 1: Server-side Tracking |
| 28 | + |
| 29 | +Server maintains on-disk index: `{parent_key -> Set<child_keys>}` where we can for now assume sets are disjoint. |
| 30 | + |
| 31 | +**Move-out handling:** |
| 32 | + |
| 33 | +- Lookup all child keys for the parent |
| 34 | +- Send delete/row-gone messages for each child |
| 35 | + |
| 36 | +**Pros:** |
| 37 | + |
| 38 | +- Simpler clients - no tracking logic needed |
| 39 | +- More consistent shape log - server knows exactly what was sent |
| 40 | +- Clean abstraction boundary |
| 41 | + |
| 42 | +**Cons:** |
| 43 | + |
| 44 | +- Disk/memory cost scales with total rows across all shapes |
| 45 | +- Requires persistence and recovery mechanisms |
| 46 | +- Doesn't play well with `changes_only` mode unless we track rows on updates too |
| 47 | + |
| 48 | +### Approach 2: Client-side Tracking |
| 49 | + |
| 50 | +Server annotates each row with "present because of parent_key X" on inserts. Client maintains mapping. |
| 51 | + |
| 52 | +**Move-out handling:** |
| 53 | + |
| 54 | +- Server sends "parent_key X moved out" message |
| 55 | +- Client removes all rows it tracked for that parent_key |
| 56 | + |
| 57 | +**Pros:** |
| 58 | + |
| 59 | +- Minimal server resources - no per-row tracking |
| 60 | +- Works naturally with `offset=now` mode where clients delete only what they've seen, no server assumptions needed |
| 61 | + |
| 62 | +**Cons:** |
| 63 | + |
| 64 | +- More complex client implementation |
| 65 | +- Clients need persistent parent-key mapping storage |
| 66 | +- Doesn't work well with `changes_only` mode unless we annotate updates too |
| 67 | +- All client implementations must support this |
| 68 | + |
| 69 | +## Trade-offs |
| 70 | + |
| 71 | +The key tension is between server resource usage and client complexity. Server-side tracking provides better consistency guarantees and simpler clients at the cost of significant disk usage. Client-side tracking pushes complexity to clients but scales better and handles partial history naturally. |
| 72 | + |
| 73 | +## Edge Cases and Considerations |
| 74 | + |
| 75 | +### Race Conditions |
| 76 | + |
| 77 | +- If project moves in → out → in while first move-in query still executing: cancel in-flight query on symmetrical move-out |
| 78 | + |
| 79 | +### Failure Modes |
| 80 | + |
| 81 | +- Move-in query failure → shape invalidation |
| 82 | +- Buffer overflow (configurable limit) → shape invalidation |
| 83 | + |
| 84 | +### Nested Subqueries |
| 85 | + |
| 86 | +With nested subqueries like `WHERE project_id IN (SELECT ... WHERE owner_id IN (SELECT ...))`, we use internally materialized shapes. Move-ins cascade: innermost → middle → outer. **This introduces lag** as each level must complete before triggering the next. |
| 87 | + |
| 88 | +### Multiple Subqueries (Future) |
| 89 | + |
| 90 | +- AND: Straightforward move-out when any condition fails |
| 91 | +- OR: Complex - query results may overlap with already-sent rows. Would require bidirectional mapping: `{child_key -> Set<parent_keys>}` for server-side tracking. Currently blocked because clients expect inserts, not upserts. |
| 92 | + |
| 93 | +### Performance |
| 94 | + |
| 95 | +Highly volatile filter conditions causing frequent move-ins will saturate the PostgreSQL query connection pool. |
| 96 | + |
| 97 | +## Multi-shape consistency |
| 98 | + |
| 99 | +To make the clients see consistently across shapes like `SELECT * FROM projects WHERE …` and `SELECT * FROM tasks WHERE project_id in (SELECT id FROM projects WHERE …)` we could insert a special "move-in pending" control message on the shape that's outer, so that the client can hold application until move-in resolves. [Sam Willis](https://electric-sql.slab.com/users/xvbarmzz) would this be at all useful/needed? |
| 100 | + |
| 101 | +## Implementation Notes |
| 102 | + |
| 103 | +### Consistency |
| 104 | + |
| 105 | +- All operations through single shape log ensures causal ordering |
| 106 | +- Move-in itself is invisible to clients (client sees resulting row operations, not original triggering operation) |
| 107 | +- Parent row change always visible before its move-in results if 2 shapes are on the same LSN |
| 108 | + |
| 109 | +### Transaction Handling |
| 110 | + |
| 111 | +- Process changes in topological order within same transaction: inner subquery → outer |
| 112 | +- Use PostgreSQL transaction visibility rules to determine if operation is "in snapshot" |
| 113 | + |
| 114 | +# Notes |
| 115 | + |
| 116 | +- Meeting notes 06-05-2025: [https://notes.granola.ai/d/349a0b89-6f4a-41a8-9f98-a447f85eacb4](https://notes.granola.ai/d/349a0b89-6f4a-41a8-9f98-a447f85eacb4?source=copy_link) |
| 117 | + |
| 118 | +# Current iteration/findings from discussions |
| 119 | + |
| 120 | +- Because Electric tends to live behind proxies, clients rarely have access to the `where` clause of a shape they're requesting. It's then unreasonable to expect the client to know how to clean up rows from a move-out without providing it some additional information |
| 121 | + - Most simple implementation seems to be "tagging" where every insert/update is somehow tagged on the "why" it's present in the shape. |
| 122 | + - Pro: clients need not understand full where clause evaluation, just be able to keep a set of indices for the cleanup |
| 123 | + - Con: in order to support `OR`-ed subquery conditions, clients will need to have a reference count of all tags in order to correctly react to just one of the parts of an `OR` clause |
| 124 | + - Con: move-ins become slightly more complicated, because we need to not only move in new rows when a new "parent" moves in, but, in case of `OR`-ed subqueries, we need to update all already-sent rows for which this parent is true as one part of an `OR` chain to have this a a new tag. |
| 125 | + - This is not as complicated as it sounds if we make 2 queries (or a `UNION` query for ease of streaming) - one that's fully disjoint from what the client currently sees (i.e. negate the current conditions) and second that's a strict subset of what the client currently sees (i.e. replace `OR` with `AND` for the new parent part) and send the former as `INSERT` with correct tags, and latter as `UPDATE` with full row value and updated tag set |
| 126 | + - Or, easier, don't make the query disjoint but make the `INSERT` be treated as `UPSERT` by the client and merge the tags |
| 127 | + - Con: clients need to be able to maintain this index, which is likely to be separate from normal storage that it's materializing into. |
| 128 | + - Note: all where clauses can be converted to a [DNF](https://en.wikipedia.org/wiki/Disjunctive_normal_form) form and then each `AND` chain will be a separate (likely composite) tag |
| 129 | + - Another approach is to make the client somehow aware of the way the tags are constructed from the source values |
| 130 | + - Pro: less information in each message, saving traffic & disk |
| 131 | + - Con: shape requests must include in selected columns all those that are referenced in subquery comparison operations |
| 132 | + - Con: client needs to be made aware of a DNF form (or AST, or both) of a where clause for invalidation, which means the client might need to be able to execute same PostgreSQL function subset that Electric can execute. It's also possible that the constants in this AST might be sensitive if they are currently server-added. |
| 133 | +- Decision currently reached - go for the disjoint + subset union queries to keep protocol simpler for now |
| 134 | +- Complexity involving move-outs based on tags: when a move-out happens, it happens "inside" one of the subqueries. The tag form however is based upon the where clause structure of the outer shape - if there is a `(id IN (SELECT 1) AND tag IN (SELECT 2))` where clause, then the row will have a tag of `{1, 2}` (in some representation), but we have access only to one part of the tag. This means that in practice, for a where clause involving an `OR` with an `AND` anywhere (like `(id IN (SELECT 1) AND tag IN (SELECT 2)) or (id IN (SELECT 1) AND tag IN (SELECT 3))`) there will be a pair of tags `{1, 2}` and `{1, 3}` and the invalidation would need to be able to specify a partial tag within an `AND` (luckily we don't care if there are any row-content dependent constants `AND`-ed with the subquery contents) (which is not the case with `OR`-ed constants). This means we need to be able to specify a move-out of a `{*, 2}` tag, which might make index-keeping on the client complicated or inefficient. (server-backed move-outs would also need to make this trade-off, but |
0 commit comments