Changelog
Version 0.3.0
Added
alchemical_queues.aio: an async counterpart to the sync API (AsyncAlchemicalQueues,AsyncAlchemicalQueue[T],AsyncAlchemicalTaskQueue[T, R]) built onsqlalchemy.ext.asyncio, with every I/O methodasync def.alchemical_queues.tasks.aio.AsyncWorkerrunsasync deftask handlers registered viaasync_task/AsyncTask. Table models, entries/responses and serializers are shared unchanged with the sync implementation. Tested and CI-covered against Postgres via bothasyncpgandpsycopg(v3 async mode).- Runnable mini example apps under
examples/-- plain Python, async plain Python, FastAPI, Flask, Starlette and Litestar -- each scheduling a task and polling for its result, rendered into the Examples docs so they never drift from the code they show.fastapi_appdemonstratesTaskResultSerializer(PydanticSerializer(...));flask_appdemonstratesJsonSerializer. Serializer: a pluggable way to control how queue entries and task responses are turned into bytes for storage and back, instead of always usingpickle. Pass an instance via the newserializer=argument ofAlchemicalQueues.get()/get_typed()/get_task_queue()/get_task_queue_typed(). Why: pickle is fine as a default (it's what every previous version always used, unchanged), but some applications want a human-readable/interoperable wire format, or schema validation on what goes through the queue.PickleSerializer: the existingpickle-based behavior, now explicit and still the default everywhere aserializer=isn't passed.JsonSerializer: serializes with the standard libraryjsonmodule.PydanticSerializer: serializes a single pydantic model type to/from JSON, given the model class. Requires the new optionalpydanticextra (pip install alchemical_queues[pydantic]) --pydanticis never a hard dependency.AlchemicalQueues.get_serialized()/get_task_queue_serialized(): likeget_typed()/get_task_queue_typed(), but infer the queue's typeTfrom theSerializer[T]you pass instead of a separatetypeofargument -- e.g.queues.get_serialized("q", PydanticSerializer(MyModel))gives you anAlchemicalQueue[MyModel], withentry.datatype-checked asMyModel, without repeating the type.AlchemicalTaskQueueis nowGeneric[T, R], with entries (put()/get(), typeT) and responses (respond()/responses(), typeR) independently typed and independently serialized via the newresponse_serializer=argument (get_task_queue()/get_task_queue_serialized()).AlchemicalResponseis nowGeneric[R]to match, soresponses()[i].datais typed without a manualcast(). Why: a task's input and its result are usually different shapes -- sharing one serializer between them meant a non-pickleserializer=on a task queue (e.g.PydanticSerializer(Job)) maderespond()unusable for anything but that same type.tasks.TaskResultSerializer: aresponse_serializerfor a queuetasks.Workerruns, which lets a task's success value go through a serializer of your choice while keeping Worker's own{"result": ...}/{"error": ..., "error_type": ...}envelope (whichQueuedTask.resultneeds to tell success from failure) -- e.g.TaskResultSerializer(PydanticSerializer(JobResult))makesQueuedTask.resultcome back as aJobResulton success, still aTaskExceptionon failure.- Tested and CI-covered database/driver support for MySQL, MariaDB and SQL Server (
pymssql, sync only) alongside the existing SQLite/PostgreSQL coverage -- see Database support. Oracle was attempted but pulled: it turned up widespread test failures and an indefinite hang, so it isn't supported yet (see Driver limitations).
Fixed
AlchemicalQueue.get()andAlchemicalTaskQueue.get()now work on MySQL and MariaDB. Both previously assumedDELETE/UPDATE ... RETURNINGwas available on every dialect inFOR UPDATE SKIP LOCKED's allowlist; MySQL supports noRETURNINGat all, and MariaDB supports it forDELETEbut notUPDATE, so everyAlchemicalTaskQueue.get()call (and, on MySQL, everyAlchemicalQueue.get()call too) raised instead of claiming/popping an entry. Both methods now fall back to locking the candidate row first (SELECT ... FOR UPDATE [SKIP LOCKED]) and issuing a plainUPDATE/DELETEby id in the same transaction when the dialect can't do it atomically viaRETURNING.- The queue table's
queue_namecolumn wasText, indexed -- MySQL/MariaDB can't put an index on an unboundedTEXT/BLOBcolumn (create_all()raised1170 (42000): BLOB/TEXT column ... used in key specification without a key length), soAlchemicalQueues(engine=...).create_all()never worked on either. It's nowString(255), which is portable across every supported backend. - Every timestamp column (
enqueued_at,schedule_at,claimed_until,delivered_at,cleanup_at) now requests microsecond precision explicitly on MySQL/MariaDB (DATETIME(6)). Unlike PostgreSQL and SQLite, MySQL'sDATETIMEsilently rounds to whole seconds unless asked otherwise, which broke anything comparing timestamps less than a second apart --visibility_timeout/schedule_atunder a second,extend(), and ordering entries enqueued within the same second. - Every write method on
AlchemicalQueues/AlchemicalQueue/AlchemicalTaskQueue(and their async counterparts), includingAlchemicalTaskQueue.responses()'s expired-response cleanup delete, now retries on a MySQL/MariaDB deadlock (error 1213), with a small randomized backoff between attempts, instead of letting it propagate. InnoDB's gap locking on thequeue_name/priorityindex range scanget()/claim() does its candidateSELECT ... FOR UPDATEwith can deadlock against concurrentput()inserts into the same range under heavy write concurrency; since the deadlock victim's transaction is rolled back in full before it's raised, retrying the whole call is always safe, and spacing retries out keeps heavily concurrent writers from repeatedly colliding in lockstep. - A queue name longer than 255 characters (the
queue_namecolumn's limit) now raisesValueErrorfromAlchemicalQueues.get()/get_task_queue()(and the async equivalents) immediately, on every backend. Previously only PostgreSQL/MySQL/SQL Server enforced it, at write time, with a backend-specific error -- SQLite's unboundedTEXTcolumn silently accepted it, so the same code could work on SQLite and fail elsewhere.
Version 0.2.0
Changed
- SQLAlchemy 2.x is now required (
sqlalchemy>=2.0,<3). The internal models moved from the legacyregistry()/DeclarativeMeta/Columnpattern toDeclarativeBase/Mapped/mapped_column. AlchemicalQueueandAlchemicalTaskQueueare now separate classes.AlchemicalQueue(fromqueues.get()/get_typed()) stays a minimal FIFO/priority queue:put(),get()(deletes and returns the next entry outright),qsize(),empty(),clear(). Everything related to reliable task delivery -- claims,visibility_timeout,release()/discard()/extend(),respond()/responses()-- moved to the newAlchemicalTaskQueue, obtained viaqueues.get_task_queue()/get_task_queue_typed().alchemical_queues.tasks.Workernow requires anAlchemicalTaskQueue. Why: most users putting plain data through a queue never touch claims or responses at all, and the claim-basedget()added below bulked up everyAlchemicalQueuewith machinery that's only relevant to the task-queue use case. See Database support.AlchemicalTaskQueue.get()no longer needs aBEGIN EXCLUSIVEtransaction on SQLite, and no longer deletes the entry it returns — it claims it instead (an atomicUPDATE ... RETURNINGon SQLite, withFOR UPDATE SKIP LOCKEDadded on PostgreSQL/MySQL/Oracle so concurrent workers don't contend on the same rows). A claimed entry stays visible toqsize()/empty()as outstanding work, and otherget()calls can't claim it, until you calldiscard()orrelease()(both new, see below). This is why: previously, a worker that died betweenget()andrespond()lost the entry silently and permanently, since the row was already gone. Now an unreleased claim simply expires after itsvisibility_timeoutand becomes claimable again, so a crashed worker costs you a delay, not the task.alchemical_queues.tasks.Workeralready does the right thing here (claim handling, retries, and keyboard interrupts all go through the same discard/release logic) — this mainly matters if you useAlchemicalTaskQueuedirectly.AlchemicalTaskQueue.respond()no longer touches the claimed entry at all -- it only ever recorded a response, and conflating "record an outcome" with "manage the claim" turned out to hide a real bug (see Fixed, below). Calldiscard()once you've responded, same astasks.Workerdoes.- The project's environment and dependencies are now managed with uv instead of Poetry.
Added
AlchemicalQueuesaccepts an optionalbaseargument: an existingDeclarativeBaseto attach its tables to, so they can share a registry and metadata with the rest of your application (e.g. Flask-SQLAlchemy'sdb.Model) instead of always getting an isolated one.AlchemicalQueues.get_task_queue()/get_task_queue_typed(): obtain anAlchemicalTaskQueueby name, accepting avisibility_timeoutto control how long a claimed entry stays claimed before becoming available again (see above).AlchemicalTaskQueue.release(entry_id, claim_token): put a claimed entry back in the queue, claimable again immediately, without recording a response.AlchemicalTaskQueue.discard(entry_id, claim_token): remove a claimed entry entirely without recording a response.AlchemicalTaskQueue.extend(entry_id, claim_token, by=None): push a claim's expiry further out, for work that runs longer thanvisibility_timeout.tasks.Worker(queue, keepalive_every=...)does this automatically in a background thread for as long as a task handler is running.AlchemicalEntry.claim_token: a random value fresh for each claim, required byrelease()/discard()/extend()alongside the entry_id. This is a deliberate fencing token: if a claim has since expired and been reclaimed by someone else (same entry_id, new claim_token), a worker that's still running late can't mistake its own stale claim for the current one -- those calls raise the newClaimExpiredinstead of silently succeeding against a claim that's moved on.QueuedTask.done:Trueonce a task has a recorded outcome (success or failure). Added becausetask.resultisNoneboth while a task hasn't finished yet and after it finishes successfully with no return value — there was no way to tell those apart for a task whose success value is legitimatelyNone.TaskException.exception_type: the failed task's original exception class name (e.g."ValueError"), alongside the existing stringifiedmsg.TaskExceptionalso now has a readable__repr__/__str__.alchemical_workeraccepts--import module:attributeas an alternative to the positional engine URL, to run against an app-providedAlchemicalQueuesinstance (one usingbase=, custom table names, or a non-defaultvisibility_timeout) instead of always constructing a default one — e.g.alchemical_worker --import myapp.queues:queues task-queue.
Fixed
- On SQLite,
entry_idandresponse_idcould be silently reused once a queue emptied out and refilled, since SQLite recycles a deleted row's rowid by default. Entry and response tables now setsqlite_autoincrement, so ids stay unique for the lifetime of the database, matching the documented guarantee. responses()deleted expired responses (past theircleanup_at) but never committed that delete, so it was silently rolled back when the session closed and expired responses were never actually purged.- A claim whose
visibility_timeoutlapsed while a worker was still (genuinely) working on it could be redelivered, finished by a second worker, and then have the first worker's now-stalerespond()call also succeed -- recording two responses for one entry. Found with a multi-threaded chaos test against SQLite mixing successful, released, discarded, and abandoned ("crashed") claims across many workers; fixed by theclaim_tokenfencing described above, which makes a stale worker'sdiscard()/release()/extend()call raiseClaimExpiredinstead of silently succeeding.tasks.Workernow proves it still holds a task's claim (viadiscard()) before responding or retrying, not after, so a duplicate response can no longer happen as long as responses go throughdiscard()-then-respond(), the same ordertasks.Workeritself uses. QueuedTask.resultraised a rawAttributeErrorif the recorded response wasn't the{"result": ...}/{"error": ...}dict shapetasks.Workeritself responds with -- whichrespond()never required, since it's a general queue-level API independent oftasks.Worker. Callingrespond()directly with anything else (a plain string, say) now hasresulthand that value back to you as-is instead of crashing.
Version 0.1.1
Previous stable release.
Version 0.1.0
Initial release.