Skip to content

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
canaryfrom
feat/sqlite-driver
Open

Michael McQuade (giraffesyo) wants to merge 20 commits into
canaryfrom
feat/sqlite-driver

Conversation

@giraffesyo

@giraffesyo Michael McQuade (giraffesyo) commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Summary

driver/hoppersqlite runs 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 planned hoppersqlite), 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 with CGO_ENABLED=0, and it links neither pgx nor anything with cgo.

d, err := hoppersqlite.Open(filepath.Join(stateDir, "hopper.db"), nil)
if _, err := hoppermigrate.Up(ctx, d, nil); err != nil { ... }
client, err := hopper.NewClient(d, cfg) // transactions are *sql.Tx, begun on d.DB()

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:

modernc.org/sqlite v1.60.1 github.com/ncruces/go-sqlite3 v0.35.5
How SQLite's C transpiled to Go SQLite compiled to WASM, run in Go
Insert, 500 a transaction 147k rows/s 130k rows/s
Claim + finalize 36k jobs/s 33k jobs/s
Binary over a 3.4 MB baseline +7.0 MB +11.3 MB
File locking SQLite's own unix VFS, transpiled: the same POSIX locks as the C library and the sqlite3 CLI its own VFS in Go
License BSD-3-Clause (deps BSD-3, MIT) MIT
FIPS SQLite does no cryptography; both run under fips140=only same

This 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:

  • Every transaction that may write takes the lock before it reads anything (BEGIN IMMEDIATE). The driver's database/sql connections (wrapping modernc's) do it lazily, on a transaction's first statement, so BeginTx never 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).
  • Claims, finalizes and the rest are plain statements in a transaction that holds the lock. The finalize reads each job, checks the state, client and fence in Go, and writes it back; nothing can change it in between. The finalizing transaction counts its jobs down on their batch rows, completes batches and inserts their callbacks, cancels the dependents of failed jobs down cancel edges, promotes ready dependents and drops satisfied edges, as Postgres's finalize tail does, with nothing left for a race to leave behind.
  • Notifications are rows of 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 when PRAGMA data_version says another connection committed. Rows are kept a minute.
  • A transaction SQLite ended is over. After an interrupted write, a full disk or an I/O error, SQLite rolls the whole transaction back itself and would run the transaction's later statements on their own, each committing at once. Each connection registers SQLite's rollback and commit hooks; once the rollback hook fires during a statement of an open transaction that failed, the transaction's later statements, its rows and its Commit fail with ErrTransactionAborted, as Postgres refuses those of an aborted transaction, and Rollback returns nil. So CompleteTx stays all or nothing. A COMMIT, END or ROLLBACK statement the caller runs in the transaction ends it too: later statements fail with sql.ErrTxDone, and Commit returns nil after a COMMIT that succeeded, sql.ErrTxDone after a ROLLBACK. (In a read-only transaction SQLite reports no COMMIT; the statements after one, reads all, run on their own.) (modernc exposes no sqlite3_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. TestHooksMatchSQLite checks fourteen such statements against modernc's own connection.)
  • A worker's write that would wait for itself fails at once. A worker that writes on the pool while its own transaction holds the write lock would wait until its context ended, holding up every write of the process, lease renewals included. The core now marks each attempt's context with the attempt (driver.WithOwner). A transaction the caller begins on DB() records its context's owner while it holds the lock, and a write of the same owner that would wait for it fails with ErrLockHeldByOwner, 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.
  • The clock is the host's, read once a transaction holds the lock, and never earlier than a time the driver returned before in the same process, so within one process each write's time is no earlier than the writes before it even if the host's clock is stepped back; across processes, a clock stepped back can still date one process's write before another's. Every process sharing the database shares that clock, because the file must be local:
  • Open refuses 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 not MNT_LOCAL on 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. Open resolves symbolic links first, as the kernel does (filepath.EvalSymlinks for 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. Open waits at most 30 s for a database another process holds; OpenContext takes a context.
  • Statements stay prepared. modernc parses every statement it is given, which cost almost half of a finalize; statements are prepared once on the pool and bound to a transaction with Tx.StmtContext, which took throughput from 4.1k to 11.3k jobs/s.
  • Durability is SQLite's synchronous=FULL by default, so a commit is durable when it returns, as Postgres's is; on macOS the driver also sets fullfsync, 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.
  • The write-ahead log stays small. Each connection sets journal_size_limit to 32 MiB, to which SQLite cuts the log back whenever a checkpoint empties it, and JobsMaintain truncates 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.md and, where the driver interface describes them, on driver.Capabilities.SingleWriter:

  • No SKIP LOCKED, and no row locks to skip. A write waits for any transaction that writes, not only one that holds the same row. JobFinalizeParams.SkipLocked and JobRecordProgressParams.NoWait wait 100 ms for the lock and then skip every job, or return ErrJobLocked. A worker's open CompleteTx or RecordStepTx transaction therefore holds up every other write in the database, not only its job's (their docs, and those of JobCancelTx, JobDeleteTx and JobRetryOrderingKeyTx, 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 with ErrLockHeldByOwner.
  • A transaction held open holds up lease renewals of every client sharing the database. A renewal now waits until the lease it renews would expire, counted from its last renewal or from registering again after a fence, and at least one renewal interval (on any engine, PLAN §7.6; on Postgres, where it locks only its client's row, it is seldom held up), then logs what the driver says of who holds the lock and tries again; a transaction held longer than about LeaseTTL costs the clients their leases, and their running jobs are rescued.
  • Across processes, writers are not served in turn. SQLite's busy handler polls for the lock, so a process that writes without pause holds another's writes up: against eight goroutines of another process inserting as fast as they can, a write here waited 70–200 ms at the median and up to 0.9 s (TestFairness). Within a process, writers queue in turn.
  • An error SQLite undoes only the statement of (a constraint's) leaves the transaction open, where Postgres would abort it; one that rolls the transaction back aborts it as above.
  • BatchSettle and WorkflowPromoteReady find nothing a race left, since the finalizing transaction settles and promotes; WorkflowPromoteReady still promotes what a direct write left pending, reading first and taking the lock only if it finds something.
  • History is one table. Retention deletes in batches of 1,000, each waiting at most HistoryMaintainParams.LockTimeout for the lock and reporting the group deferred otherwise; nothing is ever created or dropped, and HistoryIndex has nothing to do.
  • Streams: events commit in the order they are appended, so a position is an event's sequence number and Xid is always 0; a consumer's Snapshot is the sequence number it has read to. Nothing can commit late behind a younger event.
  • JSON is stored as given, not normalized as jsonb normalizes it (key order, spacing), and refused for what jsonb's input refuses (not UTF-8, \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.
  • JobsMaintain never 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.
  • No COPY (Capabilities.Copy is false), and no SQL producer functions (hopper_insert, hopper_publish): SQLite has no stored functions.
  • IDs are UUIDv7s minted in Go, with Postgres 18's layout (48 bits of milliseconds, 12 of the millisecond's fraction, 62 random from crypto/rand).

Migrations

hoppermigrate's scripts are Postgres's. A MigrationExecutor that implements the new driver.MigrationScripts gets 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 and Client.Start's schema check holds on each. hoppersqlite.Migrations() returns them, under hoppermigrate's names, for teams that migrate with their own tool, as hoppermigrate.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: the canceled rename, spelled so from v1) is a script of comments. The migrator holds the write lock from Lock to Close and 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.Run is the suite every driver passes. What no queue operation produces goes through new Fixture hooks (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 in Streams) is tested either way, by Capabilities.SingleWriter.
  • drivertest.RunPostgres takes a PostgresFixture (a Fixture with Exec and QueryText) 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: JobTagsStored holds the Postgres storage checks of JobTags (NULL for no tags, both tables' CHECKs, rows a direct write made odd, the DEFAULT partition); RetryOrderingKeyAgingOrder the heap-order deadlock round of RetryOrderingKeyConcurrent; BatchProgressRaces the progress-row races of BatchesConcurrent, which, for a PostgresFixture, still ends by checking that no hopper_batch_progress row is left; JobLogsStored (and JobLogsStoredNoBatches) the NULL-storage checks of JobLogs, under both finalize statements and keeping two logs, as before; and StreamsPartitioned the partition check of Streams, whose retention check is the neutral StreamsRetention. jsonEqual, which the neutral tests compare JSON with, compares numbers by exact value and refuses a text with anything after its value.
  • drivertest.Postgres implements the SQL hooks once for both Postgres fixtures, which now implement only NewDriver, Begin and a way to run SQL.

Breaking: drivertest.Fixture has new methods and no longer has Exec and QueryText, so a fixture written outside this repository needs the new hooks (a Postgres one can embed drivertest.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

  • The codecs of a job's errors, progress, logs and tags move from pgsql to driver/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. pgsql calls them directly.
  • driver.WithOwner / driver.Owner carry a job attempt's owner token in its context (above); the lease renewal's bound is in client.go, with register recording when a lease was taken, and a synctest unit test.
  • docs/releasing.md: since the squash body is blank, a Release-As: or BREAKING CHANGE: footer is given with gh 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, so hopperbench -compare reads it.
  • CI lints and tidies the module and tests it once (it needs no Postgres) in a test-sqlite job, after make cgo-check, which vets the module and its tests with CGO_ENABLED=0 on linux (amd64, 386, arm), darwin, windows and three BSDs, and fails if a non-standard package in them has cgo files there.
  • Releases: release-please rewrites the module's requirement on the core module, and the release workflow now tags driver/hoppersqlite/vX.Y.Z with the other sub-modules, so go get finds it at a release's version; docs/releasing.md counts 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 but InsertCopy, which skips (no COPY), three times in a row under -race (-count=3). Run and RunPostgres (21 tests) pass on hopperpgx and hoppersql against Postgres 18 from make devstack.

  • hoppersqlite's own tests (85.6% of statements), under -race -count=3 and GODEBUG=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 with ErrLockHeldByOwner before).
    • 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 TO a 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, with ErrTransactionAborted after an error and sql.ErrTxDone after a statement that ended it, and nothing refused is written. TestStatementEndsTransaction: Commit after COMMIT returns nil with the rows kept; after ROLLBACK, sql.ErrTxDone with 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 with ErrTransactionAborted, Commit does too and Rollback returns 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 gets ErrLockHeldByOwner naming 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: an INSERT … RETURNING run 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 by JobsMaintain once the reader ends.
    • TestChaos: four worker processes complete 600 jobs, each writing its effect and calling CompleteTx in 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, and integrity_check is 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 against sqlite3_stmt_readonly found missed was one after a form feed, such as "\fDELETE FROM t").
    • TestOpenContext, TestMigrations (the exported scripts, applied by hand, build what Up builds), 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, TestRenewalBoundMovesWithRenewals and TestRenewalWaitsUntilTheLeaseExpires (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) and drivertest.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: BeginTx returns 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:, a file: URI, a bad Synchronous and 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, beside TestChaos), and the latest client then works every job once, rescuing those a dead client of the earlier release left running.
    • Unit tests: topic patterns give Postgres's hopper_topic_regex results, odd patterns included; containment as jsonb's @>; filter refusals; UUIDv7 layout.
  • cgo: make cgo-check passes; the module and its tests vet (and so build) with CGO_ENABLED=0 for 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 check passes with GOFLAGS=-p=2 at 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=4 peaked at 338 connections on this branch and 329 on canary under load.

hopperbench

The Postgres drivers' insert, claim and finalize SQL is unchanged. On the Go side of those paths: the finalize calls jobcodec to encode what it stores and scans with jobcodec's decoders (the same code, moved); execute adds one context value per attempt (driver.WithOwner); and the lease loop, off the hot path, bounds a renewal's wait. Base canary aafe589 and head (a748f52) ran paired, 30 rounds, the order alternating, against Postgres 18 from make devstack on 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:

Scenario base median head median head/base 95% CI
throughput (claim + finalize) 72,904 jobs/s 75,082 jobs/s 0.998 0.964–1.033
insert 192,743 jobs/s 192,867 jobs/s 0.981 0.946–1.017
retry 42,238 jobs/s 42,315 jobs/s 0.993 0.955–1.033
workflow 44,003 jobs/s 43,371 jobs/s 1.003 0.980–1.025
mixed 80,844 jobs/s 80,543 jobs/s 1.012 0.975–1.049

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-sqlite alternately, 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, mayWrite reading 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:

Scenario synchronous=FULL (default) NORMAL
throughput 7,474 jobs/s 10,836 jobs/s
retry 3,711 jobs/s 5,799 jobs/s
workflow (ten steps each, an output each) 5,101 jobs/s 6,369 jobs/s
insert (batches of 100) 20,712 jobs/s 28,465 jobs/s
latency p50 / p99 2.4 / 4.7 ms 0.6 / 4.8 ms

These 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 FULL each 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 in max (290 and 390 ms in two of the FULL rounds).

Checklist

  • make check passes
  • No cryptography was added (tests run with GODEBUG=fips140=only); IDs read crypto/rand, through Go's FIPS DRBG
  • New or changed SQL in the claim, finalize, rescue or leader paths has a concurrency or chaos test
  • Behavior changes are reflected in docs/PLAN.md

Merging

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.'

…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
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant