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 workflow TEXT NOT NULL,\n step TEXT NOT NULL,\n value TEXT NOT NULL,\n PRIMARY KEY (workflow, step)\n) WITHOUT ROWID;\n\nCREATE TABLE IF NOT EXISTS workflow_claim (\n workflow TEXT PRIMARY KEY,\n token INTEGER NOT NULL,\n held_until 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.
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.
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.
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.
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.
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.
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
¶
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.