Implements tmp/tidaldb-fleet-hardening (20 planned tasks + 2 found by measurement). Ring 0 — restore verification. .woodpecker.yaml step pods ran at the namespace default of 1500m/2Gi, which OOMKilled a prior pipeline and starved the release gate past its budget. Both push-path steps now declare backend_options.kubernetes.resources as two YAML anchors declared once on their first consuming step. The values are CALIBRATED against measured free node capacity, not against the LimitRange max: `requests: cpu 2` (this roadmap's original figure) fits on NO node and would sit Pending forever, because `ci-build-bounds` grants permission and the nodes supply capacity, and those are not the same thing. The `nightly` cron described in this file for 216 days was never created, so tier-3 chaos, the fault classes, mTLS and the PITR test produced exactly zero signal while reading like standing coverage. nightly-chaos and nightly-security-ops now alias the anchors and have budgets matching the gate (their 120/90 were TIGHTER on the same runner, so they would have failed nightly for a budget reason, not a correctness one). nightly-soak is REMOVED, not scheduled: it drives 1000 rps for 600s gating on p99 <= 250ms, and the best node has 1700m free CPU, so it would fail on starvation rather than regression — manufacturing a nightly false alarm. Its commands move verbatim to docs/runbooks/nightly-soak.md. Ring 1 — four fabrications removed from the wire. - scatter_merge sorted and truncated without re-stamping rank, so /feed and /search returned 1,1,2 under full placement. Reuses merge_cross_shard's existing stamp; asserted on BOTH the multi-group merge path and the single-group [only] fast path that bypasses it. - aggregate_region_row's None arm invented `applied_events: 0` plus a deficit derived from it. applied_events/lag_events are now Option<u64>, null on the wire. leader_last_seq was also unwrap_or(0), so a node that could not reach the LEADER computed 0 - applied = 0 for every region and reported a converged cluster it had never measured — a fabrication pointing the dangerous way. - tidalctl inferred NO REPORT from `applied == 0 && lag > 0`. That heuristic was actively hiding the PVC-wipe shape: a measured zero with a real deficit rendered as "no report" instead of BEHIND. Now read off the wire; converged exits 0, partitioned still exits nonzero. - /sharded/* answered 201/204 for single-copy writes with nothing anywhere saying so. Now requires `x-tidal-ack: local`, rejecting with 400 via the existing invalid_input path. Six call sites migrated, not the two this roadmap predicted — including docs/runbooks/cluster.md §16.3, which told operators to run a quorum-write probe via POST /sharded/items. That probe cannot verify quorum: the surface applies locally with no WAL append. It was used as the safety check between every step of a staged deploy earlier today. Ring 2 — observability. JSON_LOGS was already implemented and the deployment simply never asked for it; the StatefulSet now sets it, plus TIDAL_SERVICE_NAME=tidaldb because enabling it silently renames the VictoriaLogs `service` stream field and would have blinded every query keyed on it. Adds tidaldb_usearch_replicated_vectors_total, incremented on BOTH the origin (wal_blob_first -> Ok(Some)) and the follower apply path — counting only the origin would mean each vector lands on exactly one node, replicas never agree, and the alert built on it pages forever. Found by measurement, not planned: the 401 path discarded every fact about every rejection. Traefik has served 101,858 rejected requests to the public ingress — 87.6% of all its traffic — with no record of who or why anywhere. unauthorized_response now emits reason (missing_token vs invalid_token, the distinction that separates a scanner from a rotation that missed a consumer) and the forwarded client. The token is never logged. Also: scripts/restore-fleet.sh --cluster started the soak monitor while deliberately leaving its gate suspended, orphaning a watcher that has reported "0/30 green nights" for 13 days. The pair now moves together. Doc-guard's three-warning backlog is cleared with real backfill for M4/M6/M12. Verified: fmt clean; clippy 5 crates 0 new warnings (74 vs 74 baseline, counted in a detached worktree at HEAD); lib 2110 passed; cluster_sharding 5; cluster_runbook 10; tidalctl 38; doc-guard 0 warnings. Playwright 32/34 with the two remaining failures asserting the rank fix against the not-yet-rolled image — they are the post-deploy proof.
125 lines
5.2 KiB
TypeScript
125 lines
5.2 KiB
TypeScript
/**
|
|
* Section 11 — ranking integrity on the deployed cluster.
|
|
*
|
|
* `rank` must be a dense ascending `1..n` over the returned page, on every
|
|
* corpus-wide ranked response the cluster serves. This is the deployed-artifact
|
|
* half of the dense-rank contract, which is why it lives here rather than only in
|
|
* the hermetic suites: the defect it guards existed ONLY under full placement, the
|
|
* production shape.
|
|
*
|
|
* ## What this file used to be
|
|
*
|
|
* A tripwire pair, asserting that `rank` was WRONG and instructing its own
|
|
* deletion once fixed. `scatter_merge` (`tidal-server/src/cluster/node.rs`) sorted
|
|
* and truncated the concatenated per-group slices but never re-stamped `rank`,
|
|
* while its sibling `merge_cross_shard` did — and full placement returns before
|
|
* reaching the sibling. Each hosted group ranked its own slice `1..k` locally, so
|
|
* live `/search?query=verification` answered `1, 1, 2`.
|
|
*
|
|
* `scatter_merge` now takes the same `set_rank` closure and stamps after
|
|
* `truncate`. The tripwires are gone; this positive assertion replaces them, so
|
|
* the deployed cluster keeps a dense-rank guard instead of the fix silently
|
|
* regressing on the one shape only a real deployment exercises.
|
|
*
|
|
* Coverage split, all three layers:
|
|
* - engine ranking — `10-ranking-semantics.spec.ts` (hermetic standalone, 1..60);
|
|
* - BOTH merge shapes — `mp_ranked_reads_stamp_dense_rank_on_both_merge_shapes`
|
|
* in `tidal-server/tests/cluster_sharding.rs` (real 3-process clusters at
|
|
* 3 groups AND 1 group, so the `[only]` fast path is asserted, not assumed);
|
|
* - the deployed cluster — this file.
|
|
*
|
|
* Read-only. Nothing here writes to the deployed cluster.
|
|
*/
|
|
|
|
import { expect, test, type APIRequestContext } from '@playwright/test';
|
|
import { recordJson } from '../support/evidence.ts';
|
|
import { PUBLIC_BASE_URL, apiKey } from '../support/env.ts';
|
|
|
|
type RankedItem = { entity_id: number; score: number; rank: number };
|
|
|
|
/**
|
|
* Enough rows that at least two shard groups must both contribute. With three
|
|
* groups a limit of 1 or 2 can be answered from one group, and a per-group
|
|
* counter that was never re-stamped would show no duplicate at all.
|
|
*/
|
|
const LIMIT = 12;
|
|
|
|
/** What a correctly stamped response looks like: dense, ascending, from 1. */
|
|
const denseRanks = (count: number): number[] =>
|
|
Array.from({ length: count }, (_unused, index) => index + 1);
|
|
|
|
async function ranked(
|
|
request: APIRequestContext,
|
|
path: string,
|
|
): Promise<{ items: RankedItem[]; ranks: number[]; scores: number[] }> {
|
|
const response = await request.get(`${PUBLIC_BASE_URL}${path}`, {
|
|
headers: { authorization: `Bearer ${apiKey()}` },
|
|
});
|
|
expect(response.status(), `${path} must answer before any conclusion is drawn`).toBe(200);
|
|
const body = (await response.json()) as { items?: RankedItem[] };
|
|
const items = body.items ?? [];
|
|
expect(
|
|
items.length,
|
|
'an empty result cannot distinguish correct ranking from broken ranking',
|
|
).toBeGreaterThan(1);
|
|
return {
|
|
items,
|
|
ranks: items.map((item) => item.rank),
|
|
scores: items.map((item) => item.score),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Scores must stay descending. The rank stamp is a renumbering, never a re-sort,
|
|
* so an ordering change here is a DIFFERENT and worse defect than the one this
|
|
* file used to pin: it would mean the merge's sort itself broke.
|
|
*/
|
|
function expectScoresOrdered(scores: number[], surface: string): void {
|
|
for (let index = 1; index < scores.length; index += 1) {
|
|
expect(
|
|
scores[index]!,
|
|
`${surface} returned scores out of order at position ${index} ` +
|
|
`(${scores[index - 1]} then ${scores[index]}). The rank stamp must not re-sort.`,
|
|
).toBeLessThanOrEqual(scores[index - 1]!);
|
|
}
|
|
}
|
|
|
|
const REGRESSION_HINT =
|
|
'Duplicate or zero ranks are the signature of per-group slices merged without a ' +
|
|
're-stamp. Check that scatter_merge still stamps after truncate (and that the ' +
|
|
'single-group [only] fast path still returns the engine order).';
|
|
|
|
/**
|
|
* The two corpus-wide ranked surfaces. Both return through the same gateway
|
|
* merge, so asserting both is what shows the stamp lives in the merge rather than
|
|
* in one query pipeline.
|
|
*
|
|
* `/search` uses a term known to be indexed on this corpus: the production corpus
|
|
* has no titles for its 33k items, so an arbitrary word returns zero candidates
|
|
* and would make the check vacuous.
|
|
*/
|
|
const RANKED_SURFACES: Record<string, string> = {
|
|
'/feed': `/feed?profile=for_you&limit=${LIMIT}`,
|
|
'/search': `/search?query=verification&limit=${LIMIT}`,
|
|
};
|
|
|
|
test.describe('section 11 — ranking integrity (deployed cluster)', () => {
|
|
for (const [surface, path] of Object.entries(RANKED_SURFACES)) {
|
|
test(`cluster ${surface} returns dense ascending ranks`, async ({ request }, testInfo) => {
|
|
const { items, ranks, scores } = await ranked(request, path);
|
|
|
|
await recordJson(testInfo, `cluster-ranks${surface.replace('/', '-')}`, {
|
|
surface: path,
|
|
ranks,
|
|
expected: denseRanks(ranks.length),
|
|
duplicateCount: ranks.length - new Set(ranks).size,
|
|
scores,
|
|
entityIds: items.map((item) => item.entity_id),
|
|
});
|
|
|
|
expect(ranks, `${surface}: ${REGRESSION_HINT}`).toEqual(denseRanks(ranks.length));
|
|
expectScoresOrdered(scores, surface);
|
|
});
|
|
}
|
|
});
|