8000 Store: run tables, run put/batch/archive/delete, runs_hash, audit actions, GC and download references for run attachments by ynishi · Pull Request #39 · ynishi/evalhub · GitHub
[go: up one dir, main page]

Skip to content

Store: run tables, run put/batch/archive/delete, runs_hash, audit actions, GC and download references for run attachments - #39

Merged
ynishi merged 1 commit into
mainfrom
feat/store-runs
Sep 25, 2026
Merged

ynishi merged 1 commit into
mainfrom
feat/store-runs

Conversation

@ynishi
@ynishi ynishi commented Sep 25, 2026
Copy link
Copy Markdown
Owner

Stage 4 of 9 of #24 (Runs as first-class rows), on top of stages 1–3 (#36, #37, #38). evalhub-store only: the DDL, the run rows, the 1.0 ingest conversion, audit, runs_summary, and the GC / download references. The Card side (used set, run_results, projection) is stage 5; the data migration is stage 6. No server code changes; the branch is mergeable on its own. The version bump is stage 9 (#35).

What changed

  • migrations/0003_runs.sql: the DDL from Store: run tables, run put/batch/archive/delete, runs_hash, audit actions, GC and download references for run attachments #30 verbatim (diffed against the issue text with comments stripped): runs, run_metrics, run_attachment_refs, run_fingerprints, run_results and card_eval_runs (populated in stage 5), data_migrations (stage 6), records.runs_hash.
  • runs.rs (new): put, put_batch, get, archive / unarchive, tombstone(reason, note). Every write takes lock_record (the ingest path's FOR UPDATE) and the latest live header, then: materialise omitted facets (evalhub_core::run::materialise) → evalhub_core::validate::run → every referenced attachment ready → content hash → upsert 8000 the four tables (stale metric / ref / fingerprint rows deleted first) → recompute records.runs_hash over every row, archived and tombstoned included → audit rows. Outcomes created / updated / unchanged (unchanged: no rows, no audit row). put_batch: one transaction, one lock, all or nothing, every failing element with its index (StoreError::RunsRejected), duplicate id → batch_duplicate_run_id on each later occurrence. run_deleted for a tombstoned id. Overwriting an archived run leaves it archived. Tombstone drops body and run_attachment_refs, keeps content_hash, run_metrics, run_fingerprints, archived_at and the reason; a second tombstone is RunDeleted. No live header → reads and writes not-found. get hides an archived run from non-members; a deleted run is returned with its tombstone to anyone who may see the Eval unless it is also archived.
  • 1.0 ingest (records.rs, the 0.2.0 compatibility window): ingest() (new; create_or_append delegates and keeps its signature) splits an evalhub.eval/1.0 body with split_v1 after validation; the 2.0 header goes through the existing steps and the runs through the same code as put_batch, in the one transaction under the one lock. Re-posting the same 1.0 body is a no-op on both; appending a run adds one row and no header version. Stored body = 2.0 header; returned content_hash = the header's; Ingested carries converted_from: evalhub.eval/1.0, the per-run results and duplicate_run_ids for stage 7's envelope.
  • Audit: run.create / run.update / run.archive / run.unarchive / run.delete, subject eval/{ns}/{name}/runs/{run_id}; a batch writes one row per created or updated run, after the runs_hash recompute, same transaction.
  • runs_summary(record_id, member) → { count, by_status, archived, deleted, runs_hash }: count / by_status over rows neither archived nor tombstoned; archived / deleted for members only; runs_hash for every reader (NULL reported as the hash of []).
  • objects.rs: referencing() also walks run_attachment_refs → runs → records (only for Evals with a live header); Referencing.version_id is Option<Uuid>, the row carries run: Option<(record_id, run_id)> and run_archived: bool for stage 7's may_download. gc_candidates and the gc re-check count a run_attachment_refs row as a reference. tests/objects.rs adjusted.
  • Errors: StoreError::{RunNotFound, RunDeleted, RunsRejected(Vec<RunRejection>), Canonical}. The server's catch-all maps them to 500 with no body until stage 7 maps them (see below).
  • Crate doc: table list, runs, run tombstones, visibility, batch transactions; runs.rs module doc with the write ordering and a Timestamps section.
  • .sqlx: 20 added, 2 removed (the old referencing / gc queries), regenerated against a migrated throwaway Postgres.
  • Tests (tests/runs.rs, 10): created → unchanged (same hash, no audit row) → updated (runs_hash moves; a dropped metric's row goes); archive keeps runs_hash, hides from non-members, same body again is Unchanged and still archived; tombstone keeps hash / metrics, drops refs, later put → run_deleted; batch with one invalid element writes nothing and lists every failure; duplicate id in a batch; a materialised facet survives a later header POST; a 1.0 body with two runs → 2.0 header + two rows, re-post no-op, re-post with one more run → one row and no header version; GC keeps an object referenced only by a run and collects it after tombstone; referencing() yields the run's Eval and run_archived, and nothing once every header version is tombstoned (written test-first, seen red); a private Eval; an unparseable timestamp; the audit rows.

Verified

Command Outcome
cargo fmt --check clean
cargo clippy --workspace --all-targets -- -D warnings clean
cargo test -p evalhub-store (Docker) 76 passed (lib 20, audit 1, auth 3, objects 3, query_sql 11, records 17, registry 4, relations 7, runs 10)
cargo test -p evalhub-server (Docker) 60 passed
cargo sqlx prepare --check (throwaway postgres:16-alpine, migrations 0001–0003) exit 0; no orphan .sqlx files
cargo doc --no-deps -p evalhub-store 0 warnings

Not run: cargo deny check (no dependency change), just e2e, schema / core / query tests (untouched crates; the server run links them).

Recorded here, decided later

  • run_id in the body. run.json requires run_id (Run.run_id: String, Schema: Run leaves the Eval body; per-run facets, status and metrics; Card.run_results; evalhub.eval/2.0 #27), while Core: validate a Run, refuse runs in an Eval body, check run_results, fingerprint a run, publish the run hash formulas #28's run_id_mismatch wording reads as if the body may omit it. The store validates the body as given, so PUT …/runs/r1 with a body lacking run_id fails the schema pass; every test here carries it. Stage 7 chooses: keep it required (body repeats the path id) or have the handler / runs::put insert the path id before validation, as prepare already does after it.
  • Timestamps. started_at / ended_at declare format: date-time, but core validates Draft 2020-12 without format assertions, so "started_at": "yesterday" is accepted, kept in the body and stored NULL in the column (sorts as NULL). Documented and tested; the fix is in evalhub-core and applies to records too.
  • Interim 500 (until stage 7 maps the new errors). A 1.0 body whose runs[].run_id contains / or exceeds 200 bytes passes 1.0 validation (eval.json has no run_id constraint), then fails validate::run after the split; the server's catch-all answers 500 with an empty body and a log line. Such a body was accepted before this stage. Stage 7 maps RunsRejected to 422.
  • Downloads of an archived run's attachment. may_download does not read run_archived yet, so on a public Eval an archived run's reference counts as public. No endpoint can archive a run before stage 7.

Refs #30

…ions, GC and download references for run attachments

Stage 4 of #24. Runs become rows of the Eval record: 0003_runs.sql
adds runs, run_metrics, run_attachment_refs, run_fingerprints, the
two Card-side tables stage 5 fills (run_results, card_eval_runs),
data_migrations for stage 6, and records.runs_hash.

runs.rs writes them. Every write locks the record row as the ingest
path does, then: materialise the facets the run omits from the
latest live header, validate, check every referenced attachment is
ready, hash, upsert the four tables, recompute runs_hash over every
row (archived and tombstoned included), write the audit row.
Outcomes are created / updated / unchanged; unchanged writes no row
and no audit row. put_batch is one transaction under one lock, all
or nothing, and reports every failing element with its index; a
run_id repeated in the batch is batch_duplicate_run_id. A tombstoned
id answers run_deleted. Overwriting an archived run leaves it
archived. A tombstone drops the body and the attachment references
and keeps the content hash, the metrics and the reason. An Eval
whose header versions are all tombstoned makes run reads and writes
not-found, and its runs no longer count as download references (the
GC still counts them).

The 0.2.0 compatibility window: an evalhub.eval/1.0 body is split
by evalhub_core::eval::split_v1 after validation; the 2.0 header
goes through the existing ingest steps and the runs through the same
code as put_batch, in the one transaction under the one lock. So a
1.0 client re-posting the same body is a no-op on both, and one that
appends a run gets a run row and no new header version. The stored
body is the 2.0 header and the returned content_hash is that
header's; ingest() reports converted_from and the per-run results
for the server's envelope (stage 7), while create_or_append keeps
its signature.

Audit gains run.create / run.update / run.archive / run.unarchive /
run.delete with subject eval/{ns}/{name}/runs/{run_id}.
runs_summary(record_id, member) gives the Eval envelope its count,
by_status, archived, deleted and runs_hash. objects::referencing()
walks run_attachment_refs, Referencing.version_id becomes optional
and the row carries run_archived; gc_candidates and the gc re-check
count a run reference. .sqlx regenerated (20 added, 2 removed).

Two things this stage records rather than changes. A run body must
carry run_id (run.json requires it; #28's wording reads as optional),
so PUT with a body that omits the path id fails the schema pass;
stage 7 decides which side is authoritative. And a started_at /
ended_at that is not RFC 3339 is accepted, kept in the body and
stored NULL in the column, because core validates Draft 2020-12
without format assertions; documented and tested here, the fix is
in core and touches records too.

Verified: cargo fmt --check; cargo clippy --workspace --all-targets
-D warnings; cargo test -p evalhub-store (76 passed, Docker; the
referencing fix was written test-first and seen red); cargo test
-p evalhub-server (60, Docker); cargo sqlx prepare --check against a
migrated throwaway Postgres; cargo doc --no-deps -p evalhub-store
(0 warnings).

Refs #30

Signed-off-by: Yutaka Nishimura <ytk.nishimura@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0