Skip to content

feat: a rows-changed counter — the one spec 013 item a consumer cannot work around #692

Description

@dpsiderius

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.rsinsert/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

  • UPDATE that matches one row reports 1, under both the
    single-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
  • UPDATE whose WHERE matches nothing reports Some(0), not None
  • INSERT of N rows reports N; DELETE of N rows reports N
  • A SELECT reports None
  • Index maintenance does not count: a table with two indexes reports the
    same number as the same table with none
  • Every count agrees with the pinned 3.53.4 oracle's changes() for the
    same statement sequence
  • Full suite unchanged against baseline (1562/0), corpus 380 (387 with
    fix: writes silently corrupt tables carrying a sqlite_autoindex_* (composite PRIMARY KEY / UNIQUE) #685), make lint / cargo fmt --check / make check-mod-files clean
  • make assurance — no dead links

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions