Skip to content
Design What vigily needs from kv0 Built 12 November 2026 · MIT License

What vigily needs from kv0

This is my list of kv0 features that vigily needs, with the reason for each. vigily uses kv0's tracking layer (kv0::tracking) through two adapters: crates/vigily-core/src/kv0_store.rs (runs, parameters, series, points) and crates/vigily-core/src/records.rs (datasets, evaluation results, schedules, alerts).

Status

vigily builds against kv0 main, which has every feature below except 7 and 23. Items 16 onward came up later, while I was building vigily on the first ones. Where vigily uses each:

Feature Status Where vigily uses it
1. Bulk reads Done The catalog reads all parameters and all series once per refresh
2. Series statistics Done The catalog reads stored statistics; start-up reads no points
3. Metadata-only updates Done update_run from the web app reads no points
4. Times on create and finish Done Imported runs keep their times without a second write
5. Change detection Done A refresh skips every table whose version did not change
6. Per-field updates Done Edits from the web app and a finishing script do not overwrite each other
7. Streaming or Arrow reads Not done Points still arrive as vectors
8. Sorted points Done (in optimize) The background optimize every 15 minutes
9. Automatic compaction Done Start-up needs no compaction
10. Kind and target columns Done kind and target are run columns
11. Time-range reads Done Repo.metric(..., start_time=, stop_time=) in the SDK
12. Records table Done, then per table (kv0 #11) Kv0Records: one table per kind and one per dataset
13. Deleting runs Done Delete in the runs table with an undo window; running runs are refused
14. No resident key-value view Done Records are read through indices; nothing is held in memory
15. Named versions Done, per table A dataset version's tag keeps only its dataset's table
16. Purge (kv0 #12) Done vigily runs purge [--force]
17. Several runs per records query, run_ids (kv0 #13) Done Drift reads a suite's results and features in one query
18. Opening repositories with the old shared records table (kv0 #13) Done vigily migrates it once on open and keeps it
19. Tags on tables with escaped names (kv0 #13) Fixed vigily names dataset tables dataset.<name> anyway
20. Lower memory for optimize (kv0 #14) Done See docs/architecture.md for vigily up memory with and without optimizing at start
21. Folding superseded run rows (kv0 #15) Done Heartbeat rows (one every 2 minutes per open run) are folded by the background optimize
22. A cheap check for the old shared records table Done has_legacy_records(): one directory listing on each open and in vigily runs purge
23. Several item ids, or an item-id prefix, per records query Not done Traces store spans in one row per batch and keep span ids in a row of their own; see below

The sections below are the original plans for each feature, kept for their reasoning.

1. Bulk reads of parameters and series

API. Repo::all_params() -> HashMap<String, Value> (run id to nested parameters) and Repo::all_series() -> Vec<SeriesInfo>, each one scan of its table. Optionally both filtered by updated since <seq or version>.

Why. vigily's catalog indexes every run's parameters and series. Today that is params(run_id) and series(run_id) once per run, and each call flushes, checks the latest version and scans the whole table with a filter. The first refresh is therefore O(runs × table size): see the scale test in docs/architecture.md.

2. Series statistics

API. Repo::series_stats(ids: &[u64]) -> HashMap<u64, SeriesStats> with count, first_step, last_step, last, min, min_step, max, max_step, non_finite, last_timestamp. Ideally maintained on flush (a small series_stats table, last writer wins per series) so the call reads one row per series.

Why. The runs table shows and sorts by the last, minimum and maximum of every metric. vigily computes these itself, which means reading every point of every series once when the server starts (then incrementally). With stored statistics, start-up reads no points at all.

3. Metadata-only run updates

API. Repo::update_run(run_id, f: impl FnOnce(&mut RunInfo)) -> Result<RunInfo> that reads the current record, applies f and appends it, without reading points.

Why. Renaming, notes, tags and archiving come from the web app. resume_run reads every point of the run to find its last steps, which is too slow for a click. vigily instead calls create_run(NewRun { run_id: Some(id), .. }) and immediately update(|i| *i = patched); both rows land in one flush and the later sequence number wins. This depends on two internals that are not part of the API contract: that create_run with an existing id is allowed, and that both rows are committed together.

4. Creation and end times on create and finish

API. NewRun::created_at: Option<i64> and Run::finish_at(status, ended_at).

Why. Importing runs from another tracker or from logs needs their real times. vigily sets them with update after create_run and before flush, which writes an extra row per run.

5. Cheap change detection

API. Repo::latest_versions() -> (u64, u64, u64, u64) that checks storage for the newest version of each table without scanning (today versions() reports the handle's loaded versions).

Why. The server refreshes every 2 seconds. With a version check it can skip the refresh when nothing was committed, which on object stores saves a scan of the runs table every 2 seconds.

6. Per-field updates, or compare-and-set, for run records

API. Either update that merges only the fields it changed (column-level last writer wins), or update_if(run_id, expected_seq, f) that fails on a concurrent change.

Why. Run records are whole-row last writer wins. If the training process finishes a run (status finished) between the web app reading the record and writing a new note, the note's row carries the old status running with a newer sequence number, and the run looks like it is running forever. vigily narrows the window (it reads immediately before writing) but cannot close it.

7. Reads that stream or return Arrow

API. read_many returning Arrow record batches, or a callback per series, instead of HashMap<u64, MetricData> of owned vectors.

Why. A chart of 200 runs × 200 000 points is 40 million points; vigily reduces them to a few thousand per run immediately. Materialising four Vecs per series first doubles peak memory.

8. Points sorted by series and step

Already planned in kv0's TODO.md (sort metrics by (series_id, step) during optimize). vigily's reads are per series and step range, so contiguous data would make zoomed reads cheap.

9. Automatic compaction in the tracking layer

API. The tracking layer compacts a table after a flush when it holds more than max_fragments fragments (as the key-value layer does), or a RepoOptions::auto_optimize setting.

Why. Every Run::finish flushes, so each run adds one fragment per table. In vigily's scale test (2 000 runs logged one after another) commits slowed down as fragments accumulated, so logging took 27 minutes, and the server's first catalog refresh took 250 to 390 seconds against 10 seconds after optimize(). vigily's server called optimize() at start and every 15 minutes, but training processes that log many runs pay the cost while writing.

10. Run kind and target columns

API. Two fields on RunInfo and NewRun: kind: Option<String> (training, eval, ...) and target: Option<Value> (a JSON object: model, prompt, endpoint, git commit). Alternatively one generic attrs: Value column for small run-level metadata.

Why. vigily runs carry a kind and a target reference. Without columns they are stored as reserved parameters (__run__.kind, __run__.target.*), so listing runs by kind needs the parameters of every run (item 1) instead of the runs table alone.

11. Time-range reads

API. StepRange-like reads by timestamp (start_time <= timestamp < stop_time), and a timestamp index or sort order that makes them cheap.

Why. Evaluation and monitoring metrics are compared over wall-clock time (a nightly score over 30 days), and charts zoom on a time axis. vigily reads the whole series and filters in memory.

12. A per-item records table

API. Versioned, append-only tables of JSON-like rows with a few typed key columns, for example records(table, run_id, item_id, version) -> rows and append_records(table, rows), with the table's Lance version exposed so a run can record exactly which version of a dataset it read.

Why. Evaluations (vigily's next milestone) store datasets (items with input, expected output and metadata), and per run one output and several scores per item. Metric series are (step, value) pairs and do not fit; a table per dataset or per run would multiply manifests (kv0's design rejects that for series for the same reason).

13. Deleting runs

API. Repo::delete_runs(ids) that removes the runs' rows from all four tables (with cleanup_old_versions to reclaim space).

Why. vigily can archive (hide) runs today; deleting needs storage support.

14. Records without a resident key-value view

API. Either the typed records table of item 12, or a key-value mode that keeps only keys (or nothing) in memory and reads values lazily through Lance's random access (already listed in kv0's TODO.md).

Why. vigily stores datasets, per-item eval results, labels, feature samples, schedule runs and alerts in kv0's key-value layer today (<repo>/records), because it exists and works on every backend. Its whole view is resident in memory in every process that opens it, including each training or evaluation process, so evaluation data is bounded by RAM and every SDK process pays the load time.

15. Named versions (tags)

API. Lance tags on a dataset (create_tag(name, version), checkout(tag)), exposed by the key-value layer and by the records table of item 12.

Why. vigily records the Lance version after each dataset commit for traceability. Tags would let a dataset version be read back exactly as committed even after compaction and cleanup_old_versions, which today could remove the version an audit record points at.

23. Several item ids, or an item-id prefix, per records query

API. RecordQuery::item_ids: Option<Vec<String>> (item_id IN (...), using the item-id index, chunked like run_ids), and ideally RecordQuery::item_prefix: Option<String> (starts_with(item_id, ...), served by the btree index as a range).

Why. LLM traces (vigily's tracing milestone) are trees of spans that arrive in batches over seconds or minutes. The natural layout is one row per span, keyed tspan/<trace>/<span>, read back by the trace id. Today a records query matches one item id or a whole table (or runs), so vigily stores one row per ingested batch (tspan/<trace>/<n>), reads a trace with one query per batch, and keeps every span id in a per-trace row (tsid/<trace>) to ignore a span sent twice, which grows with the trace and is rewritten by every batch. With item_ids or a prefix, a trace page is one query, and a resent span simply supersedes its own row.