kanade_shared/bootstrap.rs
1//! Idempotent JetStream bootstrap (Sprint 6.x follow-up).
2//!
3//! Lists every NATS JetStream resource the kanade fleet expects —
4//! streams, KV buckets, Object Stores — and asks the broker to
5//! create-or-update them. v0.25.0 switched from `create_*` to
6//! `create_or_update_*`: the old form returned error 10058 ("name
7//! already in use with a different configuration") when a release
8//! widened a stream's subjects or changed its retention policy on
9//! a broker that still held the older config. With the new form the
10//! broker reconciles its definition to the one in this file, so
11//! version bumps no longer require operator-side data wipes.
12//!
13//! Centralising the list here means a future "we added a new
14//! bucket" change touches one place and both the operator CLI +
15//! the auto-bootstrap path pick it up.
16
17use std::time::Duration;
18
19use anyhow::{Context, Result};
20use async_nats::jetstream::{
21 self,
22 kv::Config as KvConfig,
23 object_store::Config as ObjectStoreConfig,
24 stream::{Config as StreamConfig, DiscardPolicy},
25};
26use tracing::{info, warn};
27
28use crate::kv::{
29 BUCKET_AGENT_CONFIG, BUCKET_AGENT_GROUPS, BUCKET_AGENT_META, BUCKET_AGENTS_STATE,
30 BUCKET_FLEET_CONFIG, BUCKET_GROUP_CONTACTS, BUCKET_JOBS, BUCKET_JOBS_YAML,
31 BUCKET_NOTIFICATIONS_READ, BUCKET_SCHEDULES, BUCKET_SCHEDULES_YAML, BUCKET_SCRIPT_CURRENT,
32 BUCKET_SCRIPT_STATUS, BUCKET_SERVER_SETTINGS, OBJECT_AGENT_RELEASES, OBJECT_APP_PACKAGES,
33 OBJECT_COLLECTIONS, OBJECT_RESULT_OUTPUT, OBJECT_SCRIPTS, STREAM_AUDIT, STREAM_EVENTS,
34 STREAM_EXEC, STREAM_INVENTORY, STREAM_NOTIFICATIONS, STREAM_OBS_EVENTS, STREAM_RESULTS,
35};
36use crate::wire::DEFAULT_COLLECT_RETENTION_DAYS;
37
38/// Create-or-update an Object Store, but never let it wedge backend
39/// startup. `create_object_store` neither reconciles an existing
40/// store's config nor has a `create_or_update` form in async-nats
41/// 0.49, so a store whose desired config drifted — e.g. the #518
42/// `max_bytes` cap added after the bucket was first created uncapped,
43/// which the broker then rejects with error 10058 ("stream name
44/// already in use with a different configuration") — would otherwise
45/// fail `ensure_jetstream_resources` and crash the backend on boot
46/// (production outage 2026-06-11). Fall back to the existing store
47/// (uncapped, as it already was) and warn. #506 tracks real
48/// reconciliation of object-store config.
49async fn ensure_object_store(js: &jetstream::Context, cfg: ObjectStoreConfig) -> Result<()> {
50 let name = cfg.bucket.clone();
51 if let Err(e) = js.create_object_store(cfg).await {
52 // The fallback is deliberately broad — any create error is
53 // tolerated AS LONG AS the store already exists, because the
54 // alternative is a wedged backend and "never crash on boot"
55 // wins over "surface this specific error". The expected error
56 // is 10058 (config drift, the incident), but auth/network
57 // blips on an already-bootstrapped broker take this path too;
58 // they remain visible via the `warn!`. Only a genuine
59 // "can't create AND doesn't exist" is fatal.
60 if js.get_object_store(&name).await.is_err() {
61 return Err(e).with_context(|| {
62 format!("create_object_store {name} (and no existing store to fall back to)")
63 });
64 }
65 warn!(
66 store = %name, error = %e,
67 "object store exists with a different config; using it as-is (cap not reconciled)",
68 );
69 }
70 info!(store = %name, "ready");
71 Ok(())
72}
73
74/// Idempotently create every NATS JetStream resource the kanade
75/// fleet relies on. Calling repeatedly is safe — `create_*` returns
76/// the existing resource if it's already configured.
77///
78/// Returns once every resource is in place. The function is async
79/// so backends can `await` it as part of their startup sequence
80/// (one round-trip per resource — ~10 RTTs total).
81pub async fn ensure_jetstream_resources(js: &jetstream::Context) -> Result<()> {
82 // ── Streams ──────────────────────────────────────────────────
83 // #518: every stream carries a `max_bytes` cap with
84 // `Discard::Old` on top of its `max_age` window. Within their
85 // age windows the streams used to be unbounded by size, and
86 // JetStream's file store shares a disk with SQLite on the
87 // backend host — one job printing 200 KB per run fleet-wide
88 // could exhaust the store, at which point EVERY publish fails
89 // (results, obs, audit, KV puts). With the caps, worst-case
90 // degradation is "shorter history on the offending stream"
91 // instead of "broker down".
92 //
93 // Sizing: JetStream RESERVES each `max_bytes` against its
94 // available storage (min of max_file_store and free disk) at
95 // create/update time and fails with error 10047 when the sum
96 // doesn't fit, so these must stay small enough for modest
97 // hosts. That's fine: every stream here is a transport +
98 // replay buffer — the durable record is the backend's SQLite
99 // (results/inventory/obs/audit are all projected within
100 // seconds) — so the caps are runaway-output backstops, not
101 // history budgets. Total reservation ≈ 5.3 GiB including the
102 // result_output object store below.
103 const MIB: i64 = 1024 * 1024;
104 const GIB: i64 = 1024 * MIB;
105
106 // INVENTORY — 90-day rolling history (spec §2.3.1).
107 js.create_or_update_stream(StreamConfig {
108 name: STREAM_INVENTORY.into(),
109 subjects: vec!["inventory.>".into()],
110 max_age: Duration::from_secs(90 * 24 * 60 * 60),
111 max_bytes: GIB,
112 discard: DiscardPolicy::Old,
113 ..Default::default()
114 })
115 .await
116 .with_context(|| format!("create_or_update_stream {STREAM_INVENTORY}"))?;
117 info!(stream = STREAM_INVENTORY, "ready");
118
119 // RESULTS — 30-day rolling history. The biggest producer by
120 // far (every job run on every PC, with up to 256 KB of inline
121 // stdout/stderr per message), so it gets the largest slice of
122 // the disk budget.
123 js.create_or_update_stream(StreamConfig {
124 name: STREAM_RESULTS.into(),
125 subjects: vec!["results.>".into()],
126 max_age: Duration::from_secs(30 * 24 * 60 * 60),
127 max_bytes: 2 * GIB,
128 discard: DiscardPolicy::Old,
129 ..Default::default()
130 })
131 .await
132 .with_context(|| format!("create_or_update_stream {STREAM_RESULTS}"))?;
133 info!(stream = STREAM_RESULTS, "ready");
134
135 // EXEC — latest-per-subject only (spec §2.6 Layer 1). v0.22.1:
136 // catch the existing `commands.{all,group.X,pc.Y}` subjects so a
137 // single backend publish lands in BOTH the agent's live core
138 // subscription AND the stream's retention store. Reconnecting
139 // agents catch up via a durable consumer with
140 // `DeliverPolicy::LastPerSubject` — they receive the most
141 // recent Command per subject they care about, no matter how
142 // long they were offline (within `max_age`).
143 js.create_or_update_stream(StreamConfig {
144 name: STREAM_EXEC.into(),
145 subjects: vec!["commands.>".into()],
146 max_messages_per_subject: 1,
147 max_age: Duration::from_secs(7 * 24 * 60 * 60),
148 // Latest-per-subject keeps this tiny (one Command per
149 // group/pc subject); the cap is a backstop against subject
150 // cardinality bugs, not a working budget.
151 max_bytes: 64 * MIB,
152 discard: DiscardPolicy::Old,
153 ..Default::default()
154 })
155 .await
156 .with_context(|| format!("create_or_update_stream {STREAM_EXEC}"))?;
157 info!(stream = STREAM_EXEC, "ready");
158
159 // EVENTS — short-lived broadcast bus for kill / revoke / etc.
160 // 7-day window matches the EXEC spec window.
161 js.create_or_update_stream(StreamConfig {
162 name: STREAM_EVENTS.into(),
163 subjects: vec!["events.>".into()],
164 max_age: Duration::from_secs(7 * 24 * 60 * 60),
165 max_bytes: 256 * MIB,
166 discard: DiscardPolicy::Old,
167 ..Default::default()
168 })
169 .await
170 .with_context(|| format!("create_or_update_stream {STREAM_EVENTS}"))?;
171 info!(stream = STREAM_EVENTS, "ready");
172
173 // AUDIT — operator-action record (spec §2.3.1). The DURABLE
174 // copy is the backend's SQLite `audit_log` table (the projector
175 // INSERTs each message, idempotently since #501; 365-day
176 // retention since #486) — the stream is transport + replay
177 // buffer, not the archive, so it can be bounded like the rest.
178 // 90 days / 512 MiB is far more than the projector ever lags;
179 // previously this stream had NO limits at all, making it an
180 // unbounded disk leak on the broker host.
181 js.create_or_update_stream(StreamConfig {
182 name: STREAM_AUDIT.into(),
183 subjects: vec!["audit.>".into()],
184 max_age: Duration::from_secs(90 * 24 * 60 * 60),
185 max_bytes: 512 * MIB,
186 discard: DiscardPolicy::Old,
187 ..Default::default()
188 })
189 .await
190 .with_context(|| format!("create_or_update_stream {STREAM_AUDIT}"))?;
191 info!(stream = STREAM_AUDIT, "ready");
192
193 // OBS_EVENTS — per-PC observability timeline (Issue #246). The
194 // 90-day window matches `obs_events` table retention so a
195 // backend bootstrapping after long downtime can catch up but
196 // doesn't carry data the table will discard anyway. Subject
197 // filter `obs.>` catches every PC without a per-PC subscription.
198 //
199 // Days-to-seconds is spelt out once instead of `90 * 24 * 60 *
200 // 60` open-coded across bootstrap + cleanup; the matching prune
201 // window in `kanade-backend::cleanup` quotes the same number
202 // separately (SQLite-relative string syntax there, not a
203 // duration), so it can't share a constant — but a single
204 // arithmetic spell-out here makes the relationship grep-able.
205 const SECS_PER_DAY: u64 = 24 * 60 * 60;
206 const OBS_EVENTS_RETENTION_DAYS: u64 = 90;
207 js.create_or_update_stream(StreamConfig {
208 name: STREAM_OBS_EVENTS.into(),
209 subjects: vec!["obs.>".into()],
210 max_age: Duration::from_secs(OBS_EVENTS_RETENTION_DAYS * SECS_PER_DAY),
211 max_bytes: 512 * MIB,
212 discard: DiscardPolicy::Old,
213 ..Default::default()
214 })
215 .await
216 .with_context(|| format!("create_or_update_stream {STREAM_OBS_EVENTS}"))?;
217 info!(stream = STREAM_OBS_EVENTS, "ready");
218
219 // NOTIFICATIONS — end-user notification history (SPEC §2.3.1 /
220 // Phase E). 90-day window matches INVENTORY: a Client App that
221 // connects after a notification was sent fetches the missed ones
222 // via KLP `notifications.list`. Subject filter `notifications.>`
223 // catches every fan-out target (`all` / `group.X` / `pc.Y`) with
224 // one stream. Retains all messages per subject — each notification
225 // is its own history entry, not a latest-only state like EXEC.
226 // #518: 512 MiB cap + DiscardPolicy::Old, matching the other
227 // 90-day streams (AUDIT / OBS_EVENTS) — notification payloads are
228 // small, so this is generous headroom while still bounding the
229 // broker's disk lease.
230 js.create_or_update_stream(StreamConfig {
231 name: STREAM_NOTIFICATIONS.into(),
232 subjects: vec!["notifications.>".into()],
233 max_age: Duration::from_secs(90 * 24 * 60 * 60),
234 max_bytes: 512 * MIB,
235 discard: DiscardPolicy::Old,
236 ..Default::default()
237 })
238 .await
239 .with_context(|| format!("create_or_update_stream {STREAM_NOTIFICATIONS}"))?;
240 info!(stream = STREAM_NOTIFICATIONS, "ready");
241
242 // ── KV buckets ───────────────────────────────────────────────
243 // script_current — cmd_id → version (spec §2.6 Layer 2).
244 js.create_or_update_key_value(KvConfig {
245 bucket: BUCKET_SCRIPT_CURRENT.into(),
246 history: 5,
247 ..Default::default()
248 })
249 .await
250 .with_context(|| format!("create_or_update_key_value {BUCKET_SCRIPT_CURRENT}"))?;
251 info!(bucket = BUCKET_SCRIPT_CURRENT, "ready");
252
253 // script_status — cmd_id → ACTIVE / REVOKED.
254 js.create_or_update_key_value(KvConfig {
255 bucket: BUCKET_SCRIPT_STATUS.into(),
256 history: 5,
257 ..Default::default()
258 })
259 .await
260 .with_context(|| format!("create_or_update_key_value {BUCKET_SCRIPT_STATUS}"))?;
261 info!(bucket = BUCKET_SCRIPT_STATUS, "ready");
262
263 // agents_state — pc_id → latest hw snapshot (history=1).
264 js.create_or_update_key_value(KvConfig {
265 bucket: BUCKET_AGENTS_STATE.into(),
266 history: 1,
267 ..Default::default()
268 })
269 .await
270 .with_context(|| format!("create_or_update_key_value {BUCKET_AGENTS_STATE}"))?;
271 info!(bucket = BUCKET_AGENTS_STATE, "ready");
272
273 // agent_config — Sprint 6 layered scopes (global / groups.* /
274 // pcs.*) plus the legacy target_version key.
275 // history: 1 — agents only ever read the current value (the watch is
276 // DeliverPolicy::New + an initial_sync get(), never kv.history()).
277 // Retained old revisions only fed reconnect history-replay, which
278 // flapped self-update backward (#828). Operator change-history lives
279 // in the audit log, so keeping one revision loses nothing. (#830)
280 js.create_or_update_key_value(KvConfig {
281 bucket: BUCKET_AGENT_CONFIG.into(),
282 history: 1,
283 ..Default::default()
284 })
285 .await
286 .with_context(|| format!("create_or_update_key_value {BUCKET_AGENT_CONFIG}"))?;
287 info!(bucket = BUCKET_AGENT_CONFIG, "ready");
288
289 // agent_groups — Sprint 5 per-pc group membership.
290 // history: 1 — same reasoning as agent_config above: agents only need
291 // the current membership; replayed history just churned subscriptions
292 // through stale sets on every reconnect (a transient wrong membership,
293 // #830). One revision makes that replay material non-existent. (#830)
294 js.create_or_update_key_value(KvConfig {
295 bucket: BUCKET_AGENT_GROUPS.into(),
296 history: 1,
297 ..Default::default()
298 })
299 .await
300 .with_context(|| format!("create_or_update_key_value {BUCKET_AGENT_GROUPS}"))?;
301 info!(bucket = BUCKET_AGENT_GROUPS, "ready");
302
303 // agent_meta — per-PC operator-managed key/value annotations
304 // (edited via the SPA agent detail page / the `kanade meta` CLI, and
305 // typically bulk-populated by an operator AD-sync job). history: 5 —
306 // a few revisions of operator edit history, like group_contacts;
307 // nothing replays it, so the exact depth is not load-bearing.
308 js.create_or_update_key_value(KvConfig {
309 bucket: BUCKET_AGENT_META.into(),
310 history: 5,
311 ..Default::default()
312 })
313 .await
314 .with_context(|| format!("create_or_update_key_value {BUCKET_AGENT_META}"))?;
315 info!(bucket = BUCKET_AGENT_META, "ready");
316
317 // group_contacts — per-group notification email addresses
318 // (operator-managed via the SPA Groups page).
319 js.create_or_update_key_value(KvConfig {
320 bucket: BUCKET_GROUP_CONTACTS.into(),
321 history: 5,
322 ..Default::default()
323 })
324 .await
325 .with_context(|| format!("create_or_update_key_value {BUCKET_GROUP_CONTACTS}"))?;
326 info!(bucket = BUCKET_GROUP_CONTACTS, "ready");
327
328 // schedules — admin-API CRUD'd cron table (spec §2.5.3).
329 // Backend's scheduler.rs also creates this on startup; calling
330 // twice is harmless.
331 js.create_or_update_key_value(KvConfig {
332 bucket: BUCKET_SCHEDULES.into(),
333 history: 5,
334 ..Default::default()
335 })
336 .await
337 .with_context(|| format!("create_or_update_key_value {BUCKET_SCHEDULES}"))?;
338 info!(bucket = BUCKET_SCHEDULES, "ready");
339
340 // jobs — v0.15 operator-registered Manifest catalog. Schedules
341 // reference rows here by id; editing a job rewrites what future
342 // schedule fires exec.
343 js.create_or_update_key_value(KvConfig {
344 bucket: BUCKET_JOBS.into(),
345 history: 5,
346 ..Default::default()
347 })
348 .await
349 .with_context(|| format!("create_or_update_key_value {BUCKET_JOBS}"))?;
350 info!(bucket = BUCKET_JOBS, "ready");
351
352 // fleet_config — #418 Phase 5 fleet-wide singletons (the global
353 // change-freeze under KEY_FREEZE). history: 1 — only the current
354 // state matters; both schedulers watch it.
355 js.create_or_update_key_value(KvConfig {
356 bucket: BUCKET_FLEET_CONFIG.into(),
357 history: 1,
358 ..Default::default()
359 })
360 .await
361 .with_context(|| format!("create_or_update_key_value {BUCKET_FLEET_CONFIG}"))?;
362 info!(bucket = BUCKET_FLEET_CONFIG, "ready");
363
364 // server_settings — backend-side operator-editable settings (SPA
365 // Settings page "server settings" tab). A single JSON document under
366 // KEY_SERVER_SETTINGS; history: 1 since only the current state
367 // matters. First consumer is the cleanup task's dead-agent prune
368 // window.
369 js.create_or_update_key_value(KvConfig {
370 bucket: BUCKET_SERVER_SETTINGS.into(),
371 history: 1,
372 ..Default::default()
373 })
374 .await
375 .with_context(|| format!("create_or_update_key_value {BUCKET_SERVER_SETTINGS}"))?;
376 info!(bucket = BUCKET_SERVER_SETTINGS, "ready");
377
378 // notifications_read — per-(pc, user, notification) read/ack state
379 // (SPEC §2.3.2 / Phase E). The agent writes here on KLP
380 // `notifications.ack`; `notifications.list` reads it back to filter
381 // the unread bucket. history: 1 — only the latest ack per key
382 // matters.
383 js.create_or_update_key_value(KvConfig {
384 bucket: BUCKET_NOTIFICATIONS_READ.into(),
385 history: 1,
386 ..Default::default()
387 })
388 .await
389 .with_context(|| format!("create_or_update_key_value {BUCKET_NOTIFICATIONS_READ}"))?;
390 info!(bucket = BUCKET_NOTIFICATIONS_READ, "ready");
391
392 // jobs_yaml / schedules_yaml — operator source-of-truth YAML
393 // alongside the JSON catalogs above. Same key shape (manifest id
394 // / schedule id), but the value is the raw YAML bytes so the
395 // SPA's YAML editor preserves comments + script block-scalar
396 // indentation across edits. Agents/scheduler don't read these.
397 js.create_or_update_key_value(KvConfig {
398 bucket: BUCKET_JOBS_YAML.into(),
399 history: 5,
400 ..Default::default()
401 })
402 .await
403 .with_context(|| format!("create_or_update_key_value {BUCKET_JOBS_YAML}"))?;
404 info!(bucket = BUCKET_JOBS_YAML, "ready");
405
406 js.create_or_update_key_value(KvConfig {
407 bucket: BUCKET_SCHEDULES_YAML.into(),
408 history: 5,
409 ..Default::default()
410 })
411 .await
412 .with_context(|| format!("create_or_update_key_value {BUCKET_SCHEDULES_YAML}"))?;
413 info!(bucket = BUCKET_SCHEDULES_YAML, "ready");
414
415 // ── Object Store ─────────────────────────────────────────────
416 // agent_releases — one object per version, raw exe bytes.
417 ensure_object_store(
418 js,
419 ObjectStoreConfig {
420 bucket: OBJECT_AGENT_RELEASES.into(),
421 ..Default::default()
422 },
423 )
424 .await?;
425
426 // app_packages — generic operator-uploaded binary distribution
427 // (kanade-client today; third-party installers like Webex /
428 // Teams once those flows land). Object keys are
429 // `<name>/<version>`; see `kanade-shared::kv::OBJECT_APP_PACKAGES`
430 // for the full rationale.
431 ensure_object_store(
432 js,
433 ObjectStoreConfig {
434 bucket: OBJECT_APP_PACKAGES.into(),
435 ..Default::default()
436 },
437 )
438 .await?;
439
440 // scripts — manifest script bodies referenced by
441 // `Execute::script_object` (SPEC §2.4.1). Sibling of
442 // `app_packages`; see `kanade-shared::kv::OBJECT_SCRIPTS` for
443 // the bucket-split rationale (smaller payloads + manifest-
444 // coupled lifecycle vs operator-curated installers).
445 ensure_object_store(
446 js,
447 ObjectStoreConfig {
448 bucket: OBJECT_SCRIPTS.into(),
449 ..Default::default()
450 },
451 )
452 .await?;
453
454 // result_output — overflow stdout / stderr blobs for the
455 // `ExecResult` wire kind (#227). Anything larger than the agent's
456 // 256 KB inline threshold gets uploaded here under
457 // `<request_id>/{stdout,stderr}`; the backend's results
458 // projector derefs the pointer fields before INSERT so SQLite
459 // + the SPA see the full text inline. 30-day max_age matches
460 // STREAM_RESULTS so the lifetimes stay in lockstep — a row still
461 // resolvable in execution_results never points at a missing
462 // blob.
463 // #518: capped like the streams — a job whose output overflows
464 // the inline threshold writes blobs HERE instead of
465 // STREAM_RESULTS, so without its own cap this store bypasses
466 // the stream budget entirely and can still fill the file store.
467 // The projector derefs blobs within seconds of publish, so
468 // eviction only ever hits already-projected (or expired)
469 // output.
470 ensure_object_store(
471 js,
472 ObjectStoreConfig {
473 bucket: OBJECT_RESULT_OUTPUT.into(),
474 max_age: Duration::from_secs(SECS_PER_DAY * 30),
475 max_bytes: GIB,
476 ..Default::default()
477 },
478 )
479 .await?;
480
481 // #219: collected file bundles. A `collect:` job's agent zips the
482 // script's listed files and uploads the archive here under
483 // `<pc_id>/<job_id>/<rfc3339>.zip`; the SPA Collect page lists /
484 // downloads them. Default max_age = DEFAULT_COLLECT_RETENTION_DAYS —
485 // bundles are debugging / audit artifacts (not curated config like
486 // app_packages / scripts), so they auto-expire and the bucket doesn't
487 // grow unbounded. Capped at 5 GiB (DiscardPolicy::Old evicts oldest
488 // first) so a fleet's worth of bundles can't fill the file store.
489 //
490 // This is only the value a FRESH bucket is born with; the window is
491 // operator-tunable from the SPA (`ServerSettings::collect_retention_days`)
492 // and the backend reconciles the live bucket's max_age to the configured
493 // value at boot and on save — see [`reconcile_collect_retention`].
494 ensure_object_store(
495 js,
496 ObjectStoreConfig {
497 bucket: OBJECT_COLLECTIONS.into(),
498 max_age: Duration::from_secs(SECS_PER_DAY * DEFAULT_COLLECT_RETENTION_DAYS as u64),
499 max_bytes: 5 * GIB,
500 ..Default::default()
501 },
502 )
503 .await?;
504
505 Ok(())
506}
507
508/// NATS names the stream backing an Object Store `OBJ_<bucket>` (mirroring
509/// `KV_<bucket>` for key-value stores). We reconcile the collect bucket's
510/// retention through this stream because async-nats 0.49 has no
511/// create-or-update / reconcile form for Object Stores themselves (the same
512/// gap [`ensure_object_store`] works around) — but the underlying stream
513/// *does* support `update_stream`.
514fn object_store_stream_name(bucket: &str) -> String {
515 format!("OBJ_{bucket}")
516}
517
518/// Reconcile the `collections` Object Store's retention window to
519/// `retention_days` by updating the `max_age` on its backing stream.
520///
521/// Why this exists: the bucket is created once (at bootstrap) with the
522/// built-in default, and `create_object_store` neither has a
523/// create-or-update form nor reconciles config in async-nats 0.49. So to
524/// honour an operator's `ServerSettings::collect_retention_days` change on an
525/// already-provisioned bucket, we read the backing stream's config, patch
526/// **only** `max_age` (a read-modify-write that leaves every object-store-
527/// specific stream setting untouched), and `update_stream`. `max_bytes`
528/// and the discard policy are deliberately left as-is, so extending the
529/// window never lifts the 5 GiB disk ceiling.
530///
531/// Idempotent: if the stream's `max_age` already matches, it's a no-op
532/// (skips the update round-trip and returns `false`). A missing stream (the
533/// bucket was never provisioned — e.g. a broker that predates this feature
534/// and hasn't run bootstrap) is a soft error the caller can log-and-continue:
535/// bootstrap runs before this on the backend boot path, so in practice the
536/// stream is always present.
537///
538/// Returns `Ok(true)` when it actually changed the stream, `Ok(false)` when
539/// already in sync.
540pub async fn reconcile_collect_retention(
541 js: &jetstream::Context,
542 retention_days: u32,
543) -> Result<bool> {
544 const SECS_PER_DAY: u64 = 24 * 60 * 60;
545 let desired = Duration::from_secs(SECS_PER_DAY * retention_days as u64);
546 let stream_name = object_store_stream_name(OBJECT_COLLECTIONS);
547
548 let mut stream = js
549 .get_stream(&stream_name)
550 .await
551 .with_context(|| format!("get_stream {stream_name} for collect-retention reconcile"))?;
552 let info = stream
553 .info()
554 .await
555 .with_context(|| format!("stream info {stream_name}"))?;
556 if info.config.max_age == desired {
557 return Ok(false);
558 }
559 let mut cfg = info.config.clone();
560 cfg.max_age = desired;
561 js.update_stream(cfg)
562 .await
563 .with_context(|| format!("update_stream {stream_name} max_age"))?;
564 info!(
565 stream = %stream_name,
566 retention_days,
567 "collect retention: reconciled Object Store max_age",
568 );
569 Ok(true)
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use std::process::Stdio;
576
577 /// Throwaway `nats-server -js` on a random port, like the
578 /// kv_cas_live / offline_boot harnesses. Ignored tests only.
579 struct Broker {
580 js: jetstream::Context,
581 _server: tokio::process::Child,
582 _storage: tempfile::TempDir,
583 }
584
585 async fn spawn_broker() -> Broker {
586 let port = portpicker::pick_unused_port().expect("pick port");
587 let storage = tempfile::TempDir::new().expect("storage tempdir");
588 let server = tokio::process::Command::new("nats-server")
589 .arg("-js")
590 .arg("-p")
591 .arg(port.to_string())
592 .arg("-sd")
593 .arg(storage.path())
594 .stdout(Stdio::null())
595 .stderr(Stdio::null())
596 .kill_on_drop(true)
597 .spawn()
598 .expect("spawn nats-server (is it in PATH?)");
599 let url = format!("nats://127.0.0.1:{port}");
600 let mut client = None;
601 for _ in 0..50 {
602 if let Ok(c) = async_nats::connect(&url).await {
603 client = Some(c);
604 break;
605 }
606 tokio::time::sleep(Duration::from_millis(100)).await;
607 }
608 Broker {
609 js: jetstream::new(client.expect("nats-server did not come up in 5s")),
610 _server: server,
611 _storage: storage,
612 }
613 }
614
615 /// #506 / 2026-06-11 incident: `create_object_store` neither
616 /// reconciles config nor has a create-or-update form, so adding
617 /// the #518 `max_bytes` cap to a store first created uncapped made
618 /// the broker reject the create (error 10058 "name already in use
619 /// with a different configuration") and crashed the backend on
620 /// boot. `ensure_object_store` must instead accept the existing
621 /// store and let startup proceed.
622 #[tokio::test]
623 #[ignore = "requires nats-server in PATH; cargo test -- --ignored"]
624 async fn ensure_object_store_accepts_config_drift() {
625 let b = spawn_broker().await;
626 // First create: uncapped, as the pre-#518 backend did.
627 ensure_object_store(
628 &b.js,
629 ObjectStoreConfig {
630 bucket: "result_output".into(),
631 ..Default::default()
632 },
633 )
634 .await
635 .expect("fresh create");
636
637 // Second create with a conflicting config (now capped) must
638 // NOT error — it accepts the existing store.
639 ensure_object_store(
640 &b.js,
641 ObjectStoreConfig {
642 bucket: "result_output".into(),
643 max_bytes: 1024 * 1024 * 1024,
644 ..Default::default()
645 },
646 )
647 .await
648 .expect("config drift must not wedge startup");
649
650 // The store is still usable.
651 let store = b.js.get_object_store("result_output").await.expect("store");
652 store
653 .put("k", &mut &b"hi"[..])
654 .await
655 .expect("put after drift");
656 }
657
658 /// A fresh create with a cap succeeds on a broker with room (the
659 /// normal first-boot path).
660 #[tokio::test]
661 #[ignore = "requires nats-server in PATH; cargo test -- --ignored"]
662 async fn ensure_object_store_fresh_create_with_cap() {
663 let b = spawn_broker().await;
664 ensure_object_store(
665 &b.js,
666 ObjectStoreConfig {
667 bucket: "fresh".into(),
668 max_bytes: 64 * 1024 * 1024,
669 ..Default::default()
670 },
671 )
672 .await
673 .expect("fresh capped create");
674 b.js.get_object_store("fresh").await.expect("exists");
675 }
676
677 /// The fatal path: when create fails for a store that ALSO does
678 /// not exist, the error must propagate (we only swallow errors we
679 /// can fall back from). An invalid bucket name fails create's
680 /// charset validation and never creates a store to fall back to.
681 #[tokio::test]
682 #[ignore = "requires nats-server in PATH; cargo test -- --ignored"]
683 async fn ensure_object_store_propagates_when_no_fallback() {
684 let b = spawn_broker().await;
685 let err = ensure_object_store(
686 &b.js,
687 ObjectStoreConfig {
688 // Spaces / '!' are rejected by the object-store name
689 // rules, so create fails and get also finds nothing.
690 bucket: "bad name!".into(),
691 ..Default::default()
692 },
693 )
694 .await
695 .expect_err("a create failure with no existing store must be fatal");
696 assert!(
697 err.to_string()
698 .contains("no existing store to fall back to"),
699 "unexpected error: {err:#}",
700 );
701 }
702
703 /// `reconcile_collect_retention` must change the live bucket's `max_age`
704 /// (broker-side retention) without disturbing the other stream config —
705 /// the mechanism the SPA relies on to extend collect retention past the
706 /// 30-day default. Also asserts the idempotent no-op path (`Ok(false)`
707 /// when already in sync) and that `max_bytes` survives the update.
708 #[tokio::test]
709 #[ignore = "requires nats-server in PATH; cargo test -- --ignored"]
710 async fn reconcile_collect_retention_updates_max_age() {
711 use crate::kv::OBJECT_COLLECTIONS;
712 const SECS_PER_DAY: u64 = 24 * 60 * 60;
713 let b = spawn_broker().await;
714
715 // Provision the collections bucket the way bootstrap does: 30-day
716 // default max_age, 5 GiB cap.
717 ensure_object_store(
718 &b.js,
719 ObjectStoreConfig {
720 bucket: OBJECT_COLLECTIONS.into(),
721 max_age: Duration::from_secs(SECS_PER_DAY * 30),
722 max_bytes: 5 * 1024 * 1024 * 1024,
723 ..Default::default()
724 },
725 )
726 .await
727 .expect("fresh collections bucket");
728
729 let stream_name = object_store_stream_name(OBJECT_COLLECTIONS);
730
731 // Extend to 90 days — first call changes the stream.
732 assert!(
733 reconcile_collect_retention(&b.js, 90)
734 .await
735 .expect("reconcile to 90d"),
736 "first reconcile should report a change",
737 );
738 let mut stream = b.js.get_stream(&stream_name).await.expect("stream");
739 let info = stream.info().await.expect("info");
740 assert_eq!(
741 info.config.max_age,
742 Duration::from_secs(SECS_PER_DAY * 90),
743 "max_age must be extended to 90 days",
744 );
745 assert_eq!(
746 info.config.max_bytes,
747 5 * 1024 * 1024 * 1024,
748 "the size cap must survive the max_age-only update",
749 );
750
751 // Re-applying the same value is a no-op (no revision-bumping update).
752 assert!(
753 !reconcile_collect_retention(&b.js, 90)
754 .await
755 .expect("idempotent reconcile"),
756 "second reconcile with the same value should be a no-op",
757 );
758 }
759}