Skip to content

Commit 422c058

Browse files
committed
Add RFC
1 parent 571ed07 commit 422c058

File tree

1 file changed

+134
-0
lines changed

1 file changed

+134
-0
lines changed
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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

Comments
 (0)