Spaces:
Running
Running
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 +18 -0
- lib/duckdb.ts +10 -1
- scripts/linux-gate.sh +40 -0
- scripts/linux-gate/Dockerfile +14 -0
- scripts/linux-gate/README.md +45 -0
- scripts/linux-gate/smoke.mjs +58 -0
- tests/duckdb-connection-reset.test.ts +54 -0
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 |
-
|
| 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 |
+
})
|