`cargo test --workspace` could not run at all: dependency resolution failed with "aws-types@1.3.16 requires rustc 1.91.1" on the 1.91.0 default toolchain, so the gate the project documents was dead. Making it run exposed a compile break and two wrong tests that had been invisible for months. Now green end to end: 143 suites, 3155 tests, exit 0. Toolchain - rust-toolchain.toml pins the DEV toolchain to 1.91.1. The published MSRV stays `rust-version = "1.91"` (the engine builds on 1.91.0); only tidalctl's AWS SDK chain needs the patch release, and it now declares that itself. Consumer crates migrated to the current engine API (clean cutover) - iknowyou-engine: `AgentPolicy` gained five m10 read/profile-override fields; the literal now spreads `..AgentPolicy::default()` as the engine's own doc example does, so future fields do not break it again. - forage-engine: `RetrieveResult` gained p1 `reasons`. The app builds its own candidate pool, so it now tags what it knows: PreferenceMatch for the preference-vector blend, SemanticMatch (with the seed item) for similar-to-saved, ExplorationBudget for pinned discoveries. - forage-engine: `url_to_item_id` folded into the u32 item universe. The engine narrows item IDs to a u32 slot in durable per-user state and rejects anything above u32::MAX rather than alias two items forever, so every add_item with a 64-bit FNV hash failed. 9 of 28 smoke tests were failing on this alone. - forage-engine: bridge items read the top-2 preference CLUSTERS via `query_vectors`, not the single centroid from `preference_vectors().get()`. Since m12 that accessor returns only the strongest cluster, so a tech+jazz user whose interests split into two clusters looked single-interest and never bridged. Falls back to top-2 dimensions when a user has one cluster. Reconcile tests corrected to the shipped contract - tidal/tests/m8p3_reconcile_production.rs asserted `3 + 5 == 8` for a windowed count after heal. `take_crdt_snapshot` deliberately keys signal contributions to ONE canonical contributor (ShardId::SINGLE) because signals are relayed from a single writer, so per-node attribution double-counted every replicated event on every reconcile. Merge is therefore LWW on (last_update_ns, score) plus PN-counter per-node max: nodes converge on the more complete accumulator. The old expectation was asserting the bug that fix removed. - Rewrote to assert convergence, count survival (not 0), and no inflation, and added `repeated_reconcile_of_converged_nodes_does_not_creep` - the regression guard for the creep itself, which nothing covered. Pre-commit hook unified - hooks/pre-commit dropped `-D warnings`: each crate's `[lints]` table is the source of truth (`clippy::all`/`unwrap_used` deny, `pedantic` warn), and the flag promoted ~58 deliberate pedantic warnings in integration tests to errors, making every Rust commit impossible. - It now lints all five tidal crates instead of path-matching `tidal/`, which silently skipped tidal-server, tidal-net, tidal-stress, tidalctl and applications/ - the rot above lived in exactly those crates. Ported the CODING_GUIDELINES file-length, println, and unsafe-SAFETY checks from the divergent untracked copy that this replaces. - CONTRIBUTING.md now documents the real commands and the toolchain/MSRV split. Fleet recovery and soak - scripts/restore-fleet.sh: the fail-closed selective restore, promoted out of an ignored tmp/ directory into the repository. Preflights retained storage, digest-pinned images, parked state, and aggregate plus per-PV-node scheduler headroom before the first scale; writes a durable transcript under tmp/restore-logs/ with structured start/error/rollback/complete events. - k8s manifests park the standalone store, the RF3 cluster, and the soak monitor at zero replicas with restore-fleet.sh as the only supported scale-up path. - soak-eval/soak-watch and the nightly CronJob fail closed on stale or missing restart evidence instead of silently skipping the restart-aware half of the gate. - docs/ops/capacity-planning.md corrects the RAM envelope to the real hot-tier formula and separates analytic totals from the measured process envelope.
118 lines
5.8 KiB
Rust
118 lines
5.8 KiB
Rust
//! Tier-3 MULTI-PROCESS SIGTERM graceful-shutdown proof (m12p6).
|
|
//!
|
|
//! Regression test for the production bug: a `kubectl delete pod` (SIGTERM, 60s
|
|
//! grace) on a real region node did NOT run the deterministic database close, so
|
|
//! the persisted HNSW graph (`{data_dir}/vector/*.usearch`) was never written and
|
|
//! every boot paid the full multi-minute HNSW rebuild.
|
|
//!
|
|
//! Root cause: the cluster serve path's graceful HTTP drain
|
|
//! (`axum::serve(...).with_graceful_shutdown(...)`) blocks until every in-flight
|
|
//! connection closes. Sibling region nodes hold long-lived keep-alive HTTP
|
|
//! connections that do not close promptly on our SIGTERM, so the drain — and thus
|
|
//! `axum::serve(...).await` — never returned inside the 60s grace; k8s then sent
|
|
//! `SIGKILL`. That signal cannot run `Drop`/`shutdown_inner` (where
|
|
//! the WAL checkpoint marker AND the HNSW-graph checkpoint are written).
|
|
//!
|
|
//! The fix (in `main.rs` + `cluster/node.rs`):
|
|
//!
|
|
//! * `bounded_drain` caps the post-signal drain (`TIDAL_SHUTDOWN_DRAIN_MS`,
|
|
//! default 15s) and then runs the deterministic close regardless; and
|
|
//! * `ClusterNode`/`ShardReplica::shutdown` are `&self` (the db handle is an
|
|
//! `ArcSwapOption`), so the close runs even when a stuck connection task kept an
|
|
//! `Arc<ClusterNode>` alive past the drain.
|
|
//!
|
|
//! # What this proves end-to-end (real OS processes, real SIGTERM)
|
|
//!
|
|
//! After a real SIGTERM to a region node that has taken writes, the node's
|
|
//! `{data_dir}/wal/checkpoint.meta` marker exists and is FRESH — the on-disk proof
|
|
//! that `TidalDb::shutdown_inner` ran on the SIGTERM path. That marker is written
|
|
//! by the SAME method, in the SAME shutdown body, immediately after
|
|
//! `checkpoint_embedding_graphs`; reaching it is exactly what the bug prevented.
|
|
//! (The HNSW `.usearch` file itself only materializes once a slot crosses the
|
|
//! dimension-aware HNSW crossover — ≥10k vectors at the harness's 4-D slot — which
|
|
//! is impractical to seed over HTTP here; the graph SAVE itself is proven by
|
|
//! `tidal/tests/m12p6_graph_persistence.rs`. This test proves the cluster SIGTERM
|
|
//! path REACHES that close, which is the regression.)
|
|
//!
|
|
//! Then a restart on the SAME data dir comes back healthy, proving the clean
|
|
//! shutdown left a recoverable state.
|
|
//!
|
|
//! ```bash
|
|
//! cargo test -p tidal-server --features cluster-e2e --test cluster_graph_persistence -- --nocapture
|
|
//! ```
|
|
#![cfg(feature = "cluster-e2e")]
|
|
#![allow(clippy::unwrap_used, clippy::missing_panics_doc)]
|
|
|
|
mod support;
|
|
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
use support::multiproc::{
|
|
ClusterOptions, MultiProcCluster, convergence_budget, seed_items_and_embeddings,
|
|
};
|
|
|
|
/// The leader index (region 0 = `us-east`).
|
|
const LEADER: usize = 0;
|
|
|
|
/// `{data_dir}/wal/checkpoint.meta` — written ONLY by `TidalDb::shutdown_inner`
|
|
/// (after writes advance the WAL seq), in the same shutdown body as the
|
|
/// HNSW-graph checkpoint. Its presence + freshness is the on-disk proof the
|
|
/// deterministic close ran on the SIGTERM path.
|
|
const WAL_CHECKPOINT_MARKER: &str = "wal/checkpoint.meta";
|
|
|
|
/// SIGTERM on a region node that has taken writes runs the deterministic
|
|
/// database close (WAL checkpoint marker written), and a restart on the same data
|
|
/// dir recovers cleanly — the graceful-shutdown path the production bug skipped.
|
|
#[test]
|
|
fn mp_sigterm_runs_deterministic_close_and_writes_checkpoint_marker() {
|
|
// Drive the post-signal drain cap LOW on every node so the test does not wait
|
|
// the 15s production default if a sibling keep-alive connection lingers (the
|
|
// very condition the fix handles). 250ms is ample for loopback drain; if it
|
|
// is exceeded, the fix still proceeds to the close — which is the point.
|
|
let opts = (0..3).fold(ClusterOptions::new(3), |o, i| {
|
|
o.with_env(i, "TIDAL_SHUTDOWN_DRAIN_MS", "250")
|
|
});
|
|
let cluster = MultiProcCluster::start_with(opts);
|
|
cluster.wait_converged_all(convergence_budget());
|
|
|
|
// Seed items + embeddings on the leader so the WAL seq advances past 0 (the
|
|
// marker is only written when there is something to checkpoint).
|
|
seed_items_and_embeddings(&cluster, LEADER, 8);
|
|
// Let the writes settle to disk before we stop.
|
|
std::thread::sleep(Duration::from_millis(500));
|
|
|
|
let marker_path = cluster.data_dir(LEADER).join(WAL_CHECKPOINT_MARKER);
|
|
let before = SystemTime::now();
|
|
|
|
let mut cluster = cluster; // `stop_graceful` takes &mut self.
|
|
// The REAL SIGTERM: the harness sends `kill -TERM` and waits for the process
|
|
// to exit within its graceful budget (it does NOT hard-kill unless the budget
|
|
// is blown — pre-fix, the drain hung and the budget WOULD blow, leaving no
|
|
// fresh marker; post-fix the bounded drain lets the close run and exit clean).
|
|
cluster.stop_graceful(LEADER);
|
|
|
|
// The deterministic close must have written (or refreshed) the WAL checkpoint
|
|
// marker. Existence alone is necessary; freshness rules out a stale marker
|
|
// from an earlier boot.
|
|
let meta = std::fs::metadata(&marker_path).unwrap_or_else(|e| {
|
|
panic!(
|
|
"WAL checkpoint marker {} missing after SIGTERM — the deterministic close did NOT run \
|
|
on the SIGTERM path (the m12p6 regression): {e}",
|
|
marker_path.display()
|
|
)
|
|
});
|
|
let modified = meta
|
|
.modified()
|
|
.expect("checkpoint marker mtime unavailable");
|
|
assert!(
|
|
modified >= before - Duration::from_secs(2),
|
|
"WAL checkpoint marker is stale (mtime {modified:?} predates the SIGTERM at {before:?}); \
|
|
the close ran on an EARLIER boot, not on this SIGTERM"
|
|
);
|
|
|
|
// The clean shutdown must leave a recoverable state: restart on the SAME data
|
|
// dir and require health (the harness panics if it does not converge).
|
|
cluster.restart(LEADER, &[("TIDAL_SHUTDOWN_DRAIN_MS", "250")]);
|
|
cluster.wait_healthy(LEADER);
|
|
}
|