without_durability_sqlite¶
A without-durability checkpoint store and queue backed by one SQLite file, with no server and no third-party driver.
without_durability_sqlite
¶
SCHEMA
module-attribute
¶
SCHEMA = "\nCREATE TABLE IF NOT EXISTS workflow_checkpoint (\n seq INTEGER PRIMARY KEY,\n workflow TEXT NOT NULL,\n step TEXT NOT NULL,\n value TEXT NOT NULL,\n written_at REAL NOT NULL DEFAULT (unixepoch('now', 'subsec')),\n UNIQUE (workflow, step)\n);\n\nCREATE TABLE IF NOT EXISTS workflow_claim (\n workflow TEXT PRIMARY KEY,\n token INTEGER NOT NULL,\n -- The budget: the latest this claim can lapse at, whatever its holder does.\n held_until REAL NOT NULL,\n -- When it lapses if nothing more is heard, which is what `CLAIM` tests. Every write of\n -- it is a `MIN` against `held_until`, so a sign of life cannot carry a pass past its\n -- budget or take a workflow back after a `RELEASE`.\n alive_until REAL NOT NULL,\n -- What one sign of life is worth, carried here because `RECORD` is one and is not told:\n -- the statement making a write has only the workflow to go on.\n alive_for REAL NOT NULL\n) WITHOUT ROWID;\n\nCREATE TABLE IF NOT EXISTS workflow_queue (\n namespace TEXT NOT NULL,\n workflow TEXT NOT NULL,\n visible_at REAL NOT NULL,\n PRIMARY KEY (namespace, workflow)\n) WITHOUT ROWID;\n\nCREATE INDEX IF NOT EXISTS workflow_queue_visible_at ON workflow_queue (namespace, visible_at);\n"
Database
dataclass
¶
Database(connection: Connection, guard: Lock = Lock())
One SQLite connection and the lock that keeps one caller in it at a time.
The analogue of the Postgres store's connection pool, and the opposite shape for the
opposite reason: a pool exists so several statements run at once, and this exists so
they do not. Not because a connection would corrupt (SQLite is built serialized here,
so it would not), but because a transaction belongs to the connection: without this,
a caller arriving mid-BEGIN IMMEDIATE writes into somebody else's transaction and
loses its write to that transaction's rollback. See the note at the top of this
module for why the event loop's single thread does not already prevent that.
Build it with connect, which applies the pragmas that make this durable rather than
merely persistent. Share one between the checkpoint store and the queue: that is what
makes SqliteDurable.arrive a single commit, and it is checked rather than assumed.
Close it with aclose, never connection.close(), for the reason given there.
guard
class-attribute
instance-attribute
¶
run
async
¶
run(work: Callable[[Connection], T]) -> T
Do work against the connection, on a thread, with nobody else inside it.
Cancellation is where "nobody else" has to be arranged rather than assumed, and
it is the reason this is not simply async with self.guard. A thread is not
cancellable: cancelling the caller unwinds this coroutine at once while the
thread runs on, so releasing the guard on the way out would hand the connection
to the next caller while the last one is still inside it. That is not a
theoretical race. The statement in flight may be a BEGIN IMMEDIATE
transaction, and a write that lands in somebody else's open transaction is
committed or rolled back with it: record returns, a read sees the row, and the
rollback takes it away again, which is precisely the guarantee this store
exists to make.
So the guard is released by the thread finishing rather than by this coroutine returning. The work is a task, the caller awaits a shield of it (so cancelling the caller leaves the task alone), and a done-callback lets go of the connection when it is genuinely free. A cancelled caller still unwinds immediately; what it no longer does is take the connection with it.
What the shield adds beyond that is the reporting. A statement that fails after
its caller has gone has nobody left to raise to, and shield hands it to the
loop's exception handler rather than dropping it, so a write that failed on the
way out of a process is in the log instead of nowhere.
aclose
async
¶
Close the connection once nobody is inside it.
The other half of run's handshake, and the reason a caller must never reach
for connection.close() itself. sqlite3.close() frees the connection and
finalizes its statements; a thread still executing one is then reading freed
memory, which segfaults the process rather than raising. run makes that
reachable by design, since a cancelled caller unwinds while its thread runs on,
so a shutdown that follows a cancellation is exactly when the two meet: the
worker's task is cancelled, the statement it left behind is still in flight, and
the close lands on top of it.
Taking the guard is what waits that out, because run releases it from the
thread rather than from its caller. That wait is unbounded: an effect hung
inside a run thread holds the guard until it returns, and cancelling this
coroutine while it is parked on the guard abandons the close, leaving the
connection open. The guard is then let go again, so a run arriving after
this fails on a closed connection, which is the loud version of a bug that
would otherwise be silent.
The close itself goes to a thread like every other driver call: under WAL it
runs the final checkpoint (and, with synchronous=FULL, an fsync), which is
real disk I/O that does not belong on the event loop.
SqliteCheckpointer
dataclass
¶
SqliteCheckpointer(
database: Database, codec: CheckpointCodec[str] = JSON
)
A workflow's completed steps as rows in one file, and its claim as a row beside them.
The Checkpointer implementation for a deployment that is one machine, and the one
that needs nothing installed. It meets the same requirements as the others by the
simplest route any of them take: SQLite admits one writer, so a single statement or a
single BEGIN IMMEDIATE transaction is already all the exclusion this needs.
SqliteEffect is a callback over the open transaction's cursor, so a step whose
effect is a write to this file happens exactly once. Since the file is the whole
datastore, that covers every table an application on this machine keeps here, which
is a broader reach than it sounds: it is the same guarantee DBOS gets from Postgres,
for an application that never needed Postgres.
A workflow id carries no constraints at all: it is bound as a query parameter, never parsed as key structure. Nothing here derives one id from another either, so an application is free to name a workflow's sibling (a saga's rollback, say) however it likes out of its own namespace.
codec is how a step's result becomes the TEXT in a row and comes back, defaulting
to the stdlib's JSON. Swap it to widen what a step may return or to speed the encoding
up; what it MUST keep is the round trip, since a resumed pass reads what it produced.
history
async
¶
The same records load returns, each with the moment it was written.
transact
async
¶
transact(
holder: Pass, key: str, effect: SqliteEffect
) -> object
Run effect and record it in one transaction, so the step happens once.
The order is the other stores': fence first, because a superseded pass must not
act; then the existence check, because a step already recorded must not run
again, which is what makes a replay perform nothing at all; then the effect; then
the record. BEGIN IMMEDIATE holds the write lock across all four, so no other
writer can land between them and any exception rolls back the effect along with
its record.
The effect's result is written and read back through the codec rather than returned as it came, so it round-trips exactly as a later pass will see it.
What the write lock costs is stated rather than hidden: it is the connection's,
and there is one connection, so nothing else in this process reaches the store
until the effect returns, the worker's own renewal included. The claim is safe
regardless, since the sign of life at the end of the transaction lands before any
queued claim runs; what a long effect does lose is its delivery, which is not
renewed meanwhile and is redelivered after a lease to a pass that finds the
workflow held. Keep effects short, or accept that redelivery.
append
async
¶
File value in this workflow's inbox, under the next key in the table.
discard
async
¶
Forget every record this workflow has, and raise its fence, in one transaction.
One commit rather than two statements, because the two are only right together: a crash between them either leaves the records deleted with the fence unraised, so the pass that was mid-flight writes them back one at a time, or the reverse, which fences a live pass for a deletion that never happened.
What is left behind is one claim row carrying a number. Nothing here sweeps it, in keeping with the rest of this store, where nothing expires and tidying the file is the deployment's homework.
SqliteDurable
dataclass
¶
SqliteDurable(
checkpointer: SqliteCheckpointer,
scheduler: SqliteScheduler,
)
A Durable whose two stores are one file, so arrive is a single commit.
The strongest form of the guarantee, reached by the least machinery: there is nothing
to co-locate, no pool to share by accident, and no sharding to grow into. The two
stores MUST hold the same Database, checked at construction, which here is less a
warning about distributed transactions than a way of saying that two SQLite files are
two datastores however adjacent they sit on disk.
arrive
async
¶
Record the value and make the workflow ready, together or not at all.
deliver
async
¶
Append the message and make the workflow ready, together or not at all.
delete
async
¶
Cancel the workflow's wakeups and forget its records, together or not at all.
Three statements in one commit, so the ordering SplitDurable has to reason about
does not arise: there is no window in which the records are gone and a wakeup is
not, and none in which the fence has been raised for a deletion that did not
happen. Which is the same thing arrive gets from this store and for the same
reason, one file.
SqliteScheduler
dataclass
¶
SqliteScheduler(
database: Database,
namespace: str = "workflow",
lease: timedelta = LEASE,
poll: timedelta = POLL,
now: Callable[[], datetime] = now_utc,
)
Scheduler as one table, each row scored by when its workflow becomes visible.
A drop-in for every other queue here, and modelled on the same visibility scheme:
queued now is a visible_at in the past, sleeping is one in the future, and being
worked on is one a lease ahead, so wake_due, reclaim, and prepare's queue half
all have nothing to do.
It polls, like the other visibility-scored queues, so the poll interval is a floor
under how fast anything starts. SQLite offers no blocking read and no notification a
process outside this one can wait on, so unlike the Postgres store there is not even
a LISTEN/NOTIFY left on the table: within one process an asyncio.Event would do
it, across processes on one machine it would take a filesystem watch, and neither is
here.
lease_seconds
class-attribute
instance-attribute
¶
poll_seconds
class-attribute
instance-attribute
¶
prepare
async
¶
Create the tables, which every worker does at boot and all but the first find done.
wake_at
async
¶
Suspend the workflow until when, unless something asked for a pass meanwhile.
The receipt is the visibility this pass took, so anything that rescheduled the
workflow since (a confirmation, another worker taking over an overrun) wrote a
different one and this leaves it be. Which is the right answer rather than a
concession: the deadline lives in the workflow's checkpoint, so the pass that runs
sooner reaches the same sleep and writes it again.
wake_due
async
¶
Nothing to do: a workflow whose visible_at has passed is already visible.
next_ready
async
¶
The next visible workflow, waiting up to within for one to appear.
reclaim
async
¶
Nothing to take over by hand: an abandoned workflow becomes visible on its own.
extend
async
¶
Push this delivery's visibility out, and say what it is called now.
The new visibility is the new receipt, since this table's receipt is its visibility, so the caller is handed a delivery to use from here rather than left to discover that the one it holds has been renamed.
No row means this delivery is no longer this worker's: cancelled, or rescheduled by
a wakeup that arrived mid-pass, either of which wrote a visible_at that is not the
one it took. The answer to both is to hand back what came in, exactly as wake_at
and done already do.
cancel
async
¶
cancel(workflow: str) -> None
Drop the workflow's row, whichever of the three things its visible_at means.
One DELETE covers queued, sleeping, and out with a worker, because this table
holds one row per workflow and the visibility is the only thing that differs
between them. That is the same collapse that leaves wake_due and reclaim with
nothing to do.
The half of cancel a queue sweep cannot reach comes free with it: a pass still in
flight answers with wake_at, which is an UPDATE conditional on the visibility
still being the one it took, and a deleted row has none. So the deadline it was
about to write updates nothing and a deleted workflow is not put back to sleep.
done
async
¶
done(delivery: Delivery) -> None
Drop the workflow, unless something asked for another pass while this one ran.
The receipt is the visibility this pass took, so anything that rescheduled the
workflow meanwhile (a confirmation, this pass's own wake_at, another worker
taking over an overrun) wrote a different one and this leaves it alone.
connect
¶
connect(
path: Path | str,
*,
timeout: Milliseconds = BUSY_TIMEOUT,
) -> Database
Open the database this store runs on, configured for durability rather than speed.
journal_mode=WALso a reader does not block the writer, which is what lets a status query run while a pass is mid-transaction.synchronous=FULLbecause this store's entire claim is thatrecordreturning means the value survives.NORMALis the usual advice under WAL and it trades exactly that away: a commit can be lost on power loss or an OS crash. Everythingrun_durablyreasons about assumes the commit held, so this pays the fsync.busy_timeoutso a second process finding the write lock taken waits for it rather than failing immediately, which is the ordinary case when two processes share the file.
autocommit=True leaves transaction control here rather than in the driver: every
statement below is either atomic on its own or wrapped in an explicit
BEGIN IMMEDIATE, and nothing is left to a hidden implicit transaction.
migrate
async
¶
migrate(database: Database) -> None
Create the three tables, from every process, as often as it likes.
No advisory lock and no race to guard against, unlike the Postgres migration: SQLite runs the whole script in one exclusive transaction, so a second process either waits for it or finds the tables already there.
Schema migration as a whole is not what this is. There is no versioning and no path
from one shape of these tables to another; user_version is where SQLite keeps that,
and a deployment that needs it should use it.