Skip to content

Commit 06eb787

Browse files
committed
Upload sealed history segments from the server-zd engine
While a partition offloads history, the lease holder now uploads every committed SEALED history segment to the primary object store under history/ and proposes the HistorySegmentUploaded acknowledgments in seq order. - One upload task per run (TrackedWork::HistoryUpload). The committed HistorySegmentSealed effect starts it, partition rebuild starts it from the HistoryUploadPending index after a restart, takeover, or snapshot restore, and a periodic rescan restarts stopped work. - Each segment is read from committed state and encoded on a blocking thread, then written with CreateOnly. An existing object that fully decodes, on a blocking thread, with the same content checksum is acknowledged. Any other existing object is replaced with If-Match, logged as an error, and counted. - Encoded bytes live only together with an upload slot, so no object bytes are held while an acknowledgment or a backoff waits, and memory stays bounded by history.uploadConcurrency (default 16). - Every history object call ends when the node stops, so no request outlives its owner. - Retryable object storage failures retry with backoff and never delay commands. A failure without a retry, such as a permanent rejection or corrupt state, is logged and counted, and every trigger then skips the run until the next rescan allows it again. Nothing is acknowledged meanwhile. Uploads stop without a retry once the run is gone or purging. - The rescan logs, counts, and skips a run whose rows cannot be read, so the other runs of its partition are still rescanned. - Uploads continue after the offload mode is turned off, so every sealed segment is eventually acknowledged. - Retention postpones a closed run while it has a SEALED segment. The purge of a run that kept segment state waits one minute, so the client side of every earlier write has ended, then deletes the run's objects by catalog key, sweeps the run's history/ prefix, and retries a permanent rejection each minute before RunPurge. A write the store had already accepted can still leak an object of the purged run; a later orphan sweep is to remove it. - New metrics: upload backlog segments, bytes, and oldest seal age; uploads by outcome; uploaded bytes; checksum mismatches; rescan errors; and the history object kind of object store requests. - The format compatibility workload acknowledges the short-lived run's sealed segment before its retention fires, and testdata/v1 is regenerated with its generator. Refs #1325
1 parent bdf20a0 commit 06eb787

91 files changed

Lines changed: 3500 additions & 253 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/reference/server/configuration.mdx‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ disk or fail Steps.
338338
| --- | --- | --- |
339339
| **history.offloadEnabled** | false | Experimental. Whether partitions offload run history to object storage. |
340340
| **history.segmentTargetBytes** | 4194304 (4 MiB) | Experimental. Since 0.1.21. Bytes of history records, checkpoints, and superseded values after which a run seals its open history segment for upload. From 1 byte to 268435456 (256 MiB). |
341+
| **history.uploadConcurrency** | 16 | Experimental. Since 0.1.21. Most sealed history segments the lease holder reads, encodes, and uploads at once. From 1 to 1024. Each upload in flight holds about one encoded segment in memory. |
341342

342343
The setting applies to every partition, not to each run. When a node takes the
343344
lease, it records the value in every partition whose recorded value differs,
@@ -358,6 +359,27 @@ local disk until each segment is uploaded; then pruning returns to the window
358359
rule. Commands applied while offload is off belong to no segment, so turning it
359360
on again starts a new segment after them.
360361

362+
The lease holder uploads each sealed segment to the primary object store, under
363+
the **history/** prefix of the bucket that holds the commit log. It uploads a
364+
run's segments oldest first and records each upload before the next one. A
365+
segment's content depends only on committed state. A new lease holder therefore
366+
writes the same object again and accepts an existing object with the same
367+
content. An existing object with different content is replaced; the node logs an
368+
error and increments **dexzd_history_upload_checksum_mismatches_total**.
369+
370+
Uploads never delay commands. While object storage fails them, they retry
371+
with backoff, sealed segments stay on local disk, and the upload backlog metrics
372+
grow; see [Metrics](/reference/server/metrics). A permanent rejection, such as
373+
denied access, is retried once a minute instead. Uploads of segments sealed
374+
earlier continue after **history.offloadEnabled** is turned off. Retention
375+
postpones the deletion of a closed run until its sealed segments are uploaded.
376+
Deleting a run also deletes every object under its **history/** prefix. That
377+
deletion starts a minute after the run's deletion begins, so that uploads an
378+
earlier lease holder abandoned have ended on its side. An upload that object
379+
storage accepted just before can still leave an object of the deleted run; no
380+
read returns it. Do not configure a bucket lifecycle rule that expires objects
381+
under **history/**.
382+
361383
## Limits {#limits}
362384

363385
server-zd enforces these limits. A request that a limit rejects fails at once;

‎docs/reference/server/metrics.mdx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ No series has a Flow, Flow type, partition, tenant, or key label.
3535
| Timers | **dexzd_timer_lag_seconds** |
3636
| Snapshots | **dexzd_snapshots_total** with **outcome**; **dexzd_snapshot_last_duration_seconds** |
3737
| Local disk | **dexzd_local_storage_bytes** |
38+
| History offload (experimental) | **dexzd_history_upload_backlog_segments**; **dexzd_history_upload_backlog_bytes**; **dexzd_history_upload_oldest_sealed_age_seconds**; **dexzd_history_uploads_total** with **outcome**; **dexzd_history_upload_bytes_total**; **dexzd_history_upload_checksum_mismatches_total**; **dexzd_history_upload_rescan_errors_total** |
3839
| Attribute Store sync | See [Monitoring](/reference/server/attribute-store-sync#attribute-store-monitoring) |
3940
| External search projection | See [Monitoring](/reference/server/search#search-projection-monitoring) |
4041

@@ -78,6 +79,18 @@ Worker polls use one persistent stream, so there is no empty-poll count.
7879
**event="delivered"**, every task frame sent, including RPC task frames. A returned attempt
7980
that Dex hands out again counts again. Long-poll waits count toward **flow_read** latency.
8081

82+
The history offload gauges come from a rescan of every partition each minute.
83+
**dexzd_history_upload_backlog_segments** counts sealed history segments that
84+
are not uploaded yet. **dexzd_history_upload_backlog_bytes** is their
85+
uncompressed size. **outcome** of **dexzd_history_uploads_total** is one of
86+
**created**, **already_present** (a repeated upload found the same content),
87+
**replaced** (an existing object had other content), each counted once the
88+
upload is recorded, and **failed**. A transient object storage failure is
89+
retried with backoff; any other failure is retried at the next rescan. Alert when
90+
**dexzd_history_upload_oldest_sealed_age_seconds** stays above 900, or when
91+
**dexzd_history_upload_checksum_mismatches_total** or
92+
**dexzd_history_upload_rescan_errors_total** rises.
93+
8194
~~~text
8295
# Failed API calls by class and code
8396
sum by (rpc_class, code) (rate(dexzd_api_requests_total{code!="ok"}[5m]))

‎server-zd/Cargo.lock‎

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎server-zd/README.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ Every label value is from a closed set. No series has a Flow, Flow type, partiti
151151
| `dexzd_commit_records_total` | counter | — | Committed commands |
152152
| `dexzd_commit_bytes_total` | counter | — | Committed batch bytes |
153153
| `dexzd_commit_latency_seconds` | histogram, buckets 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5 | — | Batch seal to durable acknowledgement |
154-
| `dexzd_object_store_requests_total` | counter | `operation`: put, get, list, delete; `object_kind`: log, snapshot, manifest, lease, other | Object storage requests; multipart upload counts once as put |
154+
| `dexzd_object_store_requests_total` | counter | `operation`: put, get, list, delete; `object_kind`: log, snapshot, manifest, lease, history, other | Object storage requests; multipart upload counts once as put |
155155
| `dexzd_object_store_request_errors_total` | counter | `operation`: put, get, list, delete | Failed requests after SDK retries |
156156
| `dexzd_object_store_put_bytes_total` | counter | — | Uploaded bytes |
157157
| `dexzd_snapshots_total` | counter | `outcome`: ok, error | Snapshot attempts |
@@ -189,6 +189,13 @@ Every label value is from a closed set. No series has a Flow, Flow type, partiti
189189
| `dexzd_search_initial_loads_total` | counter | — | Partition loads started, including loads of progress that cannot continue; the log gives the reason |
190190
| `dexzd_search_projection_mode_skipped_partitions_total` | counter | — | Stopped partitions that kept their previous search projection mode at startup |
191191
| `dexzd_history_offload_mode_skipped_partitions_total` | counter | — | Stopped partitions that kept their previous history offload mode at startup |
192+
| `dexzd_history_upload_backlog_segments` | gauge | — | Sealed history segments not yet uploaded, summed over each partition's last rescan |
193+
| `dexzd_history_upload_backlog_bytes` | gauge | — | Uncompressed bytes of the history rows and superseded values of `dexzd_history_upload_backlog_segments` |
194+
| `dexzd_history_upload_oldest_sealed_age_seconds` | gauge | — | Age at scrape time of the oldest sealed history segment not yet uploaded, 0 when none |
195+
| `dexzd_history_uploads_total` | counter | `outcome`: created, already_present, replaced, failed | History segment uploads, counted once acknowledged; failed attempts retry with backoff when transient, otherwise at the next rescan |
196+
| `dexzd_history_upload_bytes_total` | counter | — | Bytes of the history segment objects this node created or replaced |
197+
| `dexzd_history_upload_checksum_mismatches_total` | counter | — | Existing history segment objects whose content differed from committed state and were replaced |
198+
| `dexzd_history_upload_rescan_errors_total` | counter | — | Runs or index rows that the history upload rescan could not read; the log names each and the rescan continues |
192199
| `dexzd_search_initial_load_documents_total` | counter | — | Documents written by initial SQL visibility loads |
193200

194201
Worker polling uses a persistent stream, so there is no empty poll outcome. The stream event metric counts each task delivery and each stream close.

‎server-zd/config/dex-server-zd.example.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ history:
8484
# Experimental: keep false until the release notes announce history offload.
8585
offloadEnabled: false
8686
segmentTargetBytes: 4194304
87+
uploadConcurrency: 16
8788
values:
8889
wireInlineThresholdBytes: 100
8990
maxValueBytes: 16777216

‎server-zd/crates/dexzd-apply/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ at or below U while the partition offloads history and, whatever the mode, while
7070
the run has a SEALED segment. So turning offload off never deletes sealed content
7171
before its acknowledgment; once every SEALED segment is acknowledged, pruning
7272
returns to the window rule. Segment rows count as history bytes, never toward the
73-
run state limit. Run purge deletes the pending index row with the run.
73+
run state limit. Run purge deletes the pending index row with the run. Retention postpones a closed run while it has a SEALED
74+
segment, as it does for an unacknowledged outbox message, so purging never
75+
abandons an upload.
7476

7577
## Value and storage accounting
7678

‎server-zd/crates/dexzd-apply/src/cross_flow/run_purge.rs‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ pub(crate) fn on_retention_fired(
7474
cursor: &mut RunCursor,
7575
scope: &CommandScope<'_>,
7676
) -> Result<(), ApplyError> {
77-
if has_unacknowledged_outbox(transaction, cursor.rows)? {
77+
// Purging stops uploads, so a sealed segment would never reach object storage.
78+
if has_unacknowledged_outbox(transaction, cursor.rows)?
79+
|| has_sealed_history_segment(transaction, cursor.rows)?
80+
{
7881
postpone_retention(transaction, cursor, scope.now_micros);
7982
return Ok(());
8083
}
@@ -261,6 +264,22 @@ fn has_unacknowledged_outbox(
261264
.any(|(_, message)| message.acknowledged_at_micros == 0))
262265
}
263266

267+
/// Returns whether the run has a SEALED history segment; its upload pending index row exists
268+
/// exactly while one remains.
269+
fn has_sealed_history_segment(
270+
transaction: &FlowTransaction<'_>,
271+
rows: RunRows,
272+
) -> Result<bool, ApplyError> {
273+
let key = history_upload_pending_index_key(
274+
transaction.context().partition,
275+
rows.flow_hash,
276+
rows.run_id,
277+
);
278+
Ok(transaction
279+
.get(ColumnFamily::PartitionIndex, &key)?
280+
.is_some())
281+
}
282+
264283
fn remove_timer_indexes(
265284
transaction: &mut FlowTransaction<'_>,
266285
rows: RunRows,

‎server-zd/crates/dexzd-apply/tests/history_segments.rs‎

Lines changed: 64 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1251,37 +1251,82 @@ impl Harness {
12511251
}
12521252

12531253
#[test]
1254-
fn purging_a_run_deletes_its_upload_pending_index_row() {
1254+
fn retention_waits_for_every_sealed_segment_then_the_purge_deletes_every_row() {
12551255
let mut harness = harness_with_three_sealed_segments();
12561256
harness.stop();
12571257
assert!(harness.has_upload_pending_index_row());
1258-
let record = harness.run_record();
1259-
let retention = record.retention_deadline_micros;
1258+
let retention = harness.run_record().retention_deadline_micros;
12601259
assert!(retention > 0);
12611260
harness.now_micros = retention;
1262-
let fired = harness.apply(Command::TimersFired(
1263-
dexzd_protocol::dexzd::v1::TimersFiredCommand {
1264-
timers: vec![dexzd_protocol::dexzd::v1::TimerId {
1265-
run_attempt: 0,
1266-
kind: Some(dexzd_protocol::dexzd::v1::timer_id::Kind::Retention(
1267-
dexzd_protocol::dexzd::v1::RetentionTimerId {},
1268-
)),
1269-
}],
1270-
},
1261+
let postponed = harness.apply(retention_fired());
1262+
assert!(!requests_purge(&postponed), "{:?}", postponed.effects);
1263+
let record = harness.run_record();
1264+
assert!(!record.is_purging, "purging would stop the uploads");
1265+
assert!(record.retention_deadline_micros > retention);
1266+
1267+
for segment in harness.segments() {
1268+
if segment.state == HistorySegmentState::Sealed as i32 {
1269+
let acknowledged = harness.ack(segment.first_seq, segment.last_seq);
1270+
assert!(is_ok(&acknowledged.reply), "{:?}", acknowledged.reply);
1271+
}
1272+
}
1273+
assert!(!harness.has_upload_pending_index_row());
1274+
harness.now_micros = record.retention_deadline_micros;
1275+
let fired = harness.apply(retention_fired());
1276+
assert!(requests_purge(&fired), "{:?}", fired.effects);
1277+
let purge = harness.apply(Command::RunPurge(
1278+
dexzd_protocol::dexzd::v1::RunPurgeCommand {},
12711279
));
1272-
assert!(
1273-
fired
1274-
.effects
1275-
.iter()
1276-
.any(|effect| matches!(effect, ApplyEffect::RunPurgeRequested { .. })),
1277-
"{:?}",
1278-
fired.effects
1280+
assert!(is_ok(&purge.reply), "{:?}", purge.reply);
1281+
assert_every_run_row_purged(&harness);
1282+
}
1283+
1284+
#[test]
1285+
fn purging_a_run_deletes_its_upload_pending_index_row() {
1286+
let mut harness = harness_with_three_sealed_segments();
1287+
harness.stop();
1288+
assert!(harness.has_upload_pending_index_row());
1289+
// Retention never purges a run with a SEALED segment, so the test marks the purge itself.
1290+
let key = run_singleton_key(
1291+
harness.context.flow_hash,
1292+
harness.run(),
1293+
RunTable::RunRecord,
12791294
);
1295+
let purging = RunRecord {
1296+
is_purging: true,
1297+
..harness.run_record()
1298+
};
1299+
harness
1300+
.state
1301+
.rows
1302+
.insert((ColumnFamily::Flow, key), purging.encode_to_vec());
12801303
let purge = harness.apply(Command::RunPurge(
12811304
dexzd_protocol::dexzd::v1::RunPurgeCommand {},
12821305
));
12831306
assert!(is_ok(&purge.reply), "{:?}", purge.reply);
12841307
assert!(!harness.has_upload_pending_index_row());
1308+
assert_every_run_row_purged(&harness);
1309+
}
1310+
1311+
fn retention_fired() -> Command {
1312+
Command::TimersFired(dexzd_protocol::dexzd::v1::TimersFiredCommand {
1313+
timers: vec![dexzd_protocol::dexzd::v1::TimerId {
1314+
run_attempt: 0,
1315+
kind: Some(dexzd_protocol::dexzd::v1::timer_id::Kind::Retention(
1316+
dexzd_protocol::dexzd::v1::RetentionTimerId {},
1317+
)),
1318+
}],
1319+
})
1320+
}
1321+
1322+
fn requests_purge(outcome: &ApplyOutcome) -> bool {
1323+
outcome
1324+
.effects
1325+
.iter()
1326+
.any(|effect| matches!(effect, ApplyEffect::RunPurgeRequested { .. }))
1327+
}
1328+
1329+
fn assert_every_run_row_purged(harness: &Harness) {
12851330
assert!(
12861331
harness
12871332
.state

‎server-zd/crates/dexzd-e2e-tests/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ publish.workspace = true
1111
[dependencies]
1212

1313
[dev-dependencies]
14+
dexzd-history-segment.workspace = true
15+
dexzd-keyspace.workspace = true
1416
dexzd-protocol.workspace = true
1517
dexzd-server = { workspace = true, features = ["test-util"] }
18+
dexzd-storage.workspace = true
1619
futures.workspace = true
1720
prost.workspace = true
1821
prost-types.workspace = true

‎server-zd/crates/dexzd-e2e-tests/tests/dev_server/harness.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,22 @@ impl DevServer {
104104
WorkerTaskServiceClient::new(self.channel.clone())
105105
}
106106

107+
/// Stops the server gracefully and returns its data directory, so a test can open the
108+
/// stopped server's local store.
109+
pub(crate) async fn shutdown_keeping_data(self) -> TempDir {
110+
let Self {
111+
server,
112+
_data_directory: data_directory,
113+
..
114+
} = self;
115+
server
116+
.expect("the server is shut down once")
117+
.shutdown()
118+
.await
119+
.expect("the server stops cleanly");
120+
data_directory
121+
}
122+
107123
/// Stops the server gracefully and returns how long the shutdown took.
108124
pub(crate) async fn shutdown(mut self) -> Duration {
109125
let server = self.server.take().expect("the server is shut down once");

0 commit comments

Comments
 (0)