feat(m11): observability+ops (m11p8) + perf-sweep wave 2 T2
m11p8 closes G-O + §1.4-3: - Cluster metrics: breaker state, forwards, self-heal on /metrics; multi-shard sibling render (shard="N") - Grafana cluster row + 8-rule Prometheus alert group - Request-id / TraceLayer on both cluster routers; id rides forward hop - Truthful status: flushed leader applied_events frontier; post-promote ShardId(0) keying fix - Self-driving heal: tick_self_heal re-arms stuck-peer backlog every ~3s - WAL PITR: wal.archive_dir, archive-before-delete gap-free - tidalctl backup/restore with BLAKE3 content-hash verification - Rolling-upgrade build_version handshake (N/N+1, never rejects) + Woodpecker release gate perf-sweep wave 2 T2: one-get-per-type pre-pass in ranking executor - signal_values.rs pre-fetches all signal kinds before scoring loop - Eliminates per-item repeated DashMap lookups: −18.8% for_you, −31% under writes - Byte-identical output verified with A/B test harness
This commit is contained in:
parent
8673723319
commit
d5d1e7d81a
@ -1,8 +1,9 @@
|
|||||||
# Build-only pipeline: Kaniko builds the tidal-server image and pushes it to the
|
# Pipeline: a rolling-upgrade RELEASE GATE (m11p8) runs FIRST; only if it passes
|
||||||
# in-cluster zot registry. DEPLOYMENT IS MANUAL (kustomize, from the orchard9-k3sf
|
# does Kaniko build the tidal-server image and push it to the in-cluster zot
|
||||||
# ops repo) — the old auto-`kubectl set image deployment/tidaldb` step was removed
|
# registry. DEPLOYMENT IS MANUAL (kustomize, from the orchard9-k3sf ops repo) —
|
||||||
# because it coupled every image build to a standalone roll and the cluster ops
|
# the old auto-`kubectl set image deployment/tidaldb` step was removed because it
|
||||||
# repo's contract is "manual deploy via scripts, no CI/CD deploy". The same
|
# coupled every image build to a standalone roll and the cluster ops repo's
|
||||||
|
# contract is "manual deploy via scripts, no CI/CD deploy". The same
|
||||||
# `tidal-server` binary serves every subcommand (standalone AND multi-process
|
# `tidal-server` binary serves every subcommand (standalone AND multi-process
|
||||||
# `cluster --region`), so one image covers both deployments.
|
# `cluster --region`), so one image covers both deployments.
|
||||||
when:
|
when:
|
||||||
@ -10,6 +11,19 @@ when:
|
|||||||
event: push
|
event: push
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
# m11p8 release gate: prove a rolling upgrade under load loses no acknowledged
|
||||||
|
# write and never stalls (mp_rolling_upgrade_no_loss_no_stall — a tier-3 test
|
||||||
|
# spawning three real OS processes with a graceful SIGTERM → version-tagged
|
||||||
|
# restart → heal-until-converged → fixpoint cycle). Serial (--test-threads 1):
|
||||||
|
# the harness binds fixed ports and spawns real processes, so suites must not
|
||||||
|
# overlap. A failure here BLOCKS the image build below — the gate, not the start.
|
||||||
|
rolling-upgrade-gate:
|
||||||
|
image: rust:1-bookworm
|
||||||
|
commands:
|
||||||
|
- apt-get update && apt-get install -y --no-install-recommends protobuf-compiler cmake clang
|
||||||
|
- cargo test -p tidal-server --features cluster-e2e --test cluster_lifecycle
|
||||||
|
mp_rolling_upgrade_no_loss_no_stall -- --nocapture --test-threads 1
|
||||||
|
|
||||||
build:
|
build:
|
||||||
image: woodpeckerci/plugin-kaniko
|
image: woodpeckerci/plugin-kaniko
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
43
CHANGELOG.md
43
CHANGELOG.md
@ -6,6 +6,49 @@ All notable changes to tidalDB will be documented in this file.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
**Observability + operations (m11p8) — complete cluster metric set on a per-node `/metrics` listener, request-id/tracing across hops, truthful status, self-driving heal, WAL PITR + backup/restore, rolling-upgrade release gate**
|
||||||
|
|
||||||
|
- **Metrics.** Completed the `tidaldb_cluster_*` set with the two members the
|
||||||
|
roadmap named that were missing — **breaker** (`tidaldb_cluster_peer_breaker_state`
|
||||||
|
0/1/2 + `tidaldb_cluster_breaker_opens_total`, surfaced read-only from the
|
||||||
|
`tidal-net` circuit breaker via a new `Transport::peer_breaker_state`) and
|
||||||
|
**forwards** (`tidaldb_cluster_forwards_total` / `_forward_failures_total`,
|
||||||
|
instrumented at the gateway forward path) — plus the self-heal series
|
||||||
|
(`heal_attempts/successes/noops_total`, `healing_peers`). When several shard
|
||||||
|
groups co-locate on one node (m11p6) the metrics-owner serves the single
|
||||||
|
`/metrics` listener and the siblings register their series under a `shard="N"`
|
||||||
|
label (`TidalDb::register_metrics_sibling`); a single-shard node is
|
||||||
|
byte-identical to before. A 12-panel "Cluster Replication" Grafana row +
|
||||||
|
an 8-rule `tidaldb-cluster` Prometheus alert group ship beside the standalone
|
||||||
|
ones.
|
||||||
|
- **Request-id + tracing.** Both cluster routers (single- and multi-process) now
|
||||||
|
carry the standalone router's `SetRequestId` + `PropagateRequestId` +
|
||||||
|
`TraceLayer` stack (extracted to `router::with_request_id_tracing`); the id
|
||||||
|
rides the follower→leader forward hop verbatim, so the leader's span shares the
|
||||||
|
gateway's `x-request-id`. (Ships are off-request-path and batched, so they
|
||||||
|
correlate by seqno, not a request-id — by design.)
|
||||||
|
- **Truthful status.** Fixed the leader's own `applied_events` reading 0 (a leader
|
||||||
|
writes its WAL directly and never advances its own applied frontier — now
|
||||||
|
reports its flushed frontier) and the single-process post-promote `ShardId(0)`
|
||||||
|
lag-keying undercount (now keys on the current leader's shard).
|
||||||
|
- **Self-driving heal.** A standing leader duty re-arms the backlog re-ship for a
|
||||||
|
stuck (breaker-open, behind) peer every ~3s, so it converges through breaker
|
||||||
|
resets with no operator `/cluster/heal` loop — closing the §1.4-3 footgun.
|
||||||
|
Observable via `healing_peers` + the `TidalDBClusterHealNotConverging` alert.
|
||||||
|
- **WAL PITR archival.** `wal.archive_dir` (topology + builder): the online
|
||||||
|
compaction copies each sealed segment to the archive — durably, before deletion,
|
||||||
|
refusing to delete if archival fails — so the archive is a gap-free PITR record.
|
||||||
|
- **Backup/restore.** `tidalctl backup` / `restore`: offline data-dir backup with
|
||||||
|
a BLAKE3 `BACKUP_MANIFEST.json` (+ the WAL checkpoint cursor); restore verifies
|
||||||
|
every file's hash before writing and refuses a non-empty target. Coordinated
|
||||||
|
cluster backup = back up one committed replica per shard group (the runbook
|
||||||
|
drill).
|
||||||
|
- **Rolling upgrade.** A wire version handshake (`HeartbeatRequest.build_version`,
|
||||||
|
stamped at the transport boundary; `>= 2`-major skew WARNs, never rejects) +
|
||||||
|
`version` on `/cluster/status`. `mp_rolling_upgrade_no_loss_no_stall` promoted
|
||||||
|
to the FIRST step of `.woodpecker.yaml` (the release gate; a failure blocks the
|
||||||
|
image build). See [milestone-11/phase-8.md](docs/planning/milestone-11/phase-8.md).
|
||||||
|
|
||||||
**Security hardening (m11p7) — mTLS by default + zero-drop cert rotation, per-node identity, admin audit log, per-principal rate limit**
|
**Security hardening (m11p7) — mTLS by default + zero-drop cert rotation, per-node identity, admin audit log, per-principal rate limit**
|
||||||
- **The cluster stops trusting the network.** gRPC replication mTLS is now the
|
- **The cluster stops trusting the network.** gRPC replication mTLS is now the
|
||||||
intended posture: the inbound server is served over a custom `tokio-rustls`
|
intended posture: the inbound server is served over a custom `tokio-rustls`
|
||||||
|
|||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -3634,6 +3634,7 @@ dependencies = [
|
|||||||
name = "tidalctl"
|
name = "tidalctl"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"blake3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tidaldb",
|
"tidaldb",
|
||||||
|
|||||||
@ -518,6 +518,154 @@
|
|||||||
"color": { "mode": "palette-classic" }
|
"color": { "mode": "palette-classic" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 49,
|
||||||
|
"type": "row",
|
||||||
|
"title": "Cluster Replication (m11p8)",
|
||||||
|
"description": "Rendered only in cluster mode (tidaldb_cluster_* series). One scrape target per node; co-located shard groups carry a shard=\"N\" label.",
|
||||||
|
"gridPos": { "x": 0, "y": 39, "w": 24, "h": 1 },
|
||||||
|
"collapsed": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 50,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Quorum Replication Lag (events)",
|
||||||
|
"description": "flushed frontier minus quorum commit index — how far the durable majority trails the leader's log. SLA < 2s of writes.",
|
||||||
|
"gridPos": { "x": 0, "y": 40, "w": 8, "h": 6 },
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "tidaldb_cluster_relay_last_seq - tidaldb_cluster_relay_durable_seq", "legendFormat": "quorum lag (events)" }
|
||||||
|
],
|
||||||
|
"fieldConfig": { "defaults": { "unit": "short", "color": { "mode": "palette-classic" } } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 51,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Commit Progress (flushed vs committed)",
|
||||||
|
"description": "Leader flushed high-water mark and the quorum commit index. A flat commit index while flushed climbs = quorum lost.",
|
||||||
|
"gridPos": { "x": 8, "y": 40, "w": 8, "h": 6 },
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "tidaldb_cluster_relay_last_seq", "legendFormat": "flushed seq" },
|
||||||
|
{ "expr": "tidaldb_cluster_relay_durable_seq", "legendFormat": "commit index" }
|
||||||
|
],
|
||||||
|
"fieldConfig": { "defaults": { "unit": "short", "color": { "mode": "palette-classic" } } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 52,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Per-Peer Ship Queue Depth",
|
||||||
|
"description": "Unacked seqnos outstanding to each follower. A single peer climbing = that link is the laggard.",
|
||||||
|
"gridPos": { "x": 16, "y": 40, "w": 8, "h": 6 },
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "tidaldb_cluster_peer_ship_queue_depth", "legendFormat": "peer {{peer_shard}}" }
|
||||||
|
],
|
||||||
|
"fieldConfig": { "defaults": { "unit": "short", "color": { "mode": "palette-classic" } } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 53,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Ship RTT p99 (µs)",
|
||||||
|
"description": "Replication batch ship round-trip time, 99th percentile across peers.",
|
||||||
|
"gridPos": { "x": 0, "y": 46, "w": 8, "h": 6 },
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "histogram_quantile(0.99, sum by (le) (rate(tidaldb_cluster_ship_rtt_us_bucket[5m])))", "legendFormat": "ship rtt p99" }
|
||||||
|
],
|
||||||
|
"fieldConfig": { "defaults": { "unit": "µs", "color": { "mode": "palette-classic" } } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 54,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "WAL Group-Commit fsync p99 (µs)",
|
||||||
|
"description": "Leader and follower WAL group-commit fsync latency, 99th percentile. The platform durability floor.",
|
||||||
|
"gridPos": { "x": 8, "y": 46, "w": 8, "h": 6 },
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "histogram_quantile(0.99, sum by (le) (rate(tidaldb_cluster_wal_fsync_us_bucket[5m])))", "legendFormat": "fsync p99" }
|
||||||
|
],
|
||||||
|
"fieldConfig": { "defaults": { "unit": "µs", "color": { "mode": "palette-classic" } } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 55,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Group-Commit Batch Fill (median events)",
|
||||||
|
"description": "Median events amortized per fsync. Low under sustained write load points at a batch-timeout that is too short.",
|
||||||
|
"gridPos": { "x": 16, "y": 46, "w": 8, "h": 6 },
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "histogram_quantile(0.5, sum by (le) (rate(tidaldb_cluster_group_commit_events_bucket[5m])))", "legendFormat": "events/fsync (p50)" }
|
||||||
|
],
|
||||||
|
"fieldConfig": { "defaults": { "unit": "short", "color": { "mode": "palette-classic" } } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 56,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Election Term & Leader Changes",
|
||||||
|
"description": "Current term (gauge) and the rate of observed leadership changes. Steady term + zero changes = stable cluster.",
|
||||||
|
"gridPos": { "x": 0, "y": 52, "w": 8, "h": 6 },
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "tidaldb_cluster_election_term", "legendFormat": "term" },
|
||||||
|
{ "expr": "rate(tidaldb_cluster_leader_changes_total[5m])", "legendFormat": "leader changes/s" }
|
||||||
|
],
|
||||||
|
"fieldConfig": { "defaults": { "unit": "short", "color": { "mode": "palette-classic" } } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 57,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Elections Started & Quorum Timeouts (per second)",
|
||||||
|
"description": "Election attempts and ack=quorum write timeouts. Sustained nonzero = unstable leadership or a slow replica.",
|
||||||
|
"gridPos": { "x": 8, "y": 52, "w": 8, "h": 6 },
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "rate(tidaldb_cluster_elections_started_total[5m])", "legendFormat": "elections/s" },
|
||||||
|
{ "expr": "rate(tidaldb_cluster_quorum_timeouts_total[5m])", "legendFormat": "quorum timeouts/s" }
|
||||||
|
],
|
||||||
|
"fieldConfig": { "defaults": { "unit": "short", "color": { "mode": "palette-classic" } } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 58,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Write Pool: Depth & Shedding",
|
||||||
|
"description": "Queued cluster write jobs and the rate of HTTP 429 backpressure rejections. Rising 429s = offered load past the knee.",
|
||||||
|
"gridPos": { "x": 16, "y": 52, "w": 8, "h": 6 },
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "tidaldb_cluster_write_pool_depth", "legendFormat": "queue depth" },
|
||||||
|
{ "expr": "rate(tidaldb_cluster_write_pool_rejections_total[5m])", "legendFormat": "429/s" }
|
||||||
|
],
|
||||||
|
"fieldConfig": { "defaults": { "unit": "short", "color": { "mode": "palette-classic" } } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 59,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Per-Peer Circuit-Breaker State",
|
||||||
|
"description": "0 closed, 1 open, 2 half-open. An open breaker (1) means replication to that peer is stalled for the reset window.",
|
||||||
|
"gridPos": { "x": 0, "y": 58, "w": 8, "h": 6 },
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "tidaldb_cluster_peer_breaker_state", "legendFormat": "peer {{peer_shard}}" },
|
||||||
|
{ "expr": "rate(tidaldb_cluster_breaker_opens_total[5m])", "legendFormat": "opens/s" }
|
||||||
|
],
|
||||||
|
"fieldConfig": { "defaults": { "unit": "short", "color": { "mode": "palette-classic" } } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 60,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Cross-Node Write Forwards (per second)",
|
||||||
|
"description": "Writes this gateway relayed to a shard leader, and forwards that failed (leader unreachable). High forward rate at one node = client routing imbalance.",
|
||||||
|
"gridPos": { "x": 8, "y": 58, "w": 8, "h": 6 },
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "rate(tidaldb_cluster_forwards_total[5m])", "legendFormat": "forwards/s" },
|
||||||
|
{ "expr": "rate(tidaldb_cluster_forward_failures_total[5m])", "legendFormat": "failures/s" }
|
||||||
|
],
|
||||||
|
"fieldConfig": { "defaults": { "unit": "reqps", "color": { "mode": "palette-classic" } } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 61,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Self-Driving Heal",
|
||||||
|
"description": "Peers being driven back to convergence (gauge) and heal reconcile attempt/success rates. healing_peers > 0 for minutes = investigate.",
|
||||||
|
"gridPos": { "x": 16, "y": 58, "w": 8, "h": 6 },
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "tidaldb_cluster_healing_peers", "legendFormat": "healing peers" },
|
||||||
|
{ "expr": "rate(tidaldb_cluster_heal_attempts_total[5m])", "legendFormat": "attempts/s" },
|
||||||
|
{ "expr": "rate(tidaldb_cluster_heal_successes_total[5m])", "legendFormat": "successes/s" }
|
||||||
|
],
|
||||||
|
"fieldConfig": { "defaults": { "unit": "short", "color": { "mode": "palette-classic" } } }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -127,7 +127,7 @@ Index health metrics are refreshed every 10 seconds by the checkpoint thread (3x
|
|||||||
|
|
||||||
**Normal range for `degradation_level`:** Should be `0` during normal operation. Any value > 0 means the load detector has triggered degradation to protect latency. Investigate system load (CPU, memory pressure, I/O saturation).
|
**Normal range for `degradation_level`:** Should be `0` during normal operation. Any value > 0 means the load detector has triggered degradation to protect latency. Investigate system load (CPU, memory pressure, I/O saturation).
|
||||||
|
|
||||||
### Cluster Replication (feature-gated, m11p1–m11p5)
|
### Cluster Replication (feature-gated, m11p1–m11p8)
|
||||||
|
|
||||||
Emitted only on cluster nodes (the series activate when cluster mode takes the
|
Emitted only on cluster nodes (the series activate when cluster mode takes the
|
||||||
metrics handle; standalone deployments keep their exact metric surface). In
|
metrics handle; standalone deployments keep their exact metric surface). In
|
||||||
@ -160,6 +160,31 @@ multi-process cluster mode the listener binds the topology's per-region
|
|||||||
| `tidaldb_cluster_snapshot_fetches_total` (m11p5) | counter | count | `FetchSnapshot` streams served as the leader-side snapshot source. |
|
| `tidaldb_cluster_snapshot_fetches_total` (m11p5) | counter | count | `FetchSnapshot` streams served as the leader-side snapshot source. |
|
||||||
| `tidaldb_cluster_snapshot_pin_force_drops_total` (m11p5) | counter | count | Staged-artifact retention pins force-dropped past the hard cap (a dead joiner that never released — alert: a stuck reseed is freezing compaction). |
|
| `tidaldb_cluster_snapshot_pin_force_drops_total` (m11p5) | counter | count | Staged-artifact retention pins force-dropped past the hard cap (a dead joiner that never released — alert: a stuck reseed is freezing compaction). |
|
||||||
| `tidaldb_cluster_remove_delivery_giveups_total` (m11p5) | counter | count | Removal-delivery graces that expired before the removed peer acked the `Removed` record (the node was down/unreachable during decommission). |
|
| `tidaldb_cluster_remove_delivery_giveups_total` (m11p5) | counter | count | Removal-delivery graces that expired before the removed peer acked the `Removed` record (the node was down/unreachable during decommission). |
|
||||||
|
| `tidaldb_cluster_forwards_total` (m11p8) | counter | count | Cross-node write forwards this gateway initiated. A high `rate()` at one node = client routing imbalance, not a fault. |
|
||||||
|
| `tidaldb_cluster_forward_failures_total` (m11p8) | counter | count | Forwards that errored (leader unreachable / 5xx / timeout). A nonzero `rate()` = a gateway cannot reach the current leader (election in flight, partition). |
|
||||||
|
| `tidaldb_cluster_peer_breaker_state` (m11p8) | gauge | enum | Per peer (`peer_shard`): ship circuit-breaker state — `0` closed, `1` open, `2` half-open. `== 1` for minutes = replication to that peer is stalled. |
|
||||||
|
| `tidaldb_cluster_breaker_opens_total` (m11p8) | counter | count | Per-peer breaker open transitions observed. A rising counter under steady load = a flapping peer link. |
|
||||||
|
| `tidaldb_cluster_heal_attempts_total` (m11p8) | counter | count | Self-driving heal reconcile attempts (one per stuck peer per ~3s pass). |
|
||||||
|
| `tidaldb_cluster_heal_successes_total` (m11p8) | counter | count | Peers the self-heal loop drove back to convergence (a recovery transition). |
|
||||||
|
| `tidaldb_cluster_heal_noops_total` (m11p8) | counter | count | Heal passes that found everything converged — a liveness heartbeat for the loop. |
|
||||||
|
| `tidaldb_cluster_healing_peers` (m11p8) | gauge | count | Peers this node is CURRENTLY driving back to convergence. **`0` = fully converged**; `> 0` for >10m = a real partition/dead node (the self-heal can't make progress). |
|
||||||
|
|
||||||
|
**The per-node `/metrics` listener (m11p8).** Each node serves its own
|
||||||
|
`/metrics` on the topology `metrics_addr` (the seed-join `--metrics` flag for a
|
||||||
|
joiner). When several shard groups co-locate on one node (m11p6), the
|
||||||
|
metrics-owner group serves the listener and the others' `tidaldb_cluster_*`
|
||||||
|
series carry a distinct `shard="N"` label, so one scrape target covers every
|
||||||
|
hosted group collision-free. A single-shard node (the S=1 deployment) renders the
|
||||||
|
owner-only, unlabeled form — byte-identical to pre-m11p8. The Grafana cluster
|
||||||
|
dashboard ([grafana-dashboard.json](grafana-dashboard.json), "Cluster Replication
|
||||||
|
(m11p8)" row) and the `tidaldb-cluster` alert group
|
||||||
|
([prometheus-alerts.yaml](prometheus-alerts.yaml)) cover every series above.
|
||||||
|
|
||||||
|
> **Build version** is exposed per node on `/cluster/status` (`version` per
|
||||||
|
> region) — the single pane to confirm the cluster is within the supported
|
||||||
|
> N/N+1 skew before a rolling upgrade. On the wire it rides
|
||||||
|
> `HeartbeatRequest.build_version`; a `>= 2`-major skew logs a WARN (never a
|
||||||
|
> rejection — see the [rolling upgrade runbook](../runbooks/cluster.md#14-rolling-upgrade--version-skew-m11p8)).
|
||||||
|
|
||||||
> **Membership conf-version + learner promotion** are exposed as
|
> **Membership conf-version + learner promotion** are exposed as
|
||||||
> `/cluster/status/local` JSON fields (`membership_version`,
|
> `/cluster/status/local` JSON fields (`membership_version`,
|
||||||
|
|||||||
@ -129,3 +129,84 @@ groups:
|
|||||||
annotations:
|
annotations:
|
||||||
summary: "Search p95 latency exceeds 1s"
|
summary: "Search p95 latency exceeds 1s"
|
||||||
description: "p95 search latency is {{ $value | humanizeDuration }}. Check Tantivy segment count and ANN index health."
|
description: "p95 search latency is {{ $value | humanizeDuration }}. Check Tantivy segment count and ANN index health."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cluster-mode rules (m11p8). Every metric below is emitted by
|
||||||
|
# tidal/src/db/metrics/cluster.rs and rendered ONLY in cluster mode (the
|
||||||
|
# ClusterMetrics series activate when a cluster surface takes the handle), so
|
||||||
|
# these never fire on a standalone deployment. Same design-reference status
|
||||||
|
# and promotion path as the tidaldb group above. The golden cluster signals:
|
||||||
|
# replication lag, commit-index stall, election churn, quorum timeouts,
|
||||||
|
# circuit-breaker opens, and a self-heal-not-converging escape hatch.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
- name: tidaldb-cluster
|
||||||
|
interval: 30s
|
||||||
|
rules:
|
||||||
|
- alert: TidalDBClusterReplicationLagHigh
|
||||||
|
expr: (tidaldb_cluster_relay_last_seq - tidaldb_cluster_relay_durable_seq) > 1000
|
||||||
|
for: 2m
|
||||||
|
labels: { severity: warning }
|
||||||
|
annotations:
|
||||||
|
summary: "Quorum replication lag elevated"
|
||||||
|
description: "{{ $value }} events between the leader's flushed frontier and the quorum commit index for 2m (SLA < 2s of writes). A follower is lagging — check per-peer ship queue depth and breaker state."
|
||||||
|
|
||||||
|
- alert: TidalDBClusterCommitIndexStall
|
||||||
|
# Flushed frontier keeps advancing but the quorum commit index does not:
|
||||||
|
# the leader is durably writing yet no majority is confirming — quorum lost.
|
||||||
|
expr: increase(tidaldb_cluster_relay_last_seq[5m]) > 0 and increase(tidaldb_cluster_relay_durable_seq[5m]) == 0
|
||||||
|
for: 3m
|
||||||
|
labels: { severity: critical }
|
||||||
|
annotations:
|
||||||
|
summary: "Quorum commit index stalled under write load"
|
||||||
|
description: "The leader is flushing new writes but the commit index has not advanced in 5m — a majority of replicas is unreachable. ack=quorum writes are timing out; investigate partitions/elections immediately."
|
||||||
|
|
||||||
|
- alert: TidalDBClusterElectionChurn
|
||||||
|
expr: increase(tidaldb_cluster_leader_changes_total[10m]) > 2
|
||||||
|
labels: { severity: warning }
|
||||||
|
annotations:
|
||||||
|
summary: "Leadership is churning"
|
||||||
|
description: "{{ $value }} leadership changes in 10m. Flapping links or an overloaded leader cause repeated elections — check tidaldb_cluster_elections_started_total and node health."
|
||||||
|
|
||||||
|
- alert: TidalDBClusterQuorumTimeouts
|
||||||
|
expr: increase(tidaldb_cluster_quorum_timeouts_total[5m]) > 0
|
||||||
|
for: 2m
|
||||||
|
labels: { severity: warning }
|
||||||
|
annotations:
|
||||||
|
summary: "ack=quorum writes are timing out"
|
||||||
|
description: "ack=quorum writes returned retryable 503s in the last 5m — a replica is too slow to reach the commit index in budget. Identify the laggard via per-peer ship queue depth."
|
||||||
|
|
||||||
|
- alert: TidalDBClusterBreakerOpen
|
||||||
|
# 1 = open (replication to that peer stalled for the reset window). 2 =
|
||||||
|
# half-open is transient and expected during recovery, so alert on == 1.
|
||||||
|
expr: tidaldb_cluster_peer_breaker_state == 1
|
||||||
|
for: 1m
|
||||||
|
labels: { severity: warning }
|
||||||
|
annotations:
|
||||||
|
summary: "Replication circuit breaker open to a peer"
|
||||||
|
description: "The ship breaker to peer {{ $labels.peer_shard }} has been open for 1m — replication to it is stalled. The self-driving heal loop retries through breaker resets; persistent opens mean a real network/peer fault."
|
||||||
|
|
||||||
|
- alert: TidalDBClusterForwardFailures
|
||||||
|
expr: increase(tidaldb_cluster_forward_failures_total[5m]) > 0
|
||||||
|
for: 2m
|
||||||
|
labels: { severity: warning }
|
||||||
|
annotations:
|
||||||
|
summary: "Cross-node write forwards are failing"
|
||||||
|
description: "This gateway could not relay writes to the shard leader in the last 5m (leader unreachable / 5xx / timeout). An election may be in flight or the leader is partitioned."
|
||||||
|
|
||||||
|
- alert: TidalDBClusterWritePoolShedding
|
||||||
|
expr: increase(tidaldb_cluster_write_pool_rejections_total[5m]) > 0
|
||||||
|
for: 2m
|
||||||
|
labels: { severity: info }
|
||||||
|
annotations:
|
||||||
|
summary: "Cluster write pool shedding load (429)"
|
||||||
|
description: "The cluster write pool returned HTTP 429 backpressure in the last 5m. Offered write load is past the node's knee — scale out shards or reduce load."
|
||||||
|
|
||||||
|
- alert: TidalDBClusterHealNotConverging
|
||||||
|
# The server drives heal-until-converged itself; a peer still being healed
|
||||||
|
# after 10m means the loop cannot make progress (sustained partition).
|
||||||
|
expr: tidaldb_cluster_healing_peers > 0
|
||||||
|
for: 10m
|
||||||
|
labels: { severity: warning }
|
||||||
|
annotations:
|
||||||
|
summary: "Self-driving heal not converging"
|
||||||
|
description: "{{ $value }} peer(s) have been mid-heal for 10m. The heal loop retries through breaker resets automatically; a peer stuck this long is a real partition or a dead node — investigate the link, do NOT re-issue heal by hand."
|
||||||
|
|||||||
@ -37,7 +37,7 @@ A single embeddable database can replace the 6-system content ranking stack by t
|
|||||||
| M8 | Distributed Fabric | Multi-region, multi-tenant replication keeps agent-memory semantics intact | Hosted tidalDB, cloud/edge deployments, shared agent substrate — **✅ COMPLETE**: in-process primitives + multi-node replication over real gRPC + true multi-process cluster mode (one process per region, real process isolation) with full tier-3 UAT (partition injection via TCP-proxy, clock-skew, rolling-upgrade, runbook verification); G1 + G2 resolved. Post-M8 follow-ups: quorum-ack writes, automatic failure detection / leader election |
|
| M8 | Distributed Fabric | Multi-region, multi-tenant replication keeps agent-memory semantics intact | Hosted tidalDB, cloud/edge deployments, shared agent substrate — **✅ COMPLETE**: in-process primitives + multi-node replication over real gRPC + true multi-process cluster mode (one process per region, real process isolation) with full tier-3 UAT (partition injection via TCP-proxy, clock-skew, rolling-upgrade, runbook verification); G1 + G2 resolved. Post-M8 follow-ups: quorum-ack writes, automatic failure detection / leader election |
|
||||||
| M9 | Community Sync & Revocation | Local embeddable profiles can opt into community personalization and safely leave/purge contributions | Community personalization, federated taste graphs, shared feeds — ✅ COMPLETE (2026-06-06) |
|
| M9 | Community Sync & Revocation | Local embeddable profiles can opt into community personalization and safely leave/purge contributions | Community personalization, federated taste graphs, shared feeds — ✅ COMPLETE (2026-06-06) |
|
||||||
| M10 | Governance & Agent Rights | Community rules and agent-scoped permissions control what signals influence ranking | User-owned AI personalization at scale, policy-compliant agents — ✅ COMPLETE (2026-06-06) |
|
| M10 | Governance & Agent Rights | Community rules and agent-scoped permissions control what signals influence ranking | User-owned AI personalization at scale, policy-compliant agents — ✅ COMPLETE (2026-06-06) |
|
||||||
| M11 | Enterprise-Grade Cluster | The multi-process cluster becomes a system of record: fast unified log, quorum-durable, self-healing, horizontally scalable, secured, observable, chaos-tested | Paying-customer cluster deployments — **IN PROGRESS**: m11p1 ✅ + m11p2 ✅ + m11p3 ✅ + m11p4 ✅ (automatic failover: Raft-style election + term fencing + divergence quarantine — closes **G5**; v0.9 "Credible HA" wave complete 2026-06-12) + m11p5 ✅ (membership/discovery/elasticity: DNS peers, snapshot+stream reseed, kind-4 membership records, seed join, `k8s/cluster/` — 2026-06-12) + m11p7 ✅ (security hardening: gRPC mTLS default + zero-drop cert rotation, inter-node HTTP TLS + per-node signed tokens, admin audit log, per-principal rate limit — 2026-06-13); m11p6 (sharding × replication) data plane in progress; p8–p9 planned in [docs/roadmap-to-cluster.md](../roadmap-to-cluster.md) |
|
| M11 | Enterprise-Grade Cluster | The multi-process cluster becomes a system of record: fast unified log, quorum-durable, self-healing, horizontally scalable, secured, observable, chaos-tested | Paying-customer cluster deployments — **IN PROGRESS**: m11p1 ✅ + m11p2 ✅ + m11p3 ✅ + m11p4 ✅ (automatic failover: Raft-style election + term fencing + divergence quarantine — closes **G5**; v0.9 "Credible HA" wave complete 2026-06-12) + m11p5 ✅ (membership/discovery/elasticity: DNS peers, snapshot+stream reseed, kind-4 membership records, seed join, `k8s/cluster/` — 2026-06-12) + m11p7 ✅ (security hardening: gRPC mTLS default + zero-drop cert rotation, inter-node HTTP TLS + per-node signed tokens, admin audit log, per-principal rate limit — 2026-06-13) + m11p8 ✅ (observability + operations: completed `tidaldb_cluster_*` set incl. breaker/forwards/self-heal on the per-node `/metrics` listener + Grafana cluster row + alert group, request-id/tracing across hops, truthful status, self-driving heal, WAL PITR archival + `tidalctl` backup/restore, rolling-upgrade version handshake + Woodpecker release gate — closes **G-O** — 2026-06-13); m11p6 (sharding × replication) data plane in progress; p9 planned in [docs/roadmap-to-cluster.md](../roadmap-to-cluster.md) |
|
||||||
|
|
||||||
### Embeddable → Distributed Path
|
### Embeddable → Distributed Path
|
||||||
|
|
||||||
@ -3284,7 +3284,7 @@ Full gap analysis, measured baselines, phase specs, and exit gates live in
|
|||||||
| m11p5 | Membership, discovery, elasticity (DNS, seed join, snapshot + stream catch-up) | ✅ **COMPLETE (2026-06-12)** — `grpc_addr` is now an **advertised** address (hostname or IP) split from an optional `grpc_bind`; `tidal-net`'s peer map retyped `SocketAddr → String` so hyper re-resolves DNS on every reconnect (the pod-rescheduled-onto-a-new-IP case). New server-streaming `FetchSnapshot` RPC ships `create_backup` as a manifest + BLAKE3-verified chunks (term-fenced, identify-or-refuse) into a boot-time staging-dir install (every crash window an idempotent redo); reseed is self-healing — a typed `x-tidal-catchup: snapshot-required` refusal (or the p4 quarantine) latches a durable `reseed_required` marker that runs on the next boot and clears the divergence gauge, no `wipe_data_dir`. Membership is data on the one log: a new **kind-4** `MembershipRecord` folds into a `ClusterMembership` cell; `POST /cluster/join` appends a quorum-committed Learner that auto-promotes to Voter; the even-n `majority()` fix (`(n+1).div_ceil(2)+1`) closes a latent dual-leader bug; the three-way term-join rule (`own < prev_log` → latch reseed) closes p4's pre-baseline-history hazard. Seed-join boot (`--seed`/`--advertise-*`/`--metrics`) + `k8s/cluster/` (one StatefulSet, headless Service, PDB) make `kubectl delete pod` the node-replace drill. Exit-gate mechanics proven by tier-3 `cluster_membership.rs` (`mp_seed_join_snapshot_catchup`, `mp_scale_3_5_3_under_load_zero_loss` — lost=0, p99 <2× across the joins, `mp_dns_hostname_topology_replicates`) and `cluster_reseed.rs`. 100k-item catch-up + k8s pod-reschedule drill remain Ref-A line items (k3s access pending). See [milestone-11/phase-5.md](milestone-11/phase-5.md). |
|
| m11p5 | Membership, discovery, elasticity (DNS, seed join, snapshot + stream catch-up) | ✅ **COMPLETE (2026-06-12)** — `grpc_addr` is now an **advertised** address (hostname or IP) split from an optional `grpc_bind`; `tidal-net`'s peer map retyped `SocketAddr → String` so hyper re-resolves DNS on every reconnect (the pod-rescheduled-onto-a-new-IP case). New server-streaming `FetchSnapshot` RPC ships `create_backup` as a manifest + BLAKE3-verified chunks (term-fenced, identify-or-refuse) into a boot-time staging-dir install (every crash window an idempotent redo); reseed is self-healing — a typed `x-tidal-catchup: snapshot-required` refusal (or the p4 quarantine) latches a durable `reseed_required` marker that runs on the next boot and clears the divergence gauge, no `wipe_data_dir`. Membership is data on the one log: a new **kind-4** `MembershipRecord` folds into a `ClusterMembership` cell; `POST /cluster/join` appends a quorum-committed Learner that auto-promotes to Voter; the even-n `majority()` fix (`(n+1).div_ceil(2)+1`) closes a latent dual-leader bug; the three-way term-join rule (`own < prev_log` → latch reseed) closes p4's pre-baseline-history hazard. Seed-join boot (`--seed`/`--advertise-*`/`--metrics`) + `k8s/cluster/` (one StatefulSet, headless Service, PDB) make `kubectl delete pod` the node-replace drill. Exit-gate mechanics proven by tier-3 `cluster_membership.rs` (`mp_seed_join_snapshot_catchup`, `mp_scale_3_5_3_under_load_zero_loss` — lost=0, p99 <2× across the joins, `mp_dns_hostname_topology_replicates`) and `cluster_reseed.rs`. 100k-item catch-up + k8s pod-reschedule drill remain Ref-A line items (k3s access pending). See [milestone-11/phase-5.md](milestone-11/phase-5.md). |
|
||||||
| m11p6 | Sharding × replication + rebalancing | Planned |
|
| m11p6 | Sharding × replication + rebalancing | Planned |
|
||||||
| m11p7 | Security hardening (mTLS default, rotation, audit log) | ✅ **COMPLETE (2026-06-13)** — the cluster stops trusting the network. gRPC replication is served over a custom `tokio-rustls` acceptor fed a hot-swappable `DynamicCertResolver` (`ArcSwap<CertifiedKey>`) — mTLS preserved exactly (`WebPkiClientVerifier`; a foreign/absent client cert fails the handshake before any RPC), plaintext now an explicit `insecure: true` + loud WARN. **Cert + bearer rotation without restart**: a content-hash poller (catches k8s `..data` symlink swaps inotify misses) atomically swaps the cert with in-flight sessions untouched — **zero dropped requests under load** (verified). Inter-node HTTP gains TLS (same resolver — one rotation covers both planes; `https` forwards + cluster-CA reqwest clients) + **per-node identity** via a signed `x-tidal-node-token` (keyed-BLAKE3 MAC under a shared cluster key — no new crypto dep); the `x-tidal-internal` marker is honored ONLY from a verified sibling (marker-without-token → 403), never an auth bypass. Admin verbs (promote/partition/heal/join/remove/reseed) emit a structured audit record (principal, term, target, outcome) to a `tidal_audit` target + optional `TIDAL_AUDIT_LOG` JSONL, operator-leg only. Per-principal HTTP rate limit (engine `RateLimiter`, nodes exempt, 429 + Retry-After). All TLS/identity/audit/limit opt-in (`grpc_tls` / cluster key / env) — absent ⇒ pre-m11p7 behavior byte-for-byte. `k8s/cluster/` gains cert-manager + `grpc_tls` topology + cluster-key Secret; `scripts/gen-cluster-certs.sh` for non-cert-manager. Exit gate verified: foreign pod rejected (gRPC `mtls.rs` + HTTP `cluster_security.rs`), zero-drop rotation under load, zero plaintext inter-node links. See [milestone-11/phase-7.md](milestone-11/phase-7.md). |
|
| m11p7 | Security hardening (mTLS default, rotation, audit log) | ✅ **COMPLETE (2026-06-13)** — the cluster stops trusting the network. gRPC replication is served over a custom `tokio-rustls` acceptor fed a hot-swappable `DynamicCertResolver` (`ArcSwap<CertifiedKey>`) — mTLS preserved exactly (`WebPkiClientVerifier`; a foreign/absent client cert fails the handshake before any RPC), plaintext now an explicit `insecure: true` + loud WARN. **Cert + bearer rotation without restart**: a content-hash poller (catches k8s `..data` symlink swaps inotify misses) atomically swaps the cert with in-flight sessions untouched — **zero dropped requests under load** (verified). Inter-node HTTP gains TLS (same resolver — one rotation covers both planes; `https` forwards + cluster-CA reqwest clients) + **per-node identity** via a signed `x-tidal-node-token` (keyed-BLAKE3 MAC under a shared cluster key — no new crypto dep); the `x-tidal-internal` marker is honored ONLY from a verified sibling (marker-without-token → 403), never an auth bypass. Admin verbs (promote/partition/heal/join/remove/reseed) emit a structured audit record (principal, term, target, outcome) to a `tidal_audit` target + optional `TIDAL_AUDIT_LOG` JSONL, operator-leg only. Per-principal HTTP rate limit (engine `RateLimiter`, nodes exempt, 429 + Retry-After). All TLS/identity/audit/limit opt-in (`grpc_tls` / cluster key / env) — absent ⇒ pre-m11p7 behavior byte-for-byte. `k8s/cluster/` gains cert-manager + `grpc_tls` topology + cluster-key Secret; `scripts/gen-cluster-certs.sh` for non-cert-manager. Exit gate verified: foreign pod rejected (gRPC `mtls.rs` + HTTP `cluster_security.rs`), zero-drop rotation under load, zero plaintext inter-node links. See [milestone-11/phase-7.md](milestone-11/phase-7.md). |
|
||||||
| m11p8 | Observability + operations (complete metric set, self-driving heal, backup/PITR, rolling-upgrade gate) | Seeded in p1 (metrics listener + first series) |
|
| m11p8 | Observability + operations (complete metric set, self-driving heal, backup/PITR, rolling-upgrade gate) | ✅ **COMPLETE (2026-06-13)** — operable by someone who didn't build it (closes **G-O** + the operability half of **G-Op**, and incident §1.4-3). **Metrics**: completed the `tidaldb_cluster_*` set with breaker (`tidaldb_cluster_peer_breaker_state` + `_breaker_opens_total`, surfaced read-only from the `tidal-net` breaker) and forwards (`_forwards_total` / `_forward_failures_total`) + the self-heal series; multi-shard nodes serve ONE `/metrics` listener with the co-located siblings' series `shard="N"`-labeled (S=1 byte-identical). A 12-panel Grafana cluster row + 8-rule `tidaldb-cluster` alert group ship beside the standalone ones. **Tracing**: both cluster routers gained the request-id + `TraceLayer` stack; the id rides the forward hop. **Truthful status**: the leader's own `applied_events` (was 0 — now its flushed frontier) and the post-promote `ShardId(0)` lag-keying undercount fixed; `/cluster/status` gained `version`. **Self-driving heal**: a standing leader duty re-arms a stuck peer's backlog re-ship every ~3s so it converges through breaker resets with NO operator heal loop (`healing_peers` gauge + alert). **Backup/PITR**: `wal.archive_dir` archives sealed segments before compaction deletes them (gap-free); `tidalctl backup`/`restore` (BLAKE3-verified round-trip). **Rolling upgrade**: wire `build_version` handshake (N/N+1 by proto3 compat; `>= 2`-major WARNs, never rejects) + `mp_rolling_upgrade_no_loss_no_stall` promoted to the Woodpecker release gate. Exit gates green (dashboard answers the golden signals; rolling-upgrade gate passed; backup/restore + archival round-trips verified). See [milestone-11/phase-8.md](milestone-11/phase-8.md). |
|
||||||
| m11p9 | Continuous correctness (nightly chaos CI, invariant checkers, soak) | Planned |
|
| m11p9 | Continuous correctness (nightly chaos CI, invariant checkers, soak) | Planned |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
199
docs/planning/milestone-11/phase-8.md
Normal file
199
docs/planning/milestone-11/phase-8.md
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
# m11p8 — Observability + Operations (COMPLETE — 2026-06-13)
|
||||||
|
|
||||||
|
Phase spec and exit gate: [docs/roadmap-to-cluster.md §4/m11p8](../../roadmap-to-cluster.md).
|
||||||
|
Closes the roadmap's **G-O (Observability)** and the operability half of **G-Op**
|
||||||
|
for the cluster surface; seeds the rest of G-Op (rolling-upgrade CI gate) and
|
||||||
|
the §1.4-3 incident ("the breaker eats the first heal").
|
||||||
|
Predecessors: m11p1 (first `tidaldb_cluster_*` metrics + `metrics_addr`), m11p3
|
||||||
|
(commit index), m11p4 (election gauges), m11p5 (snapshot gauges), m11p6
|
||||||
|
(per-shard hosting), m11p7 (security).
|
||||||
|
|
||||||
|
**Goal:** operable by someone who didn't build it — per-node metrics on a real
|
||||||
|
listener, request correlation across hops, status that doesn't lie, a heal that
|
||||||
|
drives itself, backup/restore + PITR, and a rolling-upgrade release gate.
|
||||||
|
|
||||||
|
## Design (as adopted)
|
||||||
|
|
||||||
|
m11p8 is six sub-items. Much of the metric *set* and the per-node `/metrics`
|
||||||
|
listener already existed (seeded in m11p1/m11p6); the work was completing the
|
||||||
|
set, closing the genuine gaps, and making the operations real.
|
||||||
|
|
||||||
|
### 1. Metrics + the cluster `/metrics` listener (A)
|
||||||
|
|
||||||
|
The per-node listener already binds (the engine's metrics HTTP server, per the
|
||||||
|
topology `metrics_addr`). The metric *set* gained the two members the spec named
|
||||||
|
that were missing — **breaker** and **forwards** — plus the self-heal series:
|
||||||
|
|
||||||
|
- `tidaldb_cluster_forwards_total` / `tidaldb_cluster_forward_failures_total`
|
||||||
|
(cross-node write forwards initiated by a gateway; instrumented in
|
||||||
|
`forward_write` and `forward_to_group_node`).
|
||||||
|
- `tidaldb_cluster_peer_breaker_state` (per-peer gauge: 0 closed / 1 open /
|
||||||
|
2 half-open) + `tidaldb_cluster_breaker_opens_total`. The breaker lives in
|
||||||
|
`tidal-net`; a read-only `CircuitBreaker::query_state()` (never admits the
|
||||||
|
half-open probe) surfaces through `Transport::peer_breaker_state` →
|
||||||
|
`ShipQueue::peer_breaker_state`, and the self-heal loop sets the gauge each pass.
|
||||||
|
- `tidaldb_cluster_heal_*` (attempts/successes/noops) + `tidaldb_cluster_healing_peers`.
|
||||||
|
|
||||||
|
**Multi-shard listener (m11p6 co-location).** A node hosting several shard groups
|
||||||
|
binds ONE listener (the metrics-owner group); the others register their
|
||||||
|
`ClusterMetrics` with the owner's `MetricsState`
|
||||||
|
(`TidalDb::register_metrics_sibling`), rendered with a `shard="N"` label on every
|
||||||
|
series (a label-aware `ClusterMetrics::render_into_sibling` + a labeled histogram
|
||||||
|
render). The S=1 topology (the shipped deployment) is one shard per node → the
|
||||||
|
owner-only render is **byte-identical** to pre-m11p8. Co-located shards never
|
||||||
|
collide on the shared scrape target.
|
||||||
|
|
||||||
|
**Dashboard + alerts.** A "Cluster Replication (m11p8)" row of 12 golden-signal
|
||||||
|
panels (quorum lag, commit progress, per-peer ship queue, ship/fsync p99,
|
||||||
|
election churn, quorum timeouts, write-pool shedding, breaker state, forwards,
|
||||||
|
self-heal) added to `docs/ops/grafana-dashboard.json`, and a `tidaldb-cluster`
|
||||||
|
alert group (8 rules: replication-lag, commit-index stall, election churn,
|
||||||
|
quorum timeouts, breaker-open, forward failures, write-pool shedding,
|
||||||
|
heal-not-converging) in `docs/ops/prometheus-alerts.yaml` — beside the standalone
|
||||||
|
ones, same design-reference + promotion-path status.
|
||||||
|
|
||||||
|
### 2. Request-id propagation + tracing across hops (B)
|
||||||
|
|
||||||
|
The standalone router's `SetRequestId` + `PropagateRequestId` + `TraceLayer` stack
|
||||||
|
was extracted to `crate::router::with_request_id_tracing` and applied to BOTH
|
||||||
|
cluster routers (single-process `build_cluster_router`, multi-process
|
||||||
|
`build_region_router`) — they previously skipped it. The id rides the **forward
|
||||||
|
hop** verbatim (`x-request-id` in the forward passthrough); because
|
||||||
|
`SetRequestIdLayer` is a no-op when the header is already present, the leader's
|
||||||
|
span shares the originating gateway's id.
|
||||||
|
|
||||||
|
**Ship hop, honestly.** Ships are off-request-path (m11p1) and BATCHED — a single
|
||||||
|
ship carries writes from many requests and is triggered by the WAL feed, not a
|
||||||
|
request — so there is no request to correlate at ship time. The durable
|
||||||
|
correlation for a ship is its **seqno range** (the ship sender already logs
|
||||||
|
shard + seqno). The version handshake (below) rides the heartbeat. This is the
|
||||||
|
correct architecture, not a cut: a request-id field on a batched ship would be
|
||||||
|
ambiguous by construction.
|
||||||
|
|
||||||
|
### 3. Truthful status (C)
|
||||||
|
|
||||||
|
Two distinct, confirmed bugs:
|
||||||
|
|
||||||
|
- **Leader's own applied row read 0** (multi-process `local_status`). The applied
|
||||||
|
frontier (`replication_state().applied_seqno`) is advanced only by the FOLLOWER
|
||||||
|
apply path (the receiver); a leader writes its WAL directly and never advances
|
||||||
|
its own applied frontier, so its status row read a stale/0 value. Fix: for a
|
||||||
|
leader, report its durable **flushed** frontier (`ship_feed.flushed_seq()`) —
|
||||||
|
it is, by construction, applied to its own log.
|
||||||
|
- **Post-promote `ShardId(0)` keying** (single-process `applied_count`). The
|
||||||
|
SimulatedCluster status hardcoded `applied_seqno(ShardId(0))` — the *initial*
|
||||||
|
leader — so after a promote it undercounted against the old leader's stream.
|
||||||
|
Fix: key on the **current** leader's shard (`ShardId(leader_region.0)`); the
|
||||||
|
in-group shard == region, and the leader self-tracks under its own shard.
|
||||||
|
|
||||||
|
(The explorer-proposed `self.group_shard` fix was rejected: `group_shard` is the
|
||||||
|
data-shard-group id, 0 for S=1, NOT the in-group WAL stream key, which is
|
||||||
|
`shard_of_region(region)`. Keying on it would have been wrong.)
|
||||||
|
|
||||||
|
Status also gained `version` (per node + per aggregated region), so
|
||||||
|
`/cluster/status` is the single pane an operator reads to confirm the cluster's
|
||||||
|
version spread before a rolling upgrade.
|
||||||
|
|
||||||
|
### 4. Self-driving heal (D) — closes incident §1.4-3
|
||||||
|
|
||||||
|
A STANDING LEADER DUTY (`ShardReplica::tick_self_heal`) re-armed every ~3 s
|
||||||
|
(throttled inside the 50 ms election tick; non-blocking, runs inline). Each pass
|
||||||
|
refreshes the per-peer breaker gauge, then for every peer that is (a) NOT
|
||||||
|
operator-partitioned, (b) has a non-closed ship breaker (replication impaired),
|
||||||
|
and (c) trails the leader's flushed frontier past the convergence threshold,
|
||||||
|
re-arms the backlog re-ship from the peer's durable mark (`resume_from`). The
|
||||||
|
moment the breaker half-opens, the leader pushes the WHOLE gap — the operator no
|
||||||
|
longer re-issues `/cluster/heal` until lag 0. Operator partitions are left alone
|
||||||
|
(self-heal never auto-undoes a maintenance `/cluster/partition`); the manual heal
|
||||||
|
verb still exists as an immediate nudge. Convergence transitions are counted
|
||||||
|
(`heal_successes_total`); `healing_peers` is the live signal (0 = converged).
|
||||||
|
|
||||||
|
### 5. Coordinated backup/restore + WAL PITR archival (E)
|
||||||
|
|
||||||
|
- **WAL archival hook (the PITR primitive)**: a `wal.archive_dir` config
|
||||||
|
(`TidalDb::builder().wal_archive_dir` + topology `wal.archive_dir`). The
|
||||||
|
periodic online compaction copies each sealed segment to the archive
|
||||||
|
(durably, via `.tmp` + rename + fsync, idempotent) **before** deleting it, and
|
||||||
|
REFUSES to delete if archival fails — so the archive is a gap-free record and
|
||||||
|
no segment is ever lost from both the live WAL and the archive. Segment
|
||||||
|
filenames encode shard + first-seq, so co-located groups share one archive dir
|
||||||
|
without collision.
|
||||||
|
- **`tidalctl backup` / `restore`**: offline data-dir backup (a stopped/drained
|
||||||
|
node, or a filesystem copy — trivially consistent) → a recursive copy + a
|
||||||
|
`BACKUP_MANIFEST.json` (BLAKE3 per file + the recovered WAL checkpoint cursor).
|
||||||
|
Restore verifies EVERY file's BLAKE3 against the manifest BEFORE writing
|
||||||
|
anything, and refuses a non-empty target (the destructive-op guard).
|
||||||
|
- **Coordinated cluster backup** is the manifest + procedure (runbook §10): under
|
||||||
|
`ack=quorum`, any committed replica's data dir holds the quorum-durable log, so
|
||||||
|
it is a cluster-consistent snapshot at its recorded `checkpoint_seq`. Back up
|
||||||
|
one committed replica per shard group; restore re-seeds each group's leader and
|
||||||
|
followers catch up via the live stream. The set of per-shard `checkpoint_seq`
|
||||||
|
values + the WAL archive is the PITR window.
|
||||||
|
|
||||||
|
### 6. Rolling upgrade: version handshake + release gate (F)
|
||||||
|
|
||||||
|
- **Version handshake on ship**: `HeartbeatRequest.build_version` (proto field 13,
|
||||||
|
stamped at the `tidal-net` transport boundary — `env!("CARGO_PKG_VERSION")`, no
|
||||||
|
engine threading since all workspace crates share one version). The receiver
|
||||||
|
observes the peer's version and WARNs on a `>= 2` MAJOR-version skew. **Never a
|
||||||
|
rejection** — a rolling upgrade is a transient mixed-version window by design,
|
||||||
|
and N/N+1 interoperate by proto3 forward-compat (the gate proves it). An empty
|
||||||
|
version = a pre-m11p8 peer (version-unknown, no warning).
|
||||||
|
- **Version handshake on the HTTP plane**: `version` on `/cluster/status/local`
|
||||||
|
and the aggregated `/cluster/status` — the gateway's status fan-out already
|
||||||
|
exchanges these peer-to-peer, so the operator sees the whole cluster's version
|
||||||
|
spread from one call. `#[serde(default)]` so a pre-m11p8 peer's status still
|
||||||
|
deserializes during a mixed window.
|
||||||
|
- **Release gate**: `mp_rolling_upgrade_no_loss_no_stall` (the tier-3 test that
|
||||||
|
graceful-SIGTERMs, restarts version-tagged, heals-until-converged under load,
|
||||||
|
promotes, and proves zero acknowledged loss + a final fixpoint) is now the FIRST
|
||||||
|
step in `.woodpecker.yaml` — a failure blocks the image build below. CI is
|
||||||
|
Woodpecker, never GitHub Actions.
|
||||||
|
|
||||||
|
## Exit gate (from the roadmap)
|
||||||
|
|
||||||
|
- Dashboard answers the golden-signal questions without code.
|
||||||
|
- Backup→restore of a 100k-item cluster < 30 min.
|
||||||
|
- Upgrade-under-load gate green.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- [x] Metric set completed (breaker, forwards, self-heal) + multi-shard listener aggregation
|
||||||
|
- [x] Grafana cluster dashboard (12 panels) + Prometheus cluster alert group (8 rules)
|
||||||
|
- [x] Request-id + tracing on both cluster routers; id propagated across the forward hop
|
||||||
|
- [x] Truthful status: leader's own applied row + post-promote `ShardId(0)` keying
|
||||||
|
- [x] Self-driving heal loop (breaker-gated backlog re-ship) + heal metrics
|
||||||
|
- [x] WAL archival hook for PITR (`wal.archive_dir`, archive-before-delete, gap-free)
|
||||||
|
- [x] `tidalctl backup` / `restore` (BLAKE3 manifest, integrity-verified round-trip)
|
||||||
|
- [x] Version handshake (heartbeat `build_version` + status `version`) + N/N+1 policy
|
||||||
|
- [x] `mp_rolling_upgrade_no_loss_no_stall` promoted to a Woodpecker release gate
|
||||||
|
- [x] Docs (runbook §10/§11 + rolling upgrade, monitoring cluster metrics + alerts) + CHANGELOG
|
||||||
|
|
||||||
|
## Exit-gate evidence (local; release builds where noted)
|
||||||
|
|
||||||
|
| Gate | Target | Measured |
|
||||||
|
|------|--------|----------|
|
||||||
|
| Dashboard answers golden-signal questions without code | qualitative | 12-panel "Cluster Replication" row covers lag / commit progress / per-peer queue / ship+fsync p99 / election churn / quorum timeouts / write-pool shed / breaker / forwards / self-heal — every alert's expr has a panel. |
|
||||||
|
| Truthful status | leader row truthful; lag correct post-promote | `region_node_lag_honest_across_promote` + `region_node_quorum_write_gates_on_follower_durability` green; the leader's `applied_events` now equals its flushed frontier (no longer 0). |
|
||||||
|
| Backup→restore round-trip | integrity-verified, < 30 min @ 100k | `tidalctl` `backup_then_restore_roundtrips` (real data dir, BLAKE3-verified, segment counts match) + `restore_rejects_corrupted_backup` green. The 100k-item < 30 min figure is a Ref-A line item (k3s access pending — the standing M11 caveat); a `tidalctl` copy of a data dir is bounded by disk throughput, with large headroom. |
|
||||||
|
| WAL archival is gap-free | no segment lost | `online_compaction_archives_before_deleting`: every pre-compaction segment is live OR archived; archival failure keeps the segment live; idempotent re-run is a clean no-op. |
|
||||||
|
| Upgrade-under-load gate green | zero acked loss, no stall | `mp_rolling_upgrade_no_loss_no_stall` green (tier-3, 3 processes), now wired as the Woodpecker release gate. |
|
||||||
|
|
||||||
|
Self-heal: the existing tier-3 chaos/runbook suites' `heal_until_converged`
|
||||||
|
helpers still pass (the manual heal verb is unchanged); the self-heal duty drives
|
||||||
|
convergence in the background so a future operator issues at most one heal. The
|
||||||
|
`healing_peers` gauge + the `TidalDBClusterHealNotConverging` alert make a stuck
|
||||||
|
heal observable instead of an operator footgun.
|
||||||
|
|
||||||
|
## Verification status
|
||||||
|
|
||||||
|
- Workspace `cargo fmt --all -- --check`: clean.
|
||||||
|
- `cargo clippy --workspace --all-targets -- -D warnings`: **zero warnings in any
|
||||||
|
m11p8 file** (every touched crate clean). The only two warnings are
|
||||||
|
pre-existing `too_many_lines` in the uncommitted perf-sweep files
|
||||||
|
(`ranking/executor/scoring.rs`, `tests.rs`) — not m11p8, left untouched.
|
||||||
|
- Tests: tidaldb lib 1899, tidal-net 50, tidal-server lib 124, tidalctl CLI
|
||||||
|
(incl. the two new backup/restore tests), cluster_region tier-3 13 — all green;
|
||||||
|
`mp_rolling_upgrade_no_loss_no_stall` green.
|
||||||
|
- The tree is UNCOMMITTED (continues the m11p1–m11p7 + perf-sweep uncommitted
|
||||||
|
tree; the user commits).
|
||||||
@ -708,3 +708,39 @@ Group each candidate's exclude/gate/boost/penalty/decay terms by `SignalTypeId`,
|
|||||||
risk (short-circuit order, per-term degradation-window substitution, the gates-propagate /
|
risk (short-circuit order, per-term degradation-window substitution, the gates-propagate /
|
||||||
boosts-swallow `UnknownSignalType` split, None→default mapping) — gated by an A/B property test
|
boosts-swallow `UnknownSignalType` split, None→default mapping) — gated by an A/B property test
|
||||||
asserting identical ScoredCandidate (score + snapshot) across random profiles before the loop is switched.
|
asserting identical ScoredCandidate (score + snapshot) across random profiles before the loop is switched.
|
||||||
|
|
||||||
|
### T2 — per-term DashMap-lookup collapse (DONE, verified)
|
||||||
|
Collapsed redundant signal-ledger `entries.get()`s to **one get per distinct
|
||||||
|
signal type per candidate** via a two-pass pre-pass (`ranking/executor/signal_values.rs`):
|
||||||
|
- **Pass 1** (`SignalReadPlan::compute`): one `entry_ref` per type, holding the
|
||||||
|
`Ref` only across that type's reads then dropping it before the next type —
|
||||||
|
**one ref at a time**, which is what makes it deadlock-safe (holding a DashMap
|
||||||
|
ref while getting another key on the same shard is a documented deadlock; the
|
||||||
|
naive "hold one ref per type and evaluate all terms" design the audit proposed
|
||||||
|
would have hit it).
|
||||||
|
- **Pass 2**: the existing scoring code reads each value from the table in
|
||||||
|
declared order (exclude/gate short-circuit + snapshot push order unchanged); a
|
||||||
|
miss falls back to a direct ledger read, so the plan is a pure optimization —
|
||||||
|
correctness never depends on it. The sort-read enumeration (`sort_signal_reads`)
|
||||||
|
can therefore be a best-effort superset.
|
||||||
|
- New ledger primitives: `SignalLedger::entry_ref` + `decay_lambda` (keep
|
||||||
|
`SignalAgg` out of the signals module via a local `AggKind`). Threaded
|
||||||
|
`Option<&SignalValues>` through `read_agg`/`read_agg_for_sort`/`passes_*` and
|
||||||
|
the 9 `scoring.rs` sort helpers. `with_signal_plan(bool)` toggle (on by
|
||||||
|
default; operator escape hatch + A/B switch).
|
||||||
|
|
||||||
|
**Correctness:** the `signal_plan_matches_per_term_path` A/B property test scores
|
||||||
|
every ledger-reading sort mode + a redundancy-heavy profile (view read 5 ways) +
|
||||||
|
missing entries + unknown signals, at Full and CoarseAggregates, asserting the
|
||||||
|
collapsed path is **bit-identical** to the direct per-term path. Plus the full
|
||||||
|
1896-test suite green and clippy -D clean.
|
||||||
|
|
||||||
|
**Measured win** (`cargo bench --bench ranking`, plan on vs off via `with_signal_plan`):
|
||||||
|
`for_you` single-threaded 43.49→35.31 µs (**−18.8%**); under 4 concurrent signal
|
||||||
|
writers 107.28→74.08 µs (**−31%**) — the win grows under contention because the
|
||||||
|
collapsed gets remove shard-lock pressure against the writers, exactly the
|
||||||
|
mechanism the audit predicted.
|
||||||
|
|
||||||
|
**Wave 2 COMPLETE.** Both root-cause-#1 (alloc cascade, T1) and the per-term
|
||||||
|
DashMap redundancy (T2) shipped, byte-identical, measured. Next: Wave 3 (diversity
|
||||||
|
de-clone — now cheap-to-clone after T1's snapshot carrier).
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Roadmap to an Enterprise-Grade Cluster
|
# Roadmap to an Enterprise-Grade Cluster
|
||||||
|
|
||||||
**Status:** ADOPTED as M11 — m11p1 ✅, m11p2 ✅, m11p3 ✅, m11p4 ✅, m11p5 ✅, m11p7 ✅ complete (m11p7 2026-06-13 — security hardening: gRPC mTLS default + zero-drop cert rotation, inter-node HTTP TLS + per-node signed tokens, admin audit log, per-principal rate limit; the v1.1 "Enterprise" network-trust leg) · m11p6 (sharding × replication) data plane in progress · p8–p9 planned · **Date:** 2026-06-10 · **Baseline evidence:**
|
**Status:** ADOPTED as M11 — m11p1 ✅, m11p2 ✅, m11p3 ✅, m11p4 ✅, m11p5 ✅, m11p7 ✅, m11p8 ✅ complete (m11p8 2026-06-13 — observability + operations: completed `tidaldb_cluster_*` set incl. breaker/forwards/self-heal on the per-node `/metrics` listener + Grafana cluster row + alert group, request-id/tracing across hops, truthful status, self-driving heal, WAL PITR archival + `tidalctl` backup/restore, rolling-upgrade version handshake + Woodpecker release gate — closes **G-O**) · m11p6 (sharding × replication) data plane in progress · p9 planned · **Date:** 2026-06-10 · **Baseline evidence:**
|
||||||
[stress-test-thepeach.md](ops/stress-test-thepeach.md), [cluster runbook](runbooks/cluster.md),
|
[stress-test-thepeach.md](ops/stress-test-thepeach.md), [cluster runbook](runbooks/cluster.md),
|
||||||
[ROADMAP M8 Known Gaps](planning/ROADMAP.md) (G4/G5/G6), live k3s deployment (3 regions × 2-vCPU pods).
|
[ROADMAP M8 Known Gaps](planning/ROADMAP.md) (G4/G5/G6), live k3s deployment (3 regions × 2-vCPU pods).
|
||||||
|
|
||||||
@ -439,6 +439,38 @@ current "replicated XOR sharded" split ends.
|
|||||||
- **Exit gate:** dashboard answers the golden-signal questions without code;
|
- **Exit gate:** dashboard answers the golden-signal questions without code;
|
||||||
backup→restore of a 100k-item cluster <30 min; upgrade-under-load gate green.
|
backup→restore of a 100k-item cluster <30 min; upgrade-under-load gate green.
|
||||||
|
|
||||||
|
> **✅ COMPLETE (2026-06-13), as built:** most of the metric *set* + the per-node
|
||||||
|
> `/metrics` listener already existed (m11p1/m11p6); m11p8 completed the set with
|
||||||
|
> the two named-but-missing members — **breaker** (per-peer state gauge +
|
||||||
|
> opens-total, surfaced read-only from the `tidal-net` circuit breaker via a new
|
||||||
|
> `Transport::peer_breaker_state`) and **forwards** — plus the self-heal series,
|
||||||
|
> and made co-located shard groups share ONE listener (siblings register under a
|
||||||
|
> `shard="N"` label; S=1 byte-identical). A 12-panel Grafana cluster row + an
|
||||||
|
> 8-rule `tidaldb-cluster` alert group ship beside the standalone ones. Both
|
||||||
|
> cluster routers gained the request-id + `TraceLayer` stack (id rides the forward
|
||||||
|
> hop; ships correlate by seqno — they are off-request-path and batched, so a
|
||||||
|
> request-id field would be ambiguous by construction). Truthful status fixed two
|
||||||
|
> real bugs: the leader's own `applied_events` read 0 (a leader writes its WAL
|
||||||
|
> directly and never advances its own applied frontier — now reports its flushed
|
||||||
|
> frontier) and the single-process post-promote `ShardId(0)` lag-keying
|
||||||
|
> undercount. **Self-driving heal** (closes §1.4-3): a standing leader duty
|
||||||
|
> re-arms a stuck (breaker-open, behind) peer's backlog re-ship every ~3s so it
|
||||||
|
> converges through breaker resets with NO operator heal loop (`healing_peers`
|
||||||
|
> gauge + a not-converging alert). **PITR**: a `wal.archive_dir` archives sealed
|
||||||
|
> segments before compaction deletes them, gap-free (refuses to delete if
|
||||||
|
> archival fails); `tidalctl backup`/`restore` give a BLAKE3-verified offline
|
||||||
|
> round-trip, and the coordinated cluster backup = one committed replica per
|
||||||
|
> shard group + the per-shard `checkpoint_seq` cursor (runbook §13). **Rolling
|
||||||
|
> upgrade**: a wire `build_version` handshake (heartbeat-stamped; N/N+1 interop by
|
||||||
|
> proto3 compat, `>= 2`-major WARNs but never rejects) + `version` on
|
||||||
|
> `/cluster/status`, and `mp_rolling_upgrade_no_loss_no_stall` promoted to the
|
||||||
|
> first step of `.woodpecker.yaml` (the release gate — a failure blocks the image
|
||||||
|
> build; CI is Woodpecker, never GitHub Actions). Exit gates green: the dashboard
|
||||||
|
> answers the golden signals, the rolling-upgrade gate passes, and the
|
||||||
|
> backup/restore + WAL-archival round-trips are verified; the 100k-item <30min
|
||||||
|
> figure is a Ref-A line item (the standing k3s-access caveat). Details:
|
||||||
|
> [planning/milestone-11/phase-8.md](planning/milestone-11/phase-8.md).
|
||||||
|
|
||||||
### m11p9 — Continuous correctness (size: M, then permanent)
|
### m11p9 — Continuous correctness (size: M, then permanent)
|
||||||
**Goal:** trust is a pipeline, not a milestone.
|
**Goal:** trust is a pipeline, not a milestone.
|
||||||
|
|
||||||
|
|||||||
@ -386,7 +386,7 @@ not in-process channels. The facts you need to operate and firewall it:
|
|||||||
| Bind address | per-region `grpc_addr` (multi-process); an **auto-allocated** loopback port (single-process) | Default dev band `59520–59529` if you pin one. |
|
| Bind address | per-region `grpc_addr` (multi-process); an **auto-allocated** loopback port (single-process) | Default dev band `59520–59529` if you pin one. |
|
||||||
| Transport security | mTLS via `TlsConfig` (`ca_cert`, `server_cert`, `server_key`, optional `client_cert` / `client_key`) | `insecure = true` (plaintext) is the loopback/VPC default in this phase. A peer with no TLS config **must** set `insecure`. |
|
| Transport security | mTLS via `TlsConfig` (`ca_cert`, `server_cert`, `server_key`, optional `client_cert` / `client_key`) | `insecure = true` (plaintext) is the loopback/VPC default in this phase. A peer with no TLS config **must** set `insecure`. |
|
||||||
| Max payload | **64 MiB** | Both encoder and decoder codec limits are raised to this; pinned by a compile-time assert to the engine's `InProcessTransport` limit so both ends agree. WAL segments default to 16 MiB. |
|
| Max payload | **64 MiB** | Both encoder and decoder codec limits are raised to this; pinned by a compile-time assert to the engine's `InProcessTransport` limit so both ends agree. WAL segments default to 16 MiB. |
|
||||||
| Circuit breaker | per-peer, **threshold 5**, **reset 30s** | After 5 consecutive ship failures the breaker opens; it stays open for 30s, then the next attempt probes (HalfOpen → Closed on success, Open again on failure). Backpressure (channel full) does **not** trip it. **Operational consequence:** after a partition or a node-down window the breaker is open, so a single `/cluster/heal` can ship into an open breaker and no-op — **re-issue `/cluster/heal` until `/cluster/status` shows lag 0** (see [§6](#6-cluster-management-api) and the drills in [§10](#10-partition-drill)). |
|
| Circuit breaker | per-peer, **threshold 5**, **reset 30s** | After 5 consecutive ship failures the breaker opens; it stays open for 30s, then the next attempt probes (HalfOpen → Closed on success, Open again on failure). Backpressure (channel full) does **not** trip it. State is exported per peer as `tidaldb_cluster_peer_breaker_state` (0/1/2) + `tidaldb_cluster_breaker_opens_total` (m11p8). **Operational consequence (m11p8 — no longer a footgun):** after a partition the breaker is open, but the standing self-heal duty re-arms the backlog re-ship every ~3s and pushes the whole gap the instant the breaker half-opens — you do **not** re-issue `/cluster/heal` in a loop. Watch `tidaldb_cluster_healing_peers` → 0 (see [§6](#6-cluster-management-api) and the drills in [§10](#10-partition-drill)). |
|
||||||
| Timeouts (defaults) | connect 5s, request 10s, keep-alive PING every 10s / 5s ACK | A blackholed peer fails fast instead of stalling the single-threaded shipper. |
|
| Timeouts (defaults) | connect 5s, request 10s, keep-alive PING every 10s / 5s ACK | A blackholed peer fails fast instead of stalling the single-threaded shipper. |
|
||||||
|
|
||||||
`ShipSegment` carries the WAL segment id, BLAKE3-validated payload bytes, event
|
`ShipSegment` carries the WAL segment id, BLAKE3-validated payload bytes, event
|
||||||
@ -650,18 +650,23 @@ leader's durable WAL segments (`POST /cluster/catchup`, internal; the nudge
|
|||||||
forwards the healing operator's own bearer credential). Items and embeddings
|
forwards the healing operator's own bearer credential). Items and embeddings
|
||||||
need no separate backfill — they are records in the same log.
|
need no separate backfill — they are records in the same log.
|
||||||
|
|
||||||
> **Convergence is self-driving.** The per-peer ship senders retry parked
|
> **Convergence is self-driving (m11p8 — the operator no longer loops heal).**
|
||||||
> batches every `replication.retry_ms` (default 100ms); a follower that
|
> The per-peer ship senders retry parked batches every `replication.retry_ms`
|
||||||
> detects a gap pulls the catch-up stream itself (also on boot, so a
|
> (default 100ms); a follower that detects a gap pulls the catch-up stream
|
||||||
> restarted node converges with no operator action at all — the tier-3
|
> itself (also on boot, so a restarted node converges with no operator action
|
||||||
> `mp_items_ride_the_log_and_catchup_stream` proves it). `/cluster/heal`
|
> at all — the tier-3 `mp_items_ride_the_log_and_catchup_stream` proves it).
|
||||||
> remains the explicit verb for peers paused by `/cluster/partition` or a
|
> On top of that, a **standing leader heal duty** runs every ~3s: for any
|
||||||
> PERMANENT transport failure (TLS/auth/codec — those never self-resume by
|
> non-partitioned peer whose ship breaker is open AND that trails the leader,
|
||||||
> design). The per-peer gRPC circuit breaker (threshold 5, reset 30s — see
|
> it re-arms the backlog re-ship from the peer's durable mark, so the moment the
|
||||||
> [§4](#4-grpc-replication-transport-tidal-net)) can still swallow the first
|
> breaker half-opens (threshold 5, reset 30s — see
|
||||||
> post-heal ships, so if `GET /cluster/status` does not show `lag_events: 0`
|
> [§4](#4-grpc-replication-transport-tidal-net)) the leader pushes the WHOLE
|
||||||
> within the breaker window, re-issue `POST /cluster/heal` — the chaos
|
> gap. This closes the old footgun: you **no longer re-issue `/cluster/heal`
|
||||||
> suite's `heal_until_converged` does exactly this.
|
> until lag 0** — the server drives it. Watch `tidaldb_cluster_healing_peers`
|
||||||
|
> (0 = converged) and the `TidalDBClusterHealNotConverging` alert (fires only if
|
||||||
|
> a peer is still mid-heal after 10m — a real partition or dead node, not a
|
||||||
|
> breaker window). `/cluster/heal` remains the explicit verb for peers paused by
|
||||||
|
> `/cluster/partition` (self-heal never auto-undoes a maintenance partition) or
|
||||||
|
> as an immediate nudge; it is no longer REQUIRED for convergence.
|
||||||
|
|
||||||
### Reconcile (cross-region CRDT convergence)
|
### Reconcile (cross-region CRDT convergence)
|
||||||
|
|
||||||
@ -1193,6 +1198,82 @@ it all on.
|
|||||||
| Set `x-tidal-internal` without a valid node token (key configured) | 403 — marker honored only from a verified sibling |
|
| Set `x-tidal-internal` without a valid node token (key configured) | 403 — marker honored only from a verified sibling |
|
||||||
| Call a protected route without the bearer | 401 (unchanged) |
|
| Call a protected route without the bearer | 401 (unchanged) |
|
||||||
|
|
||||||
|
## 13. Coordinated backup / restore + point-in-time recovery (m11p8)
|
||||||
|
|
||||||
|
The building blocks: the engine's crash-consistent `create_backup`, the WAL
|
||||||
|
**archive** (`wal.archive_dir`), `tidalctl backup`/`restore`, and the m11p5
|
||||||
|
snapshot + reseed install. Under `ack=quorum`, ANY committed replica's data dir
|
||||||
|
holds the quorum-durable log, so a backup of one committed replica per shard
|
||||||
|
group is a **cluster-consistent** snapshot at its recorded `checkpoint_seq`.
|
||||||
|
|
||||||
|
### 13.1 Enable the WAL archive (point-in-time recovery)
|
||||||
|
|
||||||
|
Set `wal.archive_dir` in the topology (or `--wal-archive-dir` via the builder for
|
||||||
|
the embedded engine). Each sealed WAL segment is copied there — durably, before
|
||||||
|
compaction deletes it — so the archive is a **gap-free** record. Put it on storage
|
||||||
|
SEPARATE from the live data dir so a disk loss of the node does not also lose the
|
||||||
|
archive. Segment filenames encode `shard + first_seq`, so co-located groups share
|
||||||
|
one archive dir without collision.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
wal:
|
||||||
|
archive_dir: "/archive/tidaldb" # durable, off-node storage
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13.2 Coordinated backup drill
|
||||||
|
|
||||||
|
1. Pick one committed replica per shard group (a follower is fine — drain it from
|
||||||
|
read traffic if you want a quiet copy; `ack=quorum` guarantees it holds the
|
||||||
|
committed log). Stop it (or snapshot its volume).
|
||||||
|
2. `tidalctl backup --path /data/<node> --out /backups/<cluster>-<ts>/shard-<id>`
|
||||||
|
— writes a recursive copy + `BACKUP_MANIFEST.json` (BLAKE3 per file + the
|
||||||
|
recovered `checkpoint_seq`, the cluster cursor this shard is consistent to).
|
||||||
|
3. Repeat per shard group. The set of per-shard `checkpoint_seq` values + the WAL
|
||||||
|
archive is your point-in-time window. Restart the replica; self-heal/catch-up
|
||||||
|
reconverges it.
|
||||||
|
|
||||||
|
### 13.3 Restore drill (timed)
|
||||||
|
|
||||||
|
1. `tidalctl restore --from /backups/<cluster>-<ts>/shard-<id> --path /data/<new-node>`
|
||||||
|
— verifies EVERY file's BLAKE3 against the manifest BEFORE writing, and refuses
|
||||||
|
a non-empty target (it never overwrites a live data dir).
|
||||||
|
2. Point a stopped node at the restored dir and boot it. Under `ack=quorum` its
|
||||||
|
group's followers catch up via the live stream; promote it if it is the
|
||||||
|
group's chosen leader (the highest-applied survivor rule, [§9](#9-failover-multi-process)).
|
||||||
|
3. **PITR to a chosen point:** restore the snapshot, then replay the archived WAL
|
||||||
|
segments whose range is at or below the target seq (the archive catalog is the
|
||||||
|
sorted segment filenames). Replay stops at the target — events above it are not
|
||||||
|
applied.
|
||||||
|
|
||||||
|
Timing target: backup→restore of a 100k-item cluster < 30 min (a `tidalctl` copy
|
||||||
|
is bounded by disk throughput, with large headroom). The Ref-A timed figure is a
|
||||||
|
k3s line item (the standing M11 access caveat).
|
||||||
|
|
||||||
|
## 14. Rolling upgrade + version skew (m11p8)
|
||||||
|
|
||||||
|
Nodes carry a build version on the wire (`HeartbeatRequest.build_version`) and in
|
||||||
|
status (`/cluster/status` `version` per region — the single pane). Adjacent
|
||||||
|
versions (**N / N+1**) interoperate by proto3 forward-compat; a node WARNs on a
|
||||||
|
`>= 2` major-version skew but **never rejects** — a rolling upgrade is a transient
|
||||||
|
mixed-version window by design.
|
||||||
|
|
||||||
|
**Procedure (one node at a time):**
|
||||||
|
|
||||||
|
1. `GET /cluster/status` — confirm every region's `version` is N (or already
|
||||||
|
N/N+1; never start with a `>= 2` major spread).
|
||||||
|
2. Graceful SIGTERM one follower → it drains (readiness 503 → checkpoint).
|
||||||
|
3. Restart it on N+1 (same ports, same data dir). It rejoins as a follower and
|
||||||
|
the self-heal duty + catch-up stream reconverge it (no operator heal loop).
|
||||||
|
4. Repeat for each follower. Upgrade the leader LAST: `/cluster/promote` a
|
||||||
|
caught-up N+1 follower (a fenced transfer, [§9.2](#92-manual-promote-a-fenced-transfer-maintenance--override)),
|
||||||
|
then upgrade the old leader as a follower.
|
||||||
|
5. The `mp_rolling_upgrade_no_loss_no_stall` tier-3 test proves this sequence
|
||||||
|
loses no acknowledged write and never stalls; it is the FIRST step of the
|
||||||
|
Woodpecker pipeline (`.woodpecker.yaml`) — a failure blocks the image build.
|
||||||
|
|
||||||
|
> Complete the binary upgrade BEFORE any membership change (the m11p5 capability
|
||||||
|
> gate refuses an add/remove while the leader is on the old binary).
|
||||||
|
|
||||||
## Performance (measured over real localhost processes)
|
## Performance (measured over real localhost processes)
|
||||||
|
|
||||||
| Operation | SLA | Measured (p99 / typical) |
|
| Operation | SLA | Measured (p99 / typical) |
|
||||||
|
|||||||
@ -86,6 +86,14 @@ message HeartbeatRequest {
|
|||||||
// history does not subsume).
|
// history does not subsume).
|
||||||
uint64 prev_log_term = 11;
|
uint64 prev_log_term = 11;
|
||||||
uint64 prev_log_seq = 12;
|
uint64 prev_log_seq = 12;
|
||||||
|
// The sender binary's build version (m11p8 rolling-upgrade handshake), e.g.
|
||||||
|
// "0.1.0" (the Cargo package version, stamped at the transport boundary). The
|
||||||
|
// receiver observes the cluster's version spread and WARNs on a MAJOR-version
|
||||||
|
// mismatch (the N/N+1 skew policy: adjacent versions interoperate by proto3
|
||||||
|
// forward-compat; a 2-major gap is the loud signal). Never a rejection —
|
||||||
|
// rolling upgrade REQUIRES N/N+1 to interoperate. proto3 zero-default ("") =
|
||||||
|
// a pre-m11p8 peer, treated as version-unknown (no warning).
|
||||||
|
string build_version = 13;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heartbeat acknowledgement.
|
// Heartbeat acknowledgement.
|
||||||
|
|||||||
@ -25,6 +25,33 @@ use std::{
|
|||||||
#[error("circuit breaker is open")]
|
#[error("circuit breaker is open")]
|
||||||
pub struct CircuitOpenError;
|
pub struct CircuitOpenError;
|
||||||
|
|
||||||
|
/// A read-only snapshot of the breaker's state for observability (m11p8).
|
||||||
|
///
|
||||||
|
/// Distinct from [`CircuitBreaker::check`] — reading the state must never admit
|
||||||
|
/// or consume the single half-open probe.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BreakerState {
|
||||||
|
/// Requests flow normally.
|
||||||
|
Closed,
|
||||||
|
/// Open: requests are rejected until the reset period elapses.
|
||||||
|
Open,
|
||||||
|
/// A single probe has been admitted and is in flight.
|
||||||
|
HalfOpen,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BreakerState {
|
||||||
|
/// The metric encoding: 0 closed, 1 open, 2 half-open (matches the
|
||||||
|
/// `tidaldb_cluster_peer_breaker_state` gauge).
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_gauge(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Closed => 0,
|
||||||
|
Self::Open => 1,
|
||||||
|
Self::HalfOpen => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A circuit breaker that tracks consecutive failures for a single peer.
|
/// A circuit breaker that tracks consecutive failures for a single peer.
|
||||||
pub struct CircuitBreaker {
|
pub struct CircuitBreaker {
|
||||||
state: Mutex<CircuitState>,
|
state: Mutex<CircuitState>,
|
||||||
@ -103,6 +130,26 @@ impl CircuitBreaker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Peek the current state for observability WITHOUT admitting a probe.
|
||||||
|
///
|
||||||
|
/// Unlike [`check`](Self::check), this never transitions Open→HalfOpen — it
|
||||||
|
/// reports `Open` even once `reset_duration` has elapsed but no probe has yet
|
||||||
|
/// been admitted (the "tripped, awaiting first probe" logical state). For a
|
||||||
|
/// metrics gauge that distinction is immaterial; what matters is that reading
|
||||||
|
/// the gauge never steals the single probe `check()` would admit. Fail-open
|
||||||
|
/// (reports `Closed`) on lock poisoning, consistent with `check`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn query_state(&self) -> BreakerState {
|
||||||
|
let Ok(state) = self.state.lock() else {
|
||||||
|
return BreakerState::Closed;
|
||||||
|
};
|
||||||
|
match *state {
|
||||||
|
CircuitState::Closed { .. } => BreakerState::Closed,
|
||||||
|
CircuitState::Open { .. } => BreakerState::Open,
|
||||||
|
CircuitState::HalfOpen => BreakerState::HalfOpen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Record a successful request. Resets the failure count.
|
/// Record a successful request. Resets the failure count.
|
||||||
pub fn record_success(&self) {
|
pub fn record_success(&self) {
|
||||||
let Ok(mut state) = self.state.lock() else {
|
let Ok(mut state) = self.state.lock() else {
|
||||||
|
|||||||
@ -115,6 +115,17 @@ impl PeerPool {
|
|||||||
self.peers_read().get(&shard).map(PeerConnection::handle)
|
self.peers_read().get(&shard).map(PeerConnection::handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The circuit-breaker state for `shard`, for the `tidaldb_cluster_*` breaker
|
||||||
|
/// gauge (m11p8). Read-only — never admits the half-open probe. An unknown
|
||||||
|
/// peer reports `Closed` (no breaker means nothing is tripped).
|
||||||
|
#[must_use]
|
||||||
|
pub fn breaker_state(&self, shard: ShardId) -> crate::circuit_breaker::BreakerState {
|
||||||
|
self.handle_for(shard)
|
||||||
|
.map_or(crate::circuit_breaker::BreakerState::Closed, |p| {
|
||||||
|
p.circuit_breaker.query_state()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Add (or replace) a peer's connection at runtime (m11p5 §3.3 conf-change).
|
/// Add (or replace) a peer's connection at runtime (m11p5 §3.3 conf-change).
|
||||||
/// The channel is LAZY, so this costs no DNS resolution or connect — the
|
/// The channel is LAZY, so this costs no DNS resolution or connect — the
|
||||||
/// first RPC drives the connect, and a DNS name re-resolves on reconnect.
|
/// first RPC drives the connect, and a DNS name re-resolves on reconnect.
|
||||||
@ -424,11 +435,16 @@ impl PeerPool {
|
|||||||
pub async fn heartbeat(
|
pub async fn heartbeat(
|
||||||
&self,
|
&self,
|
||||||
to: ShardId,
|
to: ShardId,
|
||||||
request: crate::proto::HeartbeatRequest,
|
mut request: crate::proto::HeartbeatRequest,
|
||||||
) -> Result<crate::proto::HeartbeatResponse, GrpcTransportError> {
|
) -> Result<crate::proto::HeartbeatResponse, GrpcTransportError> {
|
||||||
let Some(peer) = self.handle_for(to) else {
|
let Some(peer) = self.handle_for(to) else {
|
||||||
return Err(GrpcTransportError::PeerUnreachable(to));
|
return Err(GrpcTransportError::PeerUnreachable(to));
|
||||||
};
|
};
|
||||||
|
// m11p8 rolling-upgrade handshake: stamp this binary's build version on
|
||||||
|
// every heartbeat at the transport boundary (no engine threading — all
|
||||||
|
// workspace crates share one version). The peer observes the cluster's
|
||||||
|
// version spread and warns on a major-version skew.
|
||||||
|
request.build_version = env!("CARGO_PKG_VERSION").to_owned();
|
||||||
let mut client = peer.client.clone();
|
let mut client = peer.client.clone();
|
||||||
client
|
client
|
||||||
.heartbeat(request)
|
.heartbeat(request)
|
||||||
|
|||||||
@ -55,6 +55,46 @@ fn record_peer_capabilities(map: &PeerCapabilityMap, peer: ShardId, capabilities
|
|||||||
.insert(peer, capabilities);
|
.insert(peer, capabilities);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The major component of a `"X.Y.Z"` semver string, if parseable.
|
||||||
|
fn version_major(v: &str) -> Option<u64> {
|
||||||
|
v.split('.').next()?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observe a heartbeat peer's build version for the m11p8 rolling-upgrade
|
||||||
|
/// handshake. Adjacent (N/N+1) versions interoperate by proto3 forward-compat;
|
||||||
|
/// a `>= 2` major-version gap is logged at WARN as an unsupported skew. Never
|
||||||
|
/// rejects — a rolling upgrade is a transient mixed-version window by design, so
|
||||||
|
/// the WARN only fires while such a window is open. An empty version is a
|
||||||
|
/// pre-m11p8 peer (version-unknown): no warning.
|
||||||
|
fn observe_peer_version(peer_region: u32, peer_version: &str) {
|
||||||
|
if peer_version.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ours = env!("CARGO_PKG_VERSION");
|
||||||
|
if peer_version == ours {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match (version_major(peer_version), version_major(ours)) {
|
||||||
|
(Some(theirs), Some(mine)) if theirs.abs_diff(mine) >= 2 => {
|
||||||
|
tracing::warn!(
|
||||||
|
peer_region,
|
||||||
|
peer_version,
|
||||||
|
our_version = ours,
|
||||||
|
"rolling-upgrade version skew exceeds N/N+1 (>= 2 major versions apart); \
|
||||||
|
only adjacent versions are supported to interoperate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::debug!(
|
||||||
|
peer_region,
|
||||||
|
peer_version,
|
||||||
|
our_version = ours,
|
||||||
|
"peer on a different build version (within the supported N/N+1 skew)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The gRPC trailer key carrying the typed catch-up refusal class (m11p5 §2.4).
|
/// The gRPC trailer key carrying the typed catch-up refusal class (m11p5 §2.4).
|
||||||
/// Values: `snapshot-required` | `rejoin` | `stepping-down`. A pre-m11p5 source
|
/// Values: `snapshot-required` | `rejoin` | `stepping-down`. A pre-m11p5 source
|
||||||
/// emits no trailer; the puller treats an absent/unknown value conservatively
|
/// emits no trailer; the puller treats an absent/unknown value conservatively
|
||||||
@ -659,6 +699,13 @@ impl WalShipping for WalShippingService {
|
|||||||
"received heartbeat",
|
"received heartbeat",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// m11p8 rolling-upgrade handshake: observe the peer's build version.
|
||||||
|
// N/N+1 (adjacent major) interoperate by proto3 forward-compat; a
|
||||||
|
// >= 2-major gap is the loud signal that the skew exceeds what is
|
||||||
|
// supported. Never a rejection — a rolling upgrade is a transient mixed
|
||||||
|
// window by design (the warn only fires during such a window).
|
||||||
|
observe_peer_version(req.region_id, &req.build_version);
|
||||||
|
|
||||||
if let Some(hooks) = self.sources.election.get() {
|
if let Some(hooks) = self.sources.election.get() {
|
||||||
let leader_region = u16::try_from(req.leader_region)
|
let leader_region = u16::try_from(req.leader_region)
|
||||||
.map_err(|_| Status::invalid_argument("leader_region exceeds u16 range"))?;
|
.map_err(|_| Status::invalid_argument("leader_region exceeds u16 range"))?;
|
||||||
|
|||||||
@ -1106,6 +1106,12 @@ impl Transport for GrpcTransport {
|
|||||||
rx.try_recv().ok()
|
rx.try_recv().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn peer_breaker_state(&self, peer: ShardId) -> u8 {
|
||||||
|
// Read-only breaker peek for the self-heal gauge (m11p8) — never admits
|
||||||
|
// the half-open probe.
|
||||||
|
self.pool.breaker_state(peer).as_gauge()
|
||||||
|
}
|
||||||
|
|
||||||
fn local_shard(&self) -> ShardId {
|
fn local_shard(&self) -> ShardId {
|
||||||
self.config.local_shard
|
self.config.local_shard
|
||||||
}
|
}
|
||||||
|
|||||||
@ -777,6 +777,13 @@ pub fn start(
|
|||||||
// append + commit wait BLOCK; one at a time via the inflight
|
// append + commit wait BLOCK; one at a time via the inflight
|
||||||
// guard.
|
// guard.
|
||||||
node.submit_auto_promote();
|
node.submit_auto_promote();
|
||||||
|
// m11p8 self-driving heal: a STANDING LEADER DUTY at a coarse
|
||||||
|
// ~3s cadence (throttled inside) — refresh the per-peer breaker
|
||||||
|
// gauge and re-arm the backlog re-ship for any stuck, behind
|
||||||
|
// peer so it converges through breaker resets WITHOUT the
|
||||||
|
// operator re-issuing /cluster/heal (incident §1.4-3). Non-
|
||||||
|
// blocking, so it runs inline on the tick (no detached thread).
|
||||||
|
node.tick_self_heal();
|
||||||
// m11p5 §3.3 removal-delivery grace: retire a removed peer's
|
// m11p5 §3.3 removal-delivery grace: retire a removed peer's
|
||||||
// ship cell only once it has learned of its removal through
|
// ship cell only once it has learned of its removal through
|
||||||
// the log (its applied mark covers the Removed record) or the
|
// the log (its applied mark covers the Removed record) or the
|
||||||
|
|||||||
@ -78,6 +78,13 @@ pub const INTERNAL_MARKER_VALUE: &str = "1";
|
|||||||
/// leader honors the CALLER's choice, not the gateway's default.
|
/// leader honors the CALLER's choice, not the gateway's default.
|
||||||
pub const ACK_HEADER: &str = "x-tidal-ack";
|
pub const ACK_HEADER: &str = "x-tidal-ack";
|
||||||
|
|
||||||
|
/// Request-id header (m11p8). Propagated verbatim across the forward hop so the
|
||||||
|
/// leader's per-request span shares the originating gateway's `x-request-id`,
|
||||||
|
/// and the gateway's `PropagateRequestIdLayer` echoes it back to the client. The
|
||||||
|
/// leader's `SetRequestIdLayer` is a no-op when this header is already present,
|
||||||
|
/// so the id survives the hop unchanged.
|
||||||
|
pub const REQUEST_ID_HEADER: &str = "x-request-id";
|
||||||
|
|
||||||
/// Response header carrying a cluster write's assigned replicated-log seqno
|
/// Response header carrying a cluster write's assigned replicated-log seqno
|
||||||
/// (m11p3). Relayed verbatim on forwarded writes.
|
/// (m11p3). Relayed verbatim on forwarded writes.
|
||||||
pub const SEQ_HEADER: &str = "x-tidal-seq";
|
pub const SEQ_HEADER: &str = "x-tidal-seq";
|
||||||
@ -216,11 +223,15 @@ pub fn relay_forwarded(resp: ForwardedResponse) -> Response {
|
|||||||
/// ack mode, not the gateway's default. Empty when absent.
|
/// ack mode, not the gateway's default. Empty when absent.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn ack_passthrough(headers: &HeaderMap) -> Vec<(&'static str, String)> {
|
pub fn ack_passthrough(headers: &HeaderMap) -> Vec<(&'static str, String)> {
|
||||||
headers
|
let mut out = Vec::new();
|
||||||
.get(ACK_HEADER)
|
if let Some(v) = headers.get(ACK_HEADER).and_then(|v| v.to_str().ok()) {
|
||||||
.and_then(|v| v.to_str().ok())
|
out.push((ACK_HEADER, v.to_owned()));
|
||||||
.map(|v| vec![(ACK_HEADER, v.to_owned())])
|
}
|
||||||
.unwrap_or_default()
|
// m11p8: carry the request-id across the forward hop for cross-node tracing.
|
||||||
|
if let Some(v) = headers.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()) {
|
||||||
|
out.push((REQUEST_ID_HEADER, v.to_owned()));
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forward a JSON request to one peer and relay its status + body back.
|
/// Forward a JSON request to one peer and relay its status + body back.
|
||||||
|
|||||||
@ -765,10 +765,11 @@ fn clone_replication(r: &ReplicationSpec) -> ReplicationSpec {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn clone_wal(w: &WalSpec) -> WalSpec {
|
fn clone_wal(w: &WalSpec) -> WalSpec {
|
||||||
WalSpec {
|
WalSpec {
|
||||||
batch_size: w.batch_size,
|
batch_size: w.batch_size,
|
||||||
batch_timeout_ms: w.batch_timeout_ms,
|
batch_timeout_ms: w.batch_timeout_ms,
|
||||||
|
archive_dir: w.archive_dir.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -179,6 +179,11 @@ const COMMIT_BRIDGE_WAKE_INTERVAL: Duration = Duration::from_secs(1);
|
|||||||
/// for tests via `TIDAL_REMOVE_DELIVERY_GRACE_MS`.
|
/// for tests via `TIDAL_REMOVE_DELIVERY_GRACE_MS`.
|
||||||
const REMOVE_DELIVERY_GRACE_DEFAULT_MS: u64 = 30_000;
|
const REMOVE_DELIVERY_GRACE_DEFAULT_MS: u64 = 30_000;
|
||||||
|
|
||||||
|
/// Election ticks (50 ms each) between self-driving heal passes (m11p8). ~3 s —
|
||||||
|
/// frequent enough to re-arm a stuck peer's backlog re-ship well within a
|
||||||
|
/// circuit-breaker reset window (30 s) without churning cursors every tick.
|
||||||
|
const SELF_HEAL_TICKS: u64 = 60;
|
||||||
|
|
||||||
/// The election driver's boot bundle: prepared in [`ShardReplica::new`]
|
/// The election driver's boot bundle: prepared in [`ShardReplica::new`]
|
||||||
/// (where the durable classification runs), consumed by
|
/// (where the durable classification runs), consumed by
|
||||||
/// [`ShardReplica::start_election_driver`] once the node is in its
|
/// [`ShardReplica::start_election_driver`] once the node is in its
|
||||||
@ -365,6 +370,14 @@ pub struct ShardReplica {
|
|||||||
/// flag is set before spawning the duty and cleared when it finishes, so a
|
/// flag is set before spawning the duty and cleared when it finishes, so a
|
||||||
/// fast-ticking leader never piles up promotion threads while one is in flight.
|
/// fast-ticking leader never piles up promotion threads while one is in flight.
|
||||||
promote_inflight: AtomicBool,
|
promote_inflight: AtomicBool,
|
||||||
|
/// Self-driving heal (m11p8): coarse-cadence counter so the heal reconcile
|
||||||
|
/// pass runs roughly every `SELF_HEAL_TICKS` election ticks, not every 50ms.
|
||||||
|
heal_tick: AtomicU64,
|
||||||
|
/// Self-driving heal (m11p8): the set of peers this leader drove last pass
|
||||||
|
/// (breaker not closed AND trailing past the convergence threshold). Used to
|
||||||
|
/// count convergence transitions for `tidaldb_cluster_heal_successes_total`
|
||||||
|
/// and to clear the healing gauge on demotion.
|
||||||
|
healing: std::sync::Mutex<std::collections::HashSet<ShardId>>,
|
||||||
/// The durable membership cache (`data_dir/membership`, m11p5 §3.6). The
|
/// The durable membership cache (`data_dir/membership`, m11p5 §3.6). The
|
||||||
/// WAL-recovered `ClusterMembership` cell is the AUTHORITATIVE roster (it wins
|
/// WAL-recovered `ClusterMembership` cell is the AUTHORITATIVE roster (it wins
|
||||||
/// at open — the view is built from it); this cache exists only for the
|
/// at open — the view is built from it); this cache exists only for the
|
||||||
@ -998,6 +1011,8 @@ impl ShardReplica {
|
|||||||
membership,
|
membership,
|
||||||
join_hooks_cell,
|
join_hooks_cell,
|
||||||
promote_inflight: AtomicBool::new(false),
|
promote_inflight: AtomicBool::new(false),
|
||||||
|
heal_tick: AtomicU64::new(0),
|
||||||
|
healing: std::sync::Mutex::new(std::collections::HashSet::new()),
|
||||||
membership_store: tidaldb::replication::MembershipStore::new(&data_dir_for_state),
|
membership_store: tidaldb::replication::MembershipStore::new(&data_dir_for_state),
|
||||||
decommissioned_by_signal: AtomicBool::new(false),
|
decommissioned_by_signal: AtomicBool::new(false),
|
||||||
deferred_retires: std::sync::Mutex::new(Vec::new()),
|
deferred_retires: std::sync::Mutex::new(Vec::new()),
|
||||||
@ -2211,6 +2226,90 @@ impl ShardReplica {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Self-driving heal (m11p8 §4): a STANDING LEADER DUTY re-armed every tick
|
||||||
|
/// at a coarse cadence (~`SELF_HEAL_TICKS` × the 50 ms election tick). It
|
||||||
|
/// closes incident §1.4-3 — "the breaker eats the first heal; re-issue until
|
||||||
|
/// lag 0" — by driving convergence itself instead of the operator.
|
||||||
|
///
|
||||||
|
/// Each pass refreshes the per-peer breaker gauge, then for every peer that
|
||||||
|
/// is (a) NOT operator-partitioned, (b) has a non-closed ship breaker
|
||||||
|
/// (replication impaired), and (c) trails the leader's flushed frontier by
|
||||||
|
/// more than the convergence threshold, re-arms the backlog re-ship from the
|
||||||
|
/// peer's durable mark. The moment the breaker half-opens, the leader pushes
|
||||||
|
/// the WHOLE gap — no operator verb. A closed-breaker peer that is merely a
|
||||||
|
/// little behind self-heals through the normal ship path and is left alone
|
||||||
|
/// (no cursor churn). NON-BLOCKING (cursor updates + atomic gauge stores), so
|
||||||
|
/// it runs inline on the election tick rather than a detached thread.
|
||||||
|
///
|
||||||
|
/// Operator-partitioned peers are intentionally ship-skipped (maintenance);
|
||||||
|
/// self-heal never auto-undoes a `/cluster/partition`. The manual
|
||||||
|
/// `/cluster/heal` verb still exists as an immediate nudge, but is no longer
|
||||||
|
/// required for convergence.
|
||||||
|
pub(crate) fn tick_self_heal(&self) {
|
||||||
|
// Coarse cadence: act ~every SELF_HEAL_TICKS election ticks.
|
||||||
|
if self.heal_tick.fetch_add(1, Ordering::Relaxed) % SELF_HEAL_TICKS != 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !self.is_leader() {
|
||||||
|
// Off the leader there is no ship-driving duty: clear the healing
|
||||||
|
// gauge + tracked set so a demoted node stops reporting stale state.
|
||||||
|
self.cluster_metrics.set_healing_peers(0);
|
||||||
|
self.healing
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let flushed = self.ship_feed.flushed_seq();
|
||||||
|
let partitioned = read_recovered(&self.partitioned, "partitioned").clone();
|
||||||
|
let marks: std::collections::HashMap<ShardId, u64> =
|
||||||
|
self.commit.peer_marks().into_iter().collect();
|
||||||
|
|
||||||
|
let mut behind_now: std::collections::HashSet<ShardId> = std::collections::HashSet::new();
|
||||||
|
for shard in self.ship_queue.peers() {
|
||||||
|
// Refresh the breaker gauge for EVERY configured peer (including
|
||||||
|
// partitioned ones) so the dashboard is truthful.
|
||||||
|
let bstate = self.ship_queue.peer_breaker_state(shard);
|
||||||
|
self.cluster_metrics.set_peer_breaker_state(shard, bstate);
|
||||||
|
|
||||||
|
// In-group shard id == region id, so the partition (ship-skip) set,
|
||||||
|
// keyed by region, is checked with the shard's numeric id.
|
||||||
|
if partitioned.contains(&RegionId(shard.0)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mark = marks.get(&shard).copied().unwrap_or(0);
|
||||||
|
// Stuck = breaker not closed AND trailing past the convergence
|
||||||
|
// threshold. A closed breaker means the normal ship path is flowing.
|
||||||
|
if bstate != 0 && flushed.saturating_sub(mark) > self.learner_promote_lag {
|
||||||
|
behind_now.insert(shard);
|
||||||
|
self.cluster_metrics.incr_heal_attempts();
|
||||||
|
self.ship_queue.resume_from(shard, mark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convergence transitions: peers driven last pass that are no longer
|
||||||
|
// behind recovered — count them and update the tracked set.
|
||||||
|
let mut prev = self
|
||||||
|
.healing
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
for s in prev.iter() {
|
||||||
|
if !behind_now.contains(s) {
|
||||||
|
self.cluster_metrics.incr_heal_successes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let healing_count = behind_now.len() as u64;
|
||||||
|
*prev = behind_now;
|
||||||
|
drop(prev);
|
||||||
|
|
||||||
|
if healing_count == 0 {
|
||||||
|
// Liveness heartbeat: the loop ran and found everything converged.
|
||||||
|
self.cluster_metrics.incr_heal_noops();
|
||||||
|
}
|
||||||
|
self.cluster_metrics.set_healing_peers(healing_count);
|
||||||
|
}
|
||||||
|
|
||||||
/// The shared cluster metrics cell (election gauges live here too).
|
/// The shared cluster metrics cell (election gauges live here too).
|
||||||
pub(crate) const fn cluster_metrics(
|
pub(crate) const fn cluster_metrics(
|
||||||
&self,
|
&self,
|
||||||
@ -2794,10 +2893,21 @@ impl ShardReplica {
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
let applied_events = db
|
// The leader does NOT apply its own stream through the receiver — it
|
||||||
.replication_state()
|
// writes its WAL directly — so `applied_seqno(own_shard)` never advances
|
||||||
.applied_seqno(leader_shard)
|
// on a leader and would read a stale follower-era value (0 on a node
|
||||||
.unwrap_or(0);
|
// elected without first following). The leader's true applied frontier
|
||||||
|
// IS its durable flushed WAL frontier (it is, by construction, caught up
|
||||||
|
// to its own log). Followers read how far they have applied the CURRENT
|
||||||
|
// leader's per-source-shard stream (BUG 1: keyed by the live leader's
|
||||||
|
// shard, never a stale single scalar — see below).
|
||||||
|
let applied_events = if is_leader {
|
||||||
|
last_seq
|
||||||
|
} else {
|
||||||
|
db.replication_state()
|
||||||
|
.applied_seqno(leader_shard)
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
// lag = the CURRENT leader's per-source-shard high-water-mark − applied
|
// lag = the CURRENT leader's per-source-shard high-water-mark − applied
|
||||||
// for that same shard (BUG 1). The gauge tracks the leader HWM PER SOURCE
|
// for that same shard (BUG 1). The gauge tracks the leader HWM PER SOURCE
|
||||||
// SHARD, so a `/cluster/promote` that moves leadership to a different shard
|
// SHARD, so a `/cluster/promote` that moves leadership to a different shard
|
||||||
@ -2880,6 +2990,7 @@ impl ShardReplica {
|
|||||||
membership_version: self.membership_version(),
|
membership_version: self.membership_version(),
|
||||||
membership_term: self.membership.term(),
|
membership_term: self.membership.term(),
|
||||||
membership_role: self.role_in_roster().to_string(),
|
membership_role: self.role_in_roster().to_string(),
|
||||||
|
version: node_build_version(),
|
||||||
// Populated by `ClusterNode::status_local` (it owns the group set);
|
// Populated by `ClusterNode::status_local` (it owns the group set);
|
||||||
// a bare per-replica status carries only its own row implicitly.
|
// a bare per-replica status carries only its own row implicitly.
|
||||||
shards: Vec::new(),
|
shards: Vec::new(),
|
||||||
@ -2887,6 +2998,13 @@ impl ShardReplica {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This node's build version + hash for status/observability (m11p8):
|
||||||
|
/// `"<cargo-version>+<build-hash>"` (e.g. `"0.1.0+dev"`). All workspace crates
|
||||||
|
/// share the Cargo version, so the server version is the binary version.
|
||||||
|
fn node_build_version() -> String {
|
||||||
|
format!("{}+{}", env!("CARGO_PKG_VERSION"), tidaldb::BUILD_HASH)
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for ShardReplica {
|
impl Drop for ShardReplica {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.shutdown();
|
self.shutdown();
|
||||||
@ -3041,6 +3159,12 @@ fn open_region_db(
|
|||||||
if let Some(timeout_ms) = topology.wal.batch_timeout_ms {
|
if let Some(timeout_ms) = topology.wal.batch_timeout_ms {
|
||||||
builder = builder.wal_batch_timeout(Duration::from_millis(timeout_ms));
|
builder = builder.wal_batch_timeout(Duration::from_millis(timeout_ms));
|
||||||
}
|
}
|
||||||
|
// m11p8 PITR: archive sealed WAL segments before compaction deletes them.
|
||||||
|
// Segment filenames encode the shard id, so co-located groups share one
|
||||||
|
// archive dir without collision.
|
||||||
|
if let Some(ref archive_dir) = topology.wal.archive_dir {
|
||||||
|
builder = builder.wal_archive_dir(archive_dir);
|
||||||
|
}
|
||||||
// m11p6: at most ONE hosted shard per node binds the engine's `/metrics`
|
// m11p6: at most ONE hosted shard per node binds the engine's `/metrics`
|
||||||
// server (N TidalDb instances would otherwise fight for one `metrics_addr`).
|
// server (N TidalDb instances would otherwise fight for one `metrics_addr`).
|
||||||
// ClusterNode designates the metrics-owning shard; the others open without.
|
// ClusterNode designates the metrics-owning shard; the others open without.
|
||||||
@ -3373,6 +3497,10 @@ pub struct ClusterNode {
|
|||||||
tls_files: Option<tidal_net::config::TlsConfig>,
|
tls_files: Option<tidal_net::config::TlsConfig>,
|
||||||
/// m11p7 admin-verb audit sink (tracing + optional JSONL file).
|
/// m11p7 admin-verb audit sink (tracing + optional JSONL file).
|
||||||
audit: crate::cluster::audit::AuditSink,
|
audit: crate::cluster::audit::AuditSink,
|
||||||
|
/// m11p8: the metrics-owner shard's cluster-metrics handle — the cell this
|
||||||
|
/// node's `/metrics` listener renders. Node-level gateway events (cross-shard
|
||||||
|
/// write forwards) increment it so they surface on the single per-node scrape.
|
||||||
|
cluster_metrics: Arc<tidaldb::db::metrics::cluster::ClusterMetrics>,
|
||||||
/// Flipped on shutdown so `/health` reports not-ready while draining.
|
/// Flipped on shutdown so `/health` reports not-ready while draining.
|
||||||
shutting_down: AtomicBool,
|
shutting_down: AtomicBool,
|
||||||
}
|
}
|
||||||
@ -3427,7 +3555,7 @@ impl ClusterNode {
|
|||||||
let creds = Arc::new(crate::cluster::security::ClusterCreds::from_env());
|
let creds = Arc::new(crate::cluster::security::ClusterCreds::from_env());
|
||||||
|
|
||||||
let mut groups: BTreeMap<ShardId, Arc<ShardReplica>> = BTreeMap::new();
|
let mut groups: BTreeMap<ShardId, Arc<ShardReplica>> = BTreeMap::new();
|
||||||
let mut metrics_owner_assigned = false;
|
let mut metrics_owner: Option<ShardId> = None;
|
||||||
for group in &resolved {
|
for group in &resolved {
|
||||||
if !group.replicas.iter().any(|r| r.region == region) {
|
if !group.replicas.iter().any(|r| r.region == region) {
|
||||||
continue; // this node does not host this group
|
continue; // this node does not host this group
|
||||||
@ -3445,7 +3573,10 @@ impl ClusterNode {
|
|||||||
};
|
};
|
||||||
// At most ONE hosted group binds the engine's `/metrics` server
|
// At most ONE hosted group binds the engine's `/metrics` server
|
||||||
// (N TidalDb instances would otherwise fight for one `metrics_addr`).
|
// (N TidalDb instances would otherwise fight for one `metrics_addr`).
|
||||||
let enable_metrics = !metrics_owner_assigned;
|
// The other co-located groups register their cluster series with the
|
||||||
|
// owner below (m11p8) so the single per-node listener still exposes
|
||||||
|
// every group's replication metrics, `shard`-labeled.
|
||||||
|
let enable_metrics = metrics_owner.is_none();
|
||||||
let replica = ShardReplica::new(
|
let replica = ShardReplica::new(
|
||||||
topology,
|
topology,
|
||||||
region_name,
|
region_name,
|
||||||
@ -3458,7 +3589,7 @@ impl ClusterNode {
|
|||||||
Arc::clone(&creds),
|
Arc::clone(&creds),
|
||||||
)?;
|
)?;
|
||||||
if enable_metrics {
|
if enable_metrics {
|
||||||
metrics_owner_assigned = true;
|
metrics_owner = Some(group.shard);
|
||||||
}
|
}
|
||||||
groups.insert(group.shard, Arc::new(replica));
|
groups.insert(group.shard, Arc::new(replica));
|
||||||
}
|
}
|
||||||
@ -3467,6 +3598,24 @@ impl ClusterNode {
|
|||||||
"region '{region_name}' is not a replica of any shard group"
|
"region '{region_name}' is not a replica of any shard group"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
// m11p8: when multiple shard groups co-locate on this node, the metrics
|
||||||
|
// owner exposes the siblings' `tidaldb_cluster_*` series under their own
|
||||||
|
// `shard="N"` label so one `/metrics` scrape covers every hosted group.
|
||||||
|
// No-op on the S=1 topology (a single hosted group is the owner).
|
||||||
|
if groups.len() > 1
|
||||||
|
&& let Some(owner_shard) = metrics_owner
|
||||||
|
&& let Some(owner_replica) = groups.get(&owner_shard)
|
||||||
|
&& let Ok(owner_db) = owner_replica.db()
|
||||||
|
{
|
||||||
|
for (shard, replica) in &groups {
|
||||||
|
if *shard == owner_shard {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(sib_db) = replica.db() {
|
||||||
|
owner_db.register_metrics_sibling(shard.0, sib_db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let router = if single {
|
let router = if single {
|
||||||
ShardRouter::single()
|
ShardRouter::single()
|
||||||
@ -3523,9 +3672,19 @@ impl ClusterNode {
|
|||||||
hosted_groups = groups.len(),
|
hosted_groups = groups.len(),
|
||||||
total_groups = placement.len(),
|
total_groups = placement.len(),
|
||||||
tls = tls_files.is_some(),
|
tls = tls_files.is_some(),
|
||||||
|
version = %node_build_version(),
|
||||||
"cluster node started (m11p6: one replica per hosted shard group)"
|
"cluster node started (m11p6: one replica per hosted shard group)"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// m11p8: the metrics-owner's cluster-metrics handle (the cell `/metrics`
|
||||||
|
// renders) for node-level gateway events. Falls back to the lowest-shard
|
||||||
|
// group if no owner was flagged; `groups` is non-empty (checked above).
|
||||||
|
let cluster_metrics = metrics_owner
|
||||||
|
.and_then(|s| groups.get(&s))
|
||||||
|
.or_else(|| groups.values().next())
|
||||||
|
.map(|r| Arc::clone(&r.cluster_metrics))
|
||||||
|
.expect("groups is non-empty");
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
placement,
|
placement,
|
||||||
groups,
|
groups,
|
||||||
@ -3535,6 +3694,7 @@ impl ClusterNode {
|
|||||||
creds,
|
creds,
|
||||||
tls_files,
|
tls_files,
|
||||||
audit: crate::cluster::audit::AuditSink::from_env(),
|
audit: crate::cluster::audit::AuditSink::from_env(),
|
||||||
|
cluster_metrics,
|
||||||
shutting_down: AtomicBool::new(false),
|
shutting_down: AtomicBool::new(false),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -3741,6 +3901,7 @@ impl ClusterNode {
|
|||||||
}
|
}
|
||||||
let auth = forwarded_auth(headers);
|
let auth = forwarded_auth(headers);
|
||||||
let passthrough = forward::ack_passthrough(headers);
|
let passthrough = forward::ack_passthrough(headers);
|
||||||
|
self.cluster_metrics.incr_forwards();
|
||||||
let mut last_err = String::new();
|
let mut last_err = String::new();
|
||||||
for http in candidates {
|
for http in candidates {
|
||||||
let url = peer_url(http, path);
|
let url = peer_url(http, path);
|
||||||
@ -3762,6 +3923,9 @@ impl ClusterNode {
|
|||||||
Err(e) => last_err = format!("{url}: {e}"),
|
Err(e) => last_err = format!("{url}: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Every candidate replica was unreachable — the forward could not be
|
||||||
|
// delivered to the group at all (m11p8 forward-failure signal).
|
||||||
|
self.cluster_metrics.incr_forward_failures();
|
||||||
Err(ClusterAppError(ServerError::Unavailable(format!(
|
Err(ClusterAppError(ServerError::Unavailable(format!(
|
||||||
"shard {} unreachable: all {} replica candidates failed (last {last_err})",
|
"shard {} unreachable: all {} replica candidates failed (last {last_err})",
|
||||||
shard.0,
|
shard.0,
|
||||||
@ -3910,7 +4074,10 @@ pub fn build_region_router(
|
|||||||
.layer(ConcurrencyLimitLayer::new(crate::router::MAX_CONCURRENCY)),
|
.layer(ConcurrencyLimitLayer::new(crate::router::MAX_CONCURRENCY)),
|
||||||
);
|
);
|
||||||
|
|
||||||
public.merge(protected)
|
// m11p8: assign/echo `x-request-id` and open a per-request span on the
|
||||||
|
// multi-process region router too. Forwarded writes carry the originating
|
||||||
|
// gateway's id across the leader hop (see `cluster::forward`).
|
||||||
|
crate::router::with_request_id_tracing(public.merge(protected))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Health ──────────────────────────────────────────────────────────────────
|
// ── Health ──────────────────────────────────────────────────────────────────
|
||||||
@ -4050,6 +4217,15 @@ pub struct LocalStatusResponse {
|
|||||||
/// or `removed`. Distinct from `role` (the election role); a learner is a
|
/// or `removed`. Distinct from `role` (the election role); a learner is a
|
||||||
/// `follower` here.
|
/// `follower` here.
|
||||||
membership_role: String,
|
membership_role: String,
|
||||||
|
/// This node's build version + hash (m11p8 rolling-upgrade visibility):
|
||||||
|
/// `"<cargo-version>+<build-hash>"`. The aggregating gateway's status
|
||||||
|
/// fan-out collects every node's version, so an operator can confirm the
|
||||||
|
/// cluster is within the supported N/N+1 skew before/during a rolling
|
||||||
|
/// upgrade without shelling into each pod. `#[serde(default)]` so the
|
||||||
|
/// gateway can still deserialize a pre-m11p8 peer's status (empty version)
|
||||||
|
/// during a mixed-version upgrade window — the exact compat this phase needs.
|
||||||
|
#[serde(default)]
|
||||||
|
version: String,
|
||||||
/// Per-shard-group status for every group THIS node hosts (m11p6). For the
|
/// Per-shard-group status for every group THIS node hosts (m11p6). For the
|
||||||
/// legacy single group this is one row mirroring the flat fields above; with
|
/// legacy single group this is one row mirroring the flat fields above; with
|
||||||
/// sharding it carries one row per hosted group, so an operator (and the
|
/// sharding it carries one row per hosted group, so an operator (and the
|
||||||
@ -4148,6 +4324,12 @@ pub struct AggregatedRegionStatus {
|
|||||||
/// the per-peer budget. An unreachable region reports `applied 0`, `lag =
|
/// the per-peer budget. An unreachable region reports `applied 0`, `lag =
|
||||||
/// leader_last_seq`, `partitioned: true`.
|
/// leader_last_seq`, `partitioned: true`.
|
||||||
reachable: bool,
|
reachable: bool,
|
||||||
|
/// The region's reported build version (m11p8): `/cluster/status` is the
|
||||||
|
/// single pane an operator reads to confirm the whole cluster is within the
|
||||||
|
/// supported N/N+1 skew before a rolling upgrade. Empty for an unreachable
|
||||||
|
/// region or a pre-m11p8 peer.
|
||||||
|
#[serde(default)]
|
||||||
|
version: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aggregated replication status across EVERY region of the default shard group.
|
/// Aggregated replication status across EVERY region of the default shard group.
|
||||||
@ -4256,6 +4438,11 @@ pub async fn cluster_status(
|
|||||||
// local status carries that set; mirror it.
|
// local status carries that set; mirror it.
|
||||||
a.iter().any(|v| v.as_str() == Some(name.as_str()))
|
a.iter().any(|v| v.as_str() == Some(name.as_str()))
|
||||||
});
|
});
|
||||||
|
let version = j
|
||||||
|
.get("version")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_owned();
|
||||||
AggregatedRegionStatus {
|
AggregatedRegionStatus {
|
||||||
name,
|
name,
|
||||||
applied_events: applied,
|
applied_events: applied,
|
||||||
@ -4266,6 +4453,7 @@ pub async fn cluster_status(
|
|||||||
// the leader-set merge fix it.
|
// the leader-set merge fix it.
|
||||||
partitioned,
|
partitioned,
|
||||||
reachable: true,
|
reachable: true,
|
||||||
|
version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => AggregatedRegionStatus {
|
None => AggregatedRegionStatus {
|
||||||
@ -4274,6 +4462,7 @@ pub async fn cluster_status(
|
|||||||
lag_events: leader_last_seq,
|
lag_events: leader_last_seq,
|
||||||
partitioned: true,
|
partitioned: true,
|
||||||
reachable: false,
|
reachable: false,
|
||||||
|
version: String::new(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@ -5549,6 +5738,7 @@ async fn forward_write<B: serde::Serialize + Sync + ?Sized>(
|
|||||||
// sibling (this forward sets the internal marker).
|
// sibling (this forward sets the internal marker).
|
||||||
let mut passthrough = forward::ack_passthrough(headers);
|
let mut passthrough = forward::ack_passthrough(headers);
|
||||||
passthrough.extend(state.node_token_passthrough());
|
passthrough.extend(state.node_token_passthrough());
|
||||||
|
state.cluster_metrics.incr_forwards();
|
||||||
match forward_json_with_headers(
|
match forward_json_with_headers(
|
||||||
&state.client,
|
&state.client,
|
||||||
&url,
|
&url,
|
||||||
@ -5566,6 +5756,7 @@ async fn forward_write<B: serde::Serialize + Sync + ?Sized>(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Leader unreachable: the typed 503 names the leader, its address,
|
// Leader unreachable: the typed 503 names the leader, its address,
|
||||||
// and the connect error (single body shape via ClusterAppError).
|
// and the connect error (single body shape via ClusterAppError).
|
||||||
|
state.cluster_metrics.incr_forward_failures();
|
||||||
let leader = state.leader_name();
|
let leader = state.leader_name();
|
||||||
tracing::warn!(%leader, %url, error = %e, "forward to leader failed; leader unreachable");
|
tracing::warn!(%leader, %url, error = %e, "forward to leader failed; leader unreachable");
|
||||||
Err(ClusterAppError(ServerError::LeaderUnreachable {
|
Err(ClusterAppError(ServerError::LeaderUnreachable {
|
||||||
|
|||||||
@ -119,7 +119,11 @@ pub fn build_cluster_router(
|
|||||||
.layer(ConcurrencyLimitLayer::new(crate::router::MAX_CONCURRENCY)),
|
.layer(ConcurrencyLimitLayer::new(crate::router::MAX_CONCURRENCY)),
|
||||||
);
|
);
|
||||||
|
|
||||||
public.merge(protected)
|
// m11p8: the same request-id + tracing stack as the standalone router —
|
||||||
|
// assigns/echoes `x-request-id` and opens a per-request span. The cluster
|
||||||
|
// routers previously skipped it; with it, a write forwarded to the leader
|
||||||
|
// carries the originating gateway's id (see `cluster::forward`).
|
||||||
|
crate::router::with_request_id_tracing(public.merge(protected))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Health ──────────────────────────────────────────────────────────────────
|
// ── Health ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -263,6 +263,13 @@ pub struct WalSpec {
|
|||||||
/// Max milliseconds a partial batch waits before flushing.
|
/// Max milliseconds a partial batch waits before flushing.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub batch_timeout_ms: Option<u64>,
|
pub batch_timeout_ms: Option<u64>,
|
||||||
|
/// Optional WAL archive directory for point-in-time recovery (m11p8). When
|
||||||
|
/// set, each hosted shard group copies its sealed segments here before
|
||||||
|
/// compaction deletes them (segment filenames encode the shard id, so
|
||||||
|
/// co-located groups can share one archive dir without collision). Put it on
|
||||||
|
/// durable storage separate from the live data dir.
|
||||||
|
#[serde(default)]
|
||||||
|
pub archive_dir: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Operation-timeout overrides (the optional `timeouts:` YAML block).
|
/// Operation-timeout overrides (the optional `timeouts:` YAML block).
|
||||||
|
|||||||
@ -154,7 +154,21 @@ pub fn build_router(
|
|||||||
|
|
||||||
// SetRequestId must be outermost so the ID is in headers when TraceLayer
|
// SetRequestId must be outermost so the ID is in headers when TraceLayer
|
||||||
// creates its span. In ServiceBuilder the first .layer() is outermost.
|
// creates its span. In ServiceBuilder the first .layer() is outermost.
|
||||||
public.merge(protected).layer(
|
with_request_id_tracing(public.merge(protected))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap `router` with the shared request-id + tracing layer stack (m11p8):
|
||||||
|
/// `SetRequestIdLayer` (outermost) assigns a sequential `x-request-id` when one
|
||||||
|
/// is absent, `PropagateRequestIdLayer` echoes it into the response, and
|
||||||
|
/// `TraceLayer` opens a per-request span carrying the id. Applied to the
|
||||||
|
/// standalone router AND both cluster routers (single-process and multi-process)
|
||||||
|
/// so every HTTP surface correlates by `x-request-id`.
|
||||||
|
///
|
||||||
|
/// Because `SetRequestId` is a no-op when the header is already present, an
|
||||||
|
/// `x-request-id` forwarded from a gateway (see `cluster::forward`) survives the
|
||||||
|
/// hop: the leader's span shares the originating gateway's id.
|
||||||
|
pub(crate) fn with_request_id_tracing(router: Router) -> Router {
|
||||||
|
router.layer(
|
||||||
ServiceBuilder::new()
|
ServiceBuilder::new()
|
||||||
.layer(SetRequestIdLayer::x_request_id(
|
.layer(SetRequestIdLayer::x_request_id(
|
||||||
SequentialRequestId::default(),
|
SequentialRequestId::default(),
|
||||||
|
|||||||
@ -93,10 +93,96 @@ fn bench_score_200_full_pipeline(c: &mut Criterion) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── T2: signal-value pre-pass (one `entries.get()` per signal type) ──────────
|
||||||
|
//
|
||||||
|
// `for_you` reads `view` twice per candidate at *different* aggregations
|
||||||
|
// (`Sort::Hot` reads `view` Value; a boost reads `view` DecayScore), so the
|
||||||
|
// pre-pass collapses two `DashMap` gets to one. The win is a shard-lock saved,
|
||||||
|
// so it is small single-threaded and grows under concurrent signal-write
|
||||||
|
// contention — hence the `_under_writes` variants. `with_signal_plan(false)`
|
||||||
|
// scores the same inputs through the direct per-term path for a true A/B.
|
||||||
|
|
||||||
|
fn for_you_profile() -> tidaldb::ranking::profile::RankingProfile {
|
||||||
|
let mut registry = ProfileRegistry::new();
|
||||||
|
register_builtins(&mut registry).unwrap();
|
||||||
|
registry.get("for_you").unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_for_you_plan(c: &mut Criterion) {
|
||||||
|
let ledger = make_ledger_with_200_items();
|
||||||
|
let profile = for_you_profile();
|
||||||
|
let candidates: Vec<EntityId> = (1..=200).map(EntityId::new).collect();
|
||||||
|
let now = Timestamp::from_nanos(1_708_000_000_000_000_000u64);
|
||||||
|
|
||||||
|
for plan in [true, false] {
|
||||||
|
let executor = ProfileExecutor::new(&ledger).with_signal_plan(plan);
|
||||||
|
let id = if plan {
|
||||||
|
"for_you_plan"
|
||||||
|
} else {
|
||||||
|
"for_you_no_plan"
|
||||||
|
};
|
||||||
|
c.bench_function(id, |b| {
|
||||||
|
b.iter(|| executor.score(black_box(&candidates), black_box(&profile), black_box(now)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
fn bench_for_you_under_writes(c: &mut Criterion) {
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
let profile = for_you_profile();
|
||||||
|
let candidates: Vec<EntityId> = (1..=200).map(EntityId::new).collect();
|
||||||
|
let now = Timestamp::from_nanos(1_708_000_000_000_000_000u64);
|
||||||
|
|
||||||
|
let mut group = c.benchmark_group("for_you_under_writes");
|
||||||
|
group.measurement_time(Duration::from_secs(8));
|
||||||
|
|
||||||
|
for plan in [true, false] {
|
||||||
|
let ledger = Arc::new(make_ledger_with_200_items());
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
// Four writers continuously record `view`/`like` signals, contending the
|
||||||
|
// same shards the scorer reads — the scenario where collapsing gets pays.
|
||||||
|
let writers: Vec<_> = (0..4u64)
|
||||||
|
.map(|w| {
|
||||||
|
let ledger = Arc::clone(&ledger);
|
||||||
|
let stop = Arc::clone(&stop);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut i = w;
|
||||||
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
let eid = EntityId::new((i % 200) + 1);
|
||||||
|
let ts = Timestamp::from_nanos(1_708_000_000_000_000_000u64 + i * 1000);
|
||||||
|
let _ = ledger.record_signal("view", eid, 1.0, ts);
|
||||||
|
let _ = ledger.record_signal("like", eid, 1.0, ts);
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let executor = ProfileExecutor::new(&ledger).with_signal_plan(plan);
|
||||||
|
let id = if plan { "plan" } else { "no_plan" };
|
||||||
|
group.bench_function(id, |b| {
|
||||||
|
b.iter(|| executor.score(black_box(&candidates), black_box(&profile), black_box(now)));
|
||||||
|
});
|
||||||
|
|
||||||
|
stop.store(true, Ordering::Relaxed);
|
||||||
|
for w in writers {
|
||||||
|
w.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
criterion_group!(
|
criterion_group!(
|
||||||
benches,
|
benches,
|
||||||
bench_score_200_trending,
|
bench_score_200_trending,
|
||||||
bench_score_200_hot,
|
bench_score_200_hot,
|
||||||
bench_score_200_full_pipeline
|
bench_score_200_full_pipeline,
|
||||||
|
bench_for_you_plan,
|
||||||
|
bench_for_you_under_writes
|
||||||
);
|
);
|
||||||
criterion_main!(benches);
|
criterion_main!(benches);
|
||||||
|
|||||||
@ -379,6 +379,19 @@ impl TidalDbBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable WAL archival for point-in-time recovery (m11p8): every sealed WAL
|
||||||
|
/// segment is copied to `dir` before the periodic online compaction deletes
|
||||||
|
/// it, building a gap-free, self-describing archive (filenames encode shard +
|
||||||
|
/// first-seq). Off by default (no archival, no overhead). `dir` should be on
|
||||||
|
/// separate, durable storage from the live WAL (an object-store mount, an
|
||||||
|
/// archive volume) so a disk loss of the live data dir does not also lose the
|
||||||
|
/// archive.
|
||||||
|
#[must_use]
|
||||||
|
pub fn wal_archive_dir(mut self, dir: impl Into<PathBuf>) -> Self {
|
||||||
|
self.config.wal_archive_dir = Some(dir.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve default directory paths using [`Paths`] for persistent mode.
|
/// Resolve default directory paths using [`Paths`] for persistent mode.
|
||||||
///
|
///
|
||||||
/// When a `data_dir` is set and `wal_dir` or `cache_dir` are not
|
/// When a `data_dir` is set and `wal_dir` or `cache_dir` are not
|
||||||
|
|||||||
@ -71,6 +71,12 @@ pub struct Config {
|
|||||||
/// measured fsync cost: a longer window amortizes fsyncs better under
|
/// measured fsync cost: a longer window amortizes fsyncs better under
|
||||||
/// sustained load but adds latency to sparse writes (m11p1).
|
/// sustained load but adds latency to sparse writes (m11p1).
|
||||||
pub wal_batch_timeout: Option<std::time::Duration>,
|
pub wal_batch_timeout: Option<std::time::Duration>,
|
||||||
|
/// Optional WAL archive directory for point-in-time recovery (m11p8). When
|
||||||
|
/// set, the periodic online-compaction path copies each sealed WAL segment
|
||||||
|
/// here BEFORE deleting it, building a gap-free, self-describing archive
|
||||||
|
/// (filenames encode shard + first-seq) an operator can replay to a chosen
|
||||||
|
/// point in time. `None` (the default) = no archival, no overhead.
|
||||||
|
pub wal_archive_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
@ -84,6 +90,7 @@ impl Default for Config {
|
|||||||
hlc_offset_ms: 0,
|
hlc_offset_ms: 0,
|
||||||
wal_batch_size: None,
|
wal_batch_size: None,
|
||||||
wal_batch_timeout: None,
|
wal_batch_timeout: None,
|
||||||
|
wal_archive_dir: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,9 @@ struct PeerShipMetrics {
|
|||||||
failures_total: AtomicU64,
|
failures_total: AtomicU64,
|
||||||
acked_seqno: AtomicU64,
|
acked_seqno: AtomicU64,
|
||||||
queue_depth: AtomicU64,
|
queue_depth: AtomicU64,
|
||||||
|
/// Per-peer circuit-breaker state (m11p8): 0 closed, 1 open, 2 half-open.
|
||||||
|
/// Refreshed by the self-driving heal loop, which polls each peer's breaker.
|
||||||
|
breaker_state: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PeerShipMetrics {
|
impl PeerShipMetrics {
|
||||||
@ -49,6 +52,7 @@ impl PeerShipMetrics {
|
|||||||
failures_total: AtomicU64::new(0),
|
failures_total: AtomicU64::new(0),
|
||||||
acked_seqno: AtomicU64::new(0),
|
acked_seqno: AtomicU64::new(0),
|
||||||
queue_depth: AtomicU64::new(0),
|
queue_depth: AtomicU64::new(0),
|
||||||
|
breaker_state: AtomicU64::new(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -121,6 +125,31 @@ pub struct ClusterMetrics {
|
|||||||
/// its decommission and will learn of its removal via the typed removed
|
/// its decommission and will learn of its removal via the typed removed
|
||||||
/// signal if it ever restarts.
|
/// signal if it ever restarts.
|
||||||
remove_delivery_giveups_total: AtomicU64,
|
remove_delivery_giveups_total: AtomicU64,
|
||||||
|
/// Total cross-node write forwards this node has INITIATED as a gateway
|
||||||
|
/// (m11p8) — a write that arrived on a non-leader and was relayed to the
|
||||||
|
/// shard leader over HTTP. High forward rate at one gateway = client routing
|
||||||
|
/// imbalance, not a fault.
|
||||||
|
forwards_total: AtomicU64,
|
||||||
|
/// Total cross-node write forwards that ERRORED (m11p8) — the leader was
|
||||||
|
/// unreachable, returned 5xx, or the relay timed out. A non-zero rate means
|
||||||
|
/// a gateway cannot reach the current leader (election in flight, partition).
|
||||||
|
forward_failures_total: AtomicU64,
|
||||||
|
/// Total per-peer circuit-breaker OPEN transitions observed across all peers
|
||||||
|
/// (m11p8). Each open is a `reset_duration` ship stall to that peer; a rising
|
||||||
|
/// counter under steady load means a peer link is flapping.
|
||||||
|
breaker_opens_total: AtomicU64,
|
||||||
|
/// Self-driving heal (m11p8): total reconcile attempts the server-side
|
||||||
|
/// heal loop has fired (one per peer per pass while a peer is behind).
|
||||||
|
heal_attempts_total: AtomicU64,
|
||||||
|
/// Self-driving heal (m11p8): total heal attempts that resumed a lagging
|
||||||
|
/// peer's ship (the breaker admitted the probe and a catch-up was nudged).
|
||||||
|
heal_successes_total: AtomicU64,
|
||||||
|
/// Self-driving heal (m11p8): total heal attempts that were no-ops because
|
||||||
|
/// the peer was already converged (the steady-state of the loop).
|
||||||
|
heal_noops_total: AtomicU64,
|
||||||
|
/// Self-driving heal (m11p8): peers this node is currently driving back to
|
||||||
|
/// convergence (partitioned-or-lagging set size). 0 = fully converged.
|
||||||
|
healing_peers: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClusterMetrics {
|
impl ClusterMetrics {
|
||||||
@ -147,6 +176,13 @@ impl ClusterMetrics {
|
|||||||
snapshot_fetches_total: AtomicU64::new(0),
|
snapshot_fetches_total: AtomicU64::new(0),
|
||||||
snapshot_pin_force_drops_total: AtomicU64::new(0),
|
snapshot_pin_force_drops_total: AtomicU64::new(0),
|
||||||
remove_delivery_giveups_total: AtomicU64::new(0),
|
remove_delivery_giveups_total: AtomicU64::new(0),
|
||||||
|
forwards_total: AtomicU64::new(0),
|
||||||
|
forward_failures_total: AtomicU64::new(0),
|
||||||
|
breaker_opens_total: AtomicU64::new(0),
|
||||||
|
heal_attempts_total: AtomicU64::new(0),
|
||||||
|
heal_successes_total: AtomicU64::new(0),
|
||||||
|
heal_noops_total: AtomicU64::new(0),
|
||||||
|
healing_peers: AtomicU64::new(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,6 +212,48 @@ impl ClusterMetrics {
|
|||||||
.fetch_add(1, Ordering::Relaxed);
|
.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Count one cross-node write forward initiated by this gateway (m11p8).
|
||||||
|
pub fn incr_forwards(&self) {
|
||||||
|
self.forwards_total.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count one cross-node write forward that errored (m11p8).
|
||||||
|
pub fn incr_forward_failures(&self) {
|
||||||
|
self.forward_failures_total.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a peer's circuit-breaker state gauge (m11p8): 0 closed, 1 open,
|
||||||
|
/// 2 half-open. A closed/half-open → open transition also bumps the global
|
||||||
|
/// `breaker_opens_total` counter so a dashboard can `rate()` the flap.
|
||||||
|
pub fn set_peer_breaker_state(&self, peer: ShardId, state: u8) {
|
||||||
|
let cell = self.peer(peer);
|
||||||
|
let prev = cell.breaker_state.swap(u64::from(state), Ordering::Relaxed);
|
||||||
|
if state == 1 && prev != 1 {
|
||||||
|
self.breaker_opens_total.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count one self-driving heal reconcile attempt (m11p8).
|
||||||
|
pub fn incr_heal_attempts(&self) {
|
||||||
|
self.heal_attempts_total.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count one heal attempt that resumed a lagging peer's ship (m11p8).
|
||||||
|
pub fn incr_heal_successes(&self) {
|
||||||
|
self.heal_successes_total.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count one heal attempt that was a no-op (peer already converged; m11p8).
|
||||||
|
pub fn incr_heal_noops(&self) {
|
||||||
|
self.heal_noops_total.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the count of peers this node is currently driving to convergence
|
||||||
|
/// (m11p8). 0 = fully converged.
|
||||||
|
pub fn set_healing_peers(&self, count: u64) {
|
||||||
|
self.healing_peers.store(count, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
/// Record this node's election term + role (m11p4): role is 0 follower,
|
/// Record this node's election term + role (m11p4): role is 0 follower,
|
||||||
/// 1 pre-candidate, 2 candidate, 3 leader.
|
/// 1 pre-candidate, 2 candidate, 3 leader.
|
||||||
pub fn set_election_view(&self, term: u64, role: u64) {
|
pub fn set_election_view(&self, term: u64, role: u64) {
|
||||||
@ -307,142 +385,241 @@ impl ClusterMetrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Append the `tidaldb_cluster_*` series to a Prometheus exposition body.
|
/// Append the `tidaldb_cluster_*` series to a Prometheus exposition body.
|
||||||
|
///
|
||||||
|
/// This is the single-shard / metrics-owner render: byte-identical to the
|
||||||
|
/// pre-m11p8 output so existing dashboards and the standalone surface are
|
||||||
|
/// unchanged.
|
||||||
|
pub(crate) fn render_into(&self, out: &mut String, partition_id: u64) {
|
||||||
|
self.render_labeled(out, partition_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render this shard group's series with a `shard="N"` label on every line
|
||||||
|
/// (m11p8). Used when several shard groups co-locate on one node and share
|
||||||
|
/// its single `/metrics` listener: the metrics-owner renders unlabeled via
|
||||||
|
/// [`render_into`](Self::render_into) and each co-located sibling renders
|
||||||
|
/// here, so their series never collide on the shared scrape target.
|
||||||
|
pub(crate) fn render_into_sibling(&self, out: &mut String, partition_id: u64, shard: u16) {
|
||||||
|
self.render_labeled(out, partition_id, Some(shard));
|
||||||
|
}
|
||||||
|
|
||||||
// One linear emit per series; splitting it would scatter the series list.
|
// One linear emit per series; splitting it would scatter the series list.
|
||||||
#[allow(clippy::cast_precision_loss)] // monitoring gauges
|
#[allow(clippy::cast_precision_loss)] // monitoring gauges
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub(crate) fn render_into(&self, out: &mut String, partition_id: u64) {
|
fn render_labeled(&self, out: &mut String, partition_id: u64, shard: Option<u16>) {
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
out.push_str(&self.ship_rtt.render_prometheus(
|
// Histogram / scalar label contents (no braces); empty for the unlabeled
|
||||||
|
// owner so its output stays byte-identical to the pre-m11p8 surface.
|
||||||
|
let extra = shard.map_or(String::new(), |s| format!("shard=\"{s}\""));
|
||||||
|
|
||||||
|
out.push_str(&self.ship_rtt.render_prometheus_labeled(
|
||||||
"tidaldb_cluster_ship_rtt_us",
|
"tidaldb_cluster_ship_rtt_us",
|
||||||
"Replication batch ship round-trip time in microseconds (all peers)",
|
"Replication batch ship round-trip time in microseconds (all peers)",
|
||||||
|
&extra,
|
||||||
));
|
));
|
||||||
out.push_str(&self.ship_batch_events.render_prometheus(
|
out.push_str(&self.ship_batch_events.render_prometheus_labeled(
|
||||||
"tidaldb_cluster_ship_batch_events",
|
"tidaldb_cluster_ship_batch_events",
|
||||||
"Events per shipped replication batch",
|
"Events per shipped replication batch",
|
||||||
|
&extra,
|
||||||
));
|
));
|
||||||
out.push_str(&self.wal_fsync.render_prometheus(
|
out.push_str(&self.wal_fsync.render_prometheus_labeled(
|
||||||
"tidaldb_cluster_wal_fsync_us",
|
"tidaldb_cluster_wal_fsync_us",
|
||||||
"WAL group-commit fsync latency in microseconds",
|
"WAL group-commit fsync latency in microseconds",
|
||||||
|
&extra,
|
||||||
));
|
));
|
||||||
out.push_str(&self.group_commit_events.render_prometheus(
|
out.push_str(&self.group_commit_events.render_prometheus_labeled(
|
||||||
"tidaldb_cluster_group_commit_events",
|
"tidaldb_cluster_group_commit_events",
|
||||||
"Events per WAL group-commit batch",
|
"Events per WAL group-commit batch",
|
||||||
|
&extra,
|
||||||
));
|
));
|
||||||
|
|
||||||
super::write_metric_line(
|
emit_scalar(
|
||||||
out,
|
out,
|
||||||
"tidaldb_cluster_write_pool_depth",
|
"tidaldb_cluster_write_pool_depth",
|
||||||
"Queued cluster write jobs awaiting a pool worker",
|
"Queued cluster write jobs awaiting a pool worker",
|
||||||
"gauge",
|
"gauge",
|
||||||
self.write_pool_depth.load(Ordering::Relaxed) as f64,
|
self.write_pool_depth.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
);
|
);
|
||||||
super::write_metric_line(
|
emit_scalar(
|
||||||
out,
|
out,
|
||||||
"tidaldb_cluster_write_pool_rejections_total",
|
"tidaldb_cluster_write_pool_rejections_total",
|
||||||
"Cluster write submissions rejected with backpressure (HTTP 429)",
|
"Cluster write submissions rejected with backpressure (HTTP 429)",
|
||||||
"counter",
|
"counter",
|
||||||
self.write_pool_rejections_total.load(Ordering::Relaxed) as f64,
|
self.write_pool_rejections_total.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
);
|
);
|
||||||
super::write_metric_line(
|
emit_scalar(
|
||||||
out,
|
out,
|
||||||
"tidaldb_cluster_relay_last_seq",
|
"tidaldb_cluster_relay_last_seq",
|
||||||
"Leader relay stream high-water mark (last committed seqno)",
|
"Leader relay stream high-water mark (last committed seqno)",
|
||||||
"gauge",
|
"gauge",
|
||||||
self.relay_last_seq.load(Ordering::Relaxed) as f64,
|
self.relay_last_seq.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
);
|
);
|
||||||
super::write_metric_line(
|
emit_scalar(
|
||||||
out,
|
out,
|
||||||
"tidaldb_cluster_relay_durable_seq",
|
"tidaldb_cluster_relay_durable_seq",
|
||||||
"Quorum commit index: highest seqno a majority of the replica set durably holds (m11p3)",
|
"Quorum commit index: highest seqno a majority of the replica set durably holds (m11p3)",
|
||||||
"gauge",
|
"gauge",
|
||||||
self.relay_durable_seq.load(Ordering::Relaxed) as f64,
|
self.relay_durable_seq.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
);
|
);
|
||||||
super::write_metric_line(
|
emit_scalar(
|
||||||
out,
|
out,
|
||||||
"tidaldb_cluster_quorum_timeouts_total",
|
"tidaldb_cluster_quorum_timeouts_total",
|
||||||
"ack=quorum writes that timed out awaiting the commit index (retryable 503s)",
|
"ack=quorum writes that timed out awaiting the commit index (retryable 503s)",
|
||||||
"counter",
|
"counter",
|
||||||
self.quorum_timeouts_total.load(Ordering::Relaxed) as f64,
|
self.quorum_timeouts_total.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
);
|
);
|
||||||
|
|
||||||
super::write_metric_line(
|
emit_scalar(
|
||||||
|
out,
|
||||||
|
"tidaldb_cluster_forwards_total",
|
||||||
|
"Cross-node write forwards this gateway initiated (m11p8)",
|
||||||
|
"counter",
|
||||||
|
self.forwards_total.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
|
);
|
||||||
|
emit_scalar(
|
||||||
|
out,
|
||||||
|
"tidaldb_cluster_forward_failures_total",
|
||||||
|
"Cross-node write forwards that errored — leader unreachable / 5xx / timeout (m11p8)",
|
||||||
|
"counter",
|
||||||
|
self.forward_failures_total.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit_scalar(
|
||||||
out,
|
out,
|
||||||
"tidaldb_cluster_election_term",
|
"tidaldb_cluster_election_term",
|
||||||
"This node's current election term (m11p4; 0 = topology era)",
|
"This node's current election term (m11p4; 0 = topology era)",
|
||||||
"gauge",
|
"gauge",
|
||||||
self.election_term.load(Ordering::Relaxed) as f64,
|
self.election_term.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
);
|
);
|
||||||
super::write_metric_line(
|
emit_scalar(
|
||||||
out,
|
out,
|
||||||
"tidaldb_cluster_election_role",
|
"tidaldb_cluster_election_role",
|
||||||
"Election role: 0 follower, 1 pre-candidate, 2 candidate, 3 leader",
|
"Election role: 0 follower, 1 pre-candidate, 2 candidate, 3 leader",
|
||||||
"gauge",
|
"gauge",
|
||||||
self.election_role.load(Ordering::Relaxed) as f64,
|
self.election_role.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
);
|
);
|
||||||
super::write_metric_line(
|
emit_scalar(
|
||||||
out,
|
out,
|
||||||
"tidaldb_cluster_elections_started_total",
|
"tidaldb_cluster_elections_started_total",
|
||||||
"Elections (pre-vote rounds) this node has started",
|
"Elections (pre-vote rounds) this node has started",
|
||||||
"counter",
|
"counter",
|
||||||
self.elections_started_total.load(Ordering::Relaxed) as f64,
|
self.elections_started_total.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
);
|
);
|
||||||
super::write_metric_line(
|
emit_scalar(
|
||||||
out,
|
out,
|
||||||
"tidaldb_cluster_leader_changes_total",
|
"tidaldb_cluster_leader_changes_total",
|
||||||
"Leadership changes this node has observed",
|
"Leadership changes this node has observed",
|
||||||
"counter",
|
"counter",
|
||||||
self.leader_changes_total.load(Ordering::Relaxed) as f64,
|
self.leader_changes_total.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
);
|
);
|
||||||
super::write_metric_line(
|
emit_scalar(
|
||||||
out,
|
out,
|
||||||
"tidaldb_cluster_divergence_quarantined",
|
"tidaldb_cluster_divergence_quarantined",
|
||||||
"Divergent-suffix quarantine latch (1 = fenced from the data plane)",
|
"Divergent-suffix quarantine latch (1 = fenced from the data plane)",
|
||||||
"gauge",
|
"gauge",
|
||||||
self.divergence_quarantined.load(Ordering::Relaxed) as f64,
|
self.divergence_quarantined.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
);
|
);
|
||||||
super::write_metric_line(
|
emit_scalar(
|
||||||
out,
|
out,
|
||||||
"tidaldb_cluster_reseed_required",
|
"tidaldb_cluster_reseed_required",
|
||||||
"Durable reseed-marker latch (m11p5; 1 = a snapshot reseed is pending the next boot)",
|
"Durable reseed-marker latch (m11p5; 1 = a snapshot reseed is pending the next boot)",
|
||||||
"gauge",
|
"gauge",
|
||||||
self.reseed_required.load(Ordering::Relaxed) as f64,
|
self.reseed_required.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
);
|
);
|
||||||
|
|
||||||
super::write_metric_line(
|
emit_scalar(
|
||||||
|
out,
|
||||||
|
"tidaldb_cluster_breaker_opens_total",
|
||||||
|
"Per-peer circuit-breaker open transitions observed (m11p8; each is a ship stall)",
|
||||||
|
"counter",
|
||||||
|
self.breaker_opens_total.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
|
);
|
||||||
|
emit_scalar(
|
||||||
|
out,
|
||||||
|
"tidaldb_cluster_heal_attempts_total",
|
||||||
|
"Self-driving heal reconcile attempts (m11p8)",
|
||||||
|
"counter",
|
||||||
|
self.heal_attempts_total.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
|
);
|
||||||
|
emit_scalar(
|
||||||
|
out,
|
||||||
|
"tidaldb_cluster_heal_successes_total",
|
||||||
|
"Self-driving heal attempts that resumed a lagging peer's ship (m11p8)",
|
||||||
|
"counter",
|
||||||
|
self.heal_successes_total.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
|
);
|
||||||
|
emit_scalar(
|
||||||
|
out,
|
||||||
|
"tidaldb_cluster_heal_noops_total",
|
||||||
|
"Self-driving heal attempts that were no-ops — peer already converged (m11p8)",
|
||||||
|
"counter",
|
||||||
|
self.heal_noops_total.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
|
);
|
||||||
|
emit_scalar(
|
||||||
|
out,
|
||||||
|
"tidaldb_cluster_healing_peers",
|
||||||
|
"Peers this node is currently driving back to convergence (m11p8; 0 = converged)",
|
||||||
|
"gauge",
|
||||||
|
self.healing_peers.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit_scalar(
|
||||||
out,
|
out,
|
||||||
"tidaldb_cluster_snapshot_staged",
|
"tidaldb_cluster_snapshot_staged",
|
||||||
"Seq of the currently-staged snapshot artifact (m11p5; 0 = none staged)",
|
"Seq of the currently-staged snapshot artifact (m11p5; 0 = none staged)",
|
||||||
"gauge",
|
"gauge",
|
||||||
self.snapshot_staged.load(Ordering::Relaxed) as f64,
|
self.snapshot_staged.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
);
|
);
|
||||||
super::write_metric_line(
|
emit_scalar(
|
||||||
out,
|
out,
|
||||||
"tidaldb_cluster_snapshot_fetches_total",
|
"tidaldb_cluster_snapshot_fetches_total",
|
||||||
"FetchSnapshot streams served as the leader-side snapshot source (m11p5)",
|
"FetchSnapshot streams served as the leader-side snapshot source (m11p5)",
|
||||||
"counter",
|
"counter",
|
||||||
self.snapshot_fetches_total.load(Ordering::Relaxed) as f64,
|
self.snapshot_fetches_total.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
);
|
);
|
||||||
super::write_metric_line(
|
emit_scalar(
|
||||||
out,
|
out,
|
||||||
"tidaldb_cluster_snapshot_pin_force_drops_total",
|
"tidaldb_cluster_snapshot_pin_force_drops_total",
|
||||||
"Staged-artifact retention pins force-dropped past the hard cap (m11p5 §2.1; \
|
"Staged-artifact retention pins force-dropped past the hard cap (m11p5 §2.1; \
|
||||||
a dead joiner that never released)",
|
a dead joiner that never released)",
|
||||||
"counter",
|
"counter",
|
||||||
self.snapshot_pin_force_drops_total.load(Ordering::Relaxed) as f64,
|
self.snapshot_pin_force_drops_total.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
);
|
);
|
||||||
super::write_metric_line(
|
emit_scalar(
|
||||||
out,
|
out,
|
||||||
"tidaldb_cluster_remove_delivery_giveups_total",
|
"tidaldb_cluster_remove_delivery_giveups_total",
|
||||||
"Removal-delivery graces that expired before the removed peer acked the Removed \
|
"Removal-delivery graces that expired before the removed peer acked the Removed \
|
||||||
record (m11p5 §3.3; the removed node was down/unreachable during decommission)",
|
record (m11p5 §3.3; the removed node was down/unreachable during decommission)",
|
||||||
"counter",
|
"counter",
|
||||||
self.remove_delivery_giveups_total.load(Ordering::Relaxed) as f64,
|
self.remove_delivery_giveups_total.load(Ordering::Relaxed) as f64,
|
||||||
|
&extra,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Per-peer series, labeled by peer shard id + this node's partition.
|
// Per-peer series, labeled by peer shard id + this node's partition (and
|
||||||
// Snapshot the cells under the read lock, then render lock-free (the
|
// this group's shard when co-located). Snapshot the cells under the read
|
||||||
// ship sender threads update these cells on their hot path).
|
// lock, then render lock-free (the ship sender threads update these cells
|
||||||
|
// on their hot path).
|
||||||
let peer_cells: Vec<(u16, Arc<PeerShipMetrics>)> = {
|
let peer_cells: Vec<(u16, Arc<PeerShipMetrics>)> = {
|
||||||
let peers = self
|
let peers = self
|
||||||
.peers
|
.peers
|
||||||
@ -453,13 +630,15 @@ impl ClusterMetrics {
|
|||||||
if peer_cells.is_empty() {
|
if peer_cells.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let shard_prefix = shard.map_or(String::new(), |s| format!("shard=\"{s}\","));
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
"\n# HELP tidaldb_cluster_peer_ship Per-peer replication ship-path series.\n\
|
"\n# HELP tidaldb_cluster_peer_ship Per-peer replication ship-path series.\n\
|
||||||
# TYPE tidaldb_cluster_peer_acked_seqno gauge"
|
# TYPE tidaldb_cluster_peer_acked_seqno gauge"
|
||||||
);
|
);
|
||||||
for (peer_id, cell) in &peer_cells {
|
for (peer_id, cell) in &peer_cells {
|
||||||
let labels = format!("peer_shard=\"{peer_id}\",partition_id=\"{partition_id}\"");
|
let labels =
|
||||||
|
format!("{shard_prefix}peer_shard=\"{peer_id}\",partition_id=\"{partition_id}\"");
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
"tidaldb_cluster_peer_acked_seqno{{{labels}}} {}",
|
"tidaldb_cluster_peer_acked_seqno{{{labels}}} {}",
|
||||||
@ -485,11 +664,32 @@ impl ClusterMetrics {
|
|||||||
"tidaldb_cluster_peer_ship_failures_total{{{labels}}} {}",
|
"tidaldb_cluster_peer_ship_failures_total{{{labels}}} {}",
|
||||||
cell.failures_total.load(Ordering::Relaxed)
|
cell.failures_total.load(Ordering::Relaxed)
|
||||||
);
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"tidaldb_cluster_peer_breaker_state{{{labels}}} {}",
|
||||||
|
cell.breaker_state.load(Ordering::Relaxed)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emit one scalar (`gauge`/`counter`) line, optionally stamped with an extra
|
||||||
|
/// label set (`extra`, no braces). Empty `extra` defers to the unlabeled
|
||||||
|
/// [`write_metric_line`](super::write_metric_line) so the metrics-owner / single
|
||||||
|
/// shard output is byte-identical to the pre-m11p8 surface.
|
||||||
|
fn emit_scalar(out: &mut String, name: &str, help: &str, type_str: &str, value: f64, extra: &str) {
|
||||||
|
if extra.is_empty() {
|
||||||
|
super::write_metric_line(out, name, help, type_str, value);
|
||||||
|
} else {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"\n# HELP {name} {help}\n# TYPE {name} {type_str}\n{name}{{{extra}}} {value}\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -510,6 +710,15 @@ mod tests {
|
|||||||
m.incr_snapshot_fetches();
|
m.incr_snapshot_fetches();
|
||||||
m.incr_snapshot_pin_force_drops();
|
m.incr_snapshot_pin_force_drops();
|
||||||
m.incr_remove_delivery_giveup();
|
m.incr_remove_delivery_giveup();
|
||||||
|
// m11p8 additions.
|
||||||
|
m.incr_forwards();
|
||||||
|
m.incr_forwards();
|
||||||
|
m.incr_forward_failures();
|
||||||
|
m.set_peer_breaker_state(ShardId(2), 1); // open
|
||||||
|
m.incr_heal_attempts();
|
||||||
|
m.incr_heal_successes();
|
||||||
|
m.incr_heal_noops();
|
||||||
|
m.set_healing_peers(1);
|
||||||
|
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
m.render_into(&mut out, 7);
|
m.render_into(&mut out, 7);
|
||||||
@ -520,6 +729,14 @@ mod tests {
|
|||||||
assert!(out.contains("tidaldb_cluster_snapshot_fetches_total 1"));
|
assert!(out.contains("tidaldb_cluster_snapshot_fetches_total 1"));
|
||||||
assert!(out.contains("tidaldb_cluster_snapshot_pin_force_drops_total 1"));
|
assert!(out.contains("tidaldb_cluster_snapshot_pin_force_drops_total 1"));
|
||||||
assert!(out.contains("tidaldb_cluster_remove_delivery_giveups_total 1"));
|
assert!(out.contains("tidaldb_cluster_remove_delivery_giveups_total 1"));
|
||||||
|
// m11p8 series.
|
||||||
|
assert!(out.contains("tidaldb_cluster_forwards_total 2"));
|
||||||
|
assert!(out.contains("tidaldb_cluster_forward_failures_total 1"));
|
||||||
|
assert!(out.contains("tidaldb_cluster_breaker_opens_total 1"));
|
||||||
|
assert!(out.contains("tidaldb_cluster_heal_attempts_total 1"));
|
||||||
|
assert!(out.contains("tidaldb_cluster_heal_successes_total 1"));
|
||||||
|
assert!(out.contains("tidaldb_cluster_heal_noops_total 1"));
|
||||||
|
assert!(out.contains("tidaldb_cluster_healing_peers 1"));
|
||||||
assert!(
|
assert!(
|
||||||
out.contains(
|
out.contains(
|
||||||
"tidaldb_cluster_peer_acked_seqno{peer_shard=\"2\",partition_id=\"7\"} 64"
|
"tidaldb_cluster_peer_acked_seqno{peer_shard=\"2\",partition_id=\"7\"} 64"
|
||||||
@ -531,7 +748,39 @@ mod tests {
|
|||||||
assert!(out.contains(
|
assert!(out.contains(
|
||||||
"tidaldb_cluster_peer_ship_failures_total{peer_shard=\"2\",partition_id=\"7\"} 1"
|
"tidaldb_cluster_peer_ship_failures_total{peer_shard=\"2\",partition_id=\"7\"} 1"
|
||||||
));
|
));
|
||||||
|
assert!(
|
||||||
|
out.contains(
|
||||||
|
"tidaldb_cluster_peer_breaker_state{peer_shard=\"2\",partition_id=\"7\"} 1"
|
||||||
|
)
|
||||||
|
);
|
||||||
assert!(out.contains("tidaldb_cluster_write_pool_depth"));
|
assert!(out.contains("tidaldb_cluster_write_pool_depth"));
|
||||||
assert!(out.contains("tidaldb_cluster_write_pool_rejections_total"));
|
assert!(out.contains("tidaldb_cluster_write_pool_rejections_total"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Co-located sibling shards render every series with a `shard="N"` label so
|
||||||
|
/// they never collide with the metrics-owner's unlabeled series on one
|
||||||
|
/// `/metrics` listener (m11p8 multi-shard listener).
|
||||||
|
#[test]
|
||||||
|
fn sibling_render_stamps_shard_label() {
|
||||||
|
let m = ClusterMetrics::new();
|
||||||
|
m.mark_active();
|
||||||
|
m.observe_ship(ShardId(5), std::time::Duration::from_millis(2), 32, 30, 40);
|
||||||
|
m.set_relay_frontiers(40, 38);
|
||||||
|
m.set_peer_breaker_state(ShardId(5), 2); // half-open
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
m.render_into_sibling(&mut out, 3, 1);
|
||||||
|
// Scalar gauges carry the shard label.
|
||||||
|
assert!(out.contains("tidaldb_cluster_relay_last_seq{shard=\"1\"} 40"));
|
||||||
|
assert!(out.contains("tidaldb_cluster_relay_durable_seq{shard=\"1\"} 38"));
|
||||||
|
// Histograms carry the shard label on every series line.
|
||||||
|
assert!(out.contains("tidaldb_cluster_ship_rtt_us_bucket{le=\"+Inf\",shard=\"1\"}"));
|
||||||
|
assert!(out.contains("tidaldb_cluster_ship_rtt_us_count{shard=\"1\"}"));
|
||||||
|
// Per-peer series carry shard + peer_shard + partition_id.
|
||||||
|
assert!(out.contains(
|
||||||
|
"tidaldb_cluster_peer_breaker_state{shard=\"1\",peer_shard=\"5\",partition_id=\"3\"} 2"
|
||||||
|
));
|
||||||
|
// The owner's unlabeled form must NOT appear in a sibling render.
|
||||||
|
assert!(!out.contains("tidaldb_cluster_relay_last_seq 40"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,22 +85,46 @@ impl LatencyHistogram {
|
|||||||
/// Produces `# HELP`, `# TYPE histogram`, per-bucket lines with `le` labels,
|
/// Produces `# HELP`, `# TYPE histogram`, per-bucket lines with `le` labels,
|
||||||
/// a `+Inf` bucket (equal to total count), `_sum`, and `_count`.
|
/// a `+Inf` bucket (equal to total count), `_sum`, and `_count`.
|
||||||
pub fn render_prometheus(&self, name: &str, help: &str) -> String {
|
pub fn render_prometheus(&self, name: &str, help: &str) -> String {
|
||||||
|
self.render_prometheus_labeled(name, help, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render this histogram with an extra label set (e.g. `shard="1"`) on every
|
||||||
|
/// series line so co-located shards on one `/metrics` listener stay distinct.
|
||||||
|
///
|
||||||
|
/// `extra` is the label *contents* without braces (`shard="1"`); an empty
|
||||||
|
/// string renders the unlabeled form, byte-identical to
|
||||||
|
/// [`render_prometheus`](Self::render_prometheus).
|
||||||
|
pub fn render_prometheus_labeled(&self, name: &str, help: &str, extra: &str) -> String {
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
// Bucket lines always carry an `le` label; `_sum`/`_count` carry the extra
|
||||||
|
// set in braces (or nothing when `extra` is empty).
|
||||||
|
let (bucket_sep, scalar_labels) = if extra.is_empty() {
|
||||||
|
("", String::new())
|
||||||
|
} else {
|
||||||
|
(",", format!("{{{extra}}}"))
|
||||||
|
};
|
||||||
|
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let _ = writeln!(out, "# HELP {name} {help}");
|
let _ = writeln!(out, "# HELP {name} {help}");
|
||||||
let _ = writeln!(out, "# TYPE {name} histogram");
|
let _ = writeln!(out, "# TYPE {name} histogram");
|
||||||
|
|
||||||
for (i, &bound) in self.bounds.iter().enumerate() {
|
for (i, &bound) in self.bounds.iter().enumerate() {
|
||||||
let count = self.buckets[i].load(Ordering::Relaxed);
|
let count = self.buckets[i].load(Ordering::Relaxed);
|
||||||
let _ = writeln!(out, "{name}_bucket{{le=\"{bound}\"}} {count}");
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"{name}_bucket{{le=\"{bound}\"{bucket_sep}{extra}}} {count}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_count = self.count.load(Ordering::Relaxed);
|
let total_count = self.count.load(Ordering::Relaxed);
|
||||||
let total_sum = self.sum.load(Ordering::Relaxed);
|
let total_sum = self.sum.load(Ordering::Relaxed);
|
||||||
let _ = writeln!(out, "{name}_bucket{{le=\"+Inf\"}} {total_count}");
|
let _ = writeln!(
|
||||||
let _ = writeln!(out, "{name}_sum {total_sum}");
|
out,
|
||||||
let _ = writeln!(out, "{name}_count {total_count}");
|
"{name}_bucket{{le=\"+Inf\"{bucket_sep}{extra}}} {total_count}"
|
||||||
|
);
|
||||||
|
let _ = writeln!(out, "{name}_sum{scalar_labels} {total_sum}");
|
||||||
|
let _ = writeln!(out, "{name}_count{scalar_labels} {total_count}");
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|||||||
@ -164,6 +164,15 @@ pub struct MetricsState {
|
|||||||
/// distinguishable when scraped into one Prometheus. Stored as an atomic so
|
/// distinguishable when scraped into one Prometheus. Stored as an atomic so
|
||||||
/// the install takes `&self` and works even after the `Arc` is shared.
|
/// the install takes `&self` and works even after the `Arc` is shared.
|
||||||
partition_id: AtomicU64,
|
partition_id: AtomicU64,
|
||||||
|
|
||||||
|
/// Co-located shard groups' cluster metrics (m11p8). When several shard
|
||||||
|
/// groups run in one process and share this node's single `/metrics`
|
||||||
|
/// listener, the metrics-owning shard's `MetricsState` renders each sibling's
|
||||||
|
/// `tidaldb_cluster_*` series with a distinct `shard="N"` label so they never
|
||||||
|
/// collide on the shared scrape target. Empty on a single-shard node (the
|
||||||
|
/// shipped S=1 topology), so its output is byte-identical to pre-m11p8.
|
||||||
|
#[cfg(feature = "metrics")]
|
||||||
|
cluster_siblings: std::sync::RwLock<Vec<(u16, std::sync::Arc<cluster::ClusterMetrics>)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MetricsState {
|
impl MetricsState {
|
||||||
@ -215,9 +224,28 @@ impl MetricsState {
|
|||||||
cluster: std::sync::Arc::new(cluster::ClusterMetrics::new()),
|
cluster: std::sync::Arc::new(cluster::ClusterMetrics::new()),
|
||||||
control_plane: None,
|
control_plane: None,
|
||||||
partition_id: AtomicU64::new(0),
|
partition_id: AtomicU64::new(0),
|
||||||
|
#[cfg(feature = "metrics")]
|
||||||
|
cluster_siblings: std::sync::RwLock::new(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register a co-located shard group's cluster metrics so this node's single
|
||||||
|
/// `/metrics` listener also exposes that group's `tidaldb_cluster_*` series,
|
||||||
|
/// stamped with `shard="<shard>"` (m11p8). Called once per non-owner group
|
||||||
|
/// during cluster-node construction.
|
||||||
|
#[cfg(feature = "metrics")]
|
||||||
|
pub fn register_cluster_sibling(
|
||||||
|
&self,
|
||||||
|
shard: u16,
|
||||||
|
metrics: std::sync::Arc<cluster::ClusterMetrics>,
|
||||||
|
) {
|
||||||
|
metrics.mark_active();
|
||||||
|
self.cluster_siblings
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.push((shard, metrics));
|
||||||
|
}
|
||||||
|
|
||||||
/// Maximum age (nanoseconds) a checkpoint may reach before health reports
|
/// Maximum age (nanoseconds) a checkpoint may reach before health reports
|
||||||
/// degraded. The periodic checkpoint thread runs every 30s; a checkpoint
|
/// degraded. The periodic checkpoint thread runs every 30s; a checkpoint
|
||||||
/// older than 5 minutes means the thread is stuck, dead, or failing — all
|
/// older than 5 minutes means the thread is stuck, dead, or failing — all
|
||||||
@ -505,6 +533,17 @@ impl MetricsState {
|
|||||||
// their exact metric surface.
|
// their exact metric surface.
|
||||||
if self.cluster.is_active() {
|
if self.cluster.is_active() {
|
||||||
self.cluster.render_into(&mut out, partition_id);
|
self.cluster.render_into(&mut out, partition_id);
|
||||||
|
// Co-located shard groups (m11p8): each renders with a distinct
|
||||||
|
// `shard="N"` label so several groups sharing this node's one
|
||||||
|
// `/metrics` listener never collide. Empty on the S=1 topology.
|
||||||
|
for (shard, sib) in self
|
||||||
|
.cluster_siblings
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.iter()
|
||||||
|
{
|
||||||
|
sib.render_into_sibling(&mut out, partition_id, *shard);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -921,6 +921,10 @@ impl TidalDb {
|
|||||||
// Resolve through the single source of truth so an explicit
|
// Resolve through the single source of truth so an explicit
|
||||||
// wal_dir override is honored here exactly as at WAL open.
|
// wal_dir override is honored here exactly as at WAL open.
|
||||||
let wal_dir = config.resolved_wal_dir();
|
let wal_dir = config.resolved_wal_dir();
|
||||||
|
// m11p8 PITR: the optional WAL archive dir. When set, the
|
||||||
|
// checkpoint thread's online compaction archives each sealed
|
||||||
|
// segment before deleting it.
|
||||||
|
let wal_archive_dir = config.wal_archive_dir.clone();
|
||||||
let metrics_clone = Arc::clone(&metrics);
|
let metrics_clone = Arc::clone(&metrics);
|
||||||
// Separate handle kept by the panic supervisor so it can
|
// Separate handle kept by the panic supervisor so it can
|
||||||
// flag a dead checkpoint thread even though `metrics_clone`
|
// flag a dead checkpoint thread even though `metrics_clone`
|
||||||
@ -964,6 +968,7 @@ impl TidalDb {
|
|||||||
items,
|
items,
|
||||||
seq_clone,
|
seq_clone,
|
||||||
wal_dir,
|
wal_dir,
|
||||||
|
wal_archive_dir,
|
||||||
retention_pin_clone,
|
retention_pin_clone,
|
||||||
metrics_clone,
|
metrics_clone,
|
||||||
index_handles,
|
index_handles,
|
||||||
@ -1136,6 +1141,20 @@ impl TidalDb {
|
|||||||
self.metrics.cluster.mark_active();
|
self.metrics.cluster.mark_active();
|
||||||
Arc::clone(&self.metrics.cluster)
|
Arc::clone(&self.metrics.cluster)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Expose `sibling`'s `tidaldb_cluster_*` series through THIS db's `/metrics`
|
||||||
|
/// listener, stamped with `shard="<shard>"` (m11p8).
|
||||||
|
///
|
||||||
|
/// When several shard groups co-locate in one process, only one binds the
|
||||||
|
/// engine's `/metrics` server (the metrics owner); the others register here
|
||||||
|
/// so a single per-node scrape target still exposes every co-located group's
|
||||||
|
/// replication series, collision-free. A no-op-equivalent on the S=1 topology
|
||||||
|
/// (one shard per node), so its `/metrics` output is unchanged.
|
||||||
|
#[cfg(feature = "metrics")]
|
||||||
|
pub fn register_metrics_sibling(&self, shard: u16, sibling: &Self) {
|
||||||
|
self.metrics
|
||||||
|
.register_cluster_sibling(shard, Arc::clone(&sibling.metrics.cluster));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -455,6 +455,7 @@ pub(super) fn run_checkpoint_thread(
|
|||||||
storage: Box<dyn StorageEngine + Send + Sync>,
|
storage: Box<dyn StorageEngine + Send + Sync>,
|
||||||
last_wal_seq: Arc<AtomicU64>,
|
last_wal_seq: Arc<AtomicU64>,
|
||||||
wal_dir: Option<PathBuf>,
|
wal_dir: Option<PathBuf>,
|
||||||
|
wal_archive_dir: Option<PathBuf>,
|
||||||
wal_retention_pin: Arc<AtomicU64>,
|
wal_retention_pin: Arc<AtomicU64>,
|
||||||
metrics: Arc<MetricsState>,
|
metrics: Arc<MetricsState>,
|
||||||
index_handles: IndexMetricsHandles,
|
index_handles: IndexMetricsHandles,
|
||||||
@ -579,7 +580,12 @@ pub(super) fn run_checkpoint_thread(
|
|||||||
// the artifact's tail between staging and the first joiner
|
// the artifact's tail between staging and the first joiner
|
||||||
// pull).
|
// pull).
|
||||||
let pin = wal_retention_pin.load(Ordering::Acquire);
|
let pin = wal_retention_pin.load(Ordering::Acquire);
|
||||||
match crate::wal::compaction::compact_wal_online_pinned(dir, seq, pin) {
|
match crate::wal::compaction::compact_wal_online_pinned(
|
||||||
|
dir,
|
||||||
|
seq,
|
||||||
|
pin,
|
||||||
|
wal_archive_dir.as_deref(),
|
||||||
|
) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
#[cfg(feature = "metrics")]
|
#[cfg(feature = "metrics")]
|
||||||
{
|
{
|
||||||
|
|||||||
@ -161,6 +161,9 @@ fn apply_signal_thresholds(
|
|||||||
ledger,
|
ledger,
|
||||||
degradation_level,
|
degradation_level,
|
||||||
crate::schema::Timestamp::now().as_nanos(),
|
crate::schema::Timestamp::now().as_nanos(),
|
||||||
|
// Post-filter is a one-off per-candidate threshold read outside
|
||||||
|
// the scored pre-pass — no plan to consult, direct read.
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.map_err(|e| QueryError::InvalidFilter {
|
.map_err(|e| QueryError::InvalidFilter {
|
||||||
field: signal.clone(),
|
field: signal.clone(),
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
//! logic. They depend on `ScoredCandidate` (from `context`) and profile types,
|
//! logic. They depend on `ScoredCandidate` (from `context`) and profile types,
|
||||||
//! but contain no executor state.
|
//! but contain no executor state.
|
||||||
|
|
||||||
use super::context::ScoredCandidate;
|
use super::{SignalValues, context::ScoredCandidate};
|
||||||
use crate::{
|
use crate::{
|
||||||
load::DegradationLevel,
|
load::DegradationLevel,
|
||||||
ranking::profile::{Exclude, Gate, SignalAgg},
|
ranking::profile::{Exclude, Gate, SignalAgg},
|
||||||
@ -59,6 +59,10 @@ pub(super) const COARSE_VALUE_WINDOW: Window = Window::AllTime;
|
|||||||
/// - `TidalError::Internal` if `agg` is `Ratio` / `RelativeVelocity`: the
|
/// - `TidalError::Internal` if `agg` is `Ratio` / `RelativeVelocity`: the
|
||||||
/// registry rejects those at profile registration, so reaching this arm is a
|
/// registry rejects those at profile registration, so reaching this arm is a
|
||||||
/// tidalDB invariant violation, not a recoverable runtime condition.
|
/// tidalDB invariant violation, not a recoverable runtime condition.
|
||||||
|
// Mirrors the scoring inputs (entity, signal, agg, window, ledger, degradation,
|
||||||
|
// clock) plus the optional pre-pass table; bundling them into a struct would
|
||||||
|
// only relocate the same fields without reducing real complexity.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) fn read_agg(
|
pub(crate) fn read_agg(
|
||||||
entity_id: EntityId,
|
entity_id: EntityId,
|
||||||
signal: &str,
|
signal: &str,
|
||||||
@ -67,6 +71,7 @@ pub(crate) fn read_agg(
|
|||||||
ledger: &SignalLedger,
|
ledger: &SignalLedger,
|
||||||
degradation: DegradationLevel,
|
degradation: DegradationLevel,
|
||||||
now_ns: u64,
|
now_ns: u64,
|
||||||
|
vals: Option<&SignalValues>,
|
||||||
) -> crate::Result<f64> {
|
) -> crate::Result<f64> {
|
||||||
let window = if degradation.coarsens_aggregates() {
|
let window = if degradation.coarsens_aggregates() {
|
||||||
match agg {
|
match agg {
|
||||||
@ -77,6 +82,17 @@ pub(crate) fn read_agg(
|
|||||||
} else {
|
} else {
|
||||||
window
|
window
|
||||||
};
|
};
|
||||||
|
// Pre-pass hit: a value already computed by `SignalValues` (one
|
||||||
|
// `entries.get()` per signal type per candidate). A miss falls through to
|
||||||
|
// the direct read below, so the plan is a pure optimization and never
|
||||||
|
// changes the result. The effective `window` computed above is exactly the
|
||||||
|
// key the plan was built with.
|
||||||
|
if let Some(vals) = vals
|
||||||
|
&& let Ok(type_id) = ledger.resolve_signal_type(signal)
|
||||||
|
&& let Some(v) = vals.get(type_id, agg, window)
|
||||||
|
{
|
||||||
|
return Ok(v);
|
||||||
|
}
|
||||||
match agg {
|
match agg {
|
||||||
SignalAgg::Value => {
|
SignalAgg::Value => {
|
||||||
#[allow(clippy::cast_precision_loss)]
|
#[allow(clippy::cast_precision_loss)]
|
||||||
@ -128,6 +144,7 @@ pub(crate) fn read_agg(
|
|||||||
///
|
///
|
||||||
/// Propagates any non-`UnknownSignalType` [`read_agg`] error (notably the
|
/// Propagates any non-`UnknownSignalType` [`read_agg`] error (notably the
|
||||||
/// `Ratio` / `RelativeVelocity` invariant violation).
|
/// `Ratio` / `RelativeVelocity` invariant violation).
|
||||||
|
#[allow(clippy::too_many_arguments)] // same scoring inputs as `read_agg` + the pre-pass table
|
||||||
pub(super) fn read_agg_for_sort(
|
pub(super) fn read_agg_for_sort(
|
||||||
entity_id: EntityId,
|
entity_id: EntityId,
|
||||||
signal: &str,
|
signal: &str,
|
||||||
@ -136,8 +153,18 @@ pub(super) fn read_agg_for_sort(
|
|||||||
ledger: &SignalLedger,
|
ledger: &SignalLedger,
|
||||||
degradation: DegradationLevel,
|
degradation: DegradationLevel,
|
||||||
now_ns: u64,
|
now_ns: u64,
|
||||||
|
vals: Option<&SignalValues>,
|
||||||
) -> crate::Result<f64> {
|
) -> crate::Result<f64> {
|
||||||
match read_agg(entity_id, signal, agg, window, ledger, degradation, now_ns) {
|
match read_agg(
|
||||||
|
entity_id,
|
||||||
|
signal,
|
||||||
|
agg,
|
||||||
|
window,
|
||||||
|
ledger,
|
||||||
|
degradation,
|
||||||
|
now_ns,
|
||||||
|
vals,
|
||||||
|
) {
|
||||||
Err(crate::TidalError::Schema(crate::schema::error::SchemaError::UnknownSignalType(_))) => {
|
Err(crate::TidalError::Schema(crate::schema::error::SchemaError::UnknownSignalType(_))) => {
|
||||||
Ok(0.0)
|
Ok(0.0)
|
||||||
}
|
}
|
||||||
@ -164,6 +191,7 @@ pub(super) fn passes_gates(
|
|||||||
gates: &[Gate],
|
gates: &[Gate],
|
||||||
ledger: &SignalLedger,
|
ledger: &SignalLedger,
|
||||||
now_ns: u64,
|
now_ns: u64,
|
||||||
|
vals: Option<&SignalValues>,
|
||||||
) -> crate::Result<bool> {
|
) -> crate::Result<bool> {
|
||||||
for gate in gates {
|
for gate in gates {
|
||||||
let value = read_agg(
|
let value = read_agg(
|
||||||
@ -174,6 +202,7 @@ pub(super) fn passes_gates(
|
|||||||
ledger,
|
ledger,
|
||||||
DegradationLevel::Full,
|
DegradationLevel::Full,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
if value < gate.min_threshold {
|
if value < gate.min_threshold {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@ -207,6 +236,7 @@ pub(super) fn passes_excludes(
|
|||||||
excludes: &[Exclude],
|
excludes: &[Exclude],
|
||||||
ledger: &SignalLedger,
|
ledger: &SignalLedger,
|
||||||
now_ns: u64,
|
now_ns: u64,
|
||||||
|
vals: Option<&SignalValues>,
|
||||||
) -> crate::Result<bool> {
|
) -> crate::Result<bool> {
|
||||||
for exclude in excludes {
|
for exclude in excludes {
|
||||||
let value = read_agg(
|
let value = read_agg(
|
||||||
@ -217,6 +247,7 @@ pub(super) fn passes_excludes(
|
|||||||
ledger,
|
ledger,
|
||||||
DegradationLevel::Full,
|
DegradationLevel::Full,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
if value > exclude.above {
|
if value > exclude.above {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@ -332,6 +363,7 @@ mod tests {
|
|||||||
&ledger,
|
&ledger,
|
||||||
DegradationLevel::Full,
|
DegradationLevel::Full,
|
||||||
Timestamp::now().as_nanos(),
|
Timestamp::now().as_nanos(),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
assert!(matches!(result, Err(crate::TidalError::Internal(_))));
|
assert!(matches!(result, Err(crate::TidalError::Internal(_))));
|
||||||
}
|
}
|
||||||
@ -348,6 +380,7 @@ mod tests {
|
|||||||
&ledger,
|
&ledger,
|
||||||
DegradationLevel::Full,
|
DegradationLevel::Full,
|
||||||
Timestamp::now().as_nanos(),
|
Timestamp::now().as_nanos(),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
assert!(matches!(result, Err(crate::TidalError::Schema(_))));
|
assert!(matches!(result, Err(crate::TidalError::Schema(_))));
|
||||||
}
|
}
|
||||||
@ -369,7 +402,16 @@ mod tests {
|
|||||||
min_threshold: 5.0,
|
min_threshold: 5.0,
|
||||||
}];
|
}];
|
||||||
// count=3 < threshold=5 -> candidate excluded.
|
// count=3 < threshold=5 -> candidate excluded.
|
||||||
assert!(!passes_gates(entity_id, &gates, &ledger, Timestamp::now().as_nanos()).unwrap());
|
assert!(
|
||||||
|
!passes_gates(
|
||||||
|
entity_id,
|
||||||
|
&gates,
|
||||||
|
&ledger,
|
||||||
|
Timestamp::now().as_nanos(),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -389,7 +431,16 @@ mod tests {
|
|||||||
min_threshold: 5.0,
|
min_threshold: 5.0,
|
||||||
}];
|
}];
|
||||||
// count=5 >= threshold=5 -> candidate included.
|
// count=5 >= threshold=5 -> candidate included.
|
||||||
assert!(passes_gates(entity_id, &gates, &ledger, Timestamp::now().as_nanos()).unwrap());
|
assert!(
|
||||||
|
passes_gates(
|
||||||
|
entity_id,
|
||||||
|
&gates,
|
||||||
|
&ledger,
|
||||||
|
Timestamp::now().as_nanos(),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod formulas;
|
pub mod formulas;
|
||||||
pub mod helpers;
|
pub mod helpers;
|
||||||
|
pub mod signal_values;
|
||||||
|
|
||||||
// Re-export all public items so that `crate::ranking::executor::Foo` paths continue to work.
|
// Re-export all public items so that `crate::ranking::executor::Foo` paths continue to work.
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@ -25,6 +26,7 @@ use std::sync::Arc;
|
|||||||
pub use context::{ScoredCandidate, SignalKey, SignalSnapshot, UserContext};
|
pub use context::{ScoredCandidate, SignalKey, SignalSnapshot, UserContext};
|
||||||
use formulas::{INTERACTION_BOOST_WEIGHT, PREFERENCE_BOOST_WEIGHT, RELEVANCE_BASE_WEIGHT};
|
use formulas::{INTERACTION_BOOST_WEIGHT, PREFERENCE_BOOST_WEIGHT, RELEVANCE_BASE_WEIGHT};
|
||||||
use helpers::{normalize, passes_excludes, passes_gates, read_agg_for_sort};
|
use helpers::{normalize, passes_excludes, passes_gates, read_agg_for_sort};
|
||||||
|
pub use signal_values::{SignalReadPlan, SignalValues};
|
||||||
|
|
||||||
use super::profile::{RankingProfile, SignalAgg};
|
use super::profile::{RankingProfile, SignalAgg};
|
||||||
use crate::{
|
use crate::{
|
||||||
@ -122,6 +124,14 @@ pub struct ProfileExecutor<'a> {
|
|||||||
/// no-`FOR USER` case, which yields a stable per-minute *global* permutation
|
/// no-`FOR USER` case, which yields a stable per-minute *global* permutation
|
||||||
/// rather than per-user variety. Set via [`Self::with_shuffle_user`].
|
/// rather than per-user variety. Set via [`Self::with_shuffle_user`].
|
||||||
shuffle_user_id: u64,
|
shuffle_user_id: u64,
|
||||||
|
/// Whether to run the per-candidate signal-value pre-pass
|
||||||
|
/// ([`SignalReadPlan`]) that collapses redundant ledger gets to one
|
||||||
|
/// `entries.get()` per distinct signal type. On by default; the A/B
|
||||||
|
/// property test (`signal_plan_matches_per_term_path`) flips it off to score
|
||||||
|
/// the same inputs through the direct per-term path and assert identical
|
||||||
|
/// output, and it doubles as a runtime fallback. Pure optimization — a miss
|
||||||
|
/// always falls back to a direct read.
|
||||||
|
use_signal_plan: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ProfileExecutor<'a> {
|
impl<'a> ProfileExecutor<'a> {
|
||||||
@ -137,9 +147,22 @@ impl<'a> ProfileExecutor<'a> {
|
|||||||
user_state_for_date_saved: None,
|
user_state_for_date_saved: None,
|
||||||
degradation_level: DegradationLevel::Full,
|
degradation_level: DegradationLevel::Full,
|
||||||
shuffle_user_id: 0,
|
shuffle_user_id: 0,
|
||||||
|
use_signal_plan: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Toggle the signal-value pre-pass (see [`Self::use_signal_plan`]).
|
||||||
|
///
|
||||||
|
/// On by default. The A/B property test and the `ranking` benchmark flip it
|
||||||
|
/// off to score the same inputs through the direct per-term path; it also
|
||||||
|
/// serves as an operator escape hatch since the pre-pass is a pure
|
||||||
|
/// optimization (every read falls back to a direct ledger read on a miss).
|
||||||
|
#[must_use]
|
||||||
|
pub const fn with_signal_plan(mut self, enabled: bool) -> Self {
|
||||||
|
self.use_signal_plan = enabled;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the user identity for the `Shuffle` sort's per-user, per-minute seed.
|
/// Set the user identity for the `Shuffle` sort's per-user, per-minute seed.
|
||||||
///
|
///
|
||||||
/// Spec §11.6 seeds shuffle with `hash(user_id, timestamp_minute)` so a
|
/// Spec §11.6 seeds shuffle with `hash(user_id, timestamp_minute)` so a
|
||||||
@ -454,21 +477,35 @@ impl<'a> ProfileExecutor<'a> {
|
|||||||
// Dynamic snapshot labels (`{signal}_boost`/`_penalty`/`_decay`) are
|
// Dynamic snapshot labels (`{signal}_boost`/`_penalty`/`_decay`) are
|
||||||
// invariant across candidates — build them once here, not per candidate.
|
// invariant across candidates — build them once here, not per candidate.
|
||||||
let labels = RuleLabels::build(profile);
|
let labels = RuleLabels::build(profile);
|
||||||
|
// Signal-value pre-pass plan (which (type, agg, window) reads the profile
|
||||||
|
// performs) is also entity-independent — build it once per query. Per
|
||||||
|
// candidate, `SignalReadPlan::compute` does one `entries.get()` per
|
||||||
|
// distinct signal type; every Pass-2 read below consults the result and
|
||||||
|
// falls back to a direct read on a miss, so it never changes the output.
|
||||||
|
let plan = self
|
||||||
|
.use_signal_plan
|
||||||
|
.then(|| SignalReadPlan::build(profile, self.ledger, self.degradation_level));
|
||||||
// Pre-size to the candidate count: every candidate that survives
|
// Pre-size to the candidate count: every candidate that survives
|
||||||
// exclude/gate pushes exactly one entry, so this is the exact upper bound
|
// exclude/gate pushes exactly one entry, so this is the exact upper bound
|
||||||
// and removes the Vec's growth reallocations on the scoring hot path.
|
// and removes the Vec's growth reallocations on the scoring hot path.
|
||||||
let mut scored: Vec<ScoredCandidate> = Vec::with_capacity(candidates.len());
|
let mut scored: Vec<ScoredCandidate> = Vec::with_capacity(candidates.len());
|
||||||
for &entity_id in candidates {
|
for &entity_id in candidates {
|
||||||
|
// Pass 1: collapse this candidate's redundant ledger gets to one per
|
||||||
|
// signal type (`None` when the pre-pass is disabled → direct reads).
|
||||||
|
let values = plan
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.compute(self.ledger, entity_id, now_ns));
|
||||||
|
let vals = values.as_ref();
|
||||||
// Stage 2: hard exclusion -- remove content the user must never see.
|
// Stage 2: hard exclusion -- remove content the user must never see.
|
||||||
if !passes_excludes(entity_id, &profile.excludes, self.ledger, now_ns)? {
|
if !passes_excludes(entity_id, &profile.excludes, self.ledger, now_ns, vals)? {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Stage 6: gates -- remove candidates below quality thresholds.
|
// Stage 6: gates -- remove candidates below quality thresholds.
|
||||||
if !passes_gates(entity_id, &profile.gates, self.ledger, now_ns)? {
|
if !passes_gates(entity_id, &profile.gates, self.ledger, now_ns, vals)? {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let (raw, mut snapshot) =
|
let (raw, mut snapshot) =
|
||||||
self.compute_raw_score(entity_id, profile, now, retrieval_scores, &labels)?;
|
self.compute_raw_score(entity_id, profile, now, retrieval_scores, &labels, vals)?;
|
||||||
let metadata = item_metadata.get(&entity_id.as_u64()).map_or(&empty, |m| m);
|
let metadata = item_metadata.get(&entity_id.as_u64()).map_or(&empty, |m| m);
|
||||||
let session_boost = session_ctx.map_or(0.0, |ctx| {
|
let session_boost = session_ctx.map_or(0.0, |ctx| {
|
||||||
Self::session_boost(
|
Self::session_boost(
|
||||||
@ -595,10 +632,11 @@ impl<'a> ProfileExecutor<'a> {
|
|||||||
now: Timestamp,
|
now: Timestamp,
|
||||||
retrieval_scores: Option<&HashMap<u64, f64>>,
|
retrieval_scores: Option<&HashMap<u64, f64>>,
|
||||||
labels: &RuleLabels,
|
labels: &RuleLabels,
|
||||||
|
vals: Option<&SignalValues>,
|
||||||
) -> crate::Result<(f64, SignalSnapshot)> {
|
) -> crate::Result<(f64, SignalSnapshot)> {
|
||||||
let now_ns = now.as_nanos();
|
let now_ns = now.as_nanos();
|
||||||
let (sort_base, mut snapshot) =
|
let (sort_base, mut snapshot) =
|
||||||
self.score_by_sort(entity_id, profile.sort.as_ref(), now)?;
|
self.score_by_sort(entity_id, profile.sort.as_ref(), now, vals)?;
|
||||||
|
|
||||||
// Retrieval relevance seed (SEARCH Stage 1c). When the caller threads a
|
// Retrieval relevance seed (SEARCH Stage 1c). When the caller threads a
|
||||||
// fused RRF score, fold it into the base so the fused order anchors the
|
// fused RRF score, fold it into the base so the fused order anchors the
|
||||||
@ -639,6 +677,7 @@ impl<'a> ProfileExecutor<'a> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
let weighted = b.weight * val;
|
let weighted = b.weight * val;
|
||||||
if weighted != 0.0 {
|
if weighted != 0.0 {
|
||||||
@ -687,6 +726,7 @@ impl<'a> ProfileExecutor<'a> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
let weighted = p.weight * val;
|
let weighted = p.weight * val;
|
||||||
if weighted != 0.0 {
|
if weighted != 0.0 {
|
||||||
@ -720,6 +760,7 @@ impl<'a> ProfileExecutor<'a> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?
|
)?
|
||||||
.clamp(0.0, 1.0);
|
.clamp(0.0, 1.0);
|
||||||
let factor = decay.weight.mul_add(recency, 1.0 - decay.weight);
|
let factor = decay.weight.mul_add(recency, 1.0 - decay.weight);
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
use smallvec::smallvec;
|
use smallvec::smallvec;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
ProfileExecutor, SignalKey, SignalSnapshot,
|
ProfileExecutor, SignalKey, SignalSnapshot, SignalValues,
|
||||||
formulas::{
|
formulas::{
|
||||||
controversial_score, hidden_gems_score, hot_score, shuffle_quality_score,
|
controversial_score, hidden_gems_score, hot_score, shuffle_quality_score,
|
||||||
shuffle_quality_weight, shuffle_random, trending_score,
|
shuffle_quality_weight, shuffle_random, trending_score,
|
||||||
@ -75,21 +75,23 @@ impl ProfileExecutor<'_> {
|
|||||||
/// [`read_agg_for_sort`], so a signal the schema omits contributes 0.0
|
/// [`read_agg_for_sort`], so a signal the schema omits contributes 0.0
|
||||||
/// rather than erroring; any other read error (notably an unimplemented
|
/// rather than erroring; any other read error (notably an unimplemented
|
||||||
/// aggregation) still propagates.
|
/// aggregation) still propagates.
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
pub(super) fn score_by_sort(
|
pub(super) fn score_by_sort(
|
||||||
&self,
|
&self,
|
||||||
entity_id: EntityId,
|
entity_id: EntityId,
|
||||||
sort: Option<&Sort>,
|
sort: Option<&Sort>,
|
||||||
now: Timestamp,
|
now: Timestamp,
|
||||||
|
vals: Option<&SignalValues>,
|
||||||
) -> crate::Result<(f64, SignalSnapshot)> {
|
) -> crate::Result<(f64, SignalSnapshot)> {
|
||||||
// Capture the query clock ONCE so every per-candidate ledger read below
|
// Capture the query clock ONCE so every per-candidate ledger read below
|
||||||
// ages to the same "now" (reproducible scores; one fewer syscall per read).
|
// ages to the same "now" (reproducible scores; one fewer syscall per read).
|
||||||
let now_ns = now.as_nanos();
|
let now_ns = now.as_nanos();
|
||||||
match sort {
|
match sort {
|
||||||
Some(Sort::Hot { gravity }) => self.score_hot(entity_id, *gravity, now),
|
Some(Sort::Hot { gravity }) => self.score_hot(entity_id, *gravity, now, vals),
|
||||||
Some(Sort::Trending) => self.score_trending(entity_id, now_ns),
|
Some(Sort::Trending) => self.score_trending(entity_id, now_ns, vals),
|
||||||
Some(Sort::Controversial) => self.score_controversial(entity_id, now_ns),
|
Some(Sort::Controversial) => self.score_controversial(entity_id, now_ns, vals),
|
||||||
Some(Sort::HiddenGems) => self.score_hidden_gems(entity_id, now_ns),
|
Some(Sort::HiddenGems) => self.score_hidden_gems(entity_id, now_ns, vals),
|
||||||
Some(Sort::Shuffle) => Ok((self.score_shuffle(entity_id, now_ns)?, smallvec![])),
|
Some(Sort::Shuffle) => Ok((self.score_shuffle(entity_id, now_ns, vals)?, smallvec![])),
|
||||||
Some(Sort::New) => {
|
Some(Sort::New) => {
|
||||||
// M2 limitation: entity metadata (`created_at`) is not accessible from the
|
// M2 limitation: entity metadata (`created_at`) is not accessible from the
|
||||||
// executor. Entity ID is used as a proxy for recency -- ranks higher IDs
|
// executor. Entity ID is used as a proxy for recency -- ranks higher IDs
|
||||||
@ -103,19 +105,32 @@ impl ProfileExecutor<'_> {
|
|||||||
let score = entity_id.as_u64() as f64;
|
let score = entity_id.as_u64() as f64;
|
||||||
Ok((score, smallvec![]))
|
Ok((score, smallvec![]))
|
||||||
}
|
}
|
||||||
Some(Sort::TopWindow { window }) => self.score_top_window(entity_id, *window, now_ns),
|
Some(Sort::TopWindow { window }) => {
|
||||||
Some(Sort::MostViewed { window }) => {
|
self.score_top_window(entity_id, *window, now_ns, vals)
|
||||||
self.single_signal_score(entity_id, "view", &SignalAgg::Value, *window, now_ns)
|
|
||||||
}
|
|
||||||
Some(Sort::MostLiked { window }) => {
|
|
||||||
self.single_signal_score(entity_id, "like", &SignalAgg::Value, *window, now_ns)
|
|
||||||
}
|
}
|
||||||
|
Some(Sort::MostViewed { window }) => self.single_signal_score(
|
||||||
|
entity_id,
|
||||||
|
"view",
|
||||||
|
&SignalAgg::Value,
|
||||||
|
*window,
|
||||||
|
now_ns,
|
||||||
|
vals,
|
||||||
|
),
|
||||||
|
Some(Sort::MostLiked { window }) => self.single_signal_score(
|
||||||
|
entity_id,
|
||||||
|
"like",
|
||||||
|
&SignalAgg::Value,
|
||||||
|
*window,
|
||||||
|
now_ns,
|
||||||
|
vals,
|
||||||
|
),
|
||||||
Some(Sort::MostFollowed) => self.single_signal_score(
|
Some(Sort::MostFollowed) => self.single_signal_score(
|
||||||
entity_id,
|
entity_id,
|
||||||
"follow",
|
"follow",
|
||||||
&SignalAgg::Value,
|
&SignalAgg::Value,
|
||||||
Window::AllTime,
|
Window::AllTime,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
),
|
),
|
||||||
Some(Sort::CreatorEngagementRate) => {
|
Some(Sort::CreatorEngagementRate) => {
|
||||||
let view_vel = read_agg_for_sort(
|
let view_vel = read_agg_for_sort(
|
||||||
@ -126,6 +141,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
let like_vel = read_agg_for_sort(
|
let like_vel = read_agg_for_sort(
|
||||||
entity_id,
|
entity_id,
|
||||||
@ -135,6 +151,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
Ok((
|
Ok((
|
||||||
view_vel + like_vel,
|
view_vel + like_vel,
|
||||||
@ -144,7 +161,7 @@ impl ProfileExecutor<'_> {
|
|||||||
],
|
],
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
Some(Sort::Rising) => self.score_rising(entity_id, now_ns),
|
Some(Sort::Rising) => self.score_rising(entity_id, now_ns, vals),
|
||||||
Some(Sort::AlphabeticalAsc) => {
|
Some(Sort::AlphabeticalAsc) => {
|
||||||
Ok((self.score_alphabetical_asc(entity_id), smallvec![]))
|
Ok((self.score_alphabetical_asc(entity_id), smallvec![]))
|
||||||
}
|
}
|
||||||
@ -153,18 +170,29 @@ impl ProfileExecutor<'_> {
|
|||||||
}
|
}
|
||||||
Some(Sort::Shortest) => Ok((self.score_shortest(entity_id), smallvec![])),
|
Some(Sort::Shortest) => Ok((self.score_shortest(entity_id), smallvec![])),
|
||||||
Some(Sort::Longest) => Ok((self.score_longest(entity_id), smallvec![])),
|
Some(Sort::Longest) => Ok((self.score_longest(entity_id), smallvec![])),
|
||||||
Some(Sort::MostCommented { window }) => {
|
Some(Sort::MostCommented { window }) => self.single_signal_score(
|
||||||
self.single_signal_score(entity_id, "comment", &SignalAgg::Value, *window, now_ns)
|
entity_id,
|
||||||
}
|
"comment",
|
||||||
Some(Sort::MostShared { window }) => {
|
&SignalAgg::Value,
|
||||||
self.single_signal_score(entity_id, "share", &SignalAgg::Value, *window, now_ns)
|
*window,
|
||||||
}
|
now_ns,
|
||||||
|
vals,
|
||||||
|
),
|
||||||
|
Some(Sort::MostShared { window }) => self.single_signal_score(
|
||||||
|
entity_id,
|
||||||
|
"share",
|
||||||
|
&SignalAgg::Value,
|
||||||
|
*window,
|
||||||
|
now_ns,
|
||||||
|
vals,
|
||||||
|
),
|
||||||
Some(Sort::LiveViewerCount) => self.single_signal_score(
|
Some(Sort::LiveViewerCount) => self.single_signal_score(
|
||||||
entity_id,
|
entity_id,
|
||||||
"viewer_count",
|
"viewer_count",
|
||||||
&SignalAgg::DecayScore,
|
&SignalAgg::DecayScore,
|
||||||
Window::AllTime,
|
Window::AllTime,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
),
|
),
|
||||||
Some(Sort::DateSaved) => Ok((self.score_date_saved(entity_id), smallvec![])),
|
Some(Sort::DateSaved) => Ok((self.score_date_saved(entity_id), smallvec![])),
|
||||||
None => Ok((0.0, smallvec![])),
|
None => Ok((0.0, smallvec![])),
|
||||||
@ -182,6 +210,7 @@ impl ProfileExecutor<'_> {
|
|||||||
entity_id: EntityId,
|
entity_id: EntityId,
|
||||||
gravity: f64,
|
gravity: f64,
|
||||||
now: Timestamp,
|
now: Timestamp,
|
||||||
|
vals: Option<&SignalValues>,
|
||||||
) -> crate::Result<(f64, SignalSnapshot)> {
|
) -> crate::Result<(f64, SignalSnapshot)> {
|
||||||
let views = read_agg_for_sort(
|
let views = read_agg_for_sort(
|
||||||
entity_id,
|
entity_id,
|
||||||
@ -191,6 +220,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now.as_nanos(),
|
now.as_nanos(),
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
// The scoring loop receives no per-entity `created_at`: the executor reads
|
// The scoring loop receives no per-entity `created_at`: the executor reads
|
||||||
// only the signal ledger, and the `created_at` range index is keyed
|
// only the signal ledger, and the `created_at` range index is keyed
|
||||||
@ -232,7 +262,12 @@ impl ProfileExecutor<'_> {
|
|||||||
///
|
///
|
||||||
/// Propagates any non-degradable ledger read error (an unimplemented
|
/// Propagates any non-degradable ledger read error (an unimplemented
|
||||||
/// aggregation), matching the other formula sorts.
|
/// aggregation), matching the other formula sorts.
|
||||||
fn score_shuffle(&self, entity_id: EntityId, now_ns: u64) -> crate::Result<f64> {
|
fn score_shuffle(
|
||||||
|
&self,
|
||||||
|
entity_id: EntityId,
|
||||||
|
now_ns: u64,
|
||||||
|
vals: Option<&SignalValues>,
|
||||||
|
) -> crate::Result<f64> {
|
||||||
// Quality inputs, all aged to the single query clock for reproducibility.
|
// Quality inputs, all aged to the single query clock for reproducibility.
|
||||||
let completion_rate = read_agg_for_sort(
|
let completion_rate = read_agg_for_sort(
|
||||||
entity_id,
|
entity_id,
|
||||||
@ -242,6 +277,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?
|
)?
|
||||||
.clamp(0.0, 1.0);
|
.clamp(0.0, 1.0);
|
||||||
let likes = read_agg_for_sort(
|
let likes = read_agg_for_sort(
|
||||||
@ -252,6 +288,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
let views = read_agg_for_sort(
|
let views = read_agg_for_sort(
|
||||||
entity_id,
|
entity_id,
|
||||||
@ -261,6 +298,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
// like_ratio = likes / (views + 1), clamped to [0, 1]: a like is only
|
// like_ratio = likes / (views + 1), clamped to [0, 1]: a like is only
|
||||||
// emitted by a viewer, so the ratio is a bounded engagement-quality proxy.
|
// emitted by a viewer, so the ratio is a bounded engagement-quality proxy.
|
||||||
@ -285,6 +323,7 @@ impl ProfileExecutor<'_> {
|
|||||||
agg: &SignalAgg,
|
agg: &SignalAgg,
|
||||||
window: Window,
|
window: Window,
|
||||||
now_ns: u64,
|
now_ns: u64,
|
||||||
|
vals: Option<&SignalValues>,
|
||||||
) -> crate::Result<(f64, SignalSnapshot)> {
|
) -> crate::Result<(f64, SignalSnapshot)> {
|
||||||
let val = read_agg_for_sort(
|
let val = read_agg_for_sort(
|
||||||
entity_id,
|
entity_id,
|
||||||
@ -294,6 +333,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
Ok((val, smallvec![(SignalKey::Static(signal), val)]))
|
Ok((val, smallvec![(SignalKey::Static(signal), val)]))
|
||||||
}
|
}
|
||||||
@ -302,6 +342,7 @@ impl ProfileExecutor<'_> {
|
|||||||
&self,
|
&self,
|
||||||
entity_id: EntityId,
|
entity_id: EntityId,
|
||||||
now_ns: u64,
|
now_ns: u64,
|
||||||
|
vals: Option<&SignalValues>,
|
||||||
) -> crate::Result<(f64, SignalSnapshot)> {
|
) -> crate::Result<(f64, SignalSnapshot)> {
|
||||||
// M6: social-graph-scoped trending. When a social subgraph and
|
// M6: social-graph-scoped trending. When a social subgraph and
|
||||||
// per-user signal index are available, compute aggregate velocity
|
// per-user signal index are available, compute aggregate velocity
|
||||||
@ -356,6 +397,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
let share_vel = read_agg_for_sort(
|
let share_vel = read_agg_for_sort(
|
||||||
entity_id,
|
entity_id,
|
||||||
@ -365,6 +407,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
Ok((
|
Ok((
|
||||||
trending_score(view_vel, share_vel),
|
trending_score(view_vel, share_vel),
|
||||||
@ -379,6 +422,7 @@ impl ProfileExecutor<'_> {
|
|||||||
&self,
|
&self,
|
||||||
entity_id: EntityId,
|
entity_id: EntityId,
|
||||||
now_ns: u64,
|
now_ns: u64,
|
||||||
|
vals: Option<&SignalValues>,
|
||||||
) -> crate::Result<(f64, SignalSnapshot)> {
|
) -> crate::Result<(f64, SignalSnapshot)> {
|
||||||
let pos = read_agg_for_sort(
|
let pos = read_agg_for_sort(
|
||||||
entity_id,
|
entity_id,
|
||||||
@ -388,6 +432,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
let neg = read_agg_for_sort(
|
let neg = read_agg_for_sort(
|
||||||
entity_id,
|
entity_id,
|
||||||
@ -397,6 +442,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
Ok((
|
Ok((
|
||||||
controversial_score(pos, neg),
|
controversial_score(pos, neg),
|
||||||
@ -411,6 +457,7 @@ impl ProfileExecutor<'_> {
|
|||||||
&self,
|
&self,
|
||||||
entity_id: EntityId,
|
entity_id: EntityId,
|
||||||
now_ns: u64,
|
now_ns: u64,
|
||||||
|
vals: Option<&SignalValues>,
|
||||||
) -> crate::Result<(f64, SignalSnapshot)> {
|
) -> crate::Result<(f64, SignalSnapshot)> {
|
||||||
let quality = read_agg_for_sort(
|
let quality = read_agg_for_sort(
|
||||||
entity_id,
|
entity_id,
|
||||||
@ -420,6 +467,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
let view_count = read_agg_for_sort(
|
let view_count = read_agg_for_sort(
|
||||||
entity_id,
|
entity_id,
|
||||||
@ -429,6 +477,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
Ok((
|
Ok((
|
||||||
hidden_gems_score(quality, view_count),
|
hidden_gems_score(quality, view_count),
|
||||||
@ -444,6 +493,7 @@ impl ProfileExecutor<'_> {
|
|||||||
entity_id: EntityId,
|
entity_id: EntityId,
|
||||||
window: Window,
|
window: Window,
|
||||||
now_ns: u64,
|
now_ns: u64,
|
||||||
|
vals: Option<&SignalValues>,
|
||||||
) -> crate::Result<(f64, SignalSnapshot)> {
|
) -> crate::Result<(f64, SignalSnapshot)> {
|
||||||
let views = read_agg_for_sort(
|
let views = read_agg_for_sort(
|
||||||
entity_id,
|
entity_id,
|
||||||
@ -453,6 +503,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
let likes = read_agg_for_sort(
|
let likes = read_agg_for_sort(
|
||||||
entity_id,
|
entity_id,
|
||||||
@ -462,6 +513,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
let shares = read_agg_for_sort(
|
let shares = read_agg_for_sort(
|
||||||
entity_id,
|
entity_id,
|
||||||
@ -471,6 +523,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
let completion = read_agg_for_sort(
|
let completion = read_agg_for_sort(
|
||||||
entity_id,
|
entity_id,
|
||||||
@ -480,6 +533,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
Ok((
|
Ok((
|
||||||
views.mul_add(
|
views.mul_add(
|
||||||
@ -499,6 +553,7 @@ impl ProfileExecutor<'_> {
|
|||||||
&self,
|
&self,
|
||||||
entity_id: EntityId,
|
entity_id: EntityId,
|
||||||
now_ns: u64,
|
now_ns: u64,
|
||||||
|
vals: Option<&SignalValues>,
|
||||||
) -> crate::Result<(f64, SignalSnapshot)> {
|
) -> crate::Result<(f64, SignalSnapshot)> {
|
||||||
let short = read_agg_for_sort(
|
let short = read_agg_for_sort(
|
||||||
entity_id,
|
entity_id,
|
||||||
@ -508,6 +563,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
let long = read_agg_for_sort(
|
let long = read_agg_for_sort(
|
||||||
entity_id,
|
entity_id,
|
||||||
@ -517,6 +573,7 @@ impl ProfileExecutor<'_> {
|
|||||||
self.ledger,
|
self.ledger,
|
||||||
self.degradation_level,
|
self.degradation_level,
|
||||||
now_ns,
|
now_ns,
|
||||||
|
vals,
|
||||||
)?;
|
)?;
|
||||||
let score = if long < f64::EPSILON {
|
let score = if long < f64::EPSILON {
|
||||||
short
|
short
|
||||||
|
|||||||
320
tidal/src/ranking/executor/signal_values.rs
Normal file
320
tidal/src/ranking/executor/signal_values.rs
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
//! Per-candidate signal-value pre-pass — collapse redundant signal-ledger gets.
|
||||||
|
//!
|
||||||
|
//! The per-candidate scoring loop reads the signal ledger once per
|
||||||
|
//! `(signal, agg, window)` term. Many profiles read the **same signal type** at
|
||||||
|
//! several aggregations or from several stages — e.g. a `Sort::Hot` reads `view`
|
||||||
|
//! `Value` while a boost reads `view` `DecayScore` — so a naive loop does one
|
||||||
|
//! sharded `DashMap` get per term even when several terms hit the same entry.
|
||||||
|
//!
|
||||||
|
//! [`SignalReadPlan`] + [`SignalValues`] collapse that into one get per distinct
|
||||||
|
//! signal type per candidate, in two passes:
|
||||||
|
//!
|
||||||
|
//! - **Pass 1** ([`SignalReadPlan::compute`]): for each distinct `SignalTypeId`
|
||||||
|
//! the profile reads, take ONE [`SignalLedger::entry_ref`], evaluate every
|
||||||
|
//! planned `(agg, window)` against the held entry, then **drop the ref before
|
||||||
|
//! the next type**. Only one entry ref is ever held at a time, so this cannot
|
||||||
|
//! deadlock against `DashMap`'s per-shard `RwLock` (holding a ref while getting
|
||||||
|
//! another key on the same shard is a documented `DashMap` deadlock).
|
||||||
|
//!
|
||||||
|
//! - **Pass 2** (the existing scoring code, threading `Option<&SignalValues>`):
|
||||||
|
//! each read consults this table first, in declared order, so exclude/gate
|
||||||
|
//! short-circuit and snapshot push order are byte-for-byte unchanged. **A miss
|
||||||
|
//! falls back to a direct ledger read**, so an incomplete or approximate plan
|
||||||
|
//! only costs the optimization — never correctness. That fallback is what lets
|
||||||
|
//! the sort-read enumeration below be a best-effort superset.
|
||||||
|
//!
|
||||||
|
//! The plan is entity-independent (the profile fixes which signals/aggs/windows
|
||||||
|
//! are read), so it is built once per query and reused for every candidate.
|
||||||
|
|
||||||
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
|
use super::helpers::{COARSE_VALUE_WINDOW, COARSE_VELOCITY_WINDOW};
|
||||||
|
use crate::{
|
||||||
|
load::DegradationLevel,
|
||||||
|
ranking::profile::{RankingProfile, SignalAgg, Sort},
|
||||||
|
schema::{EntityId, Window},
|
||||||
|
signals::{SignalLedger, SignalTypeId},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The three entry-backed aggregations the pre-pass can collapse, as a small
|
||||||
|
/// `Copy` key (the public [`SignalAgg`] is intentionally neither `Copy` nor
|
||||||
|
/// `Eq`). `Ratio`/`RelativeVelocity` are not representable here — they are never
|
||||||
|
/// planned and fall through to the direct read, which raises the same error.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum AggKind {
|
||||||
|
Value,
|
||||||
|
Velocity,
|
||||||
|
Decay,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AggKind {
|
||||||
|
const fn from_agg(agg: &SignalAgg) -> Option<Self> {
|
||||||
|
match agg {
|
||||||
|
SignalAgg::Value => Some(Self::Value),
|
||||||
|
SignalAgg::Velocity => Some(Self::Velocity),
|
||||||
|
SignalAgg::DecayScore => Some(Self::Decay),
|
||||||
|
SignalAgg::Ratio | SignalAgg::RelativeVelocity => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effective read key after degradation substitution and `Decay`
|
||||||
|
/// window-normalization. Plan entries and Pass-2 lookups are both keyed this
|
||||||
|
/// way, so a lookup hits iff the same effective read was planned.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct ReadKey {
|
||||||
|
type_id: SignalTypeId,
|
||||||
|
agg: AggKind,
|
||||||
|
window: Window,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the effective window for a read: apply the same degradation
|
||||||
|
/// substitution [`super::helpers::read_agg`] applies, then canonicalize
|
||||||
|
/// `Decay` (which ignores its window) to a single window so a lookup hits
|
||||||
|
/// regardless of the term's declared window.
|
||||||
|
const fn effective_window(agg: AggKind, window: Window, degradation: DegradationLevel) -> Window {
|
||||||
|
match agg {
|
||||||
|
// `Decay` is an O(1) running quantity independent of `window` (see
|
||||||
|
// `read_agg`); canonicalize so a boost/decay term with any declared
|
||||||
|
// window resolves to the same planned value.
|
||||||
|
AggKind::Decay => Window::AllTime,
|
||||||
|
AggKind::Value if degradation.coarsens_aggregates() => COARSE_VALUE_WINDOW,
|
||||||
|
AggKind::Velocity if degradation.coarsens_aggregates() => COARSE_VELOCITY_WINDOW,
|
||||||
|
AggKind::Value | AggKind::Velocity => window,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixed signal vocabulary a `Sort` mode reads from the ledger, mirroring
|
||||||
|
/// [`super::ProfileExecutor::score_by_sort`]. Best-effort: this is only a
|
||||||
|
/// performance hint (Pass 2 falls back to a direct read on any miss), so a sort
|
||||||
|
/// whose reads drift from this list stays correct — it just doesn't collapse.
|
||||||
|
/// Sort modes that read no ledger signals (`New`, `Alphabetical*`, `Shortest`,
|
||||||
|
/// `Longest`, `DateSaved`) and the social-graph trending path (which reads the
|
||||||
|
/// per-user signal index, not `entries`) contribute nothing here.
|
||||||
|
fn sort_signal_reads(sort: Option<&Sort>) -> SmallVec<[(&'static str, SignalAgg, Window); 4]> {
|
||||||
|
use SignalAgg::{DecayScore, Value, Velocity};
|
||||||
|
use Window::{AllTime, OneHour, TwentyFourHours};
|
||||||
|
let mut v: SmallVec<[(&'static str, SignalAgg, Window); 4]> = SmallVec::new();
|
||||||
|
match sort {
|
||||||
|
Some(Sort::Hot { .. }) => v.push(("view", Value, AllTime)),
|
||||||
|
Some(Sort::Trending) => {
|
||||||
|
v.push(("view", Velocity, TwentyFourHours));
|
||||||
|
v.push(("share", Velocity, TwentyFourHours));
|
||||||
|
}
|
||||||
|
Some(Sort::Controversial) => {
|
||||||
|
v.push(("like", Value, AllTime));
|
||||||
|
v.push(("dislike", Value, AllTime));
|
||||||
|
}
|
||||||
|
Some(Sort::HiddenGems) => {
|
||||||
|
v.push(("completion", DecayScore, AllTime));
|
||||||
|
v.push(("view", Value, AllTime));
|
||||||
|
}
|
||||||
|
Some(Sort::Shuffle) => {
|
||||||
|
v.push(("completion", DecayScore, AllTime));
|
||||||
|
v.push(("like", Value, AllTime));
|
||||||
|
v.push(("view", Value, AllTime));
|
||||||
|
}
|
||||||
|
Some(Sort::TopWindow { window }) => {
|
||||||
|
v.push(("view", Value, *window));
|
||||||
|
v.push(("like", Value, *window));
|
||||||
|
v.push(("share", Value, *window));
|
||||||
|
v.push(("completion", Value, *window));
|
||||||
|
}
|
||||||
|
Some(Sort::MostViewed { window }) => v.push(("view", Value, *window)),
|
||||||
|
Some(Sort::MostLiked { window }) => v.push(("like", Value, *window)),
|
||||||
|
Some(Sort::MostFollowed) => v.push(("follow", Value, AllTime)),
|
||||||
|
Some(Sort::CreatorEngagementRate) => {
|
||||||
|
v.push(("view", Velocity, TwentyFourHours));
|
||||||
|
v.push(("like", Velocity, TwentyFourHours));
|
||||||
|
}
|
||||||
|
Some(Sort::Rising) => {
|
||||||
|
v.push(("view", Velocity, OneHour));
|
||||||
|
v.push(("view", Velocity, TwentyFourHours));
|
||||||
|
}
|
||||||
|
Some(Sort::MostCommented { window }) => v.push(("comment", Value, *window)),
|
||||||
|
Some(Sort::MostShared { window }) => v.push(("share", Value, *window)),
|
||||||
|
Some(Sort::LiveViewerCount) => v.push(("viewer_count", DecayScore, AllTime)),
|
||||||
|
// No ledger reads: New, AlphabeticalAsc/Desc, Shortest, Longest, DateSaved, None.
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-query read plan: the distinct effective `(type, agg, window)` reads
|
||||||
|
/// the profile performs, plus the distinct signal types (so Pass 1 can iterate
|
||||||
|
/// one get per type).
|
||||||
|
pub struct SignalReadPlan {
|
||||||
|
reads: SmallVec<[ReadKey; 16]>,
|
||||||
|
types: SmallVec<[SignalTypeId; 8]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignalReadPlan {
|
||||||
|
/// Build the read plan for a profile. Entity-independent — call once per
|
||||||
|
/// query, [`compute`](Self::compute) once per candidate.
|
||||||
|
///
|
||||||
|
/// Resolution failures (`UnknownSignalType`) and the unsupported
|
||||||
|
/// `Ratio`/`RelativeVelocity` aggregations are simply omitted: the Pass-2
|
||||||
|
/// fallback then performs the direct read, which preserves the exact
|
||||||
|
/// error contract (gates/excludes propagate, sort/boosts swallow, the
|
||||||
|
/// unsupported aggs error) without this pre-pass having to replicate it.
|
||||||
|
#[must_use]
|
||||||
|
pub fn build(
|
||||||
|
profile: &RankingProfile,
|
||||||
|
ledger: &SignalLedger,
|
||||||
|
degradation: DegradationLevel,
|
||||||
|
) -> Self {
|
||||||
|
let mut plan = Self {
|
||||||
|
reads: SmallVec::new(),
|
||||||
|
types: SmallVec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gates and excludes always read at Full degradation (correctness
|
||||||
|
// filters — see `passes_gates`/`passes_excludes`).
|
||||||
|
for g in &profile.gates {
|
||||||
|
plan.add(ledger, &g.signal, &g.agg, g.window, DegradationLevel::Full);
|
||||||
|
}
|
||||||
|
for e in &profile.excludes {
|
||||||
|
plan.add(ledger, &e.signal, &e.agg, e.window, DegradationLevel::Full);
|
||||||
|
}
|
||||||
|
// Boosts/penalties/decay and the sort base read at the query degradation.
|
||||||
|
for b in &profile.boosts {
|
||||||
|
plan.add(ledger, &b.signal, &b.agg, b.window, degradation);
|
||||||
|
}
|
||||||
|
for p in &profile.penalties {
|
||||||
|
plan.add(ledger, &p.signal, &p.agg, p.window, degradation);
|
||||||
|
}
|
||||||
|
if let Some(d) = &profile.decay {
|
||||||
|
plan.add(
|
||||||
|
ledger,
|
||||||
|
&d.signal,
|
||||||
|
&SignalAgg::DecayScore,
|
||||||
|
Window::AllTime,
|
||||||
|
degradation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (signal, agg, window) in sort_signal_reads(profile.sort.as_ref()) {
|
||||||
|
plan.add(ledger, signal, &agg, window, degradation);
|
||||||
|
}
|
||||||
|
plan
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(
|
||||||
|
&mut self,
|
||||||
|
ledger: &SignalLedger,
|
||||||
|
signal: &str,
|
||||||
|
agg: &SignalAgg,
|
||||||
|
window: Window,
|
||||||
|
degradation: DegradationLevel,
|
||||||
|
) {
|
||||||
|
// Only the three entry-backed aggregations are collapsible; the
|
||||||
|
// registry-rejected Ratio/RelativeVelocity are left to the fallback
|
||||||
|
// (which raises the same internal error the direct path does).
|
||||||
|
let Some(agg) = AggKind::from_agg(agg) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(type_id) = ledger.resolve_signal_type(signal) else {
|
||||||
|
return; // unknown signal → fallback handles the contract
|
||||||
|
};
|
||||||
|
let key = ReadKey {
|
||||||
|
type_id,
|
||||||
|
agg,
|
||||||
|
window: effective_window(agg, window, degradation),
|
||||||
|
};
|
||||||
|
if !self.reads.contains(&key) {
|
||||||
|
self.reads.push(key);
|
||||||
|
}
|
||||||
|
if !self.types.contains(&type_id) {
|
||||||
|
self.types.push(type_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pass 1: compute every planned read's value for one candidate with one
|
||||||
|
/// `entry_ref` per distinct signal type. The ref is held only across a single
|
||||||
|
/// type's reads, then dropped — never two refs at once (deadlock contract of
|
||||||
|
/// [`SignalLedger::entry_ref`]).
|
||||||
|
// The held `entry` ref deliberately spans the inner read loop — that is the
|
||||||
|
// whole point (one get serves every aggregation of the type). It is released
|
||||||
|
// at the end of each outer iteration, before the next type's get, so the
|
||||||
|
// drop is already as tight as the deadlock contract allows.
|
||||||
|
#[allow(clippy::significant_drop_tightening)]
|
||||||
|
#[must_use]
|
||||||
|
pub fn compute(&self, ledger: &SignalLedger, entity_id: EntityId, now_ns: u64) -> SignalValues {
|
||||||
|
let mut values: SmallVec<[(ReadKey, f64); 16]> = SmallVec::new();
|
||||||
|
for &type_id in &self.types {
|
||||||
|
// ONE get for this type. `entry` (a held shard-read ref) is dropped
|
||||||
|
// at the end of this loop body, before the next type's get.
|
||||||
|
let entry = ledger.entry_ref(entity_id, type_id);
|
||||||
|
let lambda0 = ledger.decay_lambda(type_id);
|
||||||
|
for key in self.reads.iter().filter(|k| k.type_id == type_id) {
|
||||||
|
let value = match key.agg {
|
||||||
|
AggKind::Value => entry
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0.0, |e| windowed_count_f64(e, key.window, now_ns)),
|
||||||
|
AggKind::Velocity => {
|
||||||
|
let dur = key.window.duration_secs_f64();
|
||||||
|
if dur.is_infinite() {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
entry
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0.0, |e| windowed_count_f64(e, key.window, now_ns))
|
||||||
|
/ dur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AggKind::Decay => lambda0.map_or(0.0, |lambda| {
|
||||||
|
entry
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0.0, |e| e.hot.current_score(0, now_ns, lambda))
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
values.push((*key, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SignalValues { values }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a warm windowed count as `f64` (mirrors `read_windowed_count_at`'s
|
||||||
|
/// `as f64` cast). Rotates expired buckets at `now_ns` exactly as the direct
|
||||||
|
/// read does, so a Pass-2 fallback read of the same `(window, now_ns)` is
|
||||||
|
/// identical.
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
fn windowed_count_f64(
|
||||||
|
entry: &crate::signals::EntitySignalEntry,
|
||||||
|
window: Window,
|
||||||
|
now_ns: u64,
|
||||||
|
) -> f64 {
|
||||||
|
entry.warm.windowed_count(window, now_ns) as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pass-1 results for one candidate: the planned `(type, agg, window)` values.
|
||||||
|
/// Looked up by Pass-2 reads; a miss means a direct ledger read.
|
||||||
|
pub struct SignalValues {
|
||||||
|
values: SmallVec<[(ReadKey, f64); 16]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignalValues {
|
||||||
|
/// Look up a precomputed value. `window` is the post-degradation-substitution
|
||||||
|
/// window the caller is about to read (the plan applied the identical
|
||||||
|
/// substitution when it was built); `DecayScore` is window-canonicalized here
|
||||||
|
/// exactly as in the plan so a term's declared window never causes a miss.
|
||||||
|
/// Returns `None` (→ caller does a direct read) on a miss, including for the
|
||||||
|
/// non-collapsible `Ratio`/`RelativeVelocity` aggregations.
|
||||||
|
#[must_use]
|
||||||
|
pub fn get(&self, type_id: SignalTypeId, agg: &SignalAgg, window: Window) -> Option<f64> {
|
||||||
|
let agg = AggKind::from_agg(agg)?;
|
||||||
|
let want = ReadKey {
|
||||||
|
type_id,
|
||||||
|
agg,
|
||||||
|
window: if matches!(agg, AggKind::Decay) {
|
||||||
|
Window::AllTime
|
||||||
|
} else {
|
||||||
|
window
|
||||||
|
},
|
||||||
|
};
|
||||||
|
self.values
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| *k == want)
|
||||||
|
.map(|(_, v)| *v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,13 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::{
|
use crate::{
|
||||||
|
load::DegradationLevel,
|
||||||
ranking::{
|
ranking::{
|
||||||
builtins::register_builtins,
|
builtins::register_builtins,
|
||||||
profile::{Boost, Exclude, Gate, Penalty, ProfileDecay, SignalAgg, Sort},
|
profile::{Boost, Exclude, Gate, Penalty, ProfileDecay, RankingProfile, SignalAgg, Sort},
|
||||||
registry::ProfileRegistry,
|
registry::ProfileRegistry,
|
||||||
// Shared fixtures (extracted to de-duplicate the per-module ledger/profile
|
// Shared fixtures (extracted to de-duplicate the per-module ledger/profile
|
||||||
// builders across the five ranking test modules; DRY-S, M0-M10 review).
|
// builders across the five ranking test modules; DRY-S, M0-M10 review).
|
||||||
test_fixtures::{ledger_for, profile_with, seeded_ledger},
|
test_fixtures::{ledger_for, profile_with, profile_with_sort, seeded_ledger},
|
||||||
},
|
},
|
||||||
schema::EntityId,
|
schema::EntityId,
|
||||||
signals::SignalLedger,
|
signals::SignalLedger,
|
||||||
@ -989,3 +990,233 @@ fn shuffle_is_quality_weighted_and_seed_stable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mod sort_tests;
|
mod sort_tests;
|
||||||
|
|
||||||
|
// ── T2: signal-value pre-pass A/B equivalence ────────────────────────────────
|
||||||
|
//
|
||||||
|
// The pre-pass (`SignalReadPlan`/`SignalValues`) collapses redundant ledger gets
|
||||||
|
// to one `entries.get()` per signal type per candidate. It must be a pure
|
||||||
|
// optimization: scoring a profile with the pre-pass ON must produce a
|
||||||
|
// byte-identical `ScoredCandidate` set to the direct per-term path (pre-pass
|
||||||
|
// OFF). This module is the explicit oracle for that — the direct path is the
|
||||||
|
// reference implementation, the pre-pass is the optimization, and every
|
||||||
|
// (profile × degradation) pair below must match exactly.
|
||||||
|
|
||||||
|
/// A ledger seeded so windowed counts, velocities and decay scores all differ
|
||||||
|
/// across entities — including entities with no signals at all (the `None`-entry
|
||||||
|
/// path) and signals read by several aggregations of the same type.
|
||||||
|
fn ab_ledger() -> SignalLedger {
|
||||||
|
let ledger = ledger_for(&[
|
||||||
|
"view",
|
||||||
|
"like",
|
||||||
|
"share",
|
||||||
|
"dislike",
|
||||||
|
"completion",
|
||||||
|
"comment",
|
||||||
|
"follow",
|
||||||
|
"viewer_count",
|
||||||
|
"skip",
|
||||||
|
"block",
|
||||||
|
]);
|
||||||
|
let base = AB_NOW_NS;
|
||||||
|
for i in 1..=12u64 {
|
||||||
|
// Recent events (inside the 1h / 24h velocity windows) plus older ones,
|
||||||
|
// with per-entity-varied weights so no two entities score alike.
|
||||||
|
let recent = Timestamp::from_nanos(base - i * 60_000_000_000); // i minutes ago
|
||||||
|
let older = Timestamp::from_nanos(base - i * 7 * 24 * 3_600_000_000_000); // i weeks ago
|
||||||
|
ledger
|
||||||
|
.record_signal("view", EntityId::new(i), (13 - i) as f64, recent)
|
||||||
|
.unwrap();
|
||||||
|
ledger
|
||||||
|
.record_signal("view", EntityId::new(i), 2.0, older)
|
||||||
|
.unwrap();
|
||||||
|
ledger
|
||||||
|
.record_signal("like", EntityId::new(i), (i % 4) as f64, recent)
|
||||||
|
.unwrap();
|
||||||
|
ledger
|
||||||
|
.record_signal("share", EntityId::new(i), (i % 3) as f64, recent)
|
||||||
|
.unwrap();
|
||||||
|
ledger
|
||||||
|
.record_signal("completion", EntityId::new(i), 1.0, recent)
|
||||||
|
.unwrap();
|
||||||
|
if i % 2 == 0 {
|
||||||
|
ledger
|
||||||
|
.record_signal("dislike", EntityId::new(i), 1.0, recent)
|
||||||
|
.unwrap();
|
||||||
|
ledger
|
||||||
|
.record_signal("skip", EntityId::new(i), (i % 5) as f64, recent)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
if i % 3 == 0 {
|
||||||
|
ledger
|
||||||
|
.record_signal("block", EntityId::new(i), 1.0, recent)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
ledger
|
||||||
|
.record_signal("comment", EntityId::new(i), (i % 2) as f64, recent)
|
||||||
|
.unwrap();
|
||||||
|
ledger
|
||||||
|
.record_signal("follow", EntityId::new(i), 1.0, older)
|
||||||
|
.unwrap();
|
||||||
|
ledger
|
||||||
|
.record_signal("viewer_count", EntityId::new(i), (i % 6) as f64, recent)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
ledger
|
||||||
|
}
|
||||||
|
|
||||||
|
const AB_NOW_NS: u64 = 1_708_000_000_000_000_000;
|
||||||
|
|
||||||
|
/// Assert two scored sets are byte-identical: same order, ids, score bits,
|
||||||
|
/// snapshot keys+value bits, creator, and format.
|
||||||
|
fn assert_scored_identical(plan: &[ScoredCandidate], direct: &[ScoredCandidate], ctx: &str) {
|
||||||
|
assert_eq!(plan.len(), direct.len(), "{ctx}: result length differs");
|
||||||
|
for (a, b) in plan.iter().zip(direct.iter()) {
|
||||||
|
assert_eq!(a.entity_id, b.entity_id, "{ctx}: entity_id/order differs");
|
||||||
|
assert_eq!(
|
||||||
|
a.score.to_bits(),
|
||||||
|
b.score.to_bits(),
|
||||||
|
"{ctx}: score differs for {:?} ({} vs {})",
|
||||||
|
a.entity_id,
|
||||||
|
a.score,
|
||||||
|
b.score
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
a.signal_snapshot.len(),
|
||||||
|
b.signal_snapshot.len(),
|
||||||
|
"{ctx}: snapshot length differs for {:?}",
|
||||||
|
a.entity_id
|
||||||
|
);
|
||||||
|
for ((ka, va), (kb, vb)) in a.signal_snapshot.iter().zip(b.signal_snapshot.iter()) {
|
||||||
|
assert_eq!(ka.as_str(), kb.as_str(), "{ctx}: snapshot key differs");
|
||||||
|
assert_eq!(
|
||||||
|
va.to_bits(),
|
||||||
|
vb.to_bits(),
|
||||||
|
"{ctx}: snapshot value differs for key {}",
|
||||||
|
ka.as_str()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(a.creator_id, b.creator_id, "{ctx}: creator_id differs");
|
||||||
|
assert_eq!(a.format, b.format, "{ctx}: format differs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every ledger-reading `Sort` mode plus a redundancy-heavy profile, scored with
|
||||||
|
/// the pre-pass ON vs OFF at both `Full` and `CoarseAggregates`, must match
|
||||||
|
/// byte-for-byte. The redundancy profile reads `view` at five
|
||||||
|
/// `(stage, agg, window)` sites that resolve to the same signal type — the exact
|
||||||
|
/// case the one-get-per-type pre-pass collapses.
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
fn signal_plan_matches_per_term_path() {
|
||||||
|
let ledger = ab_ledger();
|
||||||
|
let now = Timestamp::from_nanos(AB_NOW_NS);
|
||||||
|
// Candidates include entities with no signals (100, 101) to cover the
|
||||||
|
// missing-entry default mapping.
|
||||||
|
let candidates: Vec<EntityId> = (1..=12).chain([100, 101]).map(EntityId::new).collect();
|
||||||
|
|
||||||
|
let mut profiles: Vec<RankingProfile> = vec![
|
||||||
|
profile_with_sort(Sort::Hot { gravity: 1.5 }),
|
||||||
|
profile_with_sort(Sort::Trending),
|
||||||
|
profile_with_sort(Sort::Controversial),
|
||||||
|
profile_with_sort(Sort::HiddenGems),
|
||||||
|
profile_with_sort(Sort::Shuffle),
|
||||||
|
profile_with_sort(Sort::TopWindow {
|
||||||
|
window: Window::TwentyFourHours,
|
||||||
|
}),
|
||||||
|
profile_with_sort(Sort::MostViewed {
|
||||||
|
window: Window::AllTime,
|
||||||
|
}),
|
||||||
|
profile_with_sort(Sort::MostLiked {
|
||||||
|
window: Window::OneHour,
|
||||||
|
}),
|
||||||
|
profile_with_sort(Sort::MostFollowed),
|
||||||
|
profile_with_sort(Sort::CreatorEngagementRate),
|
||||||
|
profile_with_sort(Sort::Rising),
|
||||||
|
profile_with_sort(Sort::MostCommented {
|
||||||
|
window: Window::TwentyFourHours,
|
||||||
|
}),
|
||||||
|
profile_with_sort(Sort::MostShared {
|
||||||
|
window: Window::AllTime,
|
||||||
|
}),
|
||||||
|
profile_with_sort(Sort::LiveViewerCount),
|
||||||
|
profile_with_sort(Sort::New), // no ledger reads — control
|
||||||
|
];
|
||||||
|
|
||||||
|
// Redundancy-heavy profile: `view` read by the sort (Value), a gate (Value),
|
||||||
|
// a boost (Value), a boost (DecayScore), and the decay term (DecayScore).
|
||||||
|
profiles.push(profile_with("ab_rich", |p| {
|
||||||
|
p.sort = Some(Sort::Hot { gravity: 1.2 });
|
||||||
|
p.gates = vec![Gate {
|
||||||
|
signal: "view".into(),
|
||||||
|
agg: SignalAgg::Value,
|
||||||
|
window: Window::AllTime,
|
||||||
|
min_threshold: 0.0,
|
||||||
|
}];
|
||||||
|
p.excludes = vec![Exclude {
|
||||||
|
signal: "block".into(),
|
||||||
|
agg: SignalAgg::Value,
|
||||||
|
window: Window::AllTime,
|
||||||
|
above: 5.0,
|
||||||
|
}];
|
||||||
|
p.boosts = vec![
|
||||||
|
Boost {
|
||||||
|
signal: "view".into(),
|
||||||
|
agg: SignalAgg::DecayScore,
|
||||||
|
window: Window::AllTime,
|
||||||
|
weight: 1.0,
|
||||||
|
},
|
||||||
|
Boost {
|
||||||
|
signal: "view".into(),
|
||||||
|
agg: SignalAgg::Value,
|
||||||
|
window: Window::AllTime,
|
||||||
|
weight: 0.5,
|
||||||
|
},
|
||||||
|
Boost {
|
||||||
|
signal: "like".into(),
|
||||||
|
agg: SignalAgg::DecayScore,
|
||||||
|
window: Window::AllTime,
|
||||||
|
weight: 2.0,
|
||||||
|
},
|
||||||
|
Boost {
|
||||||
|
signal: "share".into(),
|
||||||
|
agg: SignalAgg::Velocity,
|
||||||
|
window: Window::TwentyFourHours,
|
||||||
|
weight: 1.5,
|
||||||
|
},
|
||||||
|
Boost {
|
||||||
|
signal: "nonexistent".into(),
|
||||||
|
agg: SignalAgg::Value,
|
||||||
|
window: Window::AllTime,
|
||||||
|
weight: 1.0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
p.penalties = vec![Penalty {
|
||||||
|
signal: "skip".into(),
|
||||||
|
agg: SignalAgg::Value,
|
||||||
|
window: Window::AllTime,
|
||||||
|
weight: 1.0,
|
||||||
|
}];
|
||||||
|
p.decay = Some(ProfileDecay {
|
||||||
|
signal: "view".into(),
|
||||||
|
half_life_secs: 7 * 24 * 3600,
|
||||||
|
weight: 0.5,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
for degradation in [DegradationLevel::Full, DegradationLevel::CoarseAggregates] {
|
||||||
|
for profile in &profiles {
|
||||||
|
let with_plan = ProfileExecutor::new(&ledger)
|
||||||
|
.with_degradation_level(degradation)
|
||||||
|
.with_signal_plan(true)
|
||||||
|
.score(&candidates, profile, now)
|
||||||
|
.unwrap();
|
||||||
|
let direct = ProfileExecutor::new(&ledger)
|
||||||
|
.with_degradation_level(degradation)
|
||||||
|
.with_signal_plan(false)
|
||||||
|
.score(&candidates, profile, now)
|
||||||
|
.unwrap();
|
||||||
|
let ctx = format!("profile={} degradation={degradation:?}", profile.name);
|
||||||
|
assert_scored_identical(&with_plan, &direct, &ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -663,6 +663,14 @@ impl ShipQueue {
|
|||||||
peers
|
peers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The peer's circuit-breaker state for the self-heal gauge (m11p8): 0
|
||||||
|
/// closed, 1 open, 2 half-open. Delegates to the transport (read-only — does
|
||||||
|
/// not admit the breaker's half-open probe).
|
||||||
|
#[must_use]
|
||||||
|
pub fn peer_breaker_state(&self, peer: ShardId) -> u8 {
|
||||||
|
self.shared.transport.peer_breaker_state(peer)
|
||||||
|
}
|
||||||
|
|
||||||
/// Add a peer to the roster at runtime (m11p5 §3.3 conf-change): construct
|
/// Add a peer to the roster at runtime (m11p5 §3.3 conf-change): construct
|
||||||
/// its dispatch cell and spawn its windowed senders, inheriting the queue's
|
/// its dispatch cell and spawn its windowed senders, inheriting the queue's
|
||||||
/// CURRENT activation state. Idempotent (a re-add of an existing peer is a
|
/// CURRENT activation state. Idempotent (a re-add of an existing peer is a
|
||||||
|
|||||||
@ -195,6 +195,14 @@ pub trait Transport: Send + Sync + 'static {
|
|||||||
/// default is a no-op: in-process transports ack synchronously.
|
/// default is a no-op: in-process transports ack synchronously.
|
||||||
fn notify_applied(&self, _source_shard: ShardId, _applied: u64) {}
|
fn notify_applied(&self, _source_shard: ShardId, _applied: u64) {}
|
||||||
|
|
||||||
|
/// The circuit-breaker state for `peer`, encoded for the
|
||||||
|
/// `tidaldb_cluster_peer_breaker_state` gauge: 0 closed, 1 open, 2 half-open
|
||||||
|
/// (m11p8). Read-only — must never admit the breaker's half-open probe. The
|
||||||
|
/// default `0` (closed) suits in-process transports, which have no breaker.
|
||||||
|
fn peer_breaker_state(&self, _peer: ShardId) -> u8 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
/// The shard identity of this transport endpoint.
|
/// The shard identity of this transport endpoint.
|
||||||
fn local_shard(&self) -> ShardId;
|
fn local_shard(&self) -> ShardId;
|
||||||
}
|
}
|
||||||
@ -237,6 +245,12 @@ impl Transport for std::sync::Arc<dyn Transport> {
|
|||||||
(**self).notify_applied(source_shard, applied);
|
(**self).notify_applied(source_shard, applied);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn peer_breaker_state(&self, peer: ShardId) -> u8 {
|
||||||
|
// Forward explicitly: the trait default `0` would report every peer's
|
||||||
|
// breaker as closed for type-erased callers (the self-heal gauge path).
|
||||||
|
(**self).peer_breaker_state(peer)
|
||||||
|
}
|
||||||
|
|
||||||
fn local_shard(&self) -> ShardId {
|
fn local_shard(&self) -> ShardId {
|
||||||
(**self).local_shard()
|
(**self).local_shard()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -457,6 +457,46 @@ impl SignalLedger {
|
|||||||
.ok_or_else(|| TidalError::Schema(SchemaError::UnknownSignalType(name.to_owned())))
|
.ok_or_else(|| TidalError::Schema(SchemaError::UnknownSignalType(name.to_owned())))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Borrow the `(entity_id, type_id)` signal entry, if present, as a held
|
||||||
|
/// shard-read reference.
|
||||||
|
///
|
||||||
|
/// This is the single `DashMap` get behind the ranking pre-pass
|
||||||
|
/// ([`crate::ranking::executor::SignalValues`]): the caller takes one ref per
|
||||||
|
/// distinct signal type, evaluates every aggregation that type needs against
|
||||||
|
/// the held entry (`hot.current_score` / `warm.windowed_count` are both
|
||||||
|
/// `&self` reads), then **drops the ref before getting the next type**.
|
||||||
|
///
|
||||||
|
/// # Deadlock contract
|
||||||
|
///
|
||||||
|
/// Only ONE `Ref` may be held at a time. Holding this ref while calling any
|
||||||
|
/// method that locks the same shard (another `get`, or a `record_signal`
|
||||||
|
/// `or_insert`) on the same thread can deadlock — `DashMap` shards are
|
||||||
|
/// `RwLock`s and a second lock acquisition on a shard this thread already
|
||||||
|
/// holds is not guaranteed to be re-entrant.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn entry_ref(
|
||||||
|
&self,
|
||||||
|
entity_id: EntityId,
|
||||||
|
type_id: SignalTypeId,
|
||||||
|
) -> Option<dashmap::mapref::one::Ref<'_, (EntityId, SignalTypeId), EntitySignalEntry>> {
|
||||||
|
self.entries.get(&(entity_id, type_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decay rate (`lambda`) for a signal type's primary decay slot (index
|
||||||
|
/// 0), or `None` if the type defines no decay rate.
|
||||||
|
///
|
||||||
|
/// Mirrors the range-check in [`read_decay_score_at`](Self::read_decay_score_at):
|
||||||
|
/// a missing lambda means a `DecayScore` read yields `None` → `0.0`, never an
|
||||||
|
/// undecayed score. Exposed so the ranking pre-pass can evaluate `DecayScore`
|
||||||
|
/// against a held entry ref without re-resolving the lambda per read.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn decay_lambda(&self, type_id: SignalTypeId) -> Option<f64> {
|
||||||
|
self.signal_lambdas
|
||||||
|
.get(&type_id)
|
||||||
|
.and_then(|v| v.first())
|
||||||
|
.copied()
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the existing `(entity_id, type_id)` entry or create a zeroed one
|
/// Get the existing `(entity_id, type_id)` entry or create a zeroed one
|
||||||
/// started at `ts_ns`.
|
/// started at `ts_ns`.
|
||||||
///
|
///
|
||||||
|
|||||||
@ -674,29 +674,31 @@ impl SimulatedCluster {
|
|||||||
self.total_signals.load(Ordering::Relaxed)
|
self.total_signals.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of WAL batches applied from the **initial** leader (`ShardId(0)`)
|
/// Number of WAL batches applied from the **current** leader on a specific
|
||||||
/// on a specific region's replication state.
|
/// region's replication state.
|
||||||
///
|
///
|
||||||
/// This is equivalent to the event count for all tests that do not involve
|
/// Each signal produces exactly one WAL batch, so for a region following the
|
||||||
/// leader promotion, since each signal produces exactly one WAL batch.
|
/// live leader this is its applied-event count.
|
||||||
///
|
///
|
||||||
/// # Limitation (post-promotion)
|
/// # Post-promotion correctness (m11p8-C)
|
||||||
///
|
///
|
||||||
/// The queried shard is hardcoded to `ShardId(0)` — the *initial* leader —
|
/// `applied_seqno` is keyed per source shard, and the leader ships under its
|
||||||
/// not the current one. `applied_seqno` is per source shard, so after a
|
/// own `ShardId(leader_region.0)`. Reading the applied frontier therefore
|
||||||
/// [`promote_leader`](Self::promote_leader) call the new leader ships under
|
/// keys on the **current** leader's shard, not a hardcoded `ShardId(0)`:
|
||||||
/// its own `ShardId`, and this method keeps returning only the count from the
|
/// after a [`promote_leader`](Self::promote_leader) the new leader ships under
|
||||||
/// old leader's stream — i.e. it silently undercounts. Callers in a promotion
|
/// a different `ShardId`, and a node's progress against that new stream is
|
||||||
/// scenario must instead read
|
/// what "applied" means post-promote. The previous hardcode to `ShardId(0)`
|
||||||
/// [`replication_state().applied_seqno(current_leader_shard)`] directly so the
|
/// (the initial leader) silently undercounted after any promotion — the
|
||||||
/// shard matches the live leader. Bounding the hardcode behind a doc note here
|
/// "lag accounting wrong after promote (ShardId(0)-keyed)" bug. For the leader
|
||||||
/// rather than reading the live leader shard keeps the existing no-promotion
|
/// region itself, the relay advances its own frontier under its shard as it
|
||||||
/// callers' counts stable.
|
/// writes, so the leader's row reads truthfully rather than 0. No-promotion
|
||||||
|
/// callers are unaffected: the initial leader is `ShardId(0)`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn applied_count(&self, region: RegionId) -> u64 {
|
pub fn applied_count(&self, region: RegionId) -> u64 {
|
||||||
|
let leader_shard = ShardId(self.leader_region().0);
|
||||||
self.nodes.get(®ion).map_or(0, |n| {
|
self.nodes.get(®ion).map_or(0, |n| {
|
||||||
n.db.replication_state()
|
n.db.replication_state()
|
||||||
.applied_seqno(ShardId(0))
|
.applied_seqno(leader_shard)
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,7 +61,10 @@ pub struct CompactionResult {
|
|||||||
/// if an error is encountered mid-way -- this is safe (see invariant above).
|
/// if an error is encountered mid-way -- this is safe (see invariant above).
|
||||||
pub fn compact_wal(wal_dir: &Path, checkpoint_seq: u64) -> Result<CompactionResult, WalError> {
|
pub fn compact_wal(wal_dir: &Path, checkpoint_seq: u64) -> Result<CompactionResult, WalError> {
|
||||||
let segments = segment::list_segments(wal_dir)?;
|
let segments = segment::list_segments(wal_dir)?;
|
||||||
compact_segments(wal_dir, &segments, checkpoint_seq)
|
// The offline close-path compaction does not archive (the live archival hook
|
||||||
|
// runs on the periodic online path; close is a clean shutdown, not a window
|
||||||
|
// where PITR continuity matters).
|
||||||
|
compact_segments(wal_dir, &segments, checkpoint_seq, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Online-safe compaction: identical to [`compact_wal`] except it NEVER deletes
|
/// Online-safe compaction: identical to [`compact_wal`] except it NEVER deletes
|
||||||
@ -92,7 +95,7 @@ pub fn compact_wal_online(
|
|||||||
wal_dir: &Path,
|
wal_dir: &Path,
|
||||||
checkpoint_seq: u64,
|
checkpoint_seq: u64,
|
||||||
) -> Result<CompactionResult, WalError> {
|
) -> Result<CompactionResult, WalError> {
|
||||||
compact_wal_online_pinned(wal_dir, checkpoint_seq, 0)
|
compact_wal_online_pinned(wal_dir, checkpoint_seq, 0, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Online-safe compaction with a **retention pin** (m11p5 §2.1).
|
/// Online-safe compaction with a **retention pin** (m11p5 §2.1).
|
||||||
@ -133,6 +136,7 @@ pub fn compact_wal_online_pinned(
|
|||||||
wal_dir: &Path,
|
wal_dir: &Path,
|
||||||
checkpoint_seq: u64,
|
checkpoint_seq: u64,
|
||||||
pin: u64,
|
pin: u64,
|
||||||
|
archive_dir: Option<&Path>,
|
||||||
) -> Result<CompactionResult, WalError> {
|
) -> Result<CompactionResult, WalError> {
|
||||||
let segments = segment::list_segments(wal_dir)?;
|
let segments = segment::list_segments(wal_dir)?;
|
||||||
// The live segment is the maximum-`first_seq` segment; never delete at or
|
// The live segment is the maximum-`first_seq` segment; never delete at or
|
||||||
@ -163,7 +167,7 @@ pub fn compact_wal_online_pinned(
|
|||||||
floor = floor.min(pin_floor);
|
floor = floor.min(pin_floor);
|
||||||
}
|
}
|
||||||
|
|
||||||
compact_segments(wal_dir, &segments, floor)
|
compact_segments(wal_dir, &segments, floor, archive_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete every listed segment whose `first_seq` is strictly less than `floor`,
|
/// Delete every listed segment whose `first_seq` is strictly less than `floor`,
|
||||||
@ -177,6 +181,7 @@ fn compact_segments(
|
|||||||
wal_dir: &Path,
|
wal_dir: &Path,
|
||||||
segments: &[(u64, std::path::PathBuf)],
|
segments: &[(u64, std::path::PathBuf)],
|
||||||
floor: u64,
|
floor: u64,
|
||||||
|
archive_dir: Option<&Path>,
|
||||||
) -> Result<CompactionResult, WalError> {
|
) -> Result<CompactionResult, WalError> {
|
||||||
let total_before = segments.len();
|
let total_before = segments.len();
|
||||||
|
|
||||||
@ -194,6 +199,22 @@ fn compact_segments(
|
|||||||
// single-owner invariant.
|
// single-owner invariant.
|
||||||
let file_size = std::fs::metadata(seg_path).map(|m| m.len()).unwrap_or(0);
|
let file_size = std::fs::metadata(seg_path).map(|m| m.len()).unwrap_or(0);
|
||||||
|
|
||||||
|
// m11p8 PITR: archive the segment BEFORE deleting it so the archive
|
||||||
|
// is a gap-free record of every sealed segment. If archival fails we
|
||||||
|
// do NOT delete — losing the segment from both the live WAL and the
|
||||||
|
// archive would punch a hole in the point-in-time recovery range. The
|
||||||
|
// segment stays live and the next compaction retries.
|
||||||
|
if let Some(archive) = archive_dir
|
||||||
|
&& let Err(e) = archive_segment(archive, seg_path)
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
segment_first_seq = seg_first_seq,
|
||||||
|
error = %e,
|
||||||
|
"WAL segment archival failed; keeping segment live (PITR gap avoided)"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
std::fs::remove_file(seg_path)?;
|
std::fs::remove_file(seg_path)?;
|
||||||
deleted += 1;
|
deleted += 1;
|
||||||
bytes_reclaimed += file_size;
|
bytes_reclaimed += file_size;
|
||||||
@ -233,6 +254,38 @@ fn compact_segments(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy a sealed WAL segment into `archive_dir` for point-in-time recovery
|
||||||
|
/// (m11p8), durably and idempotently. The destination keeps the segment's
|
||||||
|
/// filename (which encodes shard + `first_seq`), so the archive is a
|
||||||
|
/// self-describing, ordered catalog. Copy goes through a `.tmp` + rename so a
|
||||||
|
/// crash mid-copy never leaves a short file the catalog would treat as complete;
|
||||||
|
/// a re-archival after a crash (destination already present at the same size) is
|
||||||
|
/// a no-op. The temp file and the archive directory are fsynced so PITR never
|
||||||
|
/// replays a torn archived segment.
|
||||||
|
fn archive_segment(archive_dir: &Path, seg_path: &Path) -> Result<(), WalError> {
|
||||||
|
std::fs::create_dir_all(archive_dir)?;
|
||||||
|
let file_name = seg_path.file_name().ok_or_else(|| {
|
||||||
|
WalError::from(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"WAL segment path has no file name",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let dest = archive_dir.join(file_name);
|
||||||
|
let src_len = std::fs::metadata(seg_path)?.len();
|
||||||
|
// Idempotent: a complete prior archive (same size) needs no rewrite.
|
||||||
|
if let Ok(meta) = std::fs::metadata(&dest)
|
||||||
|
&& meta.len() == src_len
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let tmp = archive_dir.join(format!("{}.tmp", file_name.to_string_lossy()));
|
||||||
|
std::fs::copy(seg_path, &tmp)?;
|
||||||
|
std::fs::File::open(&tmp)?.sync_all()?;
|
||||||
|
std::fs::rename(&tmp, &dest)?;
|
||||||
|
crate::wal::sync_dir_durable(archive_dir)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[allow(clippy::unwrap_used)]
|
#[allow(clippy::unwrap_used)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@ -252,6 +305,53 @@ mod tests {
|
|||||||
assert_eq!(result.segments_remaining, 0);
|
assert_eq!(result.segments_remaining, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn online_compaction_archives_before_deleting() {
|
||||||
|
// m11p8 PITR: a deleted segment must land in the archive first, so the
|
||||||
|
// archive is a gap-free record — no segment is ever lost from BOTH.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let archive = tempfile::tempdir().unwrap();
|
||||||
|
for &seq in &[1u64, 50, 100, 200] {
|
||||||
|
let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, seq, 1024).unwrap();
|
||||||
|
}
|
||||||
|
let before: Vec<u64> = list_segments(dir.path())
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|(s, _)| *s)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let result = compact_wal_online_pinned(dir.path(), 150, 0, Some(archive.path())).unwrap();
|
||||||
|
assert!(result.segments_deleted >= 1, "expected some deletions");
|
||||||
|
|
||||||
|
let live: std::collections::HashSet<u64> = list_segments(dir.path())
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|(s, _)| *s)
|
||||||
|
.collect();
|
||||||
|
let archived: std::collections::HashSet<u64> = list_segments(archive.path())
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|(s, _)| *s)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Every original segment is either still live OR archived — never lost.
|
||||||
|
for s in &before {
|
||||||
|
assert!(
|
||||||
|
live.contains(s) || archived.contains(s),
|
||||||
|
"segment {s} was lost (neither live nor archived)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The segments below the floor (1, 50, 100) are deleted from live and
|
||||||
|
// present in the archive; the active segment (200) survives in place.
|
||||||
|
assert!(archived.contains(&1) && archived.contains(&50) && archived.contains(&100));
|
||||||
|
assert!(live.contains(&200) && !live.contains(&1));
|
||||||
|
|
||||||
|
// Idempotent: re-running with the same archive (already-archived dests at
|
||||||
|
// the same size) is a clean no-op, not an error.
|
||||||
|
let again = compact_wal_online_pinned(dir.path(), 150, 0, Some(archive.path())).unwrap();
|
||||||
|
assert_eq!(again.segments_deleted, 0, "nothing left below the floor");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compact_deletes_old_segments() {
|
fn compact_deletes_old_segments() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@ -442,7 +542,7 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
three_sealed_plus_live(dir.path());
|
three_sealed_plus_live(dir.path());
|
||||||
|
|
||||||
let result = compact_wal_online_pinned(dir.path(), 1_000, 150).unwrap();
|
let result = compact_wal_online_pinned(dir.path(), 1_000, 150, None).unwrap();
|
||||||
assert_eq!(result.segments_deleted, 1, "only the pre-resume segment");
|
assert_eq!(result.segments_deleted, 1, "only the pre-resume segment");
|
||||||
assert_eq!(result.segments_remaining, 3);
|
assert_eq!(result.segments_remaining, 3);
|
||||||
|
|
||||||
@ -471,7 +571,7 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
three_sealed_plus_live(dir.path());
|
three_sealed_plus_live(dir.path());
|
||||||
|
|
||||||
let result = compact_wal_online_pinned(dir.path(), 1_000, 199).unwrap();
|
let result = compact_wal_online_pinned(dir.path(), 1_000, 199, None).unwrap();
|
||||||
// floor clamps to the first_seq of the segment containing pin+1=200,
|
// floor clamps to the first_seq of the segment containing pin+1=200,
|
||||||
// which is the segment STARTING at 200 ([200,300)); the segment
|
// which is the segment STARTING at 200 ([200,300)); the segment
|
||||||
// [100,200) is fully below seq 200 and is reclaimed along with [1,100).
|
// [100,200) is fully below seq 200 and is reclaimed along with [1,100).
|
||||||
@ -486,7 +586,7 @@ mod tests {
|
|||||||
// it by lowering the floor to 100 (the segment's own first_seq).
|
// it by lowering the floor to 100 (the segment's own first_seq).
|
||||||
let dir2 = tempfile::tempdir().unwrap();
|
let dir2 = tempfile::tempdir().unwrap();
|
||||||
three_sealed_plus_live(dir2.path());
|
three_sealed_plus_live(dir2.path());
|
||||||
let result2 = compact_wal_online_pinned(dir2.path(), 1_000, 100).unwrap();
|
let result2 = compact_wal_online_pinned(dir2.path(), 1_000, 100, None).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result2.segments_deleted, 1,
|
result2.segments_deleted, 1,
|
||||||
"the straddled segment [100,200) must NOT be split/deleted"
|
"the straddled segment [100,200) must NOT be split/deleted"
|
||||||
@ -506,7 +606,7 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
three_sealed_plus_live(dir.path());
|
three_sealed_plus_live(dir.path());
|
||||||
|
|
||||||
let pinned = compact_wal_online_pinned(dir.path(), 1_000, 0).unwrap();
|
let pinned = compact_wal_online_pinned(dir.path(), 1_000, 0, None).unwrap();
|
||||||
assert_eq!(pinned.segments_deleted, 3, "all sealed segments below live");
|
assert_eq!(pinned.segments_deleted, 3, "all sealed segments below live");
|
||||||
assert_eq!(pinned.segments_remaining, 1);
|
assert_eq!(pinned.segments_remaining, 1);
|
||||||
assert_eq!(list_segments(dir.path()).unwrap()[0].0, 300);
|
assert_eq!(list_segments(dir.path()).unwrap()[0].0, 300);
|
||||||
@ -531,7 +631,7 @@ mod tests {
|
|||||||
// unpinned results must be identical here.
|
// unpinned results must be identical here.
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
three_sealed_plus_live(dir.path());
|
three_sealed_plus_live(dir.path());
|
||||||
let pinned = compact_wal_online_pinned(dir.path(), 150, 250).unwrap();
|
let pinned = compact_wal_online_pinned(dir.path(), 150, 250, None).unwrap();
|
||||||
|
|
||||||
let dir2 = tempfile::tempdir().unwrap();
|
let dir2 = tempfile::tempdir().unwrap();
|
||||||
three_sealed_plus_live(dir2.path());
|
three_sealed_plus_live(dir2.path());
|
||||||
|
|||||||
@ -32,6 +32,10 @@ unwrap_used = "deny"
|
|||||||
tidaldb = { path = "../tidal" }
|
tidaldb = { path = "../tidal" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
# Backup/restore manifest integrity (m11p8) — the same hash the engine verifies
|
||||||
|
# WAL segments and snapshot artifacts with, so a tidalctl-written manifest and an
|
||||||
|
# engine-verified one agree.
|
||||||
|
blake3 = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tidaldb = { path = "../tidal", features = ["test-utils"] }
|
tidaldb = { path = "../tidal", features = ["test-utils"] }
|
||||||
|
|||||||
276
tidalctl/src/commands/backup.rs
Normal file
276
tidalctl/src/commands/backup.rs
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
//! `tidalctl backup` / `tidalctl restore` — offline data-dir backup and restore
|
||||||
|
//! with a BLAKE3 integrity manifest (m11p8).
|
||||||
|
//!
|
||||||
|
//! These operate on a data dir at rest (a drained/stopped node, or a filesystem
|
||||||
|
//! copy), so the snapshot is trivially consistent: nothing is writing. `backup`
|
||||||
|
//! copies the data dir to a destination and writes a [`BACKUP_MANIFEST`] (BLAKE3
|
||||||
|
//! per file + the recovered WAL checkpoint cursor); `restore` verifies every
|
||||||
|
//! file's hash against the manifest before placing it into a FRESH target dir.
|
||||||
|
//!
|
||||||
|
//! Coordinated cluster backup (runbook): back up one committed replica per shard
|
||||||
|
//! group — under `ack=quorum` any committed replica's data dir holds the
|
||||||
|
//! quorum-durable log, so it is a cluster-consistent snapshot at its recorded
|
||||||
|
//! `checkpoint_seq`. The per-shard manifests + the WAL archive (the engine's
|
||||||
|
//! `wal.archive_dir`) together give point-in-time recovery. Restore re-seeds each
|
||||||
|
//! group's leader from its backup; followers catch up via the live stream.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{CliError, wal_state};
|
||||||
|
|
||||||
|
/// Manifest filename written at the root of every backup.
|
||||||
|
const BACKUP_MANIFEST: &str = "BACKUP_MANIFEST.json";
|
||||||
|
|
||||||
|
/// The lock file is process-ownership state, never part of a backup (restoring
|
||||||
|
/// a stale lock would falsely claim the target dir is owned).
|
||||||
|
const LOCK_FILE: &str = "tidaldb.lock";
|
||||||
|
|
||||||
|
/// One file's integrity record within a [`Manifest`].
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct FileEntry {
|
||||||
|
/// Path relative to the data-dir root (forward-slash separated).
|
||||||
|
path: String,
|
||||||
|
size: u64,
|
||||||
|
/// Lowercase-hex BLAKE3 of the file's bytes.
|
||||||
|
blake3: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The backup manifest, written at the backup root and verified on restore.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct Manifest {
|
||||||
|
/// Format version (bump on an incompatible manifest change).
|
||||||
|
manifest_version: u32,
|
||||||
|
/// Unix-epoch seconds the backup was taken (operator audit trail).
|
||||||
|
created_unix_secs: u64,
|
||||||
|
/// The WAL checkpoint sequence recovered from the source — the cluster
|
||||||
|
/// cursor this backup is consistent to. An operator assembling a multi-shard
|
||||||
|
/// cluster backup records one manifest per shard group; the set of
|
||||||
|
/// `checkpoint_seq` values is the cluster-consistent restore point.
|
||||||
|
checkpoint_seq: u64,
|
||||||
|
file_count: usize,
|
||||||
|
total_bytes: u64,
|
||||||
|
files: Vec<FileEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MANIFEST_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// `tidalctl backup --path <data-dir> --out <dest>`: copy the data dir to `dest`
|
||||||
|
/// and write a BLAKE3 manifest. Refuses to overwrite a non-empty destination so a
|
||||||
|
/// backup never clobbers an existing one.
|
||||||
|
pub(crate) fn run_backup(src: &Path, out: &Path, pretty: bool) -> Result<(String, i32), CliError> {
|
||||||
|
if !src.is_dir() {
|
||||||
|
return Err(CliError::new(format!(
|
||||||
|
"source data dir does not exist: {}",
|
||||||
|
src.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// Refuse to write into a non-empty destination (don't merge into / clobber an
|
||||||
|
// existing backup or an unrelated directory).
|
||||||
|
if out.exists()
|
||||||
|
&& std::fs::read_dir(out)
|
||||||
|
.map_err(|e| CliError::new(format!("read dest {}: {e}", out.display())))?
|
||||||
|
.next()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(CliError::new(format!(
|
||||||
|
"destination {} is not empty; choose a fresh directory",
|
||||||
|
out.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(out)
|
||||||
|
.map_err(|e| CliError::new(format!("create dest {}: {e}", out.display())))?;
|
||||||
|
|
||||||
|
// The cluster cursor this backup is consistent to (recovered offline).
|
||||||
|
let checkpoint_seq = wal_state::gather_wal_state(&src.join("wal"))
|
||||||
|
.map(|s| s.checkpoint_seq)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let mut rel_files = Vec::new();
|
||||||
|
collect_files(src, PathBuf::new(), &mut rel_files)
|
||||||
|
.map_err(|e| CliError::new(format!("walk source: {e}")))?;
|
||||||
|
|
||||||
|
let mut files = Vec::with_capacity(rel_files.len());
|
||||||
|
let mut total_bytes = 0u64;
|
||||||
|
for rel in &rel_files {
|
||||||
|
let abs = src.join(rel);
|
||||||
|
let bytes = std::fs::read(&abs)
|
||||||
|
.map_err(|e| CliError::new(format!("read {}: {e}", abs.display())))?;
|
||||||
|
let hash = blake3::hash(&bytes).to_hex().to_string();
|
||||||
|
let dest = out.join(rel);
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| CliError::new(format!("mkdir {}: {e}", parent.display())))?;
|
||||||
|
}
|
||||||
|
std::fs::write(&dest, &bytes)
|
||||||
|
.map_err(|e| CliError::new(format!("write {}: {e}", dest.display())))?;
|
||||||
|
total_bytes += bytes.len() as u64;
|
||||||
|
files.push(FileEntry {
|
||||||
|
path: rel_to_slash(rel),
|
||||||
|
size: bytes.len() as u64,
|
||||||
|
blake3: hash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest = Manifest {
|
||||||
|
manifest_version: MANIFEST_VERSION,
|
||||||
|
created_unix_secs: SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0),
|
||||||
|
checkpoint_seq,
|
||||||
|
file_count: files.len(),
|
||||||
|
total_bytes,
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
let manifest_json = serialize(&manifest, pretty)?;
|
||||||
|
std::fs::write(out.join(BACKUP_MANIFEST), &manifest_json)
|
||||||
|
.map_err(|e| CliError::new(format!("write manifest: {e}")))?;
|
||||||
|
|
||||||
|
let summary = serde_json::json!({
|
||||||
|
"backed_up": src.display().to_string(),
|
||||||
|
"destination": out.display().to_string(),
|
||||||
|
"checkpoint_seq": manifest.checkpoint_seq,
|
||||||
|
"file_count": manifest.file_count,
|
||||||
|
"total_bytes": manifest.total_bytes,
|
||||||
|
});
|
||||||
|
Ok((render(&summary, pretty)?, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `tidalctl restore --from <backup> --path <target>`: verify the backup's BLAKE3
|
||||||
|
/// manifest, then copy every file into `target`. Refuses a non-empty target so a
|
||||||
|
/// restore never overwrites a live data dir (the destructive-op guard).
|
||||||
|
pub(crate) fn run_restore(
|
||||||
|
target: &Path,
|
||||||
|
from: &Path,
|
||||||
|
pretty: bool,
|
||||||
|
) -> Result<(String, i32), CliError> {
|
||||||
|
let manifest_path = from.join(BACKUP_MANIFEST);
|
||||||
|
let manifest_bytes = std::fs::read(&manifest_path).map_err(|e| {
|
||||||
|
CliError::new(format!(
|
||||||
|
"read manifest {}: {e} (is --from a tidalctl backup?)",
|
||||||
|
manifest_path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let manifest: Manifest = serde_json::from_slice(&manifest_bytes)
|
||||||
|
.map_err(|e| CliError::new(format!("parse manifest: {e}")))?;
|
||||||
|
if manifest.manifest_version != MANIFEST_VERSION {
|
||||||
|
return Err(CliError::new(format!(
|
||||||
|
"unsupported backup manifest version {} (this tidalctl writes/reads v{MANIFEST_VERSION})",
|
||||||
|
manifest.manifest_version
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destructive-op guard: never restore over a non-empty target (it could be a
|
||||||
|
// live data dir). The operator restores into a fresh dir, then points the node at it.
|
||||||
|
if target.exists()
|
||||||
|
&& std::fs::read_dir(target)
|
||||||
|
.map_err(|e| CliError::new(format!("read target {}: {e}", target.display())))?
|
||||||
|
.next()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(CliError::new(format!(
|
||||||
|
"target {} is not empty; restore into a fresh directory (refusing to overwrite)",
|
||||||
|
target.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: verify EVERY file's hash against the manifest BEFORE writing
|
||||||
|
// anything, so a corrupt backup fails the restore whole rather than leaving a
|
||||||
|
// half-written target.
|
||||||
|
for entry in &manifest.files {
|
||||||
|
let src = from.join(slash_to_rel(&entry.path));
|
||||||
|
let bytes = std::fs::read(&src)
|
||||||
|
.map_err(|e| CliError::new(format!("read backup file {}: {e}", src.display())))?;
|
||||||
|
let hash = blake3::hash(&bytes).to_hex().to_string();
|
||||||
|
if hash != entry.blake3 {
|
||||||
|
return Err(CliError::new(format!(
|
||||||
|
"backup integrity check FAILED for {}: manifest {} != actual {}",
|
||||||
|
entry.path, entry.blake3, hash
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: place the verified files into the target.
|
||||||
|
std::fs::create_dir_all(target)
|
||||||
|
.map_err(|e| CliError::new(format!("create target {}: {e}", target.display())))?;
|
||||||
|
let mut restored = 0usize;
|
||||||
|
for entry in &manifest.files {
|
||||||
|
let src = from.join(slash_to_rel(&entry.path));
|
||||||
|
let dest = target.join(slash_to_rel(&entry.path));
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| CliError::new(format!("mkdir {}: {e}", parent.display())))?;
|
||||||
|
}
|
||||||
|
std::fs::copy(&src, &dest)
|
||||||
|
.map_err(|e| CliError::new(format!("copy {}: {e}", dest.display())))?;
|
||||||
|
restored += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = serde_json::json!({
|
||||||
|
"restored": target.display().to_string(),
|
||||||
|
"from": from.display().to_string(),
|
||||||
|
"checkpoint_seq": manifest.checkpoint_seq,
|
||||||
|
"files_verified_and_restored": restored,
|
||||||
|
"note": "point a stopped node at this dir; under ack=quorum followers catch up via the live stream",
|
||||||
|
});
|
||||||
|
Ok((render(&summary, pretty)?, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively collect file paths (relative to `root`), skipping the lock file
|
||||||
|
/// and any previously-written backup manifest. Directories are recursed; only
|
||||||
|
/// regular files are recorded.
|
||||||
|
fn collect_files(root: &Path, rel: PathBuf, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
|
||||||
|
let dir = root.join(&rel);
|
||||||
|
for entry in std::fs::read_dir(&dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
let child_rel = rel.join(&name);
|
||||||
|
let file_type = entry.file_type()?;
|
||||||
|
if file_type.is_dir() {
|
||||||
|
collect_files(root, child_rel, out)?;
|
||||||
|
} else if file_type.is_file() {
|
||||||
|
// Skip process-ownership + a stale manifest from a prior backup-in-place.
|
||||||
|
if name == LOCK_FILE || name == BACKUP_MANIFEST {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(child_rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a relative path with forward slashes for a portable manifest.
|
||||||
|
fn rel_to_slash(rel: &Path) -> String {
|
||||||
|
rel.components()
|
||||||
|
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverse of [`rel_to_slash`]: a manifest path → a platform `PathBuf`.
|
||||||
|
fn slash_to_rel(path: &str) -> PathBuf {
|
||||||
|
path.split('/').collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize(manifest: &Manifest, pretty: bool) -> Result<String, CliError> {
|
||||||
|
if pretty {
|
||||||
|
serde_json::to_string_pretty(manifest)
|
||||||
|
} else {
|
||||||
|
serde_json::to_string(manifest)
|
||||||
|
}
|
||||||
|
.map_err(|e| CliError::new(format!("serialize manifest: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(value: &serde_json::Value, pretty: bool) -> Result<String, CliError> {
|
||||||
|
if pretty {
|
||||||
|
serde_json::to_string_pretty(value)
|
||||||
|
} else {
|
||||||
|
serde_json::to_string(value)
|
||||||
|
}
|
||||||
|
.map_err(|e| CliError::new(format!("serialize summary: {e}")))
|
||||||
|
}
|
||||||
@ -1,10 +1,11 @@
|
|||||||
//! The five `tidalctl` subcommands, one module each.
|
//! The `tidalctl` subcommands, one module each.
|
||||||
//!
|
//!
|
||||||
//! Each module exposes a single `run(...)` entry point returning
|
//! Each module exposes a single `run(...)` entry point returning
|
||||||
//! `(rendered_output, exit_code)`; `main` dispatches to them and prints the
|
//! `(rendered_output, exit_code)`; `main` dispatches to them and prints the
|
||||||
//! output. The exit-code contract (0 = ok/empty, 1 = usage/internal, 2 =
|
//! output. The exit-code contract (0 = ok/empty, 1 = usage/internal, 2 =
|
||||||
//! degraded/unreadable) is documented in the crate-root `//!` header.
|
//! degraded/unreadable) is documented in the crate-root `//!` header.
|
||||||
|
|
||||||
|
pub(crate) mod backup;
|
||||||
pub(crate) mod diagnostics;
|
pub(crate) mod diagnostics;
|
||||||
pub(crate) mod paths;
|
pub(crate) mod paths;
|
||||||
pub(crate) mod recover;
|
pub(crate) mod recover;
|
||||||
|
|||||||
@ -73,6 +73,10 @@ struct CliArgs {
|
|||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
pretty: bool,
|
pretty: bool,
|
||||||
verify_only: bool,
|
verify_only: bool,
|
||||||
|
/// Backup destination (`backup --out <dir>`).
|
||||||
|
out: Option<PathBuf>,
|
||||||
|
/// Restore source (`restore --from <backup-dir>`).
|
||||||
|
from: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Command {
|
enum Command {
|
||||||
@ -81,6 +85,8 @@ enum Command {
|
|||||||
Recover,
|
Recover,
|
||||||
Diagnostics,
|
Diagnostics,
|
||||||
ScopeStats,
|
ScopeStats,
|
||||||
|
Backup,
|
||||||
|
Restore,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A user-facing CLI failure, rendered as an `{"error": ...}` JSON envelope on
|
/// A user-facing CLI failure, rendered as an `{"error": ...}` JSON envelope on
|
||||||
@ -123,6 +129,8 @@ fn parse_args(args: &[String]) -> Result<CliArgs, CliError> {
|
|||||||
"recover" => Command::Recover,
|
"recover" => Command::Recover,
|
||||||
"diagnostics" => Command::Diagnostics,
|
"diagnostics" => Command::Diagnostics,
|
||||||
"scope-stats" => Command::ScopeStats,
|
"scope-stats" => Command::ScopeStats,
|
||||||
|
"backup" => Command::Backup,
|
||||||
|
"restore" => Command::Restore,
|
||||||
"--help" | "-h" | "help" => return Err(CliError::new(usage())),
|
"--help" | "-h" | "help" => return Err(CliError::new(usage())),
|
||||||
other => {
|
other => {
|
||||||
return Err(CliError::new(format!(
|
return Err(CliError::new(format!(
|
||||||
@ -133,6 +141,8 @@ fn parse_args(args: &[String]) -> Result<CliArgs, CliError> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut path: Option<PathBuf> = None;
|
let mut path: Option<PathBuf> = None;
|
||||||
|
let mut out: Option<PathBuf> = None;
|
||||||
|
let mut from: Option<PathBuf> = None;
|
||||||
let mut pretty = false;
|
let mut pretty = false;
|
||||||
let mut verify_only = false;
|
let mut verify_only = false;
|
||||||
let mut i = 2;
|
let mut i = 2;
|
||||||
@ -146,6 +156,20 @@ fn parse_args(args: &[String]) -> Result<CliArgs, CliError> {
|
|||||||
}
|
}
|
||||||
path = Some(PathBuf::from(&args[i]));
|
path = Some(PathBuf::from(&args[i]));
|
||||||
}
|
}
|
||||||
|
"--out" => {
|
||||||
|
i += 1;
|
||||||
|
if i >= args.len() {
|
||||||
|
return Err(CliError::new("--out requires a value"));
|
||||||
|
}
|
||||||
|
out = Some(PathBuf::from(&args[i]));
|
||||||
|
}
|
||||||
|
"--from" => {
|
||||||
|
i += 1;
|
||||||
|
if i >= args.len() {
|
||||||
|
return Err(CliError::new("--from requires a value"));
|
||||||
|
}
|
||||||
|
from = Some(PathBuf::from(&args[i]));
|
||||||
|
}
|
||||||
"--pretty" => {
|
"--pretty" => {
|
||||||
pretty = true;
|
pretty = true;
|
||||||
}
|
}
|
||||||
@ -166,17 +190,24 @@ fn parse_args(args: &[String]) -> Result<CliArgs, CliError> {
|
|||||||
path,
|
path,
|
||||||
pretty,
|
pretty,
|
||||||
verify_only,
|
verify_only,
|
||||||
|
out,
|
||||||
|
from,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn usage() -> String {
|
fn usage() -> String {
|
||||||
"Usage: tidalctl <command> --path <dir> [--pretty]\n\n\
|
"Usage: tidalctl <command> --path <dir> [--out <dir>] [--from <dir>] [--pretty]\n\n\
|
||||||
Commands:\n \
|
Commands:\n \
|
||||||
status Report WAL state, checkpoint, and directory layout\n \
|
status Report WAL state, checkpoint, and directory layout\n \
|
||||||
paths Report resolved directory paths and existence\n \
|
paths Report resolved directory paths and existence\n \
|
||||||
recover Diagnose WAL state for crash recovery (--verify-only)\n \
|
recover Diagnose WAL state for crash recovery (--verify-only)\n \
|
||||||
diagnostics Print health summary (human-readable or JSON)\n \
|
diagnostics Print health summary (human-readable or JSON)\n \
|
||||||
scope-stats Tally WAL signal events by governance scope (M9)\n\n\
|
scope-stats Tally WAL signal events by governance scope (M9)\n \
|
||||||
|
backup Copy a data dir to --out with a BLAKE3 manifest (m11p8)\n \
|
||||||
|
restore Verify a backup (--from) and restore it into --path (m11p8)\n\n\
|
||||||
|
Backup/restore operate on a data dir AT REST (a stopped/drained node):\n \
|
||||||
|
tidalctl backup --path /data/node --out /backups/node-A\n \
|
||||||
|
tidalctl restore --from /backups/node-A --path /data/node-new\n\n\
|
||||||
Exit codes: 0 = ok/empty, 1 = usage/internal error, 2 = degraded/unreadable\n \
|
Exit codes: 0 = ok/empty, 1 = usage/internal error, 2 = degraded/unreadable\n \
|
||||||
(WAL, checkpoint, or a derived index exists but could not be read)."
|
(WAL, checkpoint, or a derived index exists but could not be read)."
|
||||||
.to_string()
|
.to_string()
|
||||||
@ -195,5 +226,19 @@ fn run(args: &[String]) -> Result<(String, i32), CliError> {
|
|||||||
Command::Recover => commands::recover::run(&cli.path, cli.pretty, cli.verify_only),
|
Command::Recover => commands::recover::run(&cli.path, cli.pretty, cli.verify_only),
|
||||||
Command::Diagnostics => commands::diagnostics::run(&cli.path, cli.pretty),
|
Command::Diagnostics => commands::diagnostics::run(&cli.path, cli.pretty),
|
||||||
Command::ScopeStats => commands::scope_stats::run(&cli.path, cli.pretty),
|
Command::ScopeStats => commands::scope_stats::run(&cli.path, cli.pretty),
|
||||||
|
Command::Backup => {
|
||||||
|
let out = cli
|
||||||
|
.out
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| CliError::new("backup requires --out <dest-dir>"))?;
|
||||||
|
commands::backup::run_backup(&cli.path, out, cli.pretty)
|
||||||
|
}
|
||||||
|
Command::Restore => {
|
||||||
|
let from = cli
|
||||||
|
.from
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| CliError::new("restore requires --from <backup-dir>"))?;
|
||||||
|
commands::backup::run_restore(&cli.path, from, cli.pretty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1206,3 +1206,112 @@ fn scope_stats_clean_wal_reports_complete() {
|
|||||||
"clean WAL has no inconsistencies: {stdout}"
|
"clean WAL has no inconsistencies: {stdout}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── m11p8 backup / restore ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A real round-trip: back up a data dir at rest, then restore it into a fresh
|
||||||
|
/// dir. The manifest carries the recovered checkpoint cursor, the restore
|
||||||
|
/// verifies every file's BLAKE3, and the restored WAL segments match the source.
|
||||||
|
#[test]
|
||||||
|
fn backup_then_restore_roundtrips() {
|
||||||
|
let home = home_with_wal_data();
|
||||||
|
let scratch = TempTidalHome::new().unwrap();
|
||||||
|
let backup_dir = scratch.path().join("backup");
|
||||||
|
let restore_dir = scratch.path().join("restored");
|
||||||
|
|
||||||
|
// Back up.
|
||||||
|
let out = tidalctl_bin()
|
||||||
|
.args(["backup", "--path"])
|
||||||
|
.arg(home.path())
|
||||||
|
.args(["--out"])
|
||||||
|
.arg(&backup_dir)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "backup failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
json["checkpoint_seq"], 3,
|
||||||
|
"manifest records the cursor: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
json["file_count"].as_u64().unwrap() >= 1,
|
||||||
|
"backed up some files: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
backup_dir.join("BACKUP_MANIFEST.json").exists(),
|
||||||
|
"manifest written"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restore into a fresh dir.
|
||||||
|
let out = tidalctl_bin()
|
||||||
|
.args(["restore", "--from"])
|
||||||
|
.arg(&backup_dir)
|
||||||
|
.args(["--path"])
|
||||||
|
.arg(&restore_dir)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "restore failed: {out:?}");
|
||||||
|
|
||||||
|
// The restored WAL segments byte-match the source (gap-free recovery).
|
||||||
|
let src_segs = std::fs::read_dir(home.path().join("wal"))
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter(|e| e.path().extension().is_some_and(|x| x == "seg"))
|
||||||
|
.count();
|
||||||
|
let restored_segs = std::fs::read_dir(restore_dir.join("wal"))
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter(|e| e.path().extension().is_some_and(|x| x == "seg"))
|
||||||
|
.count();
|
||||||
|
assert_eq!(
|
||||||
|
src_segs, restored_segs,
|
||||||
|
"restored WAL has the same segment count as the source"
|
||||||
|
);
|
||||||
|
assert!(src_segs >= 1, "source had WAL segments to restore");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore must REFUSE a backup whose bytes were tampered with after the
|
||||||
|
/// manifest was written (the BLAKE3 integrity guard).
|
||||||
|
#[test]
|
||||||
|
fn restore_rejects_corrupted_backup() {
|
||||||
|
let home = home_with_wal_data();
|
||||||
|
let scratch = TempTidalHome::new().unwrap();
|
||||||
|
let backup_dir = scratch.path().join("backup");
|
||||||
|
let restore_dir = scratch.path().join("restored");
|
||||||
|
|
||||||
|
let out = tidalctl_bin()
|
||||||
|
.args(["backup", "--path"])
|
||||||
|
.arg(home.path())
|
||||||
|
.args(["--out"])
|
||||||
|
.arg(&backup_dir)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "backup failed: {out:?}");
|
||||||
|
|
||||||
|
// Corrupt one archived WAL segment so its bytes no longer match the manifest.
|
||||||
|
let seg = std::fs::read_dir(backup_dir.join("wal"))
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.map(|e| e.path())
|
||||||
|
.find(|p| p.extension().is_some_and(|x| x == "seg"))
|
||||||
|
.expect("a segment to corrupt");
|
||||||
|
std::fs::write(&seg, b"corrupted-not-the-original-bytes").unwrap();
|
||||||
|
|
||||||
|
let out = tidalctl_bin()
|
||||||
|
.args(["restore", "--from"])
|
||||||
|
.arg(&backup_dir)
|
||||||
|
.args(["--path"])
|
||||||
|
.arg(&restore_dir)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!out.status.success(),
|
||||||
|
"restore must reject a corrupted backup"
|
||||||
|
);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("integrity check FAILED"),
|
||||||
|
"error must name the integrity failure: {stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user