Description
Nothing in src/vdbe/ reports how many rows an INSERT/UPDATE/DELETE
changed. Spec 013 calls this out as the one item on its list a consumer
cannot work around (013/Req 1): execute_transaction_step returns rows and
the new autocommit flag, so a caller cannot distinguish an UPDATE that
matched from one that did not, and every optimistic-concurrency scheme is
built on exactly that distinction.
SQE swaps a table's metadata pointer with a conditional UPDATE and treats
zero rows affected as a lost race. Without the count that becomes
SELECT-then-UPDATE inside a transaction — sound only while the consumer
guarantees a single writer, and every consumer reinvents it.
This is the engine half. Connection::changes is spec 013/Req 1's stated
surface and belongs to the facade ticket; this one makes the number exist and
be correct.
Why the count cannot live in the opcode handlers
Measured on the tree at 0.18.10, not assumed. Counting every Insert and
Delete as it executes gives the wrong answer three different ways, and the
error is plan-dependent:
| statement |
opcodes per row |
naive count |
INSERT |
Insert (insert.rs:790) |
1 ✓ |
DELETE |
Delete (delete.rs:121,152) |
1 ✓ |
UPDATE, single-pass |
Delete + Insert (update.rs:603,605) |
2 ✗ |
UPDATE, two-pass range-seek |
ephemeral Insert (update.rs:276) + Delete + Insert |
3 ✗ |
The two-pass plan is #666/#675's range-seek path, which stashes matched rowids
in an ephemeral b-tree using the same Opcode::Insert against eph_cursor.
So the same UPDATE reports 2 or 3 depending on which plan the optimizer
picks, and neither is 1.
Index maintenance has the same problem in reverse: IdxInsert, IdxDelete
and AutoIndexInsert are row-adjacent writes that must never count.
Design: OPFLAG_NCHANGE on P5, as SQLite does it
Codegen — not the handler — decides which mutation is the row change.
Stock SQLite does this with OPFLAG_NCHANGE in P5 on OP_Insert/OP_Delete,
and following it keeps our opcode semantics aligned with the thing we are a
replication of.
Instruction.p5 is a u16 "flags operand" that neither cursor::insert
(cursor.rs:1995) nor cursor::delete (cursor.rs:1940) reads today, so
the bit is free on exactly the two opcodes that need it. No new opcode, so
the frozen-set ADRs (0015/0018/0020) are not reopened.
src/vdbe/cursor.rs — insert/delete increment Vm's counter only when
the P5 NCHANGE bit is set.
src/codegen/stmt/insert.rs — set it on the table Insert.
src/codegen/stmt/delete.rs — set it on both Delete sites.
src/codegen/stmt/update.rs — set it on the TABLE_CURSOR Insert
(:605) and not on the paired Delete (:603), and not on the
ephemeral Insert (:276). One row changed, counted once.
Statement-level semantics, and where they stop
SQLite's rule is that a statement returning no rows does not reset the
count — so the count is per-connection state that outlives one statement.
This crate builds a fresh Vm per execution, so the engine cannot own that
rule; only the facade can.
The split that makes the facade's rule trivial: the entry points return
Option<u64> — Some(n) when the program is a counting statement,
None when it is not. The facade then stores the value on Some and leaves
its stored value alone on None, which is precisely sqlite3_changes().
Some(0) versus None is the subtle part and must be static, not dynamic:
an UPDATE whose WHERE matches nothing never executes an NCHANGE opcode
but must still report Some(0). So the discriminator is "does the program
contain an NCHANGE-flagged instruction", derived by one pass over
program.instructions rather than a new Program field — Program is
{ instructions } and its new() has many call sites, and a derived answer
cannot drift from the instructions it describes.
Scope
Non-goals
Connection::changes and the cross-statement retention rule — spec 013/Req
1's surface, facade ticket.
total_changes(). Not in spec 013, no consumer asked.
- Counting rows changed by
ON CONFLICT REPLACE deletions as separate
changes. Stock SQLite does not, and neither should we, but no test here
exercises it.
Acceptance Criteria
Complexity
Estimate: small
Reasoning: Four files, one counter, one flag bit already free on both
target opcodes, and the design question (why not count in the handler) is
answered above by measurement rather than left open. The care is in the
codegen placement — one row must be counted exactly once across two
different UPDATE plans — and that is what the first acceptance criterion
pins.
Refs: 013/Req-1, #678, #683
Description
Nothing in
src/vdbe/reports how many rows anINSERT/UPDATE/DELETEchanged. Spec 013 calls this out as the one item on its list a consumer
cannot work around (013/Req 1):
execute_transaction_stepreturns rows andthe new autocommit flag, so a caller cannot distinguish an
UPDATEthatmatched from one that did not, and every optimistic-concurrency scheme is
built on exactly that distinction.
SQE swaps a table's metadata pointer with a conditional
UPDATEand treatszero rows affected as a lost race. Without the count that becomes
SELECT-then-
UPDATEinside a transaction — sound only while the consumerguarantees a single writer, and every consumer reinvents it.
This is the engine half.
Connection::changesis spec 013/Req 1's statedsurface and belongs to the facade ticket; this one makes the number exist and
be correct.
Why the count cannot live in the opcode handlers
Measured on the tree at 0.18.10, not assumed. Counting every
InsertandDeleteas it executes gives the wrong answer three different ways, and theerror is plan-dependent:
INSERTInsert(insert.rs:790)DELETEDelete(delete.rs:121,152)UPDATE, single-passDelete+Insert(update.rs:603,605)UPDATE, two-pass range-seekInsert(update.rs:276) +Delete+InsertThe two-pass plan is #666/#675's range-seek path, which stashes matched rowids
in an ephemeral b-tree using the same
Opcode::Insertagainsteph_cursor.So the same
UPDATEreports 2 or 3 depending on which plan the optimizerpicks, and neither is 1.
Index maintenance has the same problem in reverse:
IdxInsert,IdxDeleteand
AutoIndexInsertare row-adjacent writes that must never count.Design:
OPFLAG_NCHANGEon P5, as SQLite does itCodegen — not the handler — decides which mutation is the row change.
Stock SQLite does this with
OPFLAG_NCHANGEin P5 onOP_Insert/OP_Delete,and following it keeps our opcode semantics aligned with the thing we are a
replication of.
Instruction.p5is au16"flags operand" that neithercursor::insert(
cursor.rs:1995) norcursor::delete(cursor.rs:1940) reads today, sothe bit is free on exactly the two opcodes that need it. No new opcode, so
the frozen-set ADRs (0015/0018/0020) are not reopened.
src/vdbe/cursor.rs—insert/deleteincrementVm's counter only whenthe P5
NCHANGEbit is set.src/codegen/stmt/insert.rs— set it on the tableInsert.src/codegen/stmt/delete.rs— set it on bothDeletesites.src/codegen/stmt/update.rs— set it on theTABLE_CURSORInsert(
:605) and not on the pairedDelete(:603), and not on theephemeral
Insert(:276). One row changed, counted once.Statement-level semantics, and where they stop
SQLite's rule is that a statement returning no rows does not reset the
count — so the count is per-connection state that outlives one statement.
This crate builds a fresh
Vmper execution, so the engine cannot own thatrule; only the facade can.
The split that makes the facade's rule trivial: the entry points return
Option<u64>—Some(n)when the program is a counting statement,Nonewhen it is not. The facade then stores the value onSomeand leavesits stored value alone on
None, which is preciselysqlite3_changes().Some(0)versusNoneis the subtle part and must be static, not dynamic:an
UPDATEwhoseWHEREmatches nothing never executes an NCHANGE opcodebut must still report
Some(0). So the discriminator is "does the programcontain an NCHANGE-flagged instruction", derived by one pass over
program.instructionsrather than a newProgramfield —Programis{ instructions }and itsnew()has many call sites, and a derived answercannot drift from the instructions it describes.
Scope
src/vdbe/exec.rs— achangescounter onVm, the staticprogram-contains-NCHANGE check, and the
Option<u64>on the executionentry points (plus
Executiononce feat: streaming Execution primitive — read a result row without materializing the rest #683 lands).src/vdbe/cursor.rs— honour the flag ininsert/delete.src/codegen/stmt/{insert,update,delete}.rs— set the flag on exactly onemutation per row.
Non-goals
Connection::changesand the cross-statement retention rule — spec 013/Req1's surface, facade ticket.
total_changes(). Not in spec 013, no consumer asked.ON CONFLICT REPLACEdeletions as separatechanges. Stock SQLite does not, and neither should we, but no test here
exercises it.
Acceptance Criteria
UPDATEthat matches one row reports 1, under both thesingle-pass and the two-pass range-seek plan — this is the regression
guard for the table above and must assert the same number from both
plans, not just one
UPDATEwhoseWHEREmatches nothing reportsSome(0), notNoneINSERTof N rows reports N;DELETEof N rows reports NSELECTreportsNonesame number as the same table with none
changes()for thesame statement sequence
fix: writes silently corrupt tables carrying a sqlite_autoindex_* (composite PRIMARY KEY / UNIQUE) #685),
make lint/cargo fmt --check/make check-mod-filescleanmake assurance— no dead linksComplexity
Estimate: small
Reasoning: Four files, one counter, one flag bit already free on both
target opcodes, and the design question (why not count in the handler) is
answered above by measurement rather than left open. The care is in the
codegen placement — one row must be counted exactly once across two
different
UPDATEplans — and that is what the first acceptance criterionpins.
Refs: 013/Req-1, #678, #683