1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
//! One instance per process, sharing one backfill over the durable NATS
//! JetStream store: the same binary, run twice, in two terminals.
//!
//! The other coordinated examples put two instances in one process over
//! [`MemoryStore`](spate::coordination::store::memory::MemoryStore), which
//! shows the protocol but not the deployment. This is the deployment: the
//! process is the instance, its identity comes from the environment the
//! way a pod's does, and the fleet meets in a store that outlives every
//! member of it. Nothing in the code below knows how many peers exist.
//!
//! The work being divided is a bounded backfill of a `file://` prefix,
//! 96 small NDJSON objects staged into the temp directory on first run and
//! packed into six splits at the 1 MiB target, so the only thing to stand
//! up is NATS. Whichever instance holds leadership lists the prefix once
//! and writes the split table; every instance leases the splits it is
//! assigned, reads them straight from the split descriptors, and commits
//! fenced per-split progress. Each exits `Completed` once every split is
//! complete, and the union of the two covers the whole prefix. Delivery is
//! at-least-once, so a forced revocation can replay a tail but never drop one.
//!
//! The chain paces itself on purpose (see `PACE`): without that the
//! backfill is over before you can reach the second terminal.
//!
//! # Run it
//!
//! A NATS server with JetStream enabled, version 2.11 or newer. The
//! store needs per-message age limits and KV limit markers, and the worker
//! refuses anything older at startup:
//!
//! ```sh
//! docker run --rm -p 4222:4222 nats:2.11 --jetstream
//! NATS_URL=nats://127.0.0.1:4222 POD_NAME=worker-a cargo run -p spate --features s3,json,coordination-nats --example nats_coordinated_backfill
//! NATS_URL=nats://127.0.0.1:4222 POD_NAME=worker-b cargo run -p spate --features s3,json,coordination-nats --example nats_coordinated_backfill
//! ```
//!
//! Start the second one while the first is still working. The leader
//! recomputes the assignment the moment the new member appears and revokes
//! the newcomer's share from the first instance, which drains those splits
//! cooperatively before the second claims them. It finishes the object it
//! has open, cuts at that boundary, and commits its tail before releasing
//! them, so the move replays nothing. Each instance prints the objects it
//! covered.
//!
//! The first terminal narrates that: `peer joined` as the new member's
//! presence key lands, then `assignment published` naming how many splits
//! changed hands. The second reports the fleet it walked into. Per-split
//! detail (`split claimed`, `drain started`, `drain finished`) is a level
//! down, at `RUST_LOG=info,spate_coordination=debug`, which is the run to
//! make to watch one object's worth of reassignment.
//!
//! Draining a paced chain takes time, so `drain_deadline` below sits far
//! above its default. A drain that outruns the deadline is revoked outright
//! and its uncommitted tail replays under the new owner instead. Both are
//! safe; only the first is a clean revocation.
//!
//! # Killing one instance
//!
//! **Ctrl-C** is a graceful departure. The pipeline drains, the source is
//! dropped, and the coordinator releases: every split's owner field is
//! cleared, its lease key deleted, leadership handed back, and the presence
//! key dropped, so an instance that held splits leaves nothing to expire.
//! (One holding none when the signal lands sends no release at all, and its
//! presence key goes on the age limit like any other.) The survivor sees
//! the released records on its watch and picks them up as soon as it holds
//! the leadership that assigns them, seconds after the signal rather than a
//! lease after it. Because the departing instance commits its tail before
//! letting go, the release replays nothing.
//!
//! **`kill -9`** writes nothing. The dead instance's lease keys stop
//! being rewritten and expire on the bucket's age limit one lease after the
//! last successful heartbeat; heartbeats run at about a third of the lease
//! and are jittered, so the expiry lands within a lease of the death. It
//! reaches the survivor as a limit marker, and the leader then withholds
//! the dead instance's splits for `rebalance_delay` before assigning them.
//! That window lets a restarting worker reclaim its own work instead of the
//! fleet churning around a bounce. With the values below it is at most
//! twenty seconds.
//!
//! Either way the new owner resumes from the last committed watermark, so
//! records written after it are replayed. Delivery is at-least-once.
//!
//! # Running it again
//!
//! Split records are durable, so a finished job stays finished: a later run
//! under the same job name finds every split complete and exits at once.
//! The demo's coordination state lives only inside the container above, and
//! `--rm` throws it away, so stopping that container and starting a fresh
//! one is the reset.
// The examples index renders these fields; see crates/spate/tests/examples_index.rs.
// INDEX-TIER: bounded-jobs
// INDEX-GOAL: coordinate a fleet over the durable store
// INDEX-TECH: NATS JetStream
// INDEX-NEEDS: a NATS server with JetStream; run the binary twice
// Examples talk to their user on stdout/stderr by design.
use ;
use ;
use NdjsonFramer;
use *;
use S3Source;
use ;
use BTreeSet;
use PathBuf;
use Duration;
/// One constant drives both sides of the lease agreement: the store's TTL
/// (the bucket age limit that expires an unheartbeated key) and the
/// coordinator's `lease_duration` (the protocol's takeover ceiling). They
/// are separate mechanisms and the coordinator rejects a store whose TTL
/// diverges from its config, so they are built from the same value.
///
/// Demo-fast; the default is 30s and the NATS floor is 2s, below which
/// one-second marker granularity would dominate.
const LEASE: Duration = from_secs;
/// Job identity: it suffixes both KV bucket names, every instance of one
/// job uses it, and two different jobs must never share one.
const JOB: &str = "nats-backfill-demo";
const DEFAULT_NATS: &str = "nats://127.0.0.1:4222";
const OBJECTS: usize = 96;
const RECORDS_PER_OBJECT: usize = 250;
/// Per-record pacing. A real pipeline is paced by a sink doing something;
/// this one has an in-memory sink and nothing to wait for, so it would
/// finish in about a second and leave no window in which to start the
/// second instance. At this rate one instance takes roughly a minute and
/// two take roughly half of it.
const PACE: Duration = from_millis;
/// The instance identity, the way a real deployment supplies it: unique
/// per *live* worker, and stable across a restart, so a bounced worker can
/// reclaim its own splits inside the rebalance window. `POD_NAME` is the
/// Kubernetes downward-API spelling, `HOSTNAME`
/// the equivalent elsewhere, and the last resort is unique but not stable.
///
/// Give each terminal its own `POD_NAME` when running both instances on one
/// host: `HOSTNAME` is stable but shared, and two live workers claiming one
/// id is detected and fatal.
///
/// The id must be 1..=128 bytes of `[A-Za-z0-9_-]`, so a hostname's dots are
/// rewritten rather than rejected at startup. A variable set to the empty
/// string falls through to the next rung, as an unset one does.
/// Stage the "bucket" at a fixed path. Both processes must read the same
/// objects, so a per-process tempdir would give them different jobs.
///
/// Whoever gets there first builds the prefix in a private directory and
/// renames it into place, which is atomic: a peer either finds the
/// finished listing or stages its own byte-identical copy and throws it
/// away when the rename loses.
/// The `obj` field of one staged line, without a JSON parser for a
/// two-field record.
/// `split_target_bytes` at its 1 MiB floor charges each object a 64 KiB
/// open cost, so 96 small objects pack into six splits, which is enough for
/// a fleet to divide. Real deployments keep the 64 MiB default.
///
/// The pipeline name is not instance-scoped: each instance is its own
/// process, so the metric series a name claims has one live owner
/// (INV-10) without any help. Neither instance is scraped, so neither asks
/// for an admin server; a real deployment names an address and gets
/// `/metrics`, `/healthz` and `/readyz` on it.