Repository navigation
SS-456 - storage: give each PostgreSQL source export its own output port - #39418
Conversation
d11f0b9 to
4bfd31f
Compare
cfd7e46 to
eeff683
Compare
QA LLM Review1. MEDIUM -- Per-export round-robin counters send concurrent exports' rows to the same worker
The replication cast stage now builds a separate DetailsBefore this PR there was one A simulation with W=16 and 2M uniformly distributed records measured the average share of each 20k-record window that lands on the busiest worker. Before this PR it is 10% for any N. After it, the share is 45% for N=10 tables, 93% for N=100 and 97% for N=500. Sharing one counter across all N closures restores the old distribution. + let peers = u64::cast_from(scope.peers());
+ let next_record = Rc::new(Cell::new(0u64));
for (output_index, data_stream) in data_streams.into_iter().enumerate() {
...
- let mut next_worker = (0..u64::cast_from(scope.peers()))
- .flat_map(|w| std::iter::repeat_n(w, 1000))
- .cycle();
- let round_robin = Exchange::new(move |_| next_worker.next().unwrap());
+ let next_record = Rc::clone(&next_record);
+ let round_robin = Exchange::new(move |_| {
+ let n = next_record.get();
+ next_record.set(n.wrapping_add(1));
+ (n / 1000) % peers
+ }); |
eeff683 to
8f3aec2
Compare
confirmed and addressed |
8f3aec2 to
7b03c8f
Compare
peterdukelarsen
left a comment
There was a problem hiding this comment.
Looks very reasonable to me at a high level. I have a little bit of concern that this could change performance characteristics -- called out places that look potentially different inline.
| let slot_reader = u64::cast_from(config.responsible_worker("slot")); | ||
| let (data_output, data_stream) = builder.new_output(); | ||
| // One data output port per source export, in output index order. All data port capabilities | ||
| // are managed in lockstep, so every export observes the same frontier. |
There was a problem hiding this comment.
Any concerns with performance changes in timely due to a more complex dataflow graph and more capabilities to track/downgrade?
I think we touched on this before when talking through the scoping, but want to double-check it still feels reasonable to you. IIRC we had some top level number of operators timely/materialize can support (maybe 10s or 100s of thousands) before we have to worry.
There was a problem hiding this comment.
None that I'm aware of - see https://materializeinc.slack.com/archives/CMNT2QSBZ/p1786389233719329?thread_ts=1786389144.337239&cid=CMNT2QSBZ
There was a problem hiding this comment.
This is exactly the reference I was hoping for, thanks. Sounds like we have pretty strong conviction 10k+ is well-tolerated in Materialize now and are committed to scaling past.
| let size = data.fuel_size(); | ||
| self.handles[index].give(cap, data); | ||
| if self.charge(size) { | ||
| tokio::task::yield_now().await; |
There was a problem hiding this comment.
this feels somewhat duplicative of give_fueled. Wondering if this should be in timely-util and whether some aspect of this could be combined?
Broader question about give_fueled is why it previously had the 128 * 1024 * 1024 limit per output? I'm wondering if there's any efficiency we're losing for a given output by changing how much we accrue before sleeping?
There was a problem hiding this comment.
Indeed, it serves the same function, but it does the accounting across multiple exports. give_fueled covers the case where the CDC stream is constantly producing rows, such that awaits never return Poll::Pending (which would mean that the operator would never yield).
Setting it too small would cause excessive yields, but if we doubled it, I would be surprised if we could distinguish the difference.
There was a problem hiding this comment.
We could move it to timely-util, we'd either need to change it's signature to take the size (and remove D: FuelSizeO), or move SharedFuel and FuelSize trait (plus the impls) to timely-util.. but then things would need to pull in timely-util as a dep, and there are probably other knock on effects there.
I didn't like either of those compared to the current approach. If there's some reason that another consumer needs this, we can refactor if it makes sense.
There was a problem hiding this comment.
Setting it too small would cause excessive yields, but if we doubled it, I would be surprised if we could distinguish the difference.
Is there any concern about splitting the limit up across multiple exports though? Why do we normally set up give_fueled to be associated with one output rather than tracked as a count of data emitted in total inside the current operator?
| let round_robin = Exchange::new(move |_| { | ||
| let n = record.get(); | ||
| record.set(n.wrapping_add(1)); | ||
| (n / 1000) % peers |
There was a problem hiding this comment.
We discussed some concern that this will result in far more tiny containers if there are many exports. In our conversation we discussed this would only ever run on a single pod so it's probably not a likely bottleneck. Curious if you could help point me to where sources are limited to single-pod replicas.
There was a problem hiding this comment.
I can't! I remembered that incorrect - we can technically run on a multi-process replica. I would be surprised if anyone used it for OLTP. Parallel snapshotting from 124 workers would create a substantial load on the upstream, potentially disrupting production. And there is only a single replication stream reader, which seems like it's the more likely bottleneck.
I think this is slightly misleading. There is no guarantee that each timestamp will see 1000 updates. If the unary operator emits 5 rows at ts(10) and yields, the container will only have 5 rows (to the best of my knowledge). This optimization is much the like the 128MB above, we're trying to avoid some edge cases: don't send everything to one worker and don't send one row to each worker.
peterdukelarsen
left a comment
There was a problem hiding this comment.
Nothing blocking at this point
7b03c8f to
4ab1484
Compare
The PostgreSQL snapshot and replication operators emit each export's updates on a dedicated output port instead of one stream keyed by output index. All ports are still downgraded together, so every export observes the same frontier as before. This lets a later change advance each export's frontier independently. `give_fueled` counts bytes per handle, so with one port per export an operator could emit up to the bound on every port before yielding. Add `SharedFuel` to `mz_storage::source::types`, which owns an operator's output handles, charges each update's `FuelSize` against one budget, and yields once per `MAX_OUTSTANDING_BYTES` of their total emission. Both operators hand their data ports to a `SharedFuel`, so every emission, errors included, is charged against the same budget. Co-Authored-By: Claude <noreply@anthropic.com>
4ab1484 to
28812a2
Compare
Motivation
The PostgreSQL snapshot and replication operators emit every export's updates on one output stream keyed by output index, with a single data capability. Because of that, no export's frontier can move independently of the others. Splitting the stream into one port per export is the groundwork for letting exports progress on their own.
Description
This PR has no functional change. Every export still observes the same frontier as before.
The snapshot and replication operators now create one data output port per source export, in output index order. Their cast steps run as one operator per export, and each operator returns one collection per export instead of a single collection keyed by output index. All ports are downgraded together, so the frontiers still move in lockstep.
give_fueledcounts bytes per output handle, so with one handle per export an operator could emit up to the bound on every port before yielding.SharedFuel, added tomz_storage::source::types, owns an operator's output handles, charges each update'sFuelSizeagainst one budget, and yields once perMAX_OUTSTANDING_BYTESof their total emission. Both operators hand their data ports to aSharedFuel, so every data emission, errors included, is charged against the same budget.MAX_OUTSTANDING_BYTESbecomes a module-level constant inmz_timely_util::builder_asyncso thatgive_fueledandSharedFuelshare one bound.The
InvalidSnapshotLsnerror path added in SS-463 (#39027) now emits on the affected export's own port.Verification
The existing
pg-cdcand testdrive suites cover the snapshot and replication paths, and their results should be unchanged.🤖 Generated with Claude Code