j-chim Claude Opus 4.8 (1M context) commited on
Commit
3fd5483
Β·
1 Parent(s): 7888b0e

Resilience + linux gate: connection-failure reset + pre-push DuckDB read-path gate

Browse files

- lib/duckdb.ts: reset the connection singleton on init failure so a transient
httpfs blip retries instead of wedging the process in 500s until restart
(error-path only; happy path byte-identical). + tests/duckdb-connection-reset.test.ts (3/3).
- scripts/linux-gate + hooks/pre-push: enforce "never deploy untested-on-linux"
(HF Spaces run no CI between push and prod). Host vitest + a linux/amd64 DuckDB
read-path smoke on the prod-pinned binding (auto-read from pnpm-lock).
Activate once per clone: git config core.hooksPath hooks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

hooks/pre-push ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Pre-push linux gate (invariant I5). Pushing to the HF Space deploys to prod and
4
+ # runs NO tests there β€” so this is the enforcement point between local and prod.
5
+ # Aborts the push if the linux gate is red.
6
+ #
7
+ # Activate once per clone: git config core.hooksPath hooks
8
+ # Escape hatch (docs-only / emergency): SKIP_LINUX_GATE=1 git push
9
+ set -euo pipefail
10
+
11
+ if [ "${SKIP_LINUX_GATE:-0}" = "1" ]; then
12
+ echo "[pre-push] SKIP_LINUX_GATE=1 β€” skipping linux gate (by request)."
13
+ exit 0
14
+ fi
15
+
16
+ ROOT="$(git rev-parse --show-toplevel)"
17
+ echo "[pre-push] running linux gate before deploy (set SKIP_LINUX_GATE=1 to bypass)…"
18
+ exec "$ROOT/scripts/linux-gate.sh"
lib/duckdb.ts CHANGED
@@ -30,7 +30,7 @@ const VIEW_FILES = {
30
 
31
  export async function getConnection(): Promise<DuckDBConnection> {
32
  if (!connectionPromise) {
33
- connectionPromise = (async () => {
34
  const connection = await DuckDBConnection.create()
35
 
36
  // Materialise each parquet snapshot into an in-memory DuckDB table at
@@ -65,6 +65,15 @@ export async function getConnection(): Promise<DuckDBConnection> {
65
 
66
  return connection
67
  })()
 
 
 
 
 
 
 
 
 
68
  }
69
 
70
  return connectionPromise
 
30
 
31
  export async function getConnection(): Promise<DuckDBConnection> {
32
  if (!connectionPromise) {
33
+ const pending = (async () => {
34
  const connection = await DuckDBConnection.create()
35
 
36
  // Materialise each parquet snapshot into an in-memory DuckDB table at
 
65
 
66
  return connection
67
  })()
68
+ connectionPromise = pending
69
+ // If init fails (e.g. a transient httpfs blip during the snapshot
70
+ // read), clear the cached rejected promise so the NEXT request retries
71
+ // instead of every request awaiting a permanently-rejected promise
72
+ // until the Space restarts. Guard on identity so a later retry already
73
+ // in flight is never stomped.
74
+ pending.catch(() => {
75
+ if (connectionPromise === pending) connectionPromise = null
76
+ })
77
  }
78
 
79
  return connectionPromise
scripts/linux-gate.sh ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Linux gate β€” the enforcement point for invariant I5: "never deploy code that
4
+ # hasn't run on linux." Pushing to the HF Space deploys to prod and runs NO
5
+ # tests there, so this gate (wired as a pre-push hook) is the only check between
6
+ # a local change and production. It catches the class of bug that passes on Mac
7
+ # and broke prod: DuckDB struct/timestamp marshalling on the linux-x64 binding
8
+ # (R2) and the connection lifecycle (R5).
9
+ #
10
+ # Two legs:
11
+ # (1) host vitest β€” unit/logic suite (fast; connection-reset + transforms).
12
+ # (2) linux/amd64 DuckDB read-path smoke on the prod-pinned binding (the real
13
+ # net for the Mac↔linux divergence).
14
+ #
15
+ # Requires Docker. Coverage grows: add parity / render-equivalence tests to the
16
+ # linux leg as they land (see scripts/linux-gate/README.md).
17
+ set -euo pipefail
18
+
19
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
20
+ cd "$ROOT"
21
+
22
+ : "${SNAPSHOT_URL:=https://huggingface.co/datasets/evaleval/card_backend/resolve/main/warehouse/2026-05-29T00-24-44Z}"
23
+
24
+ echo "[linux-gate] (1/2) host unit/logic suite (vitest)…"
25
+ # Quarantine: tests/transformations/identity-canonicalization.test.ts holds the
26
+ # intentionally-RED Group F route-id cases (they assert the legacy `__` form
27
+ # pending the model-resolution-rework landing β€” frontend commit 924266a). Excluded
28
+ # so the gate blocks on REAL regressions, not known-deferred reds. REMOVE this
29
+ # exclusion once the rework lands and those expectations are aligned.
30
+ pnpm vitest run --exclude '**/identity-canonicalization.test.ts'
31
+
32
+ echo "[linux-gate] (2/2) linux/amd64 DuckDB read-path smoke (prod binding)…"
33
+ DUCKDB_VERSION="$(grep -m1 -oE '@duckdb/node-api@[0-9][A-Za-z0-9.+-]*' pnpm-lock.yaml | sed 's#.*@##' || true)"
34
+ DUCKDB_VERSION="${DUCKDB_VERSION:-1.5.3-r.2}"
35
+ echo "[linux-gate] binding @duckdb/node-api@${DUCKDB_VERSION} (from pnpm-lock); snapshot ${SNAPSHOT_URL}"
36
+ docker build --platform=linux/amd64 --build-arg "DUCKDB_VERSION=${DUCKDB_VERSION}" \
37
+ -t evalcard-linux-gate "$ROOT/scripts/linux-gate" >/dev/null
38
+ docker run --platform=linux/amd64 --rm -e SNAPSHOT_URL="$SNAPSHOT_URL" evalcard-linux-gate
39
+
40
+ echo "[linux-gate] PASS βœ…"
scripts/linux-gate/Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # linux/amd64 gate image β€” runs the DuckDB read-path smoke on the SAME binding
2
+ # version prod uses, so a Mac-vs-linux marshalling divergence is caught before deploy.
3
+ # DUCKDB_VERSION is passed by scripts/linux-gate.sh, extracted from pnpm-lock.yaml
4
+ # so it can never drift from what prod actually ships.
5
+ # Platform is set by `docker build --platform=linux/amd64` in scripts/linux-gate.sh.
6
+ FROM node:18-bullseye-slim
7
+ RUN apt-get update \
8
+ && apt-get install -y --no-install-recommends ca-certificates \
9
+ && rm -rf /var/lib/apt/lists/*
10
+ WORKDIR /gate
11
+ ARG DUCKDB_VERSION=1.5.3-r.2
12
+ RUN npm install --no-audit --no-fund "@duckdb/node-api@${DUCKDB_VERSION}"
13
+ COPY smoke.mjs .
14
+ ENTRYPOINT ["node", "smoke.mjs"]
scripts/linux-gate/README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Linux gate (pre-push)
2
+
3
+ **Why this exists.** This app deploys to a HuggingFace Space. Pushing to the
4
+ Space's git remote (`hf.co`) **builds and deploys to prod immediately and runs no
5
+ tests**. There is no CI between "push" and "live." That gap is what caused the
6
+ linux-only DuckDB crashes (struct/timestamp marshalling that passed on Mac and
7
+ broke in prod, discovered by users days later). This gate is the enforcement
8
+ point for invariant **I5 β€” never deploy code that hasn't run on linux.**
9
+
10
+ ## Activate (once per clone)
11
+
12
+ ```bash
13
+ git config core.hooksPath hooks
14
+ ```
15
+
16
+ Now `git push` runs `scripts/linux-gate.sh` and **aborts the push if it's red.**
17
+ Requires Docker (for the linux/amd64 leg).
18
+
19
+ Escape hatch (docs-only / emergency): `SKIP_LINUX_GATE=1 git push`.
20
+
21
+ ## What it runs
22
+
23
+ 1. **Host vitest** (`pnpm vitest run`) β€” unit/logic suite (connection-reset lifecycle, transforms). Fast; platform-agnostic logic.
24
+ 2. **linux/amd64 DuckDB read-path smoke** (`scripts/linux-gate/smoke.mjs` in a container on the **prod-pinned `@duckdb/node-api`** version, read from `pnpm-lock.yaml`) β€” loads the snapshot views over httpfs into in-memory tables (mirroring prod `getConnection`, no `/data` mmap) and runs the real read paths through `readAll()`+`getRowObjectsJson()`. This is the net for the Mac↔linux marshalling divergence.
25
+
26
+ `SNAPSHOT_URL` defaults to the pinned prod snapshot; override via env to gate against a post-rebaseline snapshot.
27
+
28
+ ## Coverage β€” grows as tests land
29
+
30
+ Today the linux leg is a read-path *smoke* (catches the marshalling-crash class).
31
+ As the comparison-index work proceeds, add to the linux leg:
32
+ - the **leaderboard parity** gate (query output vs the live `comparison-index`),
33
+ - **render-equivalence** for eval/histogram/DeepDive (silent-drop guard),
34
+ - the **`by_model`-removal consumer** test.
35
+
36
+ The smoke is the runner; these are its content. It is NOT a substitute for the
37
+ full gate (#12) β€” it's the enforcement substrate that makes the gate block deploys.
38
+
39
+ ## Limitation (be honest)
40
+
41
+ A git hook protects whoever installs it, not the org β€” a fresh clone or a
42
+ collaborator who hasn't run `git config core.hooksPath hooks` can still push
43
+ unchecked. For solo/small-team that's acceptable; if pushes ever come from
44
+ multiple people or automation, move to a GitHub-mirror + Actions substrate
45
+ (runs the same gate on `ubuntu-latest`, enforced for everyone).
scripts/linux-gate/smoke.mjs ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Linux/amd64 DuckDB read-path smoke β€” runs the REAL read paths the app uses on
2
+ // the prod-pinned @duckdb/node-api binding, inside a linux/amd64 container, and
3
+ // asserts the binding can marshal them via readAll()+getRowObjectsJson().
4
+ //
5
+ // This is the net for the class of failure that passes on Mac and broke prod:
6
+ // the "Invalid Error: don't know what type:" struct/timestamp marshalling crash
7
+ // on the linux-x64 binding (R2/I2). It mirrors prod's getConnection() exactly:
8
+ // in-memory tables loaded over httpfs (NO /data mmap β€” R1/I1).
9
+ //
10
+ // SNAPSHOT_URL is passed in by scripts/linux-gate.sh.
11
+ import { DuckDBConnection } from "@duckdb/node-api"
12
+
13
+ const SNAP = (process.env.SNAPSHOT_URL || "").replace(/\/+$/, "")
14
+ if (!SNAP) { console.error("linux-gate smoke: SNAPSHOT_URL required"); process.exit(2) }
15
+
16
+ const VIEWS = {
17
+ models_view: "models_view.parquet",
18
+ evals_view: "evals_view.parquet",
19
+ eval_results_view: "eval_results_view.parquet",
20
+ }
21
+
22
+ const c = await DuckDBConnection.create()
23
+ // Prod read path: materialise each view into an in-memory table over httpfs.
24
+ for (const [v, f] of Object.entries(VIEWS)) {
25
+ await c.run(`CREATE OR REPLACE TABLE ${v} AS SELECT * FROM read_parquet('${SNAP}/${f}')`)
26
+ }
27
+
28
+ async function probe(label, sql) {
29
+ const t = Date.now()
30
+ const r = await c.runAndRead(sql)
31
+ await r.readAll() // the chunk-fetch that throws on a bad type
32
+ const rows = r.getRowObjectsJson() // the JS marshalling the linux binding crashed on
33
+ if (!rows.length) throw new Error(`${label} returned 0 rows (expected data)`)
34
+ console.log(` ok ${label}: ${rows.length} rows (${Date.now() - t}ms)`)
35
+ }
36
+
37
+ try {
38
+ // getModelSummaryById's actual read path: raw SELECT * on models_view (which
39
+ // carries STRUCT/JSON/TIMESTAMP columns) β€” a real prod read that marshals today.
40
+ await probe("models_view SELECT* (getModelSummaryById)", `SELECT * FROM models_view LIMIT 5`)
41
+
42
+ // The scalar leaderboard query we will serve (RANK window over eval_results_view).
43
+ await probe("leaderboard scalar query", `
44
+ SELECT evaluation_id, metric_summary_id, model_route_id, score,
45
+ RANK() OVER (PARTITION BY evaluation_id, metric_summary_id
46
+ ORDER BY (CASE WHEN lower_is_better THEN score ELSE -score END) ASC) AS rank,
47
+ COUNT(*) OVER (PARTITION BY evaluation_id, metric_summary_id) AS total
48
+ FROM eval_results_view
49
+ WHERE score IS NOT NULL AND evaluation_id IS NOT NULL
50
+ AND metric_summary_id IS NOT NULL AND model_route_id IS NOT NULL
51
+ LIMIT 200`)
52
+
53
+ console.log("LINUX-GATE SMOKE: PASS β€” prod binding marshalled the real read paths")
54
+ process.exit(0)
55
+ } catch (e) {
56
+ console.error(`LINUX-GATE SMOKE: FAIL β€” ${e?.name}: ${e?.message}`)
57
+ process.exit(1)
58
+ }
tests/duckdb-connection-reset.test.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach } from "vitest"
2
+
3
+ // `lib/duckdb` imports "server-only" (throws outside RSC) and @duckdb/node-api.
4
+ // Stub both so we can unit-test the connection-singleton lifecycle in isolation.
5
+ vi.mock("server-only", () => ({}))
6
+
7
+ const create = vi.fn()
8
+ vi.mock("@duckdb/node-api", () => ({
9
+ DuckDBConnection: { create: () => create() },
10
+ }))
11
+
12
+ describe("getConnection() failure-reset (P1 / I6)", () => {
13
+ beforeEach(() => {
14
+ vi.resetModules()
15
+ create.mockReset()
16
+ // file:// path so the CREATE TABLE loop builds a SQL string without any
17
+ // real network/disk read (connection.run is mocked).
18
+ process.env.SNAPSHOT_URL = "file:///tmp/snapshot"
19
+ })
20
+
21
+ it("clears the singleton on a transient init failure so the NEXT request retries (no permanent 500)", async () => {
22
+ const conn = { run: vi.fn().mockResolvedValue(undefined) }
23
+ create
24
+ .mockRejectedValueOnce(new Error("transient httpfs blip")) // boot blip
25
+ .mockResolvedValueOnce(conn) // retry succeeds
26
+ const { getConnection } = await import("../lib/duckdb")
27
+
28
+ await expect(getConnection()).rejects.toThrow("transient httpfs blip")
29
+ await Promise.resolve() // let the pending.catch microtask null the singleton
30
+ const got = await getConnection()
31
+
32
+ expect(got).toBe(conn)
33
+ expect(create).toHaveBeenCalledTimes(2) // retried β€” not wedged on a rejected promise
34
+ })
35
+
36
+ it("dedups concurrent first-callers into ONE init attempt and they share the rejection", async () => {
37
+ create.mockRejectedValueOnce(new Error("boom"))
38
+ const { getConnection } = await import("../lib/duckdb")
39
+ const [a, b] = await Promise.allSettled([getConnection(), getConnection()])
40
+ expect(a.status).toBe("rejected")
41
+ expect(b.status).toBe("rejected")
42
+ expect(create).toHaveBeenCalledTimes(1) // both awaited the same pending promise
43
+ })
44
+
45
+ it("reuses the connection once initialised (no re-init on the happy path)", async () => {
46
+ const conn = { run: vi.fn().mockResolvedValue(undefined) }
47
+ create.mockResolvedValue(conn)
48
+ const { getConnection } = await import("../lib/duckdb")
49
+ const a = await getConnection()
50
+ const b = await getConnection()
51
+ expect(a).toBe(b)
52
+ expect(create).toHaveBeenCalledTimes(1)
53
+ })
54
+ })