Release Notes
v1.2.0 - Waiting on work (Phase 8) - 2026-09-25
Phase 8. Everything in this release lets a caller wait on something, and lets cancellation reach it. It came out of a review of the two applications that run quiv. Four additions, all additive: waiting for a job or for a task's next job, call_on_main(), run_subprocess(), and two exceptions. The test suite now runs on Linux, macOS, and Windows. Nothing in the 1.0 API changed.
What's new
-
Wait for a job.
wait_for_job(job_id, timeout)blocks until a job finishes and returns it finalized, withstatus,duration_seconds, anderror_messageset.wait_for_task(task_id, timeout)does the same for the next job of a task, which is what an endpoint holds after it adds a one-off.await_job()andawait_task()are the same methods as coroutines, for code on the application's event loop.@app.post("/refresh") async def refresh(): task_id = scheduler.add_task("refresh", refresh_all, run_once=True) job = await scheduler.await_task(task_id, timeout=120) return {"status": job.status, "error": job.error_message}A waiter registers before it reads, and quiv resolves the waiters after it writes the terminal status and emits the
JOB_*event, so no waiter can miss a job. A timeout raises the builtinTimeoutErroron every supported Python.remove_task()wakes the waiters of a task with nothing running withTaskNotFoundError, andshutdown()wakes whatever is left withSchedulerStoppedError.This is also how to test your own handlers against a real scheduler instead of a stub of
add_task. The Testing page now opens with that recipe. -
call_on_main()is the sibling ofrun_on_main()that waits. The result comes back, an exception raised by the target reaches the handler, and the job's stop event cancels the coroutine on the main loop. A handler whose whole body is one hop to the main loop returned in a millisecond before, so quiv recorded a millisecond success whatever the work did, and a tasktimeoutcould never fire. Withcall_on_main()the job spans the work: it carries the real duration and the real failure,JOB_FAILEDfires,max_retriesapplies, andtimeoutcancels the coroutine. Every keyword goes to the target, so the function has no options of its own. From the main loop's own thread an async target raisesMainLoopUnavailableError, because waiting there would block the loop that must run it. -
run_subprocess()is a drop-in forsubprocess.runinside a handler that a cancel can stop. A stop event cannot reach a child process: a handler that checks the event between steps still waits for the running child. The helper watches the job's stop event while the child runs. When the event is set, the child getsterminate(), thenkill()afterkill_graceseconds (default 5), and the helper raisesJobCancelledError. Atimeoutstops the child the same way and raisessubprocess.TimeoutExpired, as the stdlib does, so an existingexceptclause keeps working. The stop event is found through the job context, so a call deep inside a service needs no parameter. It stops the direct child only; passstart_new_session=Trueto give the child its own process group. -
Two exceptions.
JobCancelledErroris what both helpers raise when the stop event fires while they wait. Let it propagate: quiv treats it as the stop you asked for, finalizes the job ascancelled, writes one info line, and leaveserror_messageempty. Raised by hand with no stop requested, it is an ordinary failure.SchedulerStoppedErroris what a waiter gets whenshutdown()returned first, or when it is called aftershutdown().
Housekeeping
- CI runs on Linux, macOS, and Windows, across Python 3.10 to 3.14, and mypy runs a second time for
win32. This was §0 of the process-jobs plan, pulled forward because this release holds the platform-sensitive code. Every test passed on Windows at once; the only fix was a test fixture that deletes a temp database an abandoned thread still held open. - The phase order changed. A review of trailarr and ten-acre on 2026-09-25 produced this phase and the next one. Phase 8 ships as
v1.2.0, Phase 9 (operations:is_running,run_task_immediately(after_current=True), atask_namefilter, a "Running in a container" page) asv1.3.0, and Phase 7 (process jobs) asv1.4.0. The roadmap records why. - The API page documents the four wait methods and both helpers; the cancellation page covers subprocesses; the bigger-applications page gains an endpoint that awaits the one-off it adds; the agent reference and the skill follow.
Full Changelog: https://github.com/nandyalu/quiv/compare/v1.1.0...v1.2.0
v1.1.0 - run_at on update_task and pending_main_loop_work - 2026-09-25
A minor release. update_task() can move the next run to an absolute time, and pending_main_loop_work() counts the callables that run_on_main() handed to the main loop and that have not finished. Both additions are keyword-only or new names. Nothing in the 1.0 API changed.
What's new
-
update_task(task_id, run_at=...)moves the next run (#79).run_atnames the absolute time of the next run, with the same rules asrun_atonadd_task(). A naivedatetimeis read as UTC, never as the displaytimezone. A time already past runs at once. A value that is not adatetimeraisesConfigurationError. The task keeps itstask_id.Before this release, moving a one-off alarm meant
remove_task()followed byadd_task(). That returned a new id for the caller to store, and it left a window with nothing scheduled.task_id = scheduler.add_task(task_name="wakeup", func=wake, run_at=tonight, run_once=True) # Later: the alarm should ring earlier instead. scheduler.update_task(task_id, run_at=this_afternoon)It applies to any task, whether or not it has run before. A recurring task keeps its interval and runs next at the time you give. Two rules:
run_atandintervalare mutually exclusive. A new interval already reschedules the next run, so passing both raisesConfigurationError, and neither change is applied.run_atdoes not survive a task that is alreadyrunning. When the job finishes, quiv deletes a run-once row and recomputesnext_run_atfrom the interval for a recurring task, so the time you set is discarded. quiv logs a warning when it sees this. To move a recurring task, wait for its job to finish, or changeintervalinstead.
-
pending_main_loop_work() -> int(#80) returns how many callables handed to the main loop byrun_on_main()have not finished.run_on_main()is fire-and-forget. The job that called it finishes as soon as the work is handed over, soshutdown()finds nothing running and can return while the work is still queued. quiv does not wait for that work and does not cancel it. The loop belongs to your application, and only your application knows whether a half-finished callable should stop or run to the end. This is the number that lets you decide when the loop may close:@asynccontextmanager async def lifespan(app: FastAPI): scheduler.start() yield scheduler.shutdown() while scheduler.pending_main_loop_work(): await asyncio.sleep(0.05)It reads one set under a lock and never touches the database, so it is cheap to poll. It counts every shape
run_on_main()dispatches, including a sync callable that is queued but has not started. -
shutdown()warns when it leaves main-loop work behind. The warning names the count and points topending_main_loop_work(). Before this release, that work vanished with nothing in the log to say so.
Fixes
- A cancelled listener or progress callback no longer logs a traceback (#78).
v1.0.1fixed this fault inrun_on_main(). The same fault was in two more done-callbacks: the one for an async event listener and the one for an async progress callback. Both asked the future for its exception before asking whether it was cancelled, and a cancelledconcurrent.futuresfuture raises fromexception(). Theconcurrent.futuresmodule then loggedexception calling callbackwith a traceback, once per cancelled callback at shutdown. Both callbacks now check for cancellation first, and tests now cover both. An exception raised by your own listener or callback is still logged, as before.
Housekeeping
tzdatamoves from 2026.3 to 2026.4 in the lock file (#81). The published package does not pintzdata, so nothing changes for an install.- Phase 7, process jobs, moves from
v1.1.0tov1.2.0. This release took the number. The phase numbers stay consecutive, and the version numbers do not.
Full Changelog: https://github.com/nandyalu/quiv/compare/v1.0.1...v1.1.0
v1.0.1 - Cancellation fix for run_on_main - 2026-09-19
A patch release. run_on_main() no longer logs an error when the main loop cancels a coroutine. Nothing in the API changed.
Fixes
-
run_on_main()no longer logs a traceback for a cancelled coroutine. A coroutine that you send from a worker thread rides aconcurrent.futuresfuture, because quiv passes it to the main loop withasyncio.run_coroutine_threadsafe(). When that future is cancelled, it raisesconcurrent.futures.CancelledError. That class has been separate fromasyncio.CancelledErrorsince Python 3.8, and quiv caught only theasyncioone. The done-callback of the future then raised, and theconcurrent.futuresmodule loggedexception calling callbackwith a traceback. An application saw this at shutdown, once for every coroutine that the loop cancelled.quiv now asks the future whether it was cancelled before it asks for the exception. Both kinds of future answer that question the same way. A cancelled coroutine writes nothing to the log. An exception raised by your own callable is still logged, as before.
Full Changelog: https://github.com/nandyalu/quiv/compare/v1.0.0...v1.0.1
v1.0.0 - Hardening & Release (Phase 6/6) - 2026-09-18
The first stable release. v1.0.0 freezes the public API.
What changed since v0.3
Six phases of work separate v0.3 from v1.0.0. Each one shipped as its own minor release.
| Release | Theme | Main additions |
|---|---|---|
v0.5.0 |
Correctness and stability | A guard against a double run in run_task_immediately(), locked handler registries, and shutdown(timeout=...) |
v0.6.0 |
Scheduler efficiency | The loop sleeps until the next task is due, instead of polling once a second. Intervals below one second work, and an idle scheduler queries the database zero times |
v0.7.0 |
Database locking | Reads run without a lock under WAL. Writes serialize on a writer lock |
v0.8.0 |
Execution features | timeout, max_retries with exponential backoff, and jitter |
v0.9.0 |
Management and observability | update_task(), filters and paging for the job and task queries, and stats() |
v0.10.0 |
Absolute-time scheduling | run_at sets the time of the first run |
Read Migrating from 0.x for every change that needs an edit to your code.
Breaking changes
[!WARNING] Injected handler parameters lost the underscore prefix
add_task()rejects a handler that still declares_job_id,_stop_event, or_progress_hook. Rename the parameter. The behavior of each one is unchanged.
quiv injects three parameters into a handler that declares them. Those names are now public names:
Before (0.x) |
Now (v1.0.0) |
|---|---|
_job_id |
job_id |
_stop_event |
stop_event |
_progress_hook |
progress_hook |
A leading underscore means "private, do not touch" in Python. These three parameters are the opposite of private. You declare them, you read them, and cooperative cancellation is built on them. They are part of the contract for writing a handler, so they now read as public names.
How to migrate a handler
Rename the parameter in the signature and in the body. Nothing else changes.
# 0.x
def sync_users(_job_id=None, _stop_event=None, _progress_hook=None):
if _stop_event and _stop_event.is_set():
return
_progress_hook(percent=50)
# v1.0.0
def sync_users(job_id=None, stop_event=None, progress_hook=None):
if stop_event and stop_event.is_set():
return
progress_hook(percent=50)
quiv reports a missed rename
add_task() raises ConfigurationError when a handler still declares an old name. The message names the parameter and its new spelling. quiv raises it at registration, before it writes the task row and before any job runs.
This check exists because the failure is otherwise silent. quiv no longer injects _stop_event, so the parameter keeps its default value. The handler never sees a cancellation. cancel_job() stops working, and every timeout stops working, because a timeout sets the same stop event. Nothing raises, and nothing is written to the log.
A handler that declares **kwargs is not affected. It asks for no name, so quiv has nothing to check.
A new conflict to know about
job_id, stop_event, and progress_hook are ordinary names now, so a key in kwargs can collide with one:
scheduler.add_task("sync", sync_users, interval=60, kwargs={"job_id": "external-123"})
An injected value overwrites a caller value of the same name. If sync_users accepts job_id, quiv would replace "external-123" with its own job id, and you would never know. add_task() and update_task() raise ConfigurationError instead, because quiv cannot tell which value you wanted. Rename the key, or remove the parameter from the handler signature. A handler that declares **kwargs accepts every injected name, so this check covers it too.
TaskNotScheduledError was removed
v0.9.0 deprecated this name and stopped raising it. v1.0.0 removes the class and its export. Catch TaskNotFoundError instead.
# 0.x
from quiv import TaskNotScheduledError
# v1.0.0
from quiv import TaskNotFoundError
run_on_main() raises a quiv exception
run_on_main() raised a bare RuntimeError when it could not reach a main event loop. It now raises MainLoopUnavailableError, which inherits both QuivError and RuntimeError.
This is not a breaking change. An existing except RuntimeError clause still catches it, and except QuivError now catches it as well. Every exception that quiv raises is under QuivError.
Migrating from 0.x
Work through this list. Each entry names the release that changed the behavior.
1. Rename the injected handler parameters. (v1.0.0) Change _job_id to job_id, _stop_event to stop_event, and _progress_hook to progress_hook. add_task() raises ConfigurationError if you miss one. See the section above.
2. Catch TaskNotFoundError instead of TaskNotScheduledError. (v0.9.0, removed in v1.0.0) The alias is gone.
3. Check the exceptions you catch around run_task_immediately(). (v0.5.0, v0.9.0) Two causes now report different exceptions:
- The task is
runningorpaused—TaskNotActiveError. Earlier versions dispatched a second concurrent run, or un-paused the task without telling you. Callresume_task()to un-pause a task. - The task id is unknown —
TaskNotFoundError. Earlier versions raisedHandlerNotRegisteredError, which names a different fault.HandlerNotRegisteredErrorkeeps its own meaning: the task exists, but no handler is registered for it.
4. Review timing-sensitive code. (v0.6.0) The scheduler no longer polls once a second. A due task now dispatches almost at once. Code that relied on the old delay of about one second sees a job start sooner than before.
5. Bound your shutdown if a handler can hang. (v0.5.0) shutdown(timeout=...) limits how long quiv waits for the scheduler thread and for jobs still running. A job that does not exit within the deadline is abandoned on its worker thread, and quiv writes a warning. The default still waits forever.
6. Drop interval from a run-once task, if you want to. (v0.9.0) A run-once task never repeats, so quiv never reads its interval. Passing one with run_once=True is accepted and ignored, and Task.interval_seconds reads None. A recurring task still needs interval > 0. Passing interval=None for a recurring task now raises ConfigurationError, where earlier versions raised TypeError.
7. Note the new default of delay. (v0.10.0) delay defaults to None instead of 0, so quiv can tell "no delay given" from delay=0. Omitting delay still means no delay, and delay=0 still works. Passing both delay and run_at raises ConfigurationError.
Nothing else in the 0.x API changed. Your calls to add_task(), pause_task(), resume_task(), remove_task(), cancel_job(), and the query methods keep working.
Performance
v1.0.0 adds a benchmark suite. The numbers below come from an Intel Core i5-11600 at 2.80 GHz, 12 logical CPUs, Linux, Python 3.10.12. Read them as one machine on one day.
| Measurement | Result |
|---|---|
| Time from a task becoming due to its handler starting | 14 ms at p50, 18 ms at p95 |
| 100 tasks that share one due time | about 5 ms for each extra task in the batch |
Jobs completed each second, no-op handler, pool_size=16 |
about 200 |
add_task() calls each second |
about 1000 |
The first row is the one to remember. A task starts within about 18 ms of its due time, where quiv 0.5 and earlier polled once a second and could take up to 1000 ms.
A larger pool does not raise the rate of short jobs. Between pool_size=4 and pool_size=64 the rate stays near 200 jobs per second. The limit is the database, not the pool: each job writes four times, and a write takes the writer lock, so those writes queue behind the writes of every other job. Raise pool_size when your handlers wait on something. For work that lasts a few milliseconds, the bookkeeping costs more than the work itself.
Reliability
v1.0.0 ran for 24 hours without a pause, under a workload that mixes every execution path: a sync task, an async task, a task that fails and retries, a task that another thread cancels, a task that passes its timeout, and a task on a sub-second interval. A separate thread read stats() and get_all_jobs() every second throughout.
| Measurement | Result over 24 hours |
|---|---|
| Jobs finished | 294,400 — 234,876 completed, 32,229 failed, 27,295 cancelled |
| Retries queued | 21,486 |
| Live threads | 8 at the start, 8 at the end, 8 at every sample between |
| Retained job history | reached 2,264 rows in the first hour, then flat |
| Memory | 53 MB, start to finish |
| Unexpected errors | none |
The thread count is the number to look at. It did not move across roughly 294,000 job lifecycles, each of which created a stop event and a set of injected arguments, and each run of the async task built its own event loop and closed it. The retained history stopped growing after the first hour, so the cleanup keeps pace with the work for as long as the process runs. Memory held flat, which rules out job rows, stop events, and event loops accumulating behind the scenes.
Reproduce it with uv run python scripts/soak.py --hours 24. The full log of the release run is in the repository at benchmarks/results/soak-24h-2026-09-17.log, and Testing describes the method.
Full notes: https://nandyalu.github.io/quiv/release-notes/ · Docs: https://nandyalu.github.io/quiv/ · Roadmap: https://nandyalu.github.io/quiv/roadmap/
v0.10.0 - Absolute-time scheduling - 2026-09-08
add_task() can now schedule the first run at an absolute time.
What's new
-
run_at(#66) — sets the absolute time of the first run, as an alternative todelay. It is keyword-only.scheduler.add_task( task_name="agent_wakeup_alarm", func=agent_wakeup_alarm, run_at=monday_open, run_once=True, )The rules:
run_atanddelayare mutually exclusive. Passing both raisesConfigurationError.- A naive
datetimeis read as UTC. Thetimezonesetting only formats log output, and never interpretsrun_at. - An aware
datetimein any zone is converted to UTC. - A time already past runs at once. The process may have been down when the time came due, and dropping the task is worse than running it late.
- A
run_atthat is not adatetimeraisesConfigurationError.
run_atsets one instant for the first run. It is not calendar scheduling: recurrence stays interval-based, and quiv still has no cron expressions. -
delaynow defaults toNonerather than0, so quiv can tell "no delay given" fromdelay=0and rejectrun_atcombined with either. Omittingdelaystill means no delay, anddelay=0still works.
Full Changelog: https://github.com/nandyalu/quiv/compare/v0.9.0...v0.10.0
v0.9.0 - Management & Observability API (Phase 5/6) - 2026-09-05
Phase 5 of the roadmap to v1.0.0 adds three management features: update_task(), rich job/task queries, and stats(). See the new Observability docs page.
What's new
update_task()— changes a scheduled task in place and keeps itstask_id. You can change the name, interval, scheduling mode, args/kwargs, timeout, retry settings, jitter, and the progress callback. All parameters are keyword-only; omit the parameters you do not want to change.timeout=Nonedisables the timeout, andprogress_callback=Noneremoves the callback. When you changeinterval, quiv reschedules the next run tonow + interval. When you update arunningtask, the changes apply from its next run. The method emits the newEvent.TASK_UPDATEDwith the updatedTask. You cannot changerun_once,delay, or the handlerfunc.- Rich job/task queries —
get_all_jobs()accepts new filters:task_id, andsince/untilfor a time window onstarted_at(pass timezone-aware UTC values). It also acceptsorder_by("started_at"or"ended_at"),descending, andlimit/offsetfor pagination.get_all_tasks()acceptsstatus,limit, andoffset, and returns tasks ordered bynext_run_at. stats()— returns aQuivStatssnapshot (a frozen dataclass, exported fromquiv). It contains the active job count, the pool size and utilization, task counts by status, the earliest upcoming run, and the retained job-history count.- The FastAPI example app has three new endpoints:
GET /tasks/stats,GET /tasks/{task_id}/jobs?limit=&offset=, andPATCH /tasks/{task_id}.
Fixes
add_task()no longer requires anintervalfor a run-once task (#65). A run-once task never repeats, so quiv never reads its interval. Omitintervalwhen you passrun_once=True. An interval given withrun_once=Trueis ignored, andTask.interval_secondsreadsNone. A recurring task still requiresinterval > 0.interval=Nonenow raisesConfigurationError(#65). Earlier versions raisedTypeErrorfrom an unguarded comparison.Nonenow reaches the same error as0and-1.run_task_immediately()raisesTaskNotFoundErrorfor an unknown task id (#67). Earlier versions raisedHandlerNotRegisteredError, which names a different fault.get_task(),remove_task()andrun_task_immediately()now report the same error for the same cause. A run-once task deletes itself when it finishes, so its id stops resolving after it runs.HandlerNotRegisteredErrorkeeps its own meaning: the task exists, but no handler is registered for it.
Behavior changes
TaskNotScheduledErroris deprecated. quiv no longer raises it. It is now a subclass ofTaskNotFoundError, and the name stays exported, so imports keep working. Anexcept TaskNotScheduledErrorclause no longer catches these errors. Change it toexcept TaskNotFoundError. quiv removes the alias in 1.0.0.
Full Changelog: https://github.com/nandyalu/quiv/compare/v0.8.0...v0.9.0
v0.8.0 - Execution Features (Phase 4/6) - 2026-08-21
Phase 4 of the roadmap to v1.0.0 adds three failure-handling features: per-task timeout, retry with exponential backoff, and jitter. See the new Failure Handling docs page.
What's new
- Per-task timeout —
add_task(..., timeout=30)sets a time limit for each job. When a job runs longer thantimeoutseconds, quiv sets the job's stop event, in the same way ascancel_job(). The job then finalizes ascancelledwith a timeout error message. The timeout is cooperative. If the handler ignores its stop event, it keeps its pool thread until it returns; quiv never kills threads. The scheduler loop wakes for the nearest timeout deadline, so a timeout fires within milliseconds of that deadline. - Retry with exponential backoff —
add_task(..., max_retries=3, retry_backoff=10)runs a failed job again. A job is failed when an exception escapes the handler. The next attempt starts afterretry_backoff * 2**(failures - 1)seconds: the first retry waitsretry_backoffseconds, the second waits twice that, and so on. Cancelled jobs do not retry; this includes timeouts. A successful run resets the failure counter. When retries are exhausted, a recurring task returns to its normal schedule and a run-once task is deleted. EachJobrecords itsattemptnumber. The newEvent.JOB_RETRYINGfires afterJOB_FAILEDwhen quiv schedules a retry. - Jitter —
add_task(..., jitter=5)adds a random offset between 0 andjitterseconds to each next run of a recurring task. Use it when many tasks share the same interval boundaries and would start at the same time. quiv draws a new offset for every run. Jitter does not apply to the initialdelayor to retry backoff. Taskexposes the new fieldstimeout_seconds,max_retries,retry_backoff_seconds,retry_attempt, andjitter_seconds.Jobexposesattempt.- The four new options are keyword-only. The rest of the
add_task()signature is unchanged, including positionalargs,kwargs, andprogress_callback. Existing calls continue to work.
Fixes
- Fixed-interval scheduling could set
next_run_atto a time that is not in the future. This happened when a job finished within clock resolution of its start time, or when the elapsed time landed exactly on an interval boundary. The task then dispatched again immediately. The next run is now always the next interval boundary that is strictly in the future. - When a timed-out job's handler also raised an exception, the job's
error_messageshowed only the exception text and hid the timeout. The timeout message now comes first, and the handler's exception is appended.
Full Changelog: https://github.com/nandyalu/quiv/compare/v0.7.0...v0.8.0
v0.7.0 - Database Locking Rework (Phase 3/6) - 2026-08-09
Phase 3 of the roadmap to v1.0.0: lock-free reads under SQLite WAL.
What's new
- Lock-free reads — read queries (
get_task,get_job,get_all_tasks,get_all_jobs, due-task queries) no longer serialize behind the persistence layer's global lock; SQLite WAL mode provides the reader/writer coordination. Read-modify-write operations (task/job lifecycle transitions, pause/resume, cleanup) still serialize on a dedicated write lock, preserving the no-lost-update guarantees. - SQLite pragmas — connections now set
synchronous=NORMAL(the standard WAL pairing: fsync on checkpoint instead of per-commit; the DB is an ephemeral temp file, so durability-on-crash was never a goal) and an explicitbusy_timeout=10000matching the existing driver-level timeout.
Performance
With a writer and a heavy get_all_jobs reader running concurrently, p99 latency of a small get_task read drops from ~89 ms to ~1.1 ms (~80×) — small reads no longer queue behind large scans or writes on a global lock.
Aggregate wall time on a synthetic hammer benchmark (5,000 mixed ops, 70% reads / 30% writes, 16 threads) measured 31.0 s before vs 44.5 s after. Investigated per the phase plan's guard: not connection-pool contention (unchanged with a 32-connection pool) and not WAL checkpoint starvation (a 20 MiB WAL with serialized reads stayed fast) — it is CPython GIL convoying on CPU-bound ORM deserialization when all 16 threads busy-loop, where serialized execution is faster in aggregate. An artifact of the synthetic saturation workload, not of realistic loads, and one that disappears on free-threaded builds.
The synchronous=NORMAL pragma alone improves write throughput ~20% (31.0 s → 24.6 s with reads still serialized).
Housekeeping
- New concurrency stress-test suite (
tests/test_persistence_concurrency.py): concurrent writers with no lost updates, readers seeing consistent rows during writes, a full scheduler run atpool_size=32under constant read load, and a pause/resume race. - Docs, release notes, and CLAUDE.md updated; version bumped to
0.7.0.
Full Changelog: https://github.com/nandyalu/quiv/compare/v0.6.0...v0.7.0
v0.6.0 - Scheduler Core Efficiency (Phase 2/6) - 2026-08-05
Phase 2 of the roadmap to v1.0.0: smart sleep loop and handler signature caching.
What's new
- Sub-second intervals — the scheduler loop now sleeps until the next due task on an interruptible wait instead of polling every second.
add_task(interval=0.2)works; dispatch jitter drops from up to ~1 s to milliseconds. - Zero idle polling — an idle scheduler issues no database queries (bounded by a 60-second safety-net wake-up). Schedule changes (
add_task,run_task_immediately,resume_task,remove_task) and job completions wake the loop immediately, so deferred tasks dispatch as soon as a pool slot frees andshutdown()returns promptly instead of waiting out the current sleep. - Handler signature caching — each handler's injectable kwargs (
_job_id,_stop_event,_progress_hook) are introspected once per handler lifetime (weakly cached) instead of threeinspect.signature()calls per dispatch.
Fixes
- When the pool is saturated, the loop no longer busy-polls the database at ~100 Hz over overdue tasks it cannot dispatch anyway; it sleeps until a finishing job wakes it.
Housekeeping
- 7 new tests: timing tests for dispatch latency, sub-second runs, idle no-polling, backpressure dispatch-on-slot-free, and prompt shutdown, plus a smoke test codifying the manual 0.1 s-interval throughput check. Docs, release notes, and AI-tooling artifacts updated; version bumped to
0.6.0.
Full Changelog: https://github.com/nandyalu/quiv/compare/v0.5.0...v0.6.0
v0.5.0 - Correctness & Stability (Phase 1/6) - 2026-07-26
Phase 1 of the roadmap to v1.0.0: three concurrency bug fixes.
Behavior changes
run_task_immediately()now raisesTaskNotActiveErrorwhen the task is notactive. Previously arunningtask could be dispatched a second time concurrently (breaking the no-overlap guarantee) and apausedtask was silently un-paused. Resume paused tasks explicitly withresume_task().
What's new
shutdown(timeout=...)/stop(timeout=...)— optional bound on how long shutdown waits for the scheduler thread and in-flight jobs. Jobs that do not exit within the deadline are abandoned on their worker threads with a warning, so a hung handler can no longer block application shutdown forever. Default (timeout=None) keeps the previous wait-forever behavior.- New exception
TaskNotActiveError(exported fromquiv).
Fixes
remove_task()racing the dispatch loop could raise aKeyErrorthat stalled the scheduler for 5 seconds; dispatch now skips removed tasks gracefully, and the shared handler/callback/stop-event registries are protected by a lock.shutdown()now signals cancellation only torunningjobs instead of scanning the entire retained job history.
Housekeeping
- Roadmap phases renumbered: v0.3.x/v0.4.x were already consumed by earlier feature releases, so Phase 1 ships as
v0.5.0and later phases shift down accordingly (Phase 6 staysv1.0.0). - 7 new regression tests; docs, release notes, and AI-tooling artifacts updated; version bumped to
0.5.0. - Docs cleanup: all Markdown files unwrapped to one paragraph per continuous line (zensical can break rendering when a sentence/paragraph is split across source lines). No content changes.
v0.4.1 - AI Tooling & Async teardown fix - 2026-07-20
What's new
-
AI tooling — quiv now teaches AI coding assistants (Claude Code, Cursor, Copilot, …) how to use it correctly, in #51:
- Packaged agent guide — every install ships a condensed agent-facing reference at
quiv/AGENTS.mdinside the package (lands insite-packagesnext to the code): API surface, handler injection rules, cancellation semantics, FastAPI wiring, and common pitfalls. AI tools exploring installed dependencies pick it up with zero setup. - llms.txt — the docs site publishes llms.txt (index linking every docs page as raw Markdown) and llms-full.txt (the full docs in one file) following the llms.txt convention.
- Claude Code plugin — the quiv repository doubles as a plugin marketplace shipping a
quivskill that Claude loads automatically whenever a conversation touches quiv. Install once with/plugin marketplace add nandyalu/quivthen/plugin install quiv@quiv;/plugin update quivpicks up the latest guidance.
See the new AI Tools docs page for all three entry points.
- Packaged agent guide — every install ships a condensed agent-facing reference at
Fixes
- Per-invocation async event loops now finalize outstanding async generators and shut down the loop's default executor before closing. Previously, if a handler raised while an async generator was still active, Python could emit
Task was destroyed but it is pendingandasync_generator_athrow was never awaitedwarnings during teardown. The original handler exception remains visible. by @d4rk22 in #50
Other changes
- ci: skip coverage PR comment for fork PRs
- upd: roadmap for v1.0.0
- build(deps-dev): bump zensical from 0.0.43 to 0.0.45 by @dependabot[bot] in #42
- build(deps): bump actions/checkout from 6 to 7 by @dependabot[bot] in #44
- build(deps-dev): bump pytest from 9.0.3 to 9.1.1 by @dependabot[bot] in #45
- build(deps): bump sqlmodel from 0.0.38 to 0.0.39 by @dependabot[bot] in #46
- build(deps-dev): bump zensical from 0.0.45 to 0.0.50 by @dependabot[bot] in #47
- build(deps): bump tzdata from 2026.2 to 2026.3 by @dependabot[bot] in #48
- build(deps-dev): bump mypy from 2.1.0 to 2.2.0 by @dependabot[bot] in #49
- fix: finalize async resources before closing job loops by @d4rk22 in #50
New Contributors
Full Changelog: https://github.com/nandyalu/quiv/compare/v0.4.0...v0.4.1
v0.4.0 - run_on_main helper method - 2026-05-31
What's new
-
quiv.run_on_main(func, *args, **kwargs)— fire-and-forget helper that dispatches a callable onto the active Quiv instance's main event loop. Importable at module level (from quiv import run_on_main) and callable from anywhere in a task handler's call stack - no_progress_hookparameter to thread through intermediate functions.- Auto-detects whether the caller is already on the main loop's thread: sync targets run inline on-loop and async targets are scheduled via
main_loop.create_task. From a worker thread, sync targets dispatch viacall_soon_threadsafeand async targets viarun_coroutine_threadsafe. - The same helper works from a FastAPI route handler on the main loop and from inside a Quiv task — one utility shared between request and task code (e.g., a WebSocket broadcast whose connected-clients state lives on uvicorn's loop).
- Exceptions raised by the target are logged on the active Quiv's logger and swallowed, mirroring
_progress_hookand event-listener semantics.
See Running on the main event loop for the full walkthrough.
- Auto-detects whether the caller is already on the main loop's thread: sync targets run inline on-loop and async targets are scheduled via
Other changes
Quiv.start()now registers the instance as the process-level "active" Quiv (cleared byshutdown()) sorun_on_maincan resolve a target loop when called outside a task context. Multiple concurrent instances log a warning naming the most recently started instance as the winner for out-of-task callers.- Inside
_run_job, aContextVaris set to the runningQuivinstance for the duration of each handler invocation; this propagates into nested sync calls, the per-job async event loop, andasyncio.create_taskspawned inside an async handler.
Full Changelog: https://github.com/nandyalu/quiv/compare/v0.3.5...v0.4.0
v0.3.5 - Cleanup noise & update packages - 2026-05-27
What's new
- Removed a debug logging statement from the
set_timezone_to_utcmethod to reduce log verbosity by @nandyalu in #38
Other changes
- Updated various python libraries to latest versions.
- build(deps): bump actions/upload-pages-artifact from 4 to 5 by @dependabot[bot] in #24
- build(deps-dev): bump zensical from 0.0.32 to 0.0.33 by @dependabot[bot] in #25
- build(deps-dev): bump mypy from 1.20.0 to 1.20.1 by @dependabot[bot] in #26
- build(deps-dev): bump zensical from 0.0.33 to 0.0.36 by @dependabot[bot] in #29
- build(deps): bump tzdata from 2026.1 to 2026.2 by @dependabot[bot] in #28
- build(deps-dev): bump mypy from 1.20.1 to 1.20.2 by @dependabot[bot] in #27
- build(deps): bump orgoro/coverage from 3.2 to 3.3 by @dependabot[bot] in #30
- build(deps-dev): bump zensical from 0.0.36 to 0.0.39 by @dependabot[bot] in #31
- build(deps-dev): bump zensical from 0.0.39 to 0.0.41 by @dependabot[bot] in #32
- build(deps-dev): bump mypy from 1.20.2 to 2.0.0 by @dependabot[bot] in #33
- build(deps-dev): bump zensical from 0.0.41 to 0.0.42 by @dependabot[bot] in #34
- build(deps-dev): bump mypy from 2.0.0 to 2.1.0 by @dependabot[bot] in #35
- build(deps): bump pymdown-extensions from 10.21.2 to 10.21.3 in the uv group across 1 directory by @dependabot[bot] in #36
- build(deps-dev): bump zensical from 0.0.42 to 0.0.43 by @dependabot[bot] in #37
Full Changelog: https://github.com/nandyalu/quiv/compare/v0.3.4...v0.3.5
v0.3.4 - Job and Task attrs datetime aware - 2026-04-10
Bug fixes
- Job datetime normalization:
Jobdatetimes (started_at,ended_at) loaded from SQLite are now correctly normalized to UTC-aware. Previously, the@model_validatoronJobwas dead code because SQLAlchemy bypasses Pydantic validators when hydrating table classes.
Improvements
- Generic datetime normalization via
@reconstructor: Added a@reconstructormethod onQuivModelBasethat automatically normalizes alldatetimefields to UTC-aware on every DB load. This applies to bothTaskDBandJobmodels (and any future models), replacing the per-model dead-code validators. - Removed dead
@model_validatorfromTaskDBandJobmodels. delete_task()now has test coverage forTaskNotFoundErroron missing task.- updated python packages to latest versions.
Documentation
- New Testing page documenting all 108 tests with a breakdown of edge cases covered across 18 categories.
CI
docs-release.ymlnow waits for the release to be visible in the GitHub API before generating the changelog, fixing a race condition where the current release was missing from the generated docs.
v0.3.3 - fixed inteval option for tasks - 2026-04-09
Breaking changes
- Default interval scheduling changed to fixed intervals:
fixed_intervaldefaults toTrue, meaning next run is now scheduled from the job start time rather than completion time. Setfixed_interval=Falseto restore the previous wait-between-runs behavior.
What's new
-
fixed_intervalper-task scheduling mode:add_task()accepts a newfixed_intervalparameter:True(default) — next run at fixed intervals from job start time. If a run exceeds the interval, missed intervals are skipped.False— next runintervalseconds after job completion (old behavior).
Other changes
finalize_task_after_job()acceptsjob_started_atfor fixed-interval scheduling.
Full Changelog: https://github.com/nandyalu/quiv/compare/v0.3.2...v0.3.3
v0.3.2 - Removed unique task name constraint - 2026-04-09
Breaking changes
-
Task operations now use
task_idinstead oftask_name: All public methods that previously accepted atask_namestring now accept thetask_id(UUID string) returned byadd_task(). This removes the uniqueness constraint on task names — multiple tasks can now share the sametask_name.Affected methods: -
remove_task(task_id)— previouslyremove_task(task_name)-pause_task(task_id)— previouslypause_task(task_name)-resume_task(task_id)— previouslyresume_task(task_name)-run_task_immediately(task_id)— previouslyrun_task_immediately(task_name)-get_task(task_id)— previouslyget_task(task_name)(by-name lookup)Removed methods: -
get_task_by_id()— merged intoget_task(task_id)Migration: Store the return value of
add_task()and pass it to all task operations:# Before scheduler.add_task("my-task", handler, interval=60) scheduler.pause_task("my-task") # After task_id = scheduler.add_task("my-task", handler, interval=60) scheduler.pause_task(task_id) -
Event listeners receive typed model objects instead of dicts
Event listener callbacks now receive
TaskandJobmodel objects directly instead of untypeddict[str, Any].TASK_*events:callback(event: Event, task: Task)JOB_*events:callback(event: Event, task: Task, job: Job)
Migration:
# Before def on_completed(event, data): print(data["task_name"], data["duration"]) # After from quiv.models import Task, Job def on_completed(event: Event, task: Task, job: Job): print(task.task_name, job.duration_seconds)
What's new
-
Duplicate task names allowed
add_task()no longer raisesConfigurationErroron duplicatetask_name. Each call returns a uniquetask_id(UUID), so multiple tasks can share a display name. This is especially useful for one-shot tasks that may be scheduled repeatedly with the same name. -
duration_secondsanderror_messageon the Job modelThe
Jobmodel now includes two new fields:duration_seconds: float | None— job runtime in seconds, set when the job finisheserror_message: str | None— error description, set when a job fails
Both fields are persisted in the database and available via
get_job()andget_all_jobs(), making it easy to inspect job history without parsing logs. -
Typed event listener callbacks
Event listeners now receive real
TaskandJobmodel objects with full IDE autocomplete and type checking.JOB_*events include the parentTaskalongside theJob, so listeners have full context without extra lookups.
Documentation
- Updated all docs to reflect
task_id-based API across getting-started, API reference, architecture, event listeners, bigger applications, progress callbacks, and exceptions pages. - Updated all code examples to use
task_idfor runtime operations. - Added admonitions and footnotes throughout docs for better readability.
- Added
_job_idtracing section to the "Why quiv?" page, describing how Trailarr uses injected job IDs as trace context for log correlation. - Rewrote event listeners documentation with typed callback signatures, updated examples, and new FastAPI WebSocket example using model objects.
Other changes
- Internal handler and progress callback registries are now keyed by
task_idinstead oftask_name. prepare_invocation()in the execution layer usestask_idfor progress callback dispatch.- Removed
get_task_by_name(),get_task_id_by_name()from the persistence layer. - Renamed
get_task_by_id()toget_task()in the persistence layer. delete_task()andqueue_task_for_immediate_run()in the persistence layer now accepttask_idinstead oftask_name.finalize_job()now accepts optionalduration_secondsanderror_messageparameters.- Removed unused
timezoneimport from persistence module.
Full Changelog: https://github.com/nandyalu/quiv/compare/v0.3.1...v0.3.2
v0.3.1 - Better task pickling - 2026-04-07
Breaking Changes
- The public methods
get_task(),get_task_by_id(), andget_all_tasks()returnTaskobjects with unpickledargs(tuple) andkwargs(dict) — ready for JSON serialization in FastAPI endpoints. - Internal model renamed: The SQLModel database model is now
TaskDB(internal only, not exported). External modelTaskis still the same - but is now only used for Public API responses and has correct types.
What's New
- Async callbacks without event loop: Async progress callbacks and event listeners now run in a temporary event loop when no main loop is available, instead of being skipped with a warning.
- Eager main loop resolution: The main event loop is now resolved at
start()time (in addition to lazy resolution on first callback), improving reliability in FastAPI apps.
Documentation
- Added return types to all method signatures in API docs (e.g.,
get_task(task_name: str) -> Task. - Updated architecture, progress-callbacks, and event-listeners docs to reflect new async callback behavior
Full Changelog: https://github.com/nandyalu/quiv/compare/v0.3.0...v0.3.1
v0.3.0 - Better pickling and Event listeners - 2026-04-07
What's Changed
- Changed argument serialization for task persistence from JSON to pickle, allowing most Python objects (except lambdas and inner functions) to be scheduled as arguments by @nandyalu in #19
- Added support for global event listeners via
add_listener(event, callback)andremove_listener(event, callback), including both sync and async callbacks, with robust dispatch and error handling. Events include all major task and job lifecycle transitions. by @nandyalu in #19 - Introduced
startup()as an alias forstart(), andstop()as an alias forshutdown(), makingstart/stoppairs more natural in user code by @nandyalu in #19 Job.idnow usesUUIDand can be injected into task function (as_job_id) if function accepts it - can be used for task tracing / logging by @nandyalu in #19- Updated all relevant documentation by @nandyalu in #19
Full Changelog: https://github.com/nandyalu/quiv/compare/v0.2.4...v0.3.0
v0.2.4 - Preserve task args order - 2026-04-07
What's Changed
add_taskargs as tuple to preserve order by @nandyalu in #18loggeracceptslogging.Loggeras well aslogging.LoggerAdapterby @nandyalu in #18
Full Changelog: https://github.com/nandyalu/quiv/compare/v0.2.3...v0.2.4
v0.2.3 - Exception logging improvements - 2026-04-06
What's Changed
- Bump zensical from 0.0.24 to 0.0.27 by @dependabot[bot] in #8
- Bump zensical from 0.0.27 to 0.0.28 by @dependabot[bot] in #9
- Bump pytest-cov from 7.0.0 to 7.1.0 by @dependabot[bot] in #10
- Bump actions/configure-pages from 5 to 6 by @dependabot[bot] in #11
- Bump actions/deploy-pages from 4 to 5 by @dependabot[bot] in #12
- Bump zensical from 0.0.28 to 0.0.30 by @dependabot[bot] in #13
- Bump sqlmodel from 0.0.37 to 0.0.38 by @dependabot[bot] in #14
- Bump tzdata from 2025.3 to 2026.1 by @dependabot[bot] in #15
- Bump mypy from 1.19.1 to 1.20.0 by @dependabot[bot] in #16
- Improve job logging with task names and error details by @nandyalu in #17
Full Changelog: https://github.com/nandyalu/quiv/compare/v0.2.2...v0.2.3
v0.2.2 - SQLModel Registry Fix - 2026-03-13
What's Changed
-
fix: quiv registry to not include user models. Updated the private
registryusage forSQLModelmodels ofQuivto the method from https://github.com/fastapi/sqlmodel/discussions/1539#discussioncomment-14229572 by @nandyalu in #7 -
Added a test to ensure user's
SQLModelwithtable=Truedoes not get created in Quiv database by @nandyalu in #7
Full Changelog: https://github.com/nandyalu/quiv/compare/v0.2.0...v0.2.2
v0.2.1 - Fix Release Build - 2026-03-11
What's Changed
Full Changelog: https://github.com/nandyalu/quiv/compare/v0.2.0...v0.2.1
v0.2.0-Bug Fixes and minor updates - 2026-03-09
Breaking Changes
timezone_namerenamed totimezone— BothQuiv()andQuivConfig()now usetimezonefor the display timezone parameter. Update anytimezone_name=keyword arguments.register_handler/register_progress_callbackare now private — Renamed to_register_handler/_register_progress_callback. Useadd_task()instead, which handles registration internally.add_task()raises on duplicate task names — Callremove_task()first if you need to replace a task.
New Features
remove_task(task_name)— Remove a task and its handler/callback registrations.- Cooperative cancellation —
_stop_eventinjection with per-jobthreading.Event. See Cancellation docs. - Progress callbacks with four dispatch paths — Async/sync callbacks work with or without an event loop. See Progress Callbacks docs.
- Lazy event loop resolution —
Quiv()can be instantiated at module level before any asyncio loop exists. The event loop is resolved on first progress callback dispatch. - Backpressure — Scheduler defers dispatch when the thread pool is full. Late-starting jobs log a warning suggesting to increase
pool_size. TaskStatus.RUNNING— Tasks are markedRUNNINGduring execution, preventing concurrent runs of the same task.
Bug Fixes
- Removed
logger.setLevel(logging.DEBUG)— the library no longer forces a log level - Removed
asyncio.get_event_loop()at init — fixes deprecation warnings and module-level instantiation shutdown()now cleans up SQLite WAL (-wal,-shm) sidecar files- Cancellation detection no longer depends on handler accepting
_stop_event
Improvements
TaskStatus/JobStatusare now proper(str, Enum)classes- History cleanup uses SQL-level filtering with
col()wrapper (runs every 60s, not every tick) - Next run scheduled from job completion time, not dispatch time
- All log timestamps use the configured display timezone consistently
Documentation
- New pages: Bigger Applications, Progress Callbacks, Cancellation (with mermaid diagrams)
- Architecture page now has a sequence diagram
- API page: added pool size guidance, logger/timezone note blocks
- Tabbed uv/pip install commands across all pages
- GitHub repo link in docs header with edit/view source buttons
Tests
- 8 new tests covering backpressure, late start warnings, concurrent run prevention, remove_task, progress callbacks without event loop, and task lifecycle
- All existing tests updated for API changes
Repository
- Added CODEOWNERS, issue templates, CONTRIBUTING.md, CODE_OF_CONDUCT.md
- CI workflows now include
pyproject.tomlin path filters - Removed
masterbranch reference from docs deploy workflow
Initial Release (v0.1.0) - 2026-03-08
Initial Release