Repository navigation
An unknown function in SET is refused, and a SET that fails to evaluate is an error (#1897) - #1918
Conversation
…te is an error (#1897) `MATCH (p) SET p.embedding = vecf32([...])` reported success and wrote nothing, while `RETURN vecf32(...)` was refused. Two causes: - validate: `validate_function_names` walked `all_expressions`, which covers the reading clauses only. It now also walks `write_expressions` (SET values, ON CREATE / ON MATCH values, DELETE targets, FOREACH lists and bodies) and the pattern property expressions of CREATE / MERGE / MATCH. - operator: `SetPropertyOperator` turned an evaluation error into `Null`, and a null SET removes the property, so a failed call deleted what was there. The error is now the statement's error. tests/unknown_function_in_writes.rs pins the reported statement, every updating-clause position, that known and namespaced functions are still accepted, and that a run-time failure leaves the property as it was.
Kalyanpulikanti-01
left a comment
There was a problem hiding this comment.
Approving. Two fixes here, and I'd argue the four-line one is the more important of the two — worth saying so because the PR title and the tests both lead with the other.
Err(_) => PropertyValue::Null was the actual data-loss bug
That arm meant any failure to evaluate a SET right-hand side deleted the property, because a null SET removes rather than stores (#874). Not just an unknown function — a division by zero, a type error, an arithmetic overflow, anything. The statement then reported success. So the blast radius was never "FalkorDB's vecf32"; it was every runtime evaluation failure on the write path, and the 1 / (p.x - 5) test is the one that pins the general case rather than the reported instance.
This also means the two fixes are complementary rather than belt-and-braces, which matters for how much the first one has to carry:
- compile-time check — catches un-namespaced unknown names, data-independently, before anything runs.
- the error arm — catches everything else, including every case the first one deliberately lets past.
The compile-time check cannot catch the FalkorDB vector functions, and that's fine
is_known_function allows every dotted name by design — lower.contains('.') — because rejecting namespaced names at compile time turned 345 passing TCK scenarios into errors the first time this was attempted. So vecf32 is caught (no dot), but vector.similarity.cosine(...), algo.* and db.* in a SET still pass validation untouched, before and after this PR.
That isn't a gap in the PR, but it does mean the date.truncate test proves less than it looks like it proves — a dotted name passes trivially, whatever it is. The thing actually protecting a user who writes SET n.v = vector.similarity.cosine(...) is the error arm, which now surfaces it at run time instead of silently emptying the property. Worth a line in the issue or the guide, since #1897 was filed from a FalkorDB migration and vecf32 is the only one of that family the compile-time half reaches.
Over-rejection: the risk is lower than it looks, and for a structural reason
This was my main concern, given #947's history. Checking it:
A function implemented in eval_function but missing from KNOWN_FUNCTIONS would now break a working SET. But such a function is already rejected in RETURN today, so the gap would already be a live bug for reads rather than something this PR introduces. every_accepted_function_can_actually_execute (#1456) pins the list→dispatcher direction; the dispatcher→list direction is pinned only by the first failure in production, and that is pre-existing.
The residual risk specific to this change is narrower: a parser-generated synthetic call that appears only in write positions and doesn't start with __ would now be rejected where nothing checked it before. I don't see one, and the 11 positions tested cover the surface, but that is the class worth watching if something odd turns up.
Since the suite that caught the 345-scenario regression is not in per-PR CI, it is worth saying explicitly whether the TCK was run locally against this. If it was, the result belongs in the PR body; if it wasn't, that is the one check I'd want before merge rather than after.
Scope and the rebase
Keeping the operator change to four lines because #1916 rewrites around it was the right call, and the note about it is accurate — write_expressions reads sc.items and sc.entity_items as fields, which #1916 turns into iterator methods. So whichever lands second needs .items() / .entity_items(), a mechanical change. Given #1916 is also approved, suggest landing #1916 first and rebasing this onto it: #1916's change is the larger of the two and its SetOp enum is what the validator will want to walk eventually anyway.
Worth noting for the release notes that compatibility_report counted these statements as accepted, so the FalkorDB corpus numbers in #1898 — 53 accepted / 38 planned / 32 run — were measured against an engine that accepted a write it then silently discarded. Those figures should be re-derived after both of these land, not carried forward.
… a delete (#1897) set_property_operator_paths fed an unbound variable to one SET item and asserted the property ended up absent: the old swallow-to-null behaviour this PR removes. The item is dropped from the happy-path operator and covered by its own assertion: the operator errors and `k` keeps its value.
#1916 (#1896) made `SetPropertyOperator` apply its writes in source order, which changed the value-evaluation closure to return `Option<PropertyValue>` over the new `writes` vector. This branch's fix for #1897 makes a failed evaluation the statement's error rather than `Null` -- and a null SET removes the property, so an expression that failed reported success and deleted what was there. Both are needed, and taking either side of the conflict alone would drop the other silently: - the closure now returns `ExecutionResult<Option<PropertyValue>>`, `None` for a non-property write, and the collect is `collect::<ExecutionResult<_>>()?`, so `Err(e) => return Err(e)` still propagates; - `SET a.x = a.y, a.y = a.x` still swaps, because every property value is read before any write; - writes still apply in the order they were written. `validate.rs`'s `write_expressions` reads `SetClause` through the `items()` / `entity_items()` accessors that replaced the by-kind fields. `cargo check --lib --tests` is clean.
|
I've pushed the dev merge to this branch ( It wasn't just
|
| must hold | from | test |
|---|---|---|
| writes apply in source order | #1916 | a_property_after_a_replace_is_kept |
property values all read before any write (so SET a.x = a.y, a.y = a.x swaps) |
#1916 | property_values_are_still_read_before_any_write |
| a failed evaluation errors and writes nothing | #1897 | a_set_whose_value_fails_at_run_time_is_an_error_and_writes_nothing |
Verified
cargo check --lib --tests clean, no new warnings, none in the regions touched. Then, in the debug profile CI uses:
set_order 8 passed
unknown_function_in_writes 4 passed
unknown_function 11 passed
merge_sees_its_own_writes 7 passed
30/30. The middle two rows of that table are the ones I actually cared about: property_values_are_still_read_before_any_write passing means I didn't flatten your evaluate-then-write split into evaluate-at-write, and a_set_whose_value_fails_at_run_time_is_an_error_and_writes_nothing passing means the error path survived the restructure. Either resolution that looked right but was wrong would have failed one of them.
dev has moved 3 commits since, so this needs one more update before it can merge. Queuing it.
Shout if you'd rather have resolved it yourself and I'll revert the merge commit — it's one commit on top of yours and nothing else is touched.
#1898's ratchet caught this PR, correctly. One idiom changed: MATCH (p:Person {name: 'Alice'}) SET p.embedding = vecf32([0.1, 0.2, 0.3]) It used to be counted as accepted, because validation only checked the reading clauses, so the statement planned, ran, failed to evaluate the call and reported success having written nothing. It is now refused at compile time, and the corpus count fell 38 -> 37. The count went down because the honesty went up, which is the one direction the floor is not meant to guard against. So: - the idiom joins KNOWN_REFUSALS with its reason and this issue number; - ACCEPTED_FLOOR drops to 37, with a comment saying why, because a floor lowered without one is indistinguishable from a regression being papered over -- and forcing that distinction is what the ratchet is for. `every_known_refusal_still_matches_a_corpus_query` keeps the new entry honest: reword the corpus query and the entry stops matching, failing the build rather than silently covering nothing. falkordb_compatibility_corpus 5, falkordb_migration_guide 7, set_order 8, unknown_function_in_writes 4 -- 24 passed, 0 failed. docs/MIGRATING-FROM-FALKORDB.md still states 53 accepted / 38 planned / 32 run as measured figures. The 38 is now stale; noted on #1898.
|
CI failed after the dev merge, and the cause is worth recording because it is not a defect in this PR — #1898's corpus ratchet caught it, correctly. Exactly one idiom changed: MATCH (p:Person {name: 'Alice'}) SET p.embedding = vecf32([0.1, 0.2, 0.3])refused now with That is the statement from #1897 itself. It was counted as accepted because validation only walked the reading clauses — so it planned, ran, failed to evaluate the call, and reported success having stored nothing. The count fell because the honesty rose, which is the one direction the floor is not meant to guard against. Pushed as
Verified in the debug profile CI uses: 24/24. One thing for whoever lands this: |
Fixes #1897.
The bug
MATCH (p:V {name:'Alice'}) SET p.embedding = vecf32([0.1, 0.2])UnknownFunction: vecf32 …at compile timeMATCH (p:V {name:'Alice'}) SET p.x = noSuchFunction(1)UnknownFunctionRETURN vecf32([0.1, 0.2])UnknownFunctionSET n.embedding = vecf32([...])is how FalkorDB writes a vector, so a migrating user's import reported success while storing no vectors, andcompatibility_reportcounted the statement as accepted.Two causes, both fixed
The compile-time check never saw write clauses.
validate_function_names(An unknown function returns zero rows instead of failing #947) walkedall_expressions, which covers RETURN, WITH, WHERE, UNWIND and LOAD only. It now also walks a newwrite_expressions, which covers:ON CREATE/ON MATCHvalues;It also walks the existing
pattern_property_expressions, soCREATE (n {x: noSuchFunction(1)})is refused too.all_expressionsitself is unchanged, because the other checks built on it were measured against reading clauses alone.A failed evaluation became a delete.
SetPropertyOperatormapped an evaluation error toNull(Err(_) => PropertyValue::Null), and a null SET removes the property. So an expression that failed at run time reported success and deleted what was there. The error is now the statement's error. The change is four lines, kept small on purpose because A SET applies its writes in the order they were written (#1896) #1916 rewrites the code around it.Tests
tests/unknown_function_in_writes.rs:=/+=/a second SET clause, MERGE ON CREATE/ON MATCH/bare SET, CREATE properties, FOREACH list and body, a pipeline SET) each refuse an unknown function;timestamp(),randomUUID(),size,abs,range) and a namespaced one (date.truncate) are still accepted. The An unknown function returns zero rows instead of failing #947 check was deliberately narrow, because over-rejecting cost 345 TCK scenarios the first time;1 / (p.x - 5)withx = 5) returnsDivision by zeroand leavesxas it was.cargo check --workspace --tests --examples --benchesis clean, and the lint ratchet is under its ceiling.Note
This touches the same lines of
SetPropertyOperator::next_mutas #1916 (#1896). Whichever merges second needs a small rebase, and I'll do it.