API
Quiv
Constructor
Quiv(
config: QuivConfig | None = None,
pool_size: int = 10,
history_retention_seconds: int = 86400,
timezone: str | tzinfo = "UTC",
*,
logger: logging.Logger | logging.LoggerAdapter | None = None,
main_loop: asyncio.AbstractEventLoop | None = None,
)
If you pass config, do not also pass pool_size, history_retention_seconds, or timezone.
Parameters:
config: grouped configuration object (seeQuivConfig)pool_size: the largest number of tasks that can run at the same time. The default is 10. See Choosing a pool size belowhistory_retention_seconds: how long quiv keeps the record of a finished job. The default is 86400 seconds, which is 24 hours-
timezone: IANA timezone string ortzinfofor display formatting (default"UTC")Timezone is for display only
quiv uses
timezoneonly to format the datetime values that it writes to the log. Everything inside quiv uses UTC, whatever you set here. That covers the schedule, the stored rows, and the life of each job. -
logger: optional custom logger orLoggerAdapterinstance; if not provided, a logger named"Quiv"is used. The library does not set a log level — configure it in your application (see Logging)Logger scope
quiv uses
loggeronly for its own messages, such as the events of the scheduler loop, the life of a job, the cleanup, and the warnings. quiv does not send the output of your handlers through it. Use your own logger inside a handler, as you would anywhere else. -
main_loop: optional asyncio event loop for progress callbacks and event listeners.-
If you pass nothing, quiv tries
asyncio.get_running_loop()when you callstart(). If it still has no loop, it tries again on the first callback. -
You can therefore create
Quiv()at module level, before any event loop runs. A FastAPI application usually does this. -
If no event loop is available when a callback fires, an async callback runs in a temporary event loop on the worker thread, and a sync callback runs on that thread directly.
-
add_task(...)
add_task(
task_name: str,
func: Callable[..., Any],
interval: float | None = None,
delay: float | None = None,
run_once: bool = False,
fixed_interval: bool = True,
args: tuple | None = None,
kwargs: dict | None = None,
progress_callback: Callable[..., Any] | None = None,
*,
run_at: datetime | None = None,
timeout: float | None = None,
max_retries: int = 0,
retry_backoff: float = 30.0,
jitter: float = 0.0,
) -> str
The older parameters keep the positions that they had in earlier releases. The four failure-handling options are keyword-only: timeout, max_retries, retry_backoff, and jitter.
Adds a scheduled task and returns its task id, a UUID string1.
This is the main way to register a task. One call registers the handler, registers the progress callback, and stores the task.
Validation:
task_namemust not be emptyinterval > 0— sub-second intervals (e.g.interval=0.2) are supported. Required unlessrun_once=Truedelay >= 0, and not combined withrun_atrun_atmust be adatetimetimeout > 0when providedmax_retries >= 0retry_backoff > 0jitter >= 0
Failure handling: timeout asks a job that runs too long to stop, max_retries with retry_backoff runs a failed job again after a delay that doubles each time, and jitter moves apart the tasks that share an interval. Read Failure Handling for the rules.
Duplicate task names are allowed
Several tasks can share one task_name. Each call to add_task() returns its own task_id, a UUID, and every operation on a task uses that id. task_name is a label for a reader, not a key.
Behavior:
funcmay be sync or async- a run-once task never repeats, so it needs no
interval. An interval passed withrun_once=Trueis ignored, andTask.interval_secondsreadsNone run_atschedules the first run at an absolute time, as an alternative todelay. A naivedatetimeis read as UTC — thetimezonesetting only formats log output. A time already past runs at once, because the process may have been down when the time came due. Passing bothrun_atanddelayraisesConfigurationError-
quiv serializes
argsandkwargswith pickle and stores them. Pickle accepts most Python objects, but it cannot accept a lambda or an inner function.Warning
The temporary database holds trusted internal state. Never let untrusted input reach it. An attacker who can write to it can put their own
argsandkwargsinto a task, and your application will then run them. -
With
run_once=True, the task runs one time, and quiv then deletes it -
If you pass a
progress_callback, it runs on the main loop when one is available, and on the worker thread when none isfixed_intervalscheduling modesfixed_interval=True(default) — Next run is scheduled at fixed intervals from the job start time. A task withinterval=3600runs every 3600 seconds relative to that start-time anchor (for example, if a run starts at 12:00:10, subsequent targets are 13:00:10, 14:00:10, etc.), regardless of how long the task takes. If a run exceeds the interval, missed intervals are skipped and the next run lands on the next scheduled time in that cadence.fixed_interval=False— Next run is scheduledintervalseconds after job completion. A task withinterval=3600that takes 10 minutes to run will have 70-minute gaps between start times.
update_task(...)
update_task(
task_id: str,
*,
task_name: str = ...,
interval: float = ...,
fixed_interval: bool = ...,
args: tuple = ...,
kwargs: dict = ...,
timeout: float | None = ...,
max_retries: int = ...,
retry_backoff: float = ...,
jitter: float = ...,
progress_callback: Callable[..., Any] | None = ...,
run_at: datetime = ...,
) -> Task
Changes a scheduled task in place and keeps its task_id. Only the parameters that you pass change, and quiv leaves every other value as it is. If you change interval, quiv moves the next run to now + interval. You can update a running task: the changes take effect from its next run. update_task() emits Event.TASK_UPDATED, carrying the Task as it is after the change.
None carries meaning here: timeout=None turns the timeout off, and progress_callback=None removes the callback. quiv therefore marks an argument that you did not pass with an internal sentinel. To leave a value alone, omit it.
Three things cannot change: run_once, delay, and the handler func. delay belongs to the moment when you create the task. To change the handler, remove the task and add it again.
Moving the next run with run_at
run_at names the absolute time of the next run, the same way it does in add_task(). Naive input is read as UTC, never as the display timezone, and a time already past runs at once.
It applies to any task, whether or not it has run before. A recurring task keeps its interval and simply runs next at the time you give; the interval governs everything after that.
A one-off alarm is the clearest case:
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)
Without it, moving an alarm means remove_task() followed by add_task(). That hands back a new task_id for the caller to store, and it leaves a window with no task scheduled at all. update_task() keeps the id and writes one row.
run_at and interval are mutually exclusive. Changing the interval already reschedules the next run, so passing both is ambiguous and raises ConfigurationError.
run_at does not survive a task that is already running
Finalizing a job rewrites the schedule. A run-once task has its row deleted. A recurring task has its next_run_at recomputed from its interval. Either way the time you just set is discarded when the job finishes, so the call looks as if it worked and then does nothing.
quiv writes a warning to the log when it sees this, but the check is best-effort: a task can start running between the write and the check.
To move a recurring task reliably, wait until its job finishes. Or change interval instead, which finalization does honour.
Raises:
TaskNotFoundErrorfor unknown idsConfigurationErrorfor invalid values (same rules asadd_task), or forrun_attogether withinterval
start() -> None / startup() -> None
Starts the background thread that runs the scheduler loop. You can call it more than once without harm.
startup() is an alias for start()
start() is the canonical name, and this documentation uses it everywhere. startup() calls the same code and keeps working.
shutdown(timeout: float | None = None) -> None / stop(...) -> None
shutdown() does four things:
- It stops the scheduler loop and the worker threads.
- It cancels each running job, by setting its stop event.
- It disposes the database engine.
- It deletes the temporary SQLite file of the scheduler.
Always call it when your application stops.
With timeout=None, the default, shutdown() waits for every running job to finish, however long that takes. Pass a timeout in seconds to limit the wait. quiv leaves a job that does not exit before the deadline on its worker thread, and writes a warning. Use a timeout at the end of a FastAPI lifespan, where one stuck handler must not hold up the whole application.
shutdown() does not wait for run_on_main work
It waits for the scheduler loop and for running jobs. Work that a handler handed to the main loop with run_on_main is not a running job: the handler returned the moment it handed the work over. So shutdown() can return while that work is still queued, and in a FastAPI application the loop closes soon afterwards and cancels it. It writes a warning naming how many callables it left behind.
It does not cancel that work either. The loop is your application's, and closing it is your decision. Poll pending_main_loop_work() to make it.
A job left behind still holds its database connection. When it finishes, its write reaches the database that quiv already deleted. Two things follow: the job can write errors to the log, and SQLite recreates the temporary database file, because a write creates the file again. The file is small and nothing reads it. Delete it yourself if a stray file in the temp directory matters to you.
stop() is an alias for shutdown()
shutdown() is the canonical name, and this documentation uses it everywhere. stop() calls the same code and keeps working.
pending_main_loop_work() -> int
How many callables handed to the main loop by run_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, so shutdown() finds nothing running and returns while the work may still be queued.
quiv does not wait for that work and does not cancel it. The loop is your application's: quiv never closes it, and it cannot know whether a half-finished callable should be stopped or allowed to end. This is the number your application needs in order to decide:
@asynccontextmanager
async def lifespan(app: FastAPI):
scheduler.start()
yield
scheduler.shutdown()
while scheduler.pending_main_loop_work():
await asyncio.sleep(0.05)
Cheap enough to poll. It reads one set under a lock and never touches the database, unlike stats().
It counts every shape run_on_main dispatches, including a sync callable that is queued but has not started and so has no future of its own.
wait_for_job(job_id: str, timeout: float | None = None) -> Job / await_job(...)
Blocks until the job finishes, and returns it finalized: status, duration_seconds, and error_message are set. A job that already finished returns at once. await_job() is the same method as a coroutine, for code on the application's event loop.
job = scheduler.wait_for_job(job_id, timeout=30)
job = await scheduler.await_job(job_id, timeout=30) # in an async endpoint
quiv emits the JOB_* event for the job before it wakes a waiter. Emitted, not handled: a listener runs on the main loop and may run after the waiter returns.
Raises:
JobNotFoundErrorfor an unknown idTimeoutError, the builtin, whentimeoutpasses first. quiv raises the builtin on every supported Python. On 3.10 theconcurrent.futuresandasynciotimeout classes are separate, and quiv converts them.SchedulerStoppedErrorwhenshutdown()returned before the job finished, or when the call comes aftershutdown()
wait_for_task(task_id: str, timeout: float | None = None) -> Job / await_task(...)
Blocks until the next job of the task finishes, and returns that job. The job is the next one of that task to finalize: the one running when you call, if there is one, otherwise the next one dispatched. A caller that adds a one-off task holds a task_id, not a job id, so this is the method for it:
@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 run-once task deletes its row when its job finishes. A waiter registered before that still receives the job.
Raises:
TaskNotFoundErrorfor an unknown id, and when the task is removed while nothing of it is runningTimeoutErrorandSchedulerStoppedError, as above
Both pairs are the way to test your own handlers against a real scheduler. See Testing your own handlers.
run_task_immediately(task_id: str) -> int
Queues an already-scheduled task to run now.
Raises:
TaskNotFoundErrorif no task with that id exists — including a run-once task that already fired and removed itselfHandlerNotRegisteredErrorif the task exists but no handler is registered for itTaskNotActiveErrorif the task isrunning(no concurrent second run) orpaused(useresume_task()instead)
Returns number of task rows queued.
pause_task(task_id: str) -> None
Pause blocks future runs of the task.
Raises:
TaskNotFoundErrorif no task with that id exists.
resume_task(task_id: str, delay: int = 0) -> None
Resume re-activates and sets next run with an optional delay (in seconds, default=0).
Raises:
TaskNotFoundErrorif no task with that id exists.
Info
If a delay is not provided or set to 0, next run will fire immediately.
cancel_job(job_id: str) -> bool
Asks a running job to stop, by setting its stop event.
Returns True when it finds the stop event and sets it. Returns False when it finds no such job.
Info
Cancellation is cooperative. The handler must check stop_event.is_set() and return.
get_task(task_id: str) -> Task
Returns a single Task by its UUID string.
Raises:
TaskNotFoundErrorif no task with that ID exists.
get_job(job_id: str) -> Job
Returns a single Job by its UUID string.
Raises:
JobNotFoundErrorif no job with that ID exists.
get_all_tasks(include_run_once: bool = False, status: str | None = None, limit: int | None = None, offset: int = 0) -> list[Task]
Returns persisted task rows as Task objects, ordered by next_run_at ascending.
- when
include_run_once=False, run-once tasks are excluded - when
include_run_once=True, all persisted tasks are returned statusfilters by task status (e.g."paused");limit/offsetpaginate
get_all_jobs(status=None, task_id=None, since=None, until=None, order_by="started_at", descending=True, limit=None, offset=0) -> list[Job]
Returns persisted jobs with optional filters and pagination — see Observability for details. status filters by status string (e.g. "failed", "running"); task_id restricts to one task; since/until bound started_at (pass aware UTC datetimes); order_by accepts "started_at" or "ended_at" (anything else raises ConfigurationError).
stats() -> QuivStats
Returns a snapshot of the scheduler at one moment: active_jobs, pool_size, pool_utilization, tasks_by_status, next_run_at, and job_history_count. QuivStats is a frozen dataclass, exported from quiv — serialize with dataclasses.asdict(). See Observability.
remove_task(task_id: str) -> None
Removes a scheduled task, the handler registered for it, and its progress callback. If a job of that task is running, quiv sets its stop event to ask it to stop.
Raises:
TaskNotFoundErrorif no task with that id exists.
A job that was already running finishes on its own, and quiv cleans up after it as usual.
add_listener(event: Event, callback: Callable[..., Any]) -> None
Registers a listener for one event of the scheduler. One event can have several listeners. A callback can be sync or async.
The callback signature depends on the event group:
TASK_*events:callback(event: Event, task: Task)JOB_*events:callback(event: Event, task: Task, job: Job)
A listener receives typed Task and Job model objects, so your editor completes every field and you never look up a key in a dictionary.
Raises:
ConfigurationErrorifeventis not anEventenum member orcallbackis not callable.
See Event Listeners for the full event list and dispatch details.
remove_listener(event: Event, callback: Callable[..., Any]) -> None
Removes a listener that you registered before. If quiv does not find the callback, the call does nothing and raises nothing.
run_on_main
from quiv import run_on_main
run_on_main(func: Callable[..., Any], *args: Any, **kwargs: Any) -> None
A module-level helper that sends func to the main event loop of the active Quiv instance. Call it from anywhere inside the call stack of a task handler, without passing a callback parameter through the functions in between. Call it also from code that already runs on the main loop, such as a FastAPI route handler that shares a utility with your task code.
Resolution order for the active instance:
- A
ContextVarset by_run_jobfor the duration of a handler invocation (propagates through nested sync calls, the per-job async loop, andasyncio.create_task). - A process-level fallback registered by
Quiv.start()and cleared byQuiv.shutdown().
Behavior:
- Fire-and-forget; returns
Noneimmediately on cross-thread dispatch. - Sync targets run inline when called from the main loop's thread, else via
call_soon_threadsafe. Async targets are scheduled viamain_loop.create_taskon-loop, elserun_coroutine_threadsafe. - If
funcraises, quiv writes the error to the logger of the active instance and continues.
Raises:
MainLoopUnavailableErrorif no active Quiv instance is registered, or if the active instance has no main event loop that it can resolve. It inheritsQuivErrorandRuntimeError.
See Running on the main event loop for the full walkthrough, dispatch table, and caveats.
call_on_main
from quiv import call_on_main
call_on_main(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any
The sibling of run_on_main that waits. It runs func on the main loop and returns what func returned. An exception raised by func reaches the caller. Every keyword goes to func, so the function has no options of its own.
Use it when the handler's real work lives on the main loop. With run_on_main the handler returns at once, and quiv records a one-millisecond success whatever the work did. With call_on_main the job carries the real duration and the real failure, JOB_FAILED fires, max_retries applies, and the task's timeout cancels the coroutine.
Behavior:
- From a worker thread, sync and async targets both run on the main loop, and the thread blocks until the target finishes.
- From the main loop's own thread, a sync target runs inline and returns its result. An async target cannot be waited for there, because the wait would block the loop that must run it. That raises
MainLoopUnavailableError; await it instead. - While it waits, quiv watches the job's stop event. When the event is set, the coroutine on the main loop is cancelled and
JobCancelledErroris raised. Outside a job there is no stop event, and the wait has no limit. - The work counts in
pending_main_loop_work()while it runs.
Raises:
JobCancelledErrorwhen the job's stop event was set while waiting. Let it propagate; quiv finalizes the job ascancelledwith no error in the log.MainLoopUnavailableError, asrun_on_maindoes, and for an async target from the main loop's thread.- Whatever
funcraised.
run_subprocess
from quiv import run_subprocess
run_subprocess(
args: Sequence[str] | str,
*,
input: bytes | str | None = None,
stop_event: threading.Event | None = None,
timeout: float | None = None,
kill_grace: float = 5.0,
check: bool = False,
capture_output: bool = False,
**popen_kwargs: Any,
) -> subprocess.CompletedProcess
A drop-in for subprocess.run inside a handler. Cooperative cancellation cannot reach a child process on its own: a handler that checks its stop event between steps still waits for the running child. This helper watches the stop event while the child runs.
- When the stop event is set, the child gets
terminate(), thenkill()afterkill_graceseconds if it is still alive, andJobCancelledErroris raised. It is the same rule quiv applies to a process job. - When
timeoutpasses, the child is stopped the same way andsubprocess.TimeoutExpiredis raised, assubprocess.rundoes. An existingexcept TimeoutExpiredkeeps working. input,check, andcapture_outputmean what they mean forsubprocess.run. Other keywords go tosubprocess.Popen.stop_eventdefaults to the stop event of the job the code runs inside, found through the job context, so a function deep inside a service needs nothing passed to it. Outside a job there is no stop event.
The helper stops the direct child only. Pass start_new_session=True to give a child that spawns its own children a process group of its own. On Windows terminate() and kill() are the same call, so the grace has no effect there.
See Subprocesses.
Hooks and callback injection
When quiv dispatches a task, it reads the signature of the handler:
- injects
job_id(str, UUID) only if accepted - injects
stop_event(threading.Event) only if accepted - injects
progress_hook(callable) only if accepted
If your handler declares none of those parameters, and declares no **kwargs, quiv injects nothing.
Renamed in v1.0.0
quiv 0.x injected these as _job_id, _stop_event, and _progress_hook. add_task() rejects a handler that still declares an old name, and raises ConfigurationError naming the new spelling. A key in kwargs that collides with an injected name is rejected the same way, because the injected value would overwrite yours. See the v1.0.0 release notes.
An async handler runs in an event loop that quiv creates on the worker thread, one for each invocation. It never shares the main event loop of your application.
Models
Task
The public model that get_task() and get_all_tasks() return. Return it straight from a FastAPI endpoint. You convert nothing by hand.
# Methods return Task directly
task_id = scheduler.add_task("my-task", handler, interval=60)
task = scheduler.get_task(task_id) # Task
tasks = scheduler.get_all_tasks() # list[Task]
# Use directly in FastAPI endpoints
@app.get("/tasks")
def list_tasks():
return scheduler.get_all_tasks()
Key fields:
id: str— UUID identifiertask_name: str— display name (not necessarily unique)args: tuple[Any, ...]— positional arguments (unpickled)kwargs: dict[str, Any]— keyword arguments (unpickled)interval_seconds: float | None— seconds between runs;Nonefor a run-once taskfixed_interval: bool— ifTrue, next run is measured from job start time; ifFalse, from completionrun_once: bool— ifTrue, task runs once then is removedstatus: str—"active","running", or"paused"next_run_at: datetime— next scheduled run (UTC-aware)timeout_seconds: float | None— cooperative per-job timeout;Nonewhen disabledmax_retries: int— retry limit for failed jobsretry_backoff_seconds: float— base delay for exponential retry backoffretry_attempt: int— consecutive-failure counter (0 unless mid-retry)jitter_seconds: float— random offset bound added to recurring next-run times
datetime objects are in UTC
The datetime values (next_run_at) are always returned as a UTC-aware datetime.
- You can safely return this from fastapi endpoints which will have a
Zat the end to indicate UTC datetime. - This is the golden-standard for Browsers as they can easily parse it and display in user's timezone.
Internal TaskDB model
Internally, quiv stores args and kwargs as pickle-encoded bytes in the TaskDB model for flexibility. The public API automatically converts to Task with unpickled values and correct types for JSON/OpenAPI.
Job
Key fields:
id: str— UUID identifiertask_id: str— foreign key to source tasktask_name: str— name of the task that spawned this jobstatus: str— lifecycle statusstarted_at: datetime— UTC-aware start timestampended_at: datetime | None— UTC-aware end timestampduration_seconds: float | None— job duration in seconds (set on completion)error_message: str | None— error description if job failedattempt: int— attempt number;1= first try,2= first retry, and so on
datetime objects are in UTC
The datetime values (started_at, ended_at) are always returned as UTC-aware datetimes.
- You can safely return this from fastapi endpoints which will have a
Zat the end to indicate UTC datetime. - This is the golden-standard for Browsers as they can easily parse it and display in user's timezone.
Event types
Event
task_added— fired after a task is registeredtask_removed— fired after a task is removedtask_paused— fired after a task is pausedtask_resumed— fired after a task is resumedtask_updated— fired after a task is mutated viaupdate_task()job_started— fired when a job begins executionjob_completed— fired when a job finishes successfullyjob_failed— fired when a job ends with an exceptionjob_cancelled— fired when a job is cancelledjob_retrying— fired afterjob_failedwhen a retry has been scheduled
See Event Listeners for the data each event carries.
Status constants
TaskStatus
active— task is eligible for schedulingrunning— task is currently executingpaused— task is temporarily disabled
JobStatus
scheduled— job is queued for executionrunning— job is currently executingcompleted— job finished successfullycancelled— job stopped via cancellation signalfailed— job ended with an exception
QuivConfig
QuivConfig(
pool_size: int = 10,
history_retention_seconds: int = 86400,
timezone: str | tzinfo = "UTC",
)
Frozen dataclass. Both QuivConfig and Quiv use timezone for the display timezone parameter.
Choosing a pool size
pool_size controls the maximum number of tasks that can run concurrently. It is not tied to CPU cores — quiv uses threads, not processes, so the deciding factor is your workload, not hardware.
What to consider:
- How many tasks might overlap? If you have 5 recurring tasks and at most 3 could run at the same time,
pool_size=4is sufficient. - Are tasks I/O-bound or CPU-bound? I/O-bound tasks (API calls, database queries, file downloads) spend most of their time waiting, so many threads work fine. CPU-bound tasks contend for Python's GIL — more threads won't help and can hurt. For CPU-heavy work, offload to a process pool from within the handler rather than increasing
pool_size. - Do tasks hold external resources? Database connections, API rate limits, and file handles create practical caps regardless of thread count.
Rules of thumb:
- Start with the default (
10) and only adjust if you see thethreadpool was busywarning in your logs. - For mostly I/O-bound workloads, set
pool_sizeto 2–3x your expected max concurrent tasks. - If the warning appears frequently, increase
pool_sizeor check whether tasks are taking longer than expected.
When the pool is full, quiv defers due tasks rather than queuing them unboundedly; a finishing job wakes the scheduler loop, so deferred tasks dispatch as soon as a slot frees. If a job starts late because all workers were busy, a warning is logged with the delay.
Public methods summary
Quiv(...)— create scheduler instancerun_on_main(func, *args, **kwargs)— fire-and-forget dispatch onto the active Quiv's main loopcall_on_main(func, *args, **kwargs)— the same dispatch, waiting for the result; the job's stop event cancels the waitrun_subprocess(args, *, stop_event=None, timeout=None, kill_grace=5.0, ...)—subprocess.runthat a cancel can stopwait_for_job(job_id, timeout)/await_job(...)— block until a job finishes, return it finalizedwait_for_task(task_id, timeout)/await_task(...)— block until the task's next job finishes, return itadd_task(...)— schedule a task, returnstask_idstart()/startup()— start the scheduler loopshutdown(timeout=None)/stop(timeout=None)— stop scheduler and clean up resourcesrun_task_immediately(task_id)— trigger a scheduled task nowpause_task(task_id)— pause a taskresume_task(task_id)— resume a paused taskcancel_job(job_id)— signal cancellation for a running jobremove_task(task_id)— remove a task and its registrationsadd_listener(event, callback)— register an event listenerremove_listener(event, callback)— remove an event listenerget_task(task_id)— get a single task by UUIDget_job(job_id)— get a single job by IDget_all_tasks(...)— list persisted tasksget_all_jobs(...)— list persisted jobs
-
Keep the
task_idthatadd_task()returns. Every later operation uses it:remove_task()pause_task()resume_task()run_task_immediately()get_task()update_task().