Repository navigation
feat!: hoppersqlite, a pure-Go SQLite driver, and a drivertest suite any engine can pass - #79
Open
Michael McQuade (giraffesyo) wants to merge 20 commits into
Open
Michael McQuade (giraffesyo) wants to merge 20 commits into
Michael McQuade (giraffesyo) wants to merge 20 commits into
Conversation
…cripts of their own driver.Capabilities.SingleWriter describes an engine that runs one writing transaction at a time, as SQLite does: claims need no SKIP LOCKED, the finalizing transaction settles its batch and promotes dependents itself, and SkipLocked and NoWait wait only briefly for another transaction's writes. A MigrationExecutor that implements driver.MigrationScripts gets its engine's own script for each schema version from hoppermigrate, in place of the embedded Postgres one.
…es drivers share Run's tests now set up what no queue operation produces through Fixture hooks (ExpireJob, StaleWake, MakePending, SnoozeReason, SetSnoozeReason, SetProgress, CountEdges, WaitForLockWait) instead of Postgres SQL, compare JSON values rather than jsonb's text, and test what depends on row locks either way by Capabilities.SingleWriter. Tests of partitions, their indexes, the tags tables, progress rows and planner statistics move to RunPostgres, which takes a PostgresFixture (Fixture plus Exec and QueryText). drivertest.Postgres implements the SQL hooks for the Postgres drivers' fixtures.
…nd tags to driver/internal/jobcodec They read a value a direct write left in another form as what can be made of it, and every engine needs them so; hoppersqlite will share them.
hoppersqlite runs hopper on a SQLite database through modernc.org/sqlite, in pure Go. Its connections take the write lock on a transaction's first statement (BEGIN IMMEDIATE), queued in turn within a process and through SQLite's busy handler across processes, so claims are exclusive without SKIP LOCKED and the finalizing transaction settles batches and promotes dependents itself. Notifications are rows delivered on commit, at once in the process and by polling in others. Its migrations follow hoppermigrate's versions one for one. Open refuses a database on a network filesystem.
…without the write lock unless one is ready modernc.org/sqlite parses every statement it is given anew, which took almost half of a finalize's time: statements are now prepared once on the pool and bound to a transaction's connection with Tx.StmtContext, which took throughput from 4.1k to 11.3k jobs/s. WorkflowPromoteReady, which the finalizer calls after every flush of workflow jobs, looks for ready dependents first, and takes the write lock only to promote them. A metadata filter that is not JSON is refused as Postgres refuses it.
… lock, claims and notifications across processes, and upgrades from every schema version under traffic
…Lite in hopperbench's format
…build, lint and test it in CI without cgo
… finds it at the release's version The release workflow tagged only hopperotel and hopperui, so go get of driver/hoppersqlite at a release version found no such version. It now tags every sub-module, driver/hoppersqlite among them, and pushes the tags together. releasing.md and the plan's status line count four modules, and releasing.md says why hoppersqlite is released with the core module and upgraded with it: it imports the core module's internal packages.
…ll jobcodec directly from pgsql Both engines marshalled the attempt log a finalize keeps with the same code; jobcodec.MarshalAttemptLog now does it once. pgsql's one-line wrappers around jobcodec (storableText, decodeProgress, marshalAttemptError) are gone. The bytes each engine writes are unchanged.
…RunPostgres narrowed testBatchesConcurrent checked, on Postgres, that its races left no hopper_batch_progress rows; moving it into the neutral suite dropped the check, and testBatchProgressRaces' extra sweep did not stand in for it. The check is back at the end of testBatchesConcurrent, for a PostgresFixture, and the extra sweep is gone. JobLogsStored runs under both finalize statements again, keeping two logs as it did, and jsonEqual compares numbers by their exact value rather than as float64s.
…the lease renewal's wait by the lease On an engine that runs one writer at a time, a worker that writes on the pool while its own transaction holds the write lock waits for itself until its context ends, and every write of the process waits with it, lease renewals included, until the client loses its lease and is fenced. The core now marks each attempt's context with the attempt (driver.WithOwner), which names it, so that such an engine can refuse that write at once and say who holds a lock a write waits for. A lease renewal now waits at most until the lease it renews would expire, then reports what the engine says of who holds the lock and tries again, rather than wait on without a word. On Postgres a renewal takes a row lock only on its own client's row and is not held up. CompleteTx, RecordStepTx, JobCancelTx, JobDeleteTx and JobRetryOrderingKeyTx say what their transaction holds on such an engine: every write in the database, not one job's row.
…next, a worker's write that would wait for itself, and a database a link puts on a network filesystem Found by review, each with a test that failed before: - After an interrupted write, a full disk or an I/O error, SQLite rolls the whole transaction back itself; the driver went on running the transaction's later statements, each committing on its own, and Rollback reported success, so CompleteTx was not all or nothing. The connection now learns of the rollback from SQLite's rollback hook and refuses the transaction's later statements and its Commit with ErrTransactionAborted, as Postgres does those of an aborted transaction. - A worker that wrote on the pool while its own transaction held the write lock waited for itself until its context ended. A write whose context names the owner of the transaction that holds the lock now fails at once with ErrLockHeldByOwner, and a wait that its context ends says who held the lock and for how long. - Open checked the directory named in the path, while SQLite follows a symbolic link and keeps its log beside the target, so a link to a file on NFS was opened. Open now resolves links, a dangling one too, checks the file as well as its directory, opens the resolved path, checks the file SQLite reports having opened, and keys the database by it. Parallels and VMware shared folders are refused too. - A statement that writes, run on its own through Query (INSERT ... RETURNING), skipped the in-process queue for the write lock; it now waits its turn. - JSON that jsonb refuses (a \u0000 escape, text that is not UTF-8, a number beyond numeric's range) was stored; args, metadata and output are now checked as metadata filters were. And: - The database's clock never returns a time earlier than one it returned before in the process. - Each connection cuts the write-ahead log back to 32MiB once a checkpoint empties it, and JobsMaintain truncates a log past 8MiB, which a long reader leaves behind. - On macOS, Synchronous FULL also sets fullfsync, without which a commit may sit in the disk's cache. - A read-only transaction's first statement retries SQLITE_BUSY. OpenContext opens under a context; Open waits at most 30 seconds. - Migrations returns SQLite's scripts for other migration tools, as hoppermigrate.Migrations returns Postgres's. - TestClient waited for 505 jobs of 506; TestChaos kills worker processes mid-transaction, and TestFairness measures writes against another process that writes without pause.
localfs_linux_test.go set statfs's type as an int64, which is an int32 on 386 and arm, so the test did not build there, and no target the check vetted was 32-bit. cgo-check now vets linux/386 and linux/arm among its targets, each named with its architecture.
…it, in the plan and getting started; a schema change needs a SQLite script The plan's SQLite section says what the reviews found missing: a transaction held open holds up every client sharing the database, lease renewals included; the owner token and ErrLockHeldByOwner; aborted transactions; JSON stored as given and the @> emulation; no COPY or SQL producer functions; IDs minted in Go (6.1, with crypto/rand); ANALYZE without VACUUM and the log's truncation; links followed by the filesystem check; Open's bound. 7.1 says the clock never goes back. getting-started moves the SQLite section out of the Postgres text and says the same in short. AGENTS.md: a schema change is a version in both migration directories.
… the bound A renewal waits until the lease it renews would expire. The lease loop counted that from its own renewals only, so after the leader's rescue fenced the client and registered it again, the first renewal of the new lease was given just the renewal interval. register now records when the lease was taken, and the loop reads it. A synctest test checks the bound with an executor whose renewals block: from the last renewal, from a registration in between, and the interval once the lease has expired. CompleteTx and RecordStepTx say the single-writer self-wait check needs tx begun with the worker's ctx.
It decoded one value and ignored the rest, so a text with trailing data matched. A unit test covers key order, spacing, numbers by exact value and trailing data.
…er began, and see COMMIT, links and writes as SQLite does From the second review, each with a test that failed before: - ErrLockHeldByOwner fired for writes of one attempt on the pool from several goroutines, with no transaction of the worker's open: the driver's own one-operation transaction recorded the attempt as the lock's holder, so the attempt's next write failed rather than wait (173 of 202 in TestConcurrentWritesOfOneAttempt). Only a transaction the caller began records its owner now; the driver's own, and statements on their own, still check the waiter's owner but record none, and say whose they were when a wait times out. - A COMMIT, END or ROLLBACK statement run in a transaction went unnoticed, and later statements ran on their own. The commit hook joins the rollback hook: later statements fail with sql.ErrTxDone, Commit returns nil after a COMMIT and sql.ErrTxDone after a ROLLBACK. TestHooksMatchSQLite checks fourteen statements against modernc's own connection: the driver ends the transaction exactly when SQLite did. - resolve cleaned ".." lexically, where the kernel resolves a linked directory first, so a link to sub/../x.db could be checked at the wrong place. It now resolves an existing path with filepath.EvalSymlinks and a missing one component by component, ".." from the directory resolved so far. - mayWrite matched INSERT, UPDATE, DELETE and REPLACE as substrings of a WITH statement (updated_at, replace(), 'deleted'). It now reads words, skipping literals, quoted identifiers, comments and parameters, and checks every statement of a query. TestUpgradeUnderTraffic's 5 s bound, and TestFairness's, scale under the race detector, beside which they were flaky. The package doc says the clock's floor holds within one process, and that a worker's transaction must be begun with the job's context for the owner check to apply.
… statement-ended transactions and the clock within one process; Release-As at merge time PLAN 7.6 describes the renewal's bound for every engine. 5.1 says the owner check applies to a transaction the worker began with its context, and that a COMMIT or ROLLBACK statement ends a transaction; 7.1 that the clock's floor holds within one process. getting-started says to begin a worker's transaction with the job's context. releasing.md: the repository squashes with a blank body, so a Release-As or BREAKING CHANGE footer is given at merge time with gh pr merge --body.
…T counts only when it succeeds; test that a renewal moves the lease's bound
From the third review:
- sqlTokens took a form feed for punctuation, so a statement after one
("\fDELETE FROM t") read as one that does not write; every write
that a 40,000-input fuzz against sqlite3_stmt_readonly found mayWrite
missing began so. A form feed is whitespace, as it is to SQLite; a
vertical tab is not, and fails to prepare. TestMayWrite has the cases.
- leave took the commit hook as a COMMIT statement whatever the
statement returned; it now needs the statement to have succeeded. The
case of a rollback after an error still comes first.
- TestRenewalBoundMovesWithRenewals: after renewals at 5s and 10s, the
renewal held at 15s is given until 25s. Dropping the store after a
successful renewal passed the earlier test; it fails this one.
The package doc says SQLite reports no COMMIT in a read-only
transaction, after which its reads run on their own.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
driver/hoppersqliteruns hopper on a SQLite file, for an application that runs on one machine and wants hopper without a database server: a command-line tool that runs a workflow, a single-node service, local development. It is a module of its own (PLAN §5.1's plannedhoppersqlite), pure Go, and passes the conformance suite: jobs, unique keys, claims with fencing numbers, every finalize transition, leases, the leader, rescue, retries, TTLs, cron slots, batches, workflows and their edges, messaging, streams, notifications and maintenance. Several processes may share one database. A program that uses it builds withCGO_ENABLED=0, and it links neither pgx nor anything with cgo.Which SQLite
Both pure-Go SQLites were measured on the same claim-and-finalize loop (50,000 jobs, 4 workers, WAL), in one build each:
sqlite3CLIfips140=onlyThis uses modernc: it is the more widely used and older of the two, faster here, smaller, and locks the file exactly as C SQLite does, so any SQLite tool coordinates with it. It also lets a driver register SQL functions on a private driver instance, which this one does (topic matching, jsonb-style containment), so they reach no other connection in the process. The new dependencies are modernc.org/sqlite and its own (libc, mathutil, memory, golang.org/x/sys, already in the tools module, and a few small helpers), all in the new module only; the core module's dependencies are unchanged. One cost: under
-race, modernc's transpiled code runs about 25 times slower, so the module's tests take about 40 s under race against 20 s without,TestChaos's kill-and-recover run being the longest.How it works
SQLite runs one writing transaction at a time. A transaction that reads first and writes later can find, once it gets the write lock, that another wrote in between, and has to fail. So:
BEGIN IMMEDIATE). The driver'sdatabase/sqlconnections (wrapping modernc's) do it lazily, on a transaction's first statement, soBeginTxnever blocks. Within a process, transactions queue for the lock in turn on a channel, for as long as their statement's context allows; across processes, SQLite's busy handler polls for it in 50 ms quanta, between which the context is checked. A transaction begun read-only takes no lock and may not write (query_only).hopper_notifications, written in the notifying transaction. A commit that wrote some wakes this process's listeners at once; listeners in other processes look every 50 ms (Config.ListenInterval), and only whenPRAGMA data_versionsays another connection committed. Rows are kept a minute.Commitfail withErrTransactionAborted, as Postgres refuses those of an aborted transaction, andRollbackreturns nil. SoCompleteTxstays all or nothing. ACOMMIT,ENDorROLLBACKstatement the caller runs in the transaction ends it too: later statements fail withsql.ErrTxDone, andCommitreturns nil after aCOMMITthat succeeded,sql.ErrTxDoneafter aROLLBACK. (In a read-only transaction SQLite reports noCOMMIT; the statements after one, reads all, run on their own.) (modernc exposes nosqlite3_get_autocommit; the hooks fire exactly when SQLite ends the transaction, which an error code does not tell: a busy or constraint error,RAISE(ABORT)or even a full disk on a multi-row insert leave it open.TestHooksMatchSQLitechecks fourteen such statements against modernc's own connection.)driver.WithOwner). A transaction the caller begins onDB()records its context's owner while it holds the lock, and a write of the same owner that would wait for it fails withErrLockHeldByOwner, naming the job. The driver's own transactions, each for one operation, and statements on their own record none, so writes of one attempt from several goroutines, with no transaction of the worker's open, wait their turn as on Postgres. The check needs the worker's transaction begun with the job's context (or one derived from it); one begun with another context waits as before. A wait that its context ends says who held the lock and for how long.Openrefuses a database on a network filesystem (ErrNetworkFilesystem): NFS, SMB/CIFS, Lustre, GPFS, BeeGFS, CephFS, PanFS, 9P, virtiofs, VirtualBox, Parallels and VMware shares and FUSE (Docker Desktop's bind mounts among them) by statfs magic on Linux, and anything notMNT_LOCALon macOS and the BSDs; a UNC path or remote drive on Windows. SQLite's locks and the WAL's shared memory do not hold there, and cluster home directories often are such filesystems.Openresolves symbolic links first, as the kernel does (filepath.EvalSymlinksfor a path that exists; for a dangling link, component by component,..taken from the directory resolved so far rather than lexically), since SQLite follows a link and keeps its log beside the target; it checks the file as well as its directory, opens the resolved path, and checks again the file SQLite reports having opened. It refuses:memory:too, which each connection would see as a database of its own.Openwaits at most 30 s for a database another process holds;OpenContexttakes a context.Tx.StmtContext, which took throughput from 4.1k to 11.3k jobs/s.synchronous=FULLby default, so a commit is durable when it returns, as Postgres's is; on macOS the driver also setsfullfsync, without which fsync leaves the commit in the disk's cache.Config.Synchronous = "NORMAL"trades the last commits before a checkpoint on a power failure (not a crash of the process) for faster commits.journal_size_limitto 32 MiB, to which SQLite cuts the log back whenever a checkpoint empties it, andJobsMaintaintruncates a log past 8 MiB (wal_checkpoint(TRUNCATE), waiting at most 50 ms for writers and for readers), which a reader that held up checkpoints leaves behind.Differences from the Postgres drivers
These are documented on the package, in
docs/PLAN.mdand, where the driver interface describes them, ondriver.Capabilities.SingleWriter:JobFinalizeParams.SkipLockedandJobRecordProgressParams.NoWaitwait 100 ms for the lock and then skip every job, or returnErrJobLocked. A worker's openCompleteTxorRecordStepTxtransaction therefore holds up every other write in the database, not only its job's (their docs, and those ofJobCancelTx,JobDeleteTxandJobRetryOrderingKeyTx, now say so), and code inside a transaction should write through it (InsertTx); in a worker whose transaction was begun with the job's context, a write on the pool fails withErrLockHeldByOwner.LeaseTTLcosts the clients their leases, and their running jobs are rescued.TestFairness). Within a process, writers queue in turn.BatchSettleandWorkflowPromoteReadyfind nothing a race left, since the finalizing transaction settles and promotes;WorkflowPromoteReadystill promotes what a direct write left pending, reading first and taking the lock only if it finds something.HistoryMaintainParams.LockTimeoutfor the lock and reporting the group deferred otherwise; nothing is ever created or dropped, andHistoryIndexhas nothing to do.Xidis always 0; a consumer'sSnapshotis the sequence number it has read to. Nothing can commit late behind a younger event.\u0000, an unpaired surrogate, a number beyond numeric's range): args, metadata and output on insert and finalize, and a metadata filter (ErrInvalidFilter), which matches as jsonb's@>does, numbers by value, so a listing means the same on either engine.JobsMaintainnever vacuums (SQLite reuses freed pages, and the file keeps its size); it analyzes the live, tags and dependency tables when the planner's row count is an order of magnitude off, waiting 100 ms at most for the lock, and truncates the write-ahead log as above.Capabilities.Copyis false), and no SQL producer functions (hopper_insert,hopper_publish): SQLite has no stored functions.crypto/rand).Migrations
hoppermigrate's scripts are Postgres's. AMigrationExecutorthat implements the newdriver.MigrationScriptsgets its engine's own script for each version, and hoppersqlite has one, up and down, for every version 1 to 18, so a version names the same tables and columns on both engines andClient.Start's schema check holds on each.hoppersqlite.Migrations()returns them, under hoppermigrate's names, for teams that migrate with their own tool, ashoppermigrate.Migrations()returns Postgres's (whose doc now says so). A version that changes only what SQLite lacks (v6–v8: autovacuum, truncation, function search paths; v11: batch progress rows; v12: thecanceledrename, spelled so from v1) is a script of comments. The migrator holds the write lock fromLocktoCloseand applies each version in a savepoint, so writers wait for a migration, which takes milliseconds. AGENTS.md now says a schema change is a version in both directories, and hoppersqlite's tests fail for a version it lacks.drivertest
The suite assumed Postgres: neutral tests wrote rows with Postgres SQL and checked
pg_stat_activity, and the partition and index tests ran for every driver. It is split:drivertest.Runis the suite every driver passes. What no queue operation produces goes through newFixturehooks (ExpireJob,StaleWake,MakePending,SnoozeReason,SetSnoozeReason,SetProgress,CountEdges,WaitForLockWait) instead of SQL; JSON is compared as values, not as jsonb's text; and what depends on row locks (ClaimSkipsLocked,FinalizeSkipLocked,BatchesConcurrent,WorkflowFanInConcurrent, the late commit inStreams) is tested either way, byCapabilities.SingleWriter.drivertest.RunPostgrestakes aPostgresFixture(aFixturewithExecandQueryText) and runs what the Postgres drivers share beyond the interface (21 tests): partitions and their indexes, the tags tables and their triggers, batch progress rows, rows of earlier releases, planner statistics. Most moved unchanged; five were carved out of neutral tests:JobTagsStoredholds the Postgres storage checks ofJobTags(NULL for no tags, both tables' CHECKs, rows a direct write made odd, the DEFAULT partition);RetryOrderingKeyAgingOrderthe heap-order deadlock round ofRetryOrderingKeyConcurrent;BatchProgressRacesthe progress-row races ofBatchesConcurrent, which, for aPostgresFixture, still ends by checking that nohopper_batch_progressrow is left;JobLogsStored(andJobLogsStoredNoBatches) the NULL-storage checks ofJobLogs, under both finalize statements and keeping two logs, as before; andStreamsPartitionedthe partition check ofStreams, whose retention check is the neutralStreamsRetention.jsonEqual, which the neutral tests compare JSON with, compares numbers by exact value and refuses a text with anything after its value.drivertest.Postgresimplements the SQL hooks once for both Postgres fixtures, which now implement onlyNewDriver,Beginand a way to run SQL.Breaking:
drivertest.Fixturehas new methods and no longer hasExecandQueryText, so a fixture written outside this repository needs the new hooks (a Postgres one can embeddrivertest.Postgres). Applications are not affected. The!is for this, in the core module; hoppersqlite is new. The repository squashes with a blank body, so this paragraph never reaches the commit: the footer that says so is given at merge time (see Merging below).Smaller changes
pgsqltodriver/internal/jobcodec, for both engines to share: decoding (errors, progress, logs, tags) and encoding (storable text, an attempt's error, the attempt log a finalize keeps), byte for byte as before.pgsqlcalls them directly.driver.WithOwner/driver.Ownercarry a job attempt's owner token in its context (above); the lease renewal's bound is inclient.go, withregisterrecording when a lease was taken, and a synctest unit test.docs/releasing.md: since the squash body is blank, aRelease-As:orBREAKING CHANGE:footer is given withgh pr merge --body, not in the PR description.hopperbench-sqlite(make bench-sqlite) runs hopperbench's throughput, retry, workflow, insert and latency scenarios on SQLite and prints hopperbench's format, sohopperbench -comparereads it.test-sqlitejob, aftermake cgo-check, which vets the module and its tests withCGO_ENABLED=0on linux (amd64, 386, arm), darwin, windows and three BSDs, and fails if a non-standard package in them has cgo files there.driver/hoppersqlite/vX.Y.Zwith the other sub-modules, sogo getfinds it at a release's version;docs/releasing.mdcounts four modules and says to upgrade hopper and hoppersqlite together, since hoppersqlite imports the core module's internal packages (internal/jobtags,driver/internal/jobcodec). Dependabot knows the module.docs/PLAN.md§5.1 (what a single writer costs and how the driver guards against it), §6.1 (IDs minted in Go), §7.1, §7.11, §12, §14 and §16, the README and getting started describe the driver.The PR has one commit per step, the last eleven from two rounds of review. Squash-merging keeps the title.
Testing
Conformance: all 68 of
drivertest.Run's tests pass on hoppersqlite butInsertCopy, which skips (no COPY), three times in a row under-race(-count=3).RunandRunPostgres(21 tests) pass on hopperpgx and hoppersql against Postgres 18 frommake devstack.hoppersqlite's own tests (85.6% of statements), under
-race -count=3andGODEBUG=fips140=only. From the reviews, each failing before its fix:TestConcurrentWritesOfOneAttempt: a worker inserts 200 jobs from eight goroutines on the pool, with no transaction of its own open, then records a step while it inserts: all 202 writes succeed (173 failed withErrLockHeldByOwnerbefore).TestHooksMatchSQLite: fourteen statements in a transaction (full disk on one row and on many,INSERT OR ROLLBACK,RAISE(ROLLBACK|ABORT|FAIL), a unique constraint, savepoints,ROLLBACK TOa missing savepoint,ROLLBACK,COMMIT,END), run on the driver and on modernc's own connection: the driver refuses the next statement exactly when SQLite ended the transaction, withErrTransactionAbortedafter an error andsql.ErrTxDoneafter a statement that ended it, and nothing refused is written.TestStatementEndsTransaction:CommitafterCOMMITreturns nil with the rows kept; afterROLLBACK,sql.ErrTxDonewith none.TestResolve:..after a linked directory, in a link's target and in the path given, resolves from the link's target as the kernel does, for a file that exists and one that does not yet; a chain of links, a missing directory and a link to itself.TestInterruptedTransaction: after a write its context interrupts, a transaction's statements, queries and executor calls fail withErrTransactionAborted,Commitdoes too andRollbackreturns nil, nothing it wrote is left, and the lock is free; a constraint error and an interrupted read leave the transaction open, and it commits.TestInterruptedFinalizeInTx: a finalize in a transaction after an interrupted statement is refused, and the job stays running once the transaction rolls back.TestSelfWait: a worker that records a step on the pool inside its own transaction getsErrLockHeldByOwnernaming the job at once (it waited out its 10 s timeout before), the job completes, and no client's lease suffers.TestNetworkFilesystemThroughLink(Linux): a database on NFS reached through a link to it, a dangling link, a relative link, or a linked directory is refused, and nothing is created there; a link to a local database opens the target, shares its Driver's lock and keeps the log beside it.TestQueryThatWrites: anINSERT … RETURNINGrun through Query on its own waits its turn for the lock and says who holds it.TestJSONRefused: args with\u0000, invalid UTF-8, an unpaired surrogate or a number beyond numeric's range are refused.TestLogTruncated: a log grown past 16 MiB under a reader is cut to under 1 MiB byJobsMaintainonce the reader ends.TestChaos: four worker processes complete 600 jobs, each writing its effect and callingCompleteTxin one transaction, while one is killed with SIGKILL every 100–300 ms (about 25 kills) and replaced; every job ends completed with exactly one effect, andintegrity_checkis ok.TestStaleFinalizeAfterRescue: a rescued job's lost client's result applies neither before nor after the job is claimed again, nor one with the old fence from the new client; the new client's does.TestFairness: writes here against another process writing without pause complete, the slowest within 2 s (scaled under-race).TestMayWrite: which statements run through Query on their own take the lock, words in literals, quoted identifiers, comments and parameters not counting (updated_at,replace(...),'deleted'no longer do), every statement of a query counting, and a form feed being whitespace, as it is to SQLite (each of the 1,745 writes a reviewer's 40,000-input fuzz againstsqlite3_stmt_readonlyfound missed was one after a form feed, such as"\fDELETE FROM t").TestOpenContext,TestMigrations(the exported scripts, applied by hand, build whatUpbuilds),TestClock(never goes back),TestOwnerWaits(a lock the driver's own transaction holds is waited for by its owner's other writes); and in the core,driver.TestOwner,TestRenewalBoundMovesWithRenewalsandTestRenewalWaitsUntilTheLeaseExpires(synctest: after renewals at 5 s and 10 s, the renewal held at 15 s is given 10 s, until 25 s, where without the renewals moving the bound it got 5 s; a registration at 8 s moves the bound too, so the renewal held at 10 s is given 13 s; and one interval, 5 s, once the lease has expired) anddrivertest.TestJSONEqual.And from before:
TestAcrossProcesses: four processes (the test binary re-run) and four goroutines claim and complete 1,200 jobs from one file: every job is claimed exactly once, and history holds each, completed at fence 1 and attempt 1. A notification another process commits reaches this process's listener, and a write waits for another process's open transaction for as long as its context allows.TestTransactions:BeginTxreturns at once while another transaction writes; a read-only transaction reads meanwhile and cannot write; a transaction's first statement waits for the lock, and what it reads then cannot go stale before it writes.TestOpen,TestOpenRefuses,TestNetworkFilesystem: two Drivers on one file share its lock;:memory:, afile:URI, a badSynchronousand a missing directory are refused; NFS, Lustre, GPFS, FUSE, CIFS, Parallels and VMware shares are refused by name.TestClient: two clients on the driver work 500 jobs exactly once, an awaited job's output, a job completed in the worker's own transaction (CompleteTx) that inserts another in it (InsertTx), and a three-step workflow in order: all 506 jobs, each once (it waited for 505 before, and could pass with one unworked).TestMigrate,TestUpConcurrent: up, down to zero (nothing of hopper's left), up again, and one version at a time each way; four Drivers migrating at once apply each version once.TestUpgradeUnderTraffic: from every version 1 to 17 to 18 while jobs wait in every state and an earlier release's inserts, claims and finishes run, written with v1's columns: none fails, none waits more than 5 s (30 s under-race, besideTestChaos), and the latest client then works every job once, rescuing those a dead client of the earlier release left running.hopper_topic_regexresults, odd patterns included; containment as jsonb's@>; filter refusals; UUIDv7 layout.cgo:
make cgo-checkpasses; the module and its tests vet (and so build) withCGO_ENABLED=0for linux (amd64, 386, arm, arm64, riscv64, s390x), darwin (amd64, arm64), windows (amd64, arm64), freebsd, openbsd and netbsd. The test on 32-bit Linux did not build before.Lint: golangci-lint (the pinned version) reports 0 issues on the core module and on hoppersqlite.
make checkpasses withGOFLAGS=-p=2at the branch's head. Run it with-p=2: on the dev machine's 30 cores, every package at once exhausts the devstack's 300 connections (too many clients), and even-p=4peaked at 338 connections on this branch and 329 oncanaryunder load.hopperbench
The Postgres drivers' insert, claim and finalize SQL is unchanged. On the Go side of those paths: the finalize calls
jobcodecto encode what it stores and scans withjobcodec's decoders (the same code, moved);executeadds one context value per attempt (driver.WithOwner); and the lease loop, off the hot path, bounds a renewal's wait. Basecanaryaafe589 and head (a748f52) ran paired, 30 rounds, the order alternating, against Postgres 18 frommake devstackon the dev machine, which other work shared (-scenarios throughput,insert,retry,workflow,mixed -jobs 20000, 4 clients × 100 workers). Head/base is the geometric mean of the 30 paired ratios, with its 95% confidence interval:The medians are of base's and head's runs taken apart, not paired, so they can differ from the paired ratio (throughput's medians differ by +3% where the ratio is 0.998).
Every interval contains 1: no change is measurable, and none is as large as 10%. An independent paired run at 71af3a8 found the same (every lower bound at least 0.95 on the changed paths). Single unpaired runs on this machine varied by up to ±35%, which is why the medians of earlier, unpaired rounds showed throughput −8%.
On SQLite, the review fixes put a little more in each statement (the rollback hook's flag, the lock's holder, the JSON check on insert). The branch before them (7c3933d) and its head ran
hopperbench-sqlitealternately, 5 rounds,synchronous=FULL, on the same machine: medians throughput 6,843 → 7,223 jobs/s, workflow 4,800 → 5,321, insert 20,104 → 19,569 (−2.7%), latency p50 2.6 → 2.6 ms, p99 6.4 → 5.5 ms; no regression beyond the noise. The second round's fixes (the commit hook, the owner recorded only for a caller's transaction,mayWritereading words) against 71af3a8, 4 rounds alternating: medians throughput 7,625 → 7,425 jobs/s (−2.6%), workflow 5,183 → 5,309 (+2.4%), insert 19,179 → 19,229 (+0.3%), latency p50 2.6–2.9 ms on both; again within the noise.At the head (
hopperbench-sqlite, a file on local NVMe,-jobs 50000, 4 clients × 100 workers, 1,000 latency samples), 3 rounds each, the faster two averaged:synchronous=FULL(default)NORMALThese are 5–9% below the same table on the earlier branch, measured when the machine was quieter; the alternating runs above are the like-for-like comparison.
SQLite has no group commit: with
FULLeach commit waits for an fsync, which the client's batching of claims and finalizes amortizes as load grows, and which sets the latency floor. On this machine, whose disk is shared and nearly full, single fsyncs occasionally took 30–800 ms, which shows as a rare outlier inmax(290 and 390 ms in two of theFULLrounds).Checklist
make checkpassesGODEBUG=fips140=only); IDs readcrypto/rand, through Go's FIPS DRBGdocs/PLAN.mdMerging
The repository squashes a PR with its title and a blank body, so nothing above reaches the commit, and release-please reads a breaking change's description only from a
BREAKING CHANGE:footer in it. Merge with the footer, keeping the!in the title:gh pr merge 79 --squash --admin --body 'BREAKING CHANGE: drivertest.Fixture gained ExpireJob, StaleWake, MakePending, SnoozeReason, SetSnoozeReason, SetProgress, CountEdges and WaitForLockWait, and lost Exec and QueryText, now on drivertest.PostgresFixture; a Postgres fixture can embed drivertest.Postgres.'