8000 feat(udf): fallible UDFs, QRY-014, registry snapshot and re-entry (#180 PR 1) by adityamukho · Pull Request #746 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content

feat(udf): fallible UDFs, QRY-014, registry snapshot and re-entry (#180 PR 1) - #746

Merged
adityamukho merged 17 commits into
v3from
feat/180-fallible-udfs
Oct 24, 2026
Merged

adityamukho merged 17 commits into
v3from
feat/180-fallible-udfs

Conversation

@adityamukho
@adityamukho adityamukho commented Oct 24, 2026 •
Copy link
Copy Markdown
Collaborator

#180 PR 1 (plan steps 5–13; spec docs/superpowers/specs/2026-10-24-udf-uniffi-design.md §3–§5, §7, §8). Core only: the binding PRs follow. #180 stays open until the last binding PR.

What changes

  • try_register_predicate / try_register_aggregate take closures that return Result<_, UdfError>. UdfError is exported at the crate root. register_predicate / register_aggregate keep their signatures: they wrap their closures in Ok, so both kinds of registration share one failure path.
  • QRY-014 user function `<name>` failed: <message> is raised at the first failing call: a predicate (in :where, rule bodies, not bodies, or branches), or an aggregate's init, step or finalise (as a :find aggregate or an :over window). No later call of any UDF is made for that query. eval_expr now returns EvalError::{NoValue, Udf}: NoValue drops the row as Err(()) did before, and Udf fails the query. Before this PR, QRY-014 was unclaimed on every branch, feat(query): add set membership predicates #316's included.
  • Registry snapshot. Functions (RwLock<Arc<FunctionRegistry>>) replaces Arc<RwLock<FunctionRegistry>> for every holder. open_query takes one snapshot, and the QRY-007/QRY-010/QRY-011 checks, the stream and rule evaluation all read it. Stream::pull and open_rules no longer lock. Registration copies the registry, inserts the function and swaps the copy in. Before, a UDF that registered a function deadlocked.
  • Re-entry. A UDF may call back into its own database: every row of spec §5's table runs from inside a plain read, and inside a WriteTransaction each call returns INT-001 except registration (checkpoint/verify/rebuild_indexes on a thread holding a WriteTransaction deadlock instead of INT-001 #734).

Deviation from the plan

  • The cursor case of spec §4 ("rows gathered before the failure are returned first") behaves as any other read error does: the cursor returns the rows of the batches the engine finished before the failure, and drops the rows of the internal batch the failure happens in. Codex flagged this. Returning those rows too would mean every operator holding back an error until its batch is out, which no other read error does. The CHANGELOG, ERROR_REFERENCE.md and the docs site describe the behaviour as it is.

Tests

  • tests/udf_test.rs: every existing UDF test runs through both kinds of registration. New: a failing predicate in four positions; through a cursor (rows first, then the error, then None); through a prepared query; a failing aggregate in each of the three phases, as an aggregate and as a window. Each checks that no call follows the failing one.
  • tests/udf_reentry_test.rs (new, file-backed): registration from inside a predicate, an aggregate and a rule body, each under a 30 s timeout (they deadlocked before); a cursor keeps the functions it opened with; one test per row of spec §5's table, in both columns, on a file database with committed and pending facts.
  • Unit tests: UdfError, and QRY-014's code, category and text.

Bench machine (merged with v3 at 8f2d51f)

  • cargo test: 1,790 passing, 13 ignored. --features test-hooks: 1,808 passing, 14 ignored. No failures.
  • Coverage gates (nightly-2026-10-01, cargo llvm-cov --branch): all pass. Crate 95.37% lines / 85.59% branches.
  • Release binary: 1,218,744 B, against v3's 1,217,824 B (+920 B).
  • cargo clippy --all-features -- -D warnings and the wasm32 browser clippy: clean.
  • The WASI passing count in TEST_COVERAGE.md will be updated from this PR's wasm.yml run (no Wasmtime on the bench).

Lock audit (plan step 11)

I checked every .read(), .write() and .lock() under src/query, src/graph/storage.rs, src/storage/cache.rs and src/storage/reader.rs, plus the other guards a read reaches (btree.rs, dict.rs, pins.rs, db.rs). Line numbers are at this PR's head. UDFs are called from engine/filter.rs eval (predicates) and from aggregate.rs Agg/Running/windows/finish_groups through engine/aggregate.rs and engine/window.rs (aggregates and windows). Every one of those calls runs inside Stream::pull or inside recursive::evaluate in open_rules. No guard below is alive at any of them. Test-module guards are left out.

Guard (file:line) Why it is not held across a UDF call
src/query/datalog/functions.rs:210 Functions::snapshot (read) Held only to clone the Arc; the query keeps the clone. It replaces the read locks that Stream::pull held for each batch and open_rules held through recursive::evaluate.
src/query/datalog/functions.rs:220 Functions::register (write) Held while the registry is copied and the function inserted. Calls no user code.
src/query/datalog/executor.rs:362 open_rule_query, rules.read() Inside the let plan = { … } block (magic-sets rewrite, planning), dropped before open_rules evaluates anything.
src/query/datalog/executor.rs:441 execute_rule, rules.write() Rule registration. No query runs.
src/graph/storage.rs:406 Snapshot::read_pending Hands the pending read guard to tx_bound, extend_with_pending, live_facts_of_scans (test only), pending_records and pending_live. Each copies the facts out and drops the guard on return, before the scan passes its batch on.
src/graph/storage.rs:807/811 write_pending, 826/830 restore_tx_counter, 940 set_committed, 962/966/967 publish Write, open and checkpoint paths. They run no query.
src/graph/storage.rs:895/900 FactStorage::snapshot (data, pending) Dropped before snapshot() returns. The query opens on the result.
src/graph/storage.rs:885/886, 981, 989/990, 1269/1270, 1297/1298, 1318/1319, 1378/1385 (get_pending_facts, generation, pending_index_counts, get_facts_by_*, fact_count) Local to the method; each returns copies.
src/storage/cache.rs:112/126 get_or_load, 153 put_dirty, 187 flush, 201 invalidate, 210 evict, 219 cached_page_count, 230 capacity Local to the method. A page read returns an Arc of the page.
src/storage/reader.rs No guards.
src/storage/btree.rs:885/900/906/911 MutexStorageBackend One backend call each.
src/storage/dict.rs:47/55 shared_get / shared_put One map lookup or insert each.
src/storage/pins.rs:54 Pins::lock Taken by pin when a snapshot is taken and by release when it drops, not during a pull.
src/db.rs:765 (execute_inner query branch), 979 query_inner, prepared.rs execute/query, fact_log Take no lock: they snapshot, then run the executor.
src/db.rs:774 (rule branch), 808 execute_statement, 1110 checkpoint_inner, 1148 verify_inner, 1197 rebuild_indexes_inner, 454 drop, 1239 generation, 1249 kill (write_lock) Held for a write, maintenance or close call that runs no query.
src/db.rs:1052 begin_write → WriteTransaction The one exception (spec §5). write_lock is held for the transaction's whole lifetime, so it is held across a UDF in WriteTransaction::execute. Every call that could wait on it refuses on that thread with INT-001 (#734); the write-transaction column of udf_reentry_test.rs checks this.

Nothing needed a fix, so the audit adds no re-entry test.

Docs

db.rs doc comments on all four registration methods; CHANGELOG.md (Added); ERROR_REFERENCE.md (QRY-014, plus API-008 and QRY-008, whose causes no longer include a panicking UDF); ROADMAP.md, CLAUDE.md, docs/TEST_COVERAGE.md. Docs-site PR: project-minigraf/minigraf-docs#65, to merge after this one.

Codex review: the two findings above (API-008/QRY-008 docs fixed; cursor wording made exact). It found no query path that reads the registry after open, and no swallowed failure.

🤖 Generated with Claude Code

https://claude.ai/code/session_015NBT2Yx9JUQKYvv2CvC9py

adityamukho and others added 10 commits October 24, 2026 18:03
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NBT2Yx9JUQKYvv2CvC9py
A query snapshots the function registry when it opens and holds no lock
while a user function runs; registration swaps in a new registry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NBT2Yx9JUQKYvv2CvC9py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NBT2Yx9JUQKYvv2CvC9py
@codecov
codecov Bot commented Oct 24, 2026 •
Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.11765% with 12 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/query/datalog/functions.rs 90.74% 5 Missing ⚠️
src/query/datalog/executor.rs 94.33% 3 Missing ⚠️
src/db.rs 95.34% 2 Missing ⚠️
src/query/datalog/aggregate.rs 94.11% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

adityamukho and others added 7 commits October 24, 2026 19:38
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NBT2Yx9JUQKYvv2CvC9py
# Conflicts:
#	CLAUDE.md
#	docs/ERROR_REFERENCE.md
#	docs/TEST_COVERAGE.md
#	src/error.rs
# Conflicts:
#	CLAUDE.md
#	docs/TEST_COVERAGE.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Urda7FD9CYYoxdeGGJa11x
# Conflicts:
#	CHANGELOG.md
#	CLAUDE.md
#	docs/TEST_COVERAGE.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Urda7FD9CYYoxdeGGJa11x
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Urda7FD9CYYoxdeGGJa11x
@adityamukho
adityamukho merged commit 614b76e into v3 Oct 24, 2026
19 checks passed
@adityamukho
adityamukho deleted the feat/180-fallible-udfs branch October 24, 2026 22:44
@adityamukho adityamukho mentioned this pull request Oct 25, 2026
10 tasks done
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