Repository navigation
SS-492 - storage: let PostgreSQL exports progress independently during a snapshot - #39479
Conversation
8536d9c to
21d9143
Compare
21d9143 to
6fc87ee
Compare
6fc87ee to
b465c30
Compare
c39f0c9 to
7e6aab2
Compare
721d91c to
c1edc45
Compare
peterdukelarsen
left a comment
There was a problem hiding this comment.
Looks good to me overall, some more nitty small requests that don't change the logic.
| let mut data_outputs = SharedFuel::new(data_outputs); | ||
| let concurrent_replication = !STORAGE_PERSIST_SINK_DESCRIPTION_LOOKAHEAD | ||
| .get(config.config.config_set()) | ||
| .is_zero(); |
There was a problem hiding this comment.
Is there a risk of a race here where one part of the full rendered source saw a different value for this flag? Wondering if we should pull the config value up higher and wire down the flag lower.
There was a problem hiding this comment.
this is addressed, moved this out of the async operator
| let mut builder = AsyncOperatorBuilder::new(op_name, scope.clone()); | ||
|
|
||
| // Read at render time, matching the value the persist sink reads in the same dataflow build. | ||
| // A read in the async body can observe an `UpdateConfiguration` sequenced after this dataflow's |
There was a problem hiding this comment.
nit: stylistically I'd pref just wiring down a shared boolean if it's less than lets say three layers of functions between the nearest common ancestor. I buy that this is correct, but it feels complex.
There was a problem hiding this comment.
Fair. I avoid adding parameters when I can derive from the existing inputs cheaply. The shared boolean pushes render to 9 parameters, and it sets a pattern for future flags that just keeps growing that input list.
I'm down with refactoring this down the road since it definitely seems like we can group some things together.
| .await; | ||
| if concurrent_replication { | ||
| data_cap_sets[output_index] = CapabilitySet::new(); | ||
| } |
There was a problem hiding this comment.
Is there a way we can collapse these repetitive pre-continue data_cap_sets[output_index] = CapabilitySet::new();? One Idea I have would be moving the piece inside the for (&output_index, info) in outputs.iter() { into a function and then wrapping that in another function that looks structurally like:
fn handle_output_wrapping() {
handle_output_existing();
if concurrent_replication {
data_cap_sets[output_index] = CapabilitySet::new();
}
}
fn handle_output_existing() {
// all existing logic (replace continues with returns).
}
There was a problem hiding this comment.
If I understand correctly, wouldn't handle_output_existing be different in each case? Wouldn't we have to break them all up in to function (or pass them as FnMut)?
Replacing:
if concurrent_replication {
data_cap_sets[output_index] = CapabilitySet::new();
}
with this is simpler to read, but not by much:
drop_caps_if_concurrent(data_cap_sets, output_index);
There was a problem hiding this comment.
I'm not sure we're on the same page. I was recommending putting the entirety of the existing code in the for loop into a function. Then slotting in the new code for capability dropping in a wrapping function.
Definitely wasn't trying to recommend something so narrow as changing:
if concurrent_replication {
data_cap_sets[output_index] = CapabilitySet::new();
}
to:
drop_caps_if_concurrent(data_cap_sets, output_index);
There was a problem hiding this comment.
ah, gotcha! hmm.. there's probably close to 10 local variables that then need to be passed into the function, and that's a mess on its own.
An alternative is to do an async block, but this is a little weird because you have to return a result from it.
let copied = async {
if let Err(err) = verify_schema(oid, info, &upstream_info) {
// give the error
return Ok(());
}
let Some(ctid_range) = worker_ctid_range(&config, block_count, oid) else {
return Ok(());
};
// COPY
Ok::<_, TransientError>(())
}.await;
copied?;
if concurrent_replication {
data_cap_sets[output_index] = CapabilitySet::new();
}
That's worth it if we're planning to add more exit paths, but honestly, I don't think that's the case.
There was a problem hiding this comment.
I tried to rewrite this morning (11 parameters as is). A few of them were derived from existing structs, so I adjusted. Should be ok now (at least no clippy exception).
There was a problem hiding this comment.
Looks great to me now, thank you!!
martykulma
left a comment
There was a problem hiding this comment.
thanks for the review!
| let mut builder = AsyncOperatorBuilder::new(op_name, scope.clone()); | ||
|
|
||
| // Read at render time, matching the value the persist sink reads in the same dataflow build. | ||
| // A read in the async body can observe an `UpdateConfiguration` sequenced after this dataflow's |
There was a problem hiding this comment.
Fair. I avoid adding parameters when I can derive from the existing inputs cheaply. The shared boolean pushes render to 9 parameters, and it sets a pattern for future flags that just keeps growing that input list.
I'm down with refactoring this down the road since it definitely seems like we can group some things together.
| .await; | ||
| if concurrent_replication { | ||
| data_cap_sets[output_index] = CapabilitySet::new(); | ||
| } |
There was a problem hiding this comment.
If I understand correctly, wouldn't handle_output_existing be different in each case? Wouldn't we have to break them all up in to function (or pass them as FnMut)?
Replacing:
if concurrent_replication {
data_cap_sets[output_index] = CapabilitySet::new();
}
with this is simpler to read, but not by much:
drop_caps_if_concurrent(data_cap_sets, output_index);
With `storage_persist_sink_description_lookahead` > 0, each export's port holds the minimum capability only while that export has snapshot data or a pending rewind. Exports that are not snapshotting advance with the replication stream, and a snapshotted export is released as soon as its own copy finishes. Co-Authored-By: Claude <noreply@anthropic.com>
… out of async operator
5457141 to
0694adc
Compare
…pshot (#39419) ### Motivation Today a PostgreSQL source snapshots all of its tables before it reads any of the replication stream. This PR lets the replication operator read the stream while the snapshot runs. #39479 then lets each export's frontier advance on its own. ### Description All new behavior is keyed off `storage_persist_sink_description_lookahead` being nonzero. That dyncfg already controls whether the persist sink groups a snapshotting export's updates into one batch, and it defaults to zero in production and to 5s in mzcompose. Concurrent replication is what makes a snapshot accumulate many timestamps, and reading the stream concurrently without the grouping keeps one batch builder per timestamp resident until each reaches the blob target size. The two are therefore one setting rather than a separate flag. With a nonzero lookahead, the snapshot operator emits its rewind requests as soon as the snapshot LSN is known, before any table is copied. The replication operator then reads the stream while tables are copied. Every export still advances together, so replication data is staged in the dataflow until the snapshot completes. This trades cluster memory during hydration for progress on the replication stream. With the lookahead at zero, rewinds are emitted after the copy completes, as before. The snapshot operator reads the setting once, since the early and the late emission must not both happen or both be skipped. Only the snapshot operator reads it in this PR, so a change takes effect for snapshots that start afterwards. parallel-workload already rolls the lookahead through zero and nonzero values. The persist sink logs the resolved lookahead per export at dataflow build time, so a hydration's logs show whether grouping and concurrent replication are on. ### Verification The mzcompose default sets the lookahead to 5s, so the existing mzcompose-based PostgreSQL source tests exercise the early rewind path. #39420 adds targeted coverage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
Motivation
Every export's frontier stays at the minimum until the last table's COPY finishes. Adding one large table to a source therefore still stalls ingestion for every table already hydrated in that source.
Description
With
storage_persist_sink_description_lookaheadnonzero, each export's port holds the minimum capability only while that export still has snapshot data to emit or a pending rewind.With the lookahead at zero, all ports advance together, so behavior matches #39419.
The replication operator now reads the setting as well. The two operators read it independently at startup, and parallel-workload rolls the lookahead through zero and nonzero values, so a change can land between those two reads and leave the operators in different modes within one dataflow. That is benign in both directions. With only the snapshot operator concurrent, the replication operator holds every port until the rewinds resolve and then releases them all. With only the replication operator concurrent, it still waits for the rewind input to close, which the serial snapshot operator does after the copy.
Verification
The mzcompose default sets the lookahead, so the existing mzcompose-based PostgreSQL source tests exercise per-export progress. #39420 adds targeted coverage.
🤖 Generated with Claude Code