Testing
quiv has more than 200 tests. They cover the whole life of a task and of a job, the event listeners, the progress callbacks, the configuration, the models, and the rare cases. CI runs them on every commit, against Python 3.10 through 3.14.
# Run all tests
uv run pytest
# Run with coverage
uv run pytest --cov=quiv
# Run a specific test file
uv run pytest tests/test_scheduler.py
# Run a single test
uv run pytest tests/test_scheduler.py::test_backpressure_skips_dispatch_when_pool_full
Testing your own handlers
Test a handler through a real scheduler, not through a stub of add_task. A stub proves the handler was registered. It proves nothing about the schedule, the injected parameters, cancellation, or the events. wait_for_task() makes the real thing a short test:
from quiv import JobStatus, Quiv
def test_refresh_runs_and_succeeds():
scheduler = Quiv()
try:
task_id = scheduler.add_task("refresh", refresh_all, run_once=True)
scheduler.start()
job = scheduler.wait_for_task(task_id, timeout=10)
assert job.status == JobStatus.COMPLETED
assert job.error_message is None
finally:
scheduler.shutdown()
The job comes back finalized, so status, duration_seconds, and error_message are all there to assert on. For a recurring task, each call to wait_for_task() returns the next job. For a job id you already hold, use wait_for_job().
In an async test on the application's loop, await_task() and await_job() are the same methods as coroutines:
async def test_refresh_endpoint(client):
response = await client.post("/refresh") # the endpoint adds a one-off
job = await scheduler.await_task(response.json()["task_id"], timeout=10)
assert job.status == JobStatus.COMPLETED
Both raise the builtin TimeoutError when the deadline passes, and SchedulerStoppedError if the scheduler shut down first.
Test architecture
Most tests need a running asyncio event loop, because quiv dispatches callbacks onto it. The running_main_loop fixture in conftest.py starts an event loop in a background thread and yields it to the test. Every test calls scheduler.shutdown() in a finally block, which releases the threads and deletes the temporary database files.
What is tested
Scheduler lifecycle and configuration
- Mixing
config=QuivConfig(...)with explicit kwargs raisesConfigurationError pool_size <= 0andhistory_retention_seconds < 0are rejectedstart()is idempotent (safe to call multiple times)shutdown()continues and logs the problem when it cannot delete the database files- Database initialization failure raises
DatabaseInitializationError - The internal tables of quiv (
quiv_task,quiv_job) stay out of the SQLModel metadata of the application
Task input validation
- Empty
task_nameis rejected interval <= 0anddelay < 0are rejectedargsmust be atuple(not list)kwargsmust be adict(not string)- Unpicklable args (e.g. lambdas) raise
ConfigurationErrorwith a clear message
Task registration and identification
add_task()returns a uniquetask_id(UUID)- Duplicate
task_namevalues are allowed, each getting a distincttask_id - Handlers, progress callbacks, and DB rows are all keyed by
task_id remove_task()cleans up handler, progress callback, and DB row- Removing a non-existent task raises
TaskNotFoundError - Deleting a non-existent task from persistence raises
TaskNotFoundError
Task execution
- Sync run-once task executes and produces a
completedjob - Async run-once task executes via thread-local event loop
- Sync handler without
stop_eventorprogress_hookstill runs correctly - Failed handler sets job status to
failed job_idis injected as a UUID string when handler accepts itargsandkwargsordering is preserved through pickle round-trip (tested with 8 positional args and 5 keyword args)
Concurrent execution and backpressure
- Same task is never dispatched concurrently (status set to
runningblocks re-dispatch) - While the thread pool is full, quiv holds due tasks back instead of adding them to a queue that never stops growing. Each one dispatches as soon as a job finishes and frees a slot
- A task that quiv held back runs as soon as a worker is free
_active_job_countdecrements correctly after job completion- A job that starts late, because the pool was full, logs a warning that names the delay
Interval scheduling (fixed_interval)
- Fixed interval (
fixed_interval=True): next run is aligned tostart_time + interval - Skipped intervals: a 70-second job with 60-second interval skips to
start_time + 120s; a 130-second job skips tostart_time + 180s - Wait between runs (
fixed_interval=False): next run iscompletion_time + interval - Recurring task finalization sets status back to
activeand updatesnext_run_at - Run-once task finalization deletes the task row
Cancellation
cancel_job()returnsTruewhen stop event exists,Falseotherwise- Handler that sets
stop_eventresults incancelledstatus remove_task()on a running task cancels its active jobshutdown()cancels all tracked running jobs
Progress callbacks
- Async progress callback dispatched on main event loop via handler's
progress_hook - Sync progress callback dispatched on main event loop via
call_soon_threadsafe - Async handler with sync progress callback works correctly
- Progress callback registration and clearing via
None - Sync callback works without an event loop (runs on worker thread)
- Async callback works without an event loop (runs in temporary event loop)
- quiv logs a sync callback that raises, and the job continues
- quiv logs an async callback that raises, and the job continues
- Closed main loop does not crash progress dispatch
Event listeners
- Invalid event type (non-
Eventenum) raisesConfigurationError - Non-callable callback raises
ConfigurationError - Removing a listener that was never registered does nothing and raises nothing
TASK_ADDED: listener receivesEventandTaskwith correcttask_nameandtask_idTASK_REMOVED: listener receives snapshot of task before deletionTASK_PAUSED: listener receives task withpausedstatusTASK_RESUMED: listener receives task withactivestatusJOB_STARTED: listener receivesTaskandJobwithrunningstatusJOB_COMPLETED: listener receivesJobwithduration_secondsset anderror_messageasNoneJOB_FAILED: listener receivesJobwitherror_messagematching the exceptionJOB_CANCELLED: listener receivesJobwithcancelledstatus- Multiple listeners for the same event are all called
- Async listener dispatched on main event loop
- quiv logs a listener that raises and continues. The listeners after it still run
- Sync listener works without an event loop
- Async listener works without an event loop (temporary event loop)
- Async listener failure without an event loop is caught and logged
Handler injection
job_id,stop_event, andprogress_hookare injected when handler accepts them- Injection is skipped when handler signature does not include the parameters
- Handlers with
**kwargsreceive all injected parameters - A callable whose signature quiv cannot read, such as
object(), injects nothing and raises nothing
Deserialization safety
- Corrupt pickle data in
args_pickledraisesConfigurationError - Corrupt pickle data in
kwargs_pickledraisesConfigurationError - Pickled kwargs that are not a
dictraiseConfigurationError - Corrupt pickle in
Task.model_validate()from dict falls back to empty defaults - Corrupt pickle in
Task.model_validate()fromTaskDBobject falls back to empty defaults - Non-standard input types pass through the validator without crashing
Datetime normalization
- Naive datetimes are normalized to UTC-aware (treated as UTC)
- Timezone-aware datetimes are converted to UTC
Nonedatetimes pass through unchangedTaskpublic model normalizesnext_run_atfromTaskDBTaskDBdatetimes are normalized on DB load via@reconstructorJobdatetimes (started_at,ended_at) are normalized on DB load via@reconstructorJobwithNoneended_atis handled correctlyget_all_tasks()returns UTC-awarenext_run_atregardless of configured display timezoneget_job()returns UTC-awarestarted_atandended_at
Persistence layer
queue_task_for_immediate_run()raisesTaskNotFoundErrorfor missing taskpause_task()andresume_task()raiseTaskNotFoundErrorfor missing taskmark_task_running()raisesTaskNotFoundErrorfor missing taskmark_job_running()andfinalize_job()raiseJobNotFoundErrorfor missing job- History cleanup deletes old finished jobs while keeping recent ones
- Job status filtering (
completed,failed) returns correct subsets get_all_tasks(include_run_once=False)excludes run-once tasks- Paused tasks are excluded from due-task queries
- Resumed tasks appear in due-task queries
Immediate execution
run_task_immediately()raisesHandlerNotRegisteredErrorfor unregistered handlerrun_task_immediately()raisesTaskNotFoundErrorwhen task row is missingrun_task_immediately()successfully queues a registered task
Configuration
- IANA timezone string resolves correctly
tzinfoinstance passes through- Invalid timezone string raises
InvalidTimezoneError - Invalid type raises
InvalidTimezoneError QuivConfigworks without conflict when no explicit kwargs are passed
Scheduler loop resilience
- The loop catches an exception, logs it, and tries again after a sleep
- The loop keeps running when the same error repeats, which the test checks over several turns
Model serialization
TaskDBfield serializer unpickles valid bytes for JSON outputTaskmodel serializes args/kwargs as unpickled Python objects- JSON serialization produces correct output (tested with
model_dump_json()) - Time helpers (
next_run_time,get_current_time) return UTC-aware datetimes id_generator()returns UUID strings
Soak test
The test suite runs in under a minute, so it cannot show what happens to a scheduler that runs for a day. A separate script does that. scripts/soak.py runs a mixed workload for as long as you ask, and checks for the faults that only appear with time: threads that accumulate, a database that never stops growing, memory that climbs, and errors that nobody sees.
uv run python scripts/soak.py --minutes 10 # while you work
uv run python scripts/soak.py --hours 24 # before a release
The workload covers every execution path at once: 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 calls stats() and get_all_jobs() every second, the way an admin page would.
The script exits non-zero on any of four conditions:
- The number of live threads grows past its settled baseline.
- The retained job history grows past what the retention window allows.
- The
"Quiv"logger records an ERROR that the failing task does not explain. - The scheduler thread dies.
Result for v1.0.0
Run on 2026-09-17, for the full 24 hours, on an Intel Core i5-11600 with Python 3.10.12 on Linux. It passed. The complete log is in the repository at benchmarks/results/soak-24h-2026-09-17.log.
| Measurement | Result |
|---|---|
| Ran for | 24.00 h |
| Jobs finished | 294,400, at 3.4 each second |
| — completed | 234,876 |
| — failed | 32,229 |
| — cancelled | 27,295 |
| — retries queued | 21,486 |
| Live threads | 8 at the baseline, 8 at the peak |
| Retained job history | 2,264 rows at the peak, against a bound of 5,006 |
| Peak memory | 53 MB |
| Unexpected errors | 0 |
Three of those rows carry the weight:
The thread count never moved. It read 8 in all 280 samples, across roughly 294,000 job lifecycles. Each of those built a stop event and a set of injected arguments, and each run of the async task built its own event loop and closed it again. Nothing accumulated.
The database stopped growing after the first hour. The retained history reached about 2,260 rows and stayed there for the next 22 hours, which means the cleanup keeps pace with the work indefinitely. The file does not grow without limit.
Memory held at 53 MB for the whole day. That rules out the slow leaks: job rows kept in memory, stop events never dropped from the registry, and event loops retained after their job ends.