AtlatestRepositorysigil-sqlite
sigil-sqlite / tree / testtest-sqlite-connection-pin.sgl
1
;;; Regression test for the shared-connection snapshot pin.2
;;;3
;;; THE DEFECT. SQLite ends a connection's implicit read transaction only when4
;;; NO statement on it is active. So if fiber A has a statement open on a5
;;; connection -- parked in the cooperative busy-retry, say -- and fiber B runs6
;;; a query on the SAME connection, B's read transaction cannot be released7
;;; when B finalizes: A is still using the btree. The connection is pinned to8
;;; B's snapshot until A completes, and every later read on it silently misses9
;;; anything committed in between.10
;;;11
;;; It produced a CI controller serving a pre-commit view of its own database:12
;;; HTTP 200, no error, and permanent, because the parked write then could not13
;;; complete either -- it exhausted the busy budget, RAISED, and leaked its14
;;; unfinalized statement, which held the read transaction open for the life of15
;;; the process.16
;;;17
;;; THE ORDER IS THE VARIABLE, which is why arm 1 has a control that differs by18
;;; exactly one read. Before the fix, READ-DURING-PARK returned the pre-commit19
;;; count and NO-READ-DURING-PARK did not.20
;;;21
;;; Arms:22
;;; 1. NO-READ-DURING-PARK control -- no read while the write is parked23
;;; 2. READ-DURING-PARK the defect: a read on the shared connection while24
;;; a write on it is parked, across an external commit25
;;; 3. RAISE-LEAKS-STATEMENT a write that exhausts the budget and raises must26
;;; still finalize, or it pins the connection forever27
;;; 4. BUSY-TIMEOUT-PRAGMA `PRAGMA busy_timeout=N` must actually set the28
;;; retry budget rather than be silently swallowed30
(import (sigil core)31
(sigil async)32
(sigil sqlite)33
(sigil time)34
(sigil string))36
(display "Testing SQLite shared-connection snapshot pin...\n")38
(define SEED 700)39
(define BASE 10)41
;; The parked write must RAISE, not merely wait. A pin only survives to be42
;; measured if the statement that caused it outlives the call -- which is43
;; what a raise used to do. With a budget longer than the external hold the44
;; write completes, everything finalizes, the pin resolves before anyone45
;; looks, and this test passes whether or not the connection is serialised.46
;; That is exactly how the first version of this file was vacuous.47
;; The measuring read is taken AFTER the external commit and WHILE the shared48
;; write is still parked. That is the only moment the defect is visible:49
;; - without the lock it runs during the park, joins the read transaction50
;; pinned by the earlier read, and returns the PRE-COMMIT count;51
;; - with the lock it cannot run during the park at all -- it queues behind52
;; the write and returns current data, which is the fix working.53
;; Measuring after the park ends passes either way; that is how the first54
;; version of this file was vacuous.55
(define failures '())56
(define (fail! msg) (set! failures (cons msg failures)))58
(define (fresh-count path)59
(let* ((F (sqlite-open path))60
(r (sqlite-query-row F "SELECT count(*) AS n FROM t"))61
(n (if r (assoc-ref 'n r) 'query-failed)))62
(sqlite-close F)63
n))65
(define (shared-count C)66
(let ((r (sqlite-query-row C "SELECT count(*) AS n FROM t")))67
(if r (assoc-ref 'n r) 'query-failed)))69
(define (make-db path)70
(let ((C (sqlite-open path)))71
(sqlite-exec C "PRAGMA journal_mode=WAL")72
(sqlite-exec C "DROP TABLE IF EXISTS t")73
(sqlite-exec C "DROP TABLE IF EXISTS w")74
(sqlite-exec C "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")75
(sqlite-exec C "CREATE TABLE w (id INTEGER PRIMARY KEY, v TEXT)")76
(let loop ((i 0))77
(when (< i BASE)78
(sqlite-run C "INSERT INTO t (v) VALUES (?)" "base")79
(loop (+ i 1))))80
C))82
;; ---------------------------------------------------------------- arms 1 & 283
;;84
;; `read-during?` is the ONLY difference between them.85
(define (run-pin-arm name path read-during?)86
(let ((C (make-db path))87
(P #f)88
(committed #f) (reheld #f) (released #f)89
(started 0) (finished 0)90
(early (quote not-taken))91
(during (quote not-taken))92
(shared 'unset) (fresh 'unset))93
(set! P (sqlite-open path))94
(with-async95
;; The external writer: holds the lock, commits SEED rows, re-takes it so96
;; the park outlives the commit, then releases.97
(go (begin98
(sqlite-exec P "BEGIN IMMEDIATE")99
(sleep 0.30)100
(let loop ((i 0))101
(when (< i SEED)102
(sqlite-run P "INSERT INTO t (v) VALUES (?)" "seed")103
(loop (+ i 1))))104
(sqlite-exec P "COMMIT")105
(set! committed #t)106
(sqlite-exec P "BEGIN IMMEDIATE")107
(set! reheld #t)108
(sleep 0.80)109
(sqlite-exec P "COMMIT")110
(set! released #t)))111
;; The write on the SHARED connection. Parks on the external lock.112
(go (begin113
(sleep 0.05)114
(set! started (+ started 1))115
(guard (exn (#t #f))116
(sqlite-run C "INSERT INTO w (v) VALUES (?)" "shared-write"))117
(set! finished (+ finished 1))))118
;; THE ONE DIFFERENCE: a read on the shared connection during the park.119
(go (when read-during?120
(sleep 0.15)121
(set! early (shared-count C))))122
;; THE MEASUREMENT: after the external commit, while the write is parked.123
(go (begin124
(let loop ((n 0))125
(when (and (not reheld) (< n 4000)) (sleep 0.005) (loop (+ n 1))))126
(set! during (shared-count C))))127
;; And a final reading once everything has drained.128
(go (begin129
(let loop ((n 0))130
(when (and (not reheld) (< n 2000)) (sleep 0.005) (loop (+ n 1))))131
(let loop ((n 0))132
(when (and (not (and released (= finished started))) (< n 4000))133
(sleep 0.005) (loop (+ n 1))))134
(sleep 0.05)135
(set! shared (shared-count C))136
(set! fresh (fresh-count path)))))137
(sqlite-set-busy-budget! 5.0)138
(sqlite-close C)139
(sqlite-close P)140
(display (string-append " " name141
": shared=" (number->string shared)142
" fresh=" (number->string fresh)143
" during-park-read=" (if (eq? during (quote not-taken)) "not-taken"144
(number->string during))145
" early-read=" (if (eq? early (quote not-taken)) "not-taken"146
(number->string early))147
"\n"))148
;; SETUP ASSERTIONS FIRST. Without these an "agree" could be two equal149
;; wrong numbers -- a seed that never landed makes every arm pass.150
(when (not committed)151
(fail! (string-append name ": SETUP -- the external writer never committed")))152
(when (not (= fresh (+ BASE SEED)))153
(fail! (string-append name ": SETUP -- an independent connection sees "154
(number->string fresh) ", expected "155
(number->string (+ BASE SEED)))))156
(when (and read-during? (eq? early 'not-taken))157
(fail! (string-append name ": SETUP -- the during-park read never happened, "158
"so this arm did not test what it claims")))159
;; THE ASSERTION: the read taken after the commit must see the commit.160
(when (eq? during (quote not-taken))161
(fail! (string-append name ": SETUP -- the during-park read never ran")))162
(when (and (not (eq? during (quote not-taken))) (not (= during (+ BASE SEED))))163
(fail! (string-append name ": THE CONNECTION IS PINNED. A read taken after the "164
"external commit returned " (number->string during)165
" rows, not " (number->string (+ BASE SEED))166
". It ran while a write on the same connection was parked "167
"and joined a read transaction that could not be released.")))168
(when (not (equal? shared fresh))169
(fail! (string-append name ": THE CONNECTION IS PINNED. It reports "170
(number->string shared)171
" rows while an independent connection sees "172
(number->string fresh)173
" in the same database. A read ran on the shared "174
"connection while a write on it was parked, and the "175
"read transaction it opened could not be released.")))176
(list shared fresh)))178
(run-pin-arm "arm1 NO-READ-DURING-PARK (control)" "/tmp/sqlite-pin-a.db" #f)179
(run-pin-arm "arm2 READ-DURING-PARK" "/tmp/sqlite-pin-b.db" #t)181
;; ------------------------------------------------------------------- arm 3182
;;183
;; A write that exhausts the busy budget RAISES. If it does not finalize on the184
;; way out, the statement outlives the call and keeps the connection's btree in185
;; use -- which is what stops a pinned read transaction from ever being186
;; released, and what turned a transient stale read into a permanent one.187
;;188
;; ASSERTED DIRECTLY on the open-statement count rather than through a189
;; downstream symptom. The first version of this arm asserted staleness after a190
;; raise, and was VACUOUS: a parked write holds no read transaction of its own,191
;; so leaking one pins nothing by itself. Sabotage caught that; the count does192
;; not depend on the leak having a visible consequence.193
(define (run-leak-arm)194
(let* ((path "/tmp/sqlite-pin-c.db")195
(C (make-db path))196
(P (sqlite-open path))197
(before 'unset) (raised #f) (after 'unset))198
(sqlite-set-busy-budget! 0.25)199
(set! before (sqlite-open-statements))200
(sqlite-exec P "BEGIN IMMEDIATE")201
(guard (exn (#t (set! raised #t)))202
(sqlite-run C "INSERT INTO w (v) VALUES (?)" "doomed"))203
(set! after (sqlite-open-statements))204
(sqlite-exec P "COMMIT")205
(sqlite-set-busy-budget! 5.0)206
(sqlite-close C)207
(sqlite-close P)208
(display (string-append " arm3 RAISE-LEAKS-STATEMENT: raised=" (if raised "#t" "#f")209
" open-statements before=" (number->string before)210
" after=" (number->string after) "\n"))211
;; SETUP FIRST: with no raise there is nothing to leak and the assertion212
;; below would pass for free.213
(when (not raised)214
(fail! "arm3: SETUP -- the contended write did not raise, so nothing could leak"))215
(when (not (= before 0))216
(fail! (string-append "arm3: SETUP -- " (number->string before)217
" statements were already open before this arm ran")))218
(when (not (= after 0))219
(fail! (string-append "arm3: A RAISED STATEMENT WAS NOT FINALIZED -- "220
(number->string after) " left open. It holds the "221
"connection's btree in use, so any read transaction on "222
"that connection can never be released.")))))223
(run-leak-arm)225
;; ------------------------------------------------------------------- arm 4226
;;227
;; `PRAGMA busy_timeout=N` was accepted and silently ignored: the native layer228
;; forces SQLite's own busy_timeout to 0 (it must -- a blocking step freezes the229
;; whole cooperative scheduler). It now sets the cooperative retry budget.230
(define (run-pragma-arm)231
(let ((path "/tmp/sqlite-pin-d.db"))232
(let ((C (sqlite-open path)))233
(sqlite-set-busy-budget! 5.0)234
;; CONTROL FIRST: an unrelated pragma must NOT move the budget, or this235
;; arm would pass for any pragma at all.236
(sqlite-exec C "PRAGMA journal_mode=WAL")237
(when (not (= (sqlite-busy-budget) 5.0))238
(fail! (string-append "arm4: CONTROL -- an unrelated pragma changed the budget to "239
(number->string (sqlite-busy-budget)))))240
(sqlite-exec C "PRAGMA busy_timeout=250")241
(display (string-append " arm4 BUSY-TIMEOUT-PRAGMA: budget after PRAGMA busy_timeout=250 is "242
(number->string (sqlite-busy-budget)) "s\n"))243
(when (not (= (sqlite-busy-budget) 0.25))244
(fail! (string-append "arm4: `PRAGMA busy_timeout=250` did not set the retry budget; "245
"it is " (number->string (sqlite-busy-budget))246
"s. Dead configuration that reads as live.")))247
(sqlite-set-busy-budget! 5.0)248
(sqlite-close C))))249
(run-pragma-arm)251
;; ---------------------------------------------------------------- verdict252
(if (null? failures)253
(display "PASS: the shared connection is serialised; no pin, no leaked statement, busy_timeout honoured\n")254
(begin255
(display "FAIL:\n")256
(let loop ((f (reverse failures)))257
(when (not (null? f))258
(display (string-append " - " (car f) "\n"))259
(loop (cdr f))))))261
;; Final top-level value drives the harness exit code: 0 on pass, error on fail.262
(if (null? failures)263
0264
(error "shared-connection snapshot pin regression"))