Skip to main content

taskmill/
lib.rs

1//! # Taskmill
2//!
3//! Adaptive priority work scheduler with IO-aware concurrency and SQLite persistence.
4//!
5//! Taskmill provides a generic task scheduling system that:
6//! - Persists tasks to SQLite so the queue survives restarts
7//! - Schedules by priority (0 = highest, 255 = lowest) with [named tiers](Priority)
8//! - Deduplicates tasks by key with configurable [`DuplicateStrategy`] (skip, supersede, or reject)
9//! - Tracks expected and actual IO bytes (disk and network) per task for budget-based scheduling
10//! - Monitors system CPU, disk, and network throughput to adjust concurrency
11//! - Supports [composable backpressure](PressureSource) from arbitrary external sources
12//! - Preempts lower-priority work when high-priority tasks arrive
13//! - [Retries](TaskError::retryable) failed tasks with configurable [backoff](BackoffStrategy)
14//!   ([`Constant`](BackoffStrategy::Constant), [`Linear`](BackoffStrategy::Linear),
15//!   [`Exponential`](BackoffStrategy::Exponential), [`ExponentialJitter`](BackoffStrategy::ExponentialJitter))
16//!   and per-type [retry policies](RetryPolicy)
17//! - Records completed/failed [task history](TaskHistoryRecord) for queries and IO learning
18//! - Supports [batch submission](Scheduler::submit_batch) with intra-batch dedup and chunking
19//! - Emits [lifecycle events](SchedulerEvent) including progress for UI integration
20//! - Reports [byte-level transfer progress](TaskProgress) with EWMA-smoothed throughput and ETA
21//! - Supports [task cancellation](Scheduler::cancel) with history recording and cleanup hooks
22//! - Supports [task superseding](DuplicateStrategy::Supersede) for atomic cancel-and-replace
23//! - Supports [task TTL](TtlFrom) with automatic expiry, per-type defaults, and child inheritance
24//! - Supports [graceful shutdown](ShutdownMode) with configurable drain timeout
25//!
26//! # Concepts
27//!
28//! ## Task lifecycle
29//!
30//! A task flows through a linear pipeline:
31//!
32//! ```text
33//! submit → blocked ─(deps met)─→ pending ──────────────→ running → completed
34//!                                   ↑    ↓     ↘ paused ↗     ↘ failed (retryable → pending, with backoff delay)
35//!                         (run_after elapsed)                  ↘ failed (permanent → history)
36//!                                   │                          ↘ dead_letter (retries exhausted → history)
37//!                           pending (gated)                    ↘ cancelled (via cancel() or supersede)
38//!                               cancelled                      ↘ expired (TTL, cascade to children)
39//!                               superseded
40//!                               expired (TTL)
41//!    blocked ─(dep failed)─→ dep_failed (history)
42//! ```
43//!
44//! 1. **Submit** — [`DomainHandle::submit`] (or [`submit_with`](DomainHandle::submit_with),
45//!    [`submit_batch`](DomainHandle::submit_batch))
46//!    enqueues a typed task into the SQLite store. The domain handle
47//!    auto-prefixes the task type with the domain name and applies defaults.
48//! 2. **Pending** — the task waits in a priority queue. The scheduler's run loop
49//!    pops the highest-priority pending task on each tick.
50//! 3. **Running** — the scheduler calls the [`TypedExecutor::execute`] method with the
51//!    deserialized payload and a [`TaskContext`] containing the task record,
52//!    a cancellation token, and a progress reporter.
53//! 4. **Terminal** — on success the task moves to the history table. On failure,
54//!    a [`retryable`](TaskError::retryable) error requeues it (up to
55//!    [`SchedulerBuilder::max_retries`] or per-type [`RetryPolicy::max_retries`])
56//!    with a configurable [`BackoffStrategy`] delay; a non-retryable
57//!    ([`permanent`](TaskError::permanent)) error moves it to history as failed.
58//!    Tasks that exhaust all retries enter [`dead_letter`](HistoryStatus::DeadLetter)
59//!    state — queryable via [`DomainHandle::dead_letter_tasks`] and manually
60//!    re-submittable via [`DomainHandle::retry_dead_letter`].
61//!
62//! ## Deduplication & duplicate strategies
63//!
64//! Every task has a dedup key derived from its type name and either an explicit
65//! key string or the serialized payload (via SHA-256). What happens when a
66//! submission's key matches an existing task depends on the
67//! [`DuplicateStrategy`]:
68//!
69//! - **`Skip`** (default) — attempt a priority upgrade or requeue, otherwise
70//!   return [`SubmitOutcome::Duplicate`]. Safe for idempotent `submit` calls.
71//! - **`Supersede`** — cancel the existing task (recording it in history as
72//!   [`HistoryStatus::Superseded`]) and replace it with the new submission.
73//!   For running tasks the cancellation token is fired, the
74//!   [`on_cancel`](TypedExecutor::on_cancel) hook runs, and children are
75//!   cascade-cancelled. Returns [`SubmitOutcome::Superseded`].
76//! - **`Reject`** — return [`SubmitOutcome::Rejected`] without modifying the
77//!   existing task.
78//!
79//! Within a single [`submit_batch`](Scheduler::submit_batch) call, intra-batch
80//! dedup applies a **last-wins** policy: if two tasks share a dedup key, only
81//! the last occurrence is submitted and earlier ones receive `Duplicate`.
82//!
83//! ## Priority & preemption
84//!
85//! [`Priority`] is a `u8` newtype where **lower values = higher priority**.
86//! Named constants ([`REALTIME`](Priority::REALTIME),
87//! [`HIGH`](Priority::HIGH), [`NORMAL`](Priority::NORMAL),
88//! [`BACKGROUND`](Priority::BACKGROUND), [`IDLE`](Priority::IDLE)) cover
89//! common tiers. When a task at or above the
90//! [`preempt_priority`](SchedulerBuilder::preempt_priority) threshold is
91//! submitted, lower-priority running tasks are cancelled and paused so the
92//! urgent work runs immediately.
93//!
94//! ## IO budgeting
95//!
96//! Each task declares an [`IoBudget`] covering expected disk and network
97//! bytes (via [`TypedTask::config()`] or [`TaskSubmission::expected_io`]).
98//! The scheduler tracks running IO totals and, when
99//! [resource monitoring](SchedulerBuilder::with_resource_monitoring) is enabled,
100//! compares them against observed system throughput to avoid over-saturating
101//! the disk or network. Executors report actual IO via
102//! [`TaskContext::record_read_bytes`] / [`record_write_bytes`](TaskContext::record_write_bytes) /
103//! [`record_net_rx_bytes`](TaskContext::record_net_rx_bytes) /
104//! [`record_net_tx_bytes`](TaskContext::record_net_tx_bytes),
105//! which feeds back into historical throughput averages for future scheduling
106//! decisions.
107//!
108//! To throttle tasks when network bandwidth is saturated, set a bandwidth
109//! cap with [`SchedulerBuilder::bandwidth_limit`] — this registers a built-in
110//! [`NetworkPressure`] source that maps observed throughput to backpressure.
111//!
112//! ## Task groups
113//!
114//! Tasks can be assigned to a named group via [`TaskSubmission::group`] (or
115//! [`TypedTask::config()`]). The scheduler enforces per-group concurrency
116//! limits — for example, limiting uploads to any single S3 bucket to 4
117//! concurrent tasks. Configure limits at build time with
118//! [`SchedulerBuilder::group_concurrency`] and
119//! [`SchedulerBuilder::default_group_concurrency`], or adjust at runtime via
120//! [`Scheduler::set_group_limit`] and [`Scheduler::set_default_group_concurrency`].
121//!
122//! ## Child tasks & two-phase execution
123//!
124//! An executor can spawn child tasks via [`TaskContext::spawn_child`]. When
125//! children exist, the parent enters a **waiting** state after its executor
126//! returns. Once all children complete, the parent's
127//! [`TypedExecutor::finalize`] method is called — useful for assembly work
128//! like `CompleteMultipartUpload`. If any child fails and
129//! [`fail_fast`](TaskSubmission::fail_fast) is `true` (the default), siblings
130//! are cancelled and the parent fails immediately.
131//!
132//! ## Task TTL & automatic expiry
133//!
134//! Tasks can be given a time-to-live (TTL) so they expire automatically if they
135//! haven't started running within the allowed window. TTL is resolved with a
136//! priority chain: **per-task** > **per-type** > **global default** > none.
137//!
138//! The TTL clock can start at submission time ([`TtlFrom::Submission`], the
139//! default) or when the task is first dispatched ([`TtlFrom::FirstAttempt`]).
140//! Expired tasks are moved to history with [`HistoryStatus::Expired`] and a
141//! [`SchedulerEvent::TaskExpired`] event is emitted.
142//!
143//! The scheduler catches expired tasks in two places:
144//! - **Dispatch time** — when a task is about to be dispatched, its `expires_at`
145//!   is checked first.
146//! - **Periodic sweep** — a background sweep (default every 30s, configurable
147//!   via [`SchedulerBuilder::expiry_sweep_interval`]) batch-expires pending and
148//!   paused tasks whose deadline has passed.
149//!
150//! When a parent task expires, its pending and paused children are
151//! cascade-expired.
152//!
153//! Child tasks without an explicit TTL inherit the **remaining** parent TTL
154//! (with `TtlFrom::Submission`), so a child can never outlive its parent's
155//! deadline.
156//!
157//! ## Task metadata tags
158//!
159//! Tasks can carry schema-free key-value metadata tags for filtering, grouping,
160//! and display — without deserializing the task payload. Tags are immutable
161//! after submission and are persisted, indexed, and queryable.
162//!
163//! Set tags per-task via [`TaskSubmission::tag`], per-type via
164//! [`TypedTask::tags`], or as batch defaults via
165//! [`BatchSubmission::default_tag`]. Tag keys and values are validated at submit
166//! time against [`MAX_TAG_KEY_LEN`], [`MAX_TAG_VALUE_LEN`], and
167//! [`MAX_TAGS_PER_TASK`].
168//!
169//! Child tasks inherit parent tags by default (child tags take precedence).
170//! Tags are copied to history on all terminal transitions and are included in
171//! [`TaskEventHeader`] for event subscribers.
172//!
173//! Query by tags via the domain handle with [`DomainHandle::tasks_by_tags`]
174//! (AND semantics).
175//!
176//! ## Delayed & scheduled tasks
177//!
178//! A task can declare **when** it becomes eligible for dispatch:
179//!
180//! - **Immediate** (default) — dispatched as soon as a slot is free.
181//! - **Delayed** (one-shot) — [`TaskSubmission::run_after`] or
182//!   [`TaskSubmission::run_at`] sets a `run_after` timestamp. The task enters
183//!   `pending` immediately but is invisible to the dispatch loop until its
184//!   timestamp passes.
185//! - **Recurring** — [`TaskSubmission::recurring`] or
186//!   [`TaskSubmission::recurring_schedule`] configures automatic re-enqueueing.
187//!   After each execution, the scheduler creates a new pending instance with
188//!   `run_after` set to `now + interval`.
189//!
190//! Delayed tasks interact naturally with other features:
191//! - **Priority**: `run_after` tasks respect normal priority ordering among
192//!   eligible tasks.
193//! - **TTL**: A delayed task's TTL clock ticks from submission (or first
194//!   attempt). If the TTL expires before `run_after`, the task expires.
195//! - **Dedup**: Recurring tasks reuse the same dedup key. Pile-up prevention
196//!   skips creating a new instance if the previous one is still pending.
197//! - **Parent/Child**: Recurring tasks cannot be children (enforced at submit).
198//!
199//! Recurring schedules can be paused, resumed, or cancelled via
200//! [`DomainHandle::pause_recurring`], [`DomainHandle::resume_recurring`], and
201//! [`DomainHandle::cancel_recurring`]. Pausing stops new instances from being
202//! created without affecting any currently running instance.
203//!
204//! The scheduler optimizes idle wakeups: when the next scheduled task is far
205//! in the future, the run loop sleeps until `min(poll_interval, next_run_after)`
206//! instead of waking every poll interval.
207//!
208//! ## Task dependencies
209//!
210//! Tasks can declare **dependencies on other tasks** so they only become
211//! eligible for dispatch after their prerequisites complete. Dependencies
212//! are peer-to-peer relationships — distinct from the parent-child hierarchy
213//! used for fan-out/finalize patterns. Parent-child means "I spawned you and
214//! I finalize after you." A dependency means "I cannot start until you finish."
215//! The two compose orthogonally: a child task can depend on an unrelated peer,
216//! and a parent can depend on another peer.
217//!
218//! ### Blocked status
219//!
220//! A task with unresolved dependencies enters the [`TaskStatus::Blocked`]
221//! state. Blocked tasks are invisible to the dispatch loop — the existing
222//! `WHERE status = 'pending'` filter excludes them automatically. Resolution
223//! is event-driven: when a dependency completes, the scheduler checks whether
224//! the dependent's remaining edges have all been satisfied. If so, the task
225//! transitions to `pending` and becomes eligible for dispatch. If a dependency
226//! was already completed at submission time, its edge is skipped entirely; if
227//! all dependencies are already complete, the task starts as `pending`
228//! immediately.
229//!
230//! ### Failure policy
231//!
232//! [`DependencyFailurePolicy`] controls what happens when a dependency fails
233//! permanently (after exhausting retries):
234//!
235//! - **`Cancel`** (default) — the dependent is moved to history with
236//!   [`HistoryStatus::DependencyFailed`] and its own dependents are
237//!   cascade-failed.
238//! - **`Fail`** — same terminal status, but does not cascade to other
239//!   dependents in the same chain (useful for manual intervention).
240//! - **`Ignore`** — the failed edge is removed and, if no other edges
241//!   remain, the dependent is unblocked. Use with caution — the dependent
242//!   must tolerate missing upstream results.
243//!
244//! ### Circular dependency detection
245//!
246//! At submission time the scheduler walks the dependency graph upward from
247//! each declared dependency using iterative BFS (bounded stack depth). If
248//! the new task's ID is encountered during the walk, submission fails with
249//! a [`StoreError::CyclicDependency`] error. This catches both direct
250//! cycles (A depends on B, B depends on A) and transitive cycles
251//! (A → B → C → A).
252//!
253//! ### Interaction with other features
254//!
255//! - **Dedup**: a blocked task still occupies its dedup key. Duplicate
256//!   submissions follow normal [`DuplicateStrategy`] rules.
257//! - **TTL**: a blocked task's TTL clock ticks normally. If the TTL expires
258//!   while blocked, the task moves to history as [`HistoryStatus::Expired`]
259//!   and its edges are cleaned up.
260//! - **Recurring**: recurring tasks can declare dependencies. Each generated
261//!   instance starts as `blocked` independently — useful for "run B every
262//!   hour, but only after A's latest run completes."
263//! - **Delayed**: `run_after` and dependencies compose. A task with both
264//!   starts as `blocked`, transitions to `pending` when deps are met, but
265//!   is still gated by `run_after` in the dispatch query.
266//! - **Groups**: blocked tasks are not dispatched, so they do not count
267//!   against group concurrency limits.
268//!
269//! ## Cancellation
270//!
271//! Tasks can be cancelled via the [`DomainHandle`] — individually with
272//! [`DomainHandle::cancel`], all at once with [`DomainHandle::cancel_all`],
273//! or by predicate with [`DomainHandle::cancel_where`]. Cancelled tasks are
274//! recorded in the history table as [`HistoryStatus::Cancelled`] rather than
275//! silently deleted.
276//!
277//! For running tasks, cancellation fires the
278//! [`on_cancel`](TypedExecutor::on_cancel) hook (with a configurable
279//! [`cancel_hook_timeout`](SchedulerBuilder::cancel_hook_timeout)) so
280//! executors can clean up external resources — for example, aborting an S3
281//! multipart upload. Executors can check for cancellation cooperatively via
282//! [`TaskContext::check_cancelled`].
283//!
284//! Cancelling a parent task cascade-cancels all its children.
285//!
286//! ## Byte-level progress
287//!
288//! For long-running transfers (file copies, uploads, downloads), executors can
289//! report byte-level progress via [`TaskContext::set_bytes_total`] and
290//! [`TaskContext::add_bytes`]. The scheduler maintains per-task atomic counters
291//! on the `IoTracker` — updates are lock-free and
292//! impose no overhead on the executor hot path.
293//!
294//! When [`SchedulerBuilder::progress_interval`] is set, a background ticker
295//! task polls these counters and emits [`TaskProgress`] events on a dedicated
296//! broadcast channel (via [`Scheduler::subscribe_progress`]). Each event
297//! includes EWMA-smoothed throughput and an estimated time remaining (ETA).
298//! The ticker is opt-in — when not configured, there is zero runtime cost.
299//!
300//! For a one-shot query without the ticker, [`Scheduler::byte_progress`]
301//! returns instantaneous snapshots (throughput = 0, no ETA).
302//!
303//! # Quick start
304//!
305//! ```ignore
306//! use taskmill::{
307//!     Domain, DomainKey, DomainHandle, Scheduler, TypedExecutor,
308//!     TaskContext, TaskError, TypedTask, TaskTypeConfig, IoBudget, Priority,
309//! };
310//! use serde::{Serialize, Deserialize};
311//! use tokio_util::sync::CancellationToken;
312//!
313//! // 1. Define a domain (typed module identity).
314//! struct Media;
315//! impl DomainKey for Media { const NAME: &'static str = "media"; }
316//!
317//! // 2. Define a typed task payload.
318//! #[derive(Serialize, Deserialize)]
319//! struct Thumbnail { path: String, size: u32 }
320//!
321//! impl TypedTask for Thumbnail {
322//!     type Domain = Media;
323//!     const TASK_TYPE: &'static str = "thumbnail";
324//!
325//!     fn config() -> TaskTypeConfig {
326//!         TaskTypeConfig::new()
327//!             .expected_io(IoBudget::disk(4_096, 1_024))
328//!     }
329//! }
330//!
331//! // 3. Implement the typed executor.
332//! struct ThumbnailExecutor;
333//!
334//! impl TypedExecutor<Thumbnail> for ThumbnailExecutor {
335//!     async fn execute(&self, thumb: Thumbnail, ctx: DomainTaskContext<'_, Media>) -> Result<(), TaskError> {
336//!         ctx.progress().report(0.5, Some("resizing".into()));
337//!         // ... do work, check ctx.token().is_cancelled() ...
338//!         ctx.record_read_bytes(4_096);
339//!         ctx.record_write_bytes(1_024);
340//!         Ok(())
341//!     }
342//! }
343//!
344//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
345//! // 4. Build and run the scheduler.
346//! let scheduler = Scheduler::builder()
347//!     .store_path("tasks.db")
348//!     .domain(Domain::<Media>::new().task::<Thumbnail>(ThumbnailExecutor))
349//!     .max_concurrency(4)
350//!     .with_resource_monitoring()
351//!     .build()
352//!     .await?;
353//!
354//! // 5. Submit work via a typed domain handle.
355//! let media: DomainHandle<Media> = scheduler.domain::<Media>();
356//! media.submit(Thumbnail { path: "/photos/a.jpg".into(), size: 256 }).await?;
357//!
358//! // 6. Run until cancelled.
359//! let token = CancellationToken::new();
360//! scheduler.run(token).await;
361//! # Ok(())
362//! # }
363//! ```
364//!
365//! # Common patterns
366//!
367//! ## Shared application state
368//!
369//! Register shared services (database pools, HTTP clients, etc.) at build time
370//! and retrieve them from any executor via [`TaskContext::state`]. State can be
371//! domain-scoped (checked first) or global (fallback):
372//!
373//! ```ignore
374//! struct AppServices { db: DatabasePool, http: reqwest::Client }
375//! struct IngestConfig { bucket: String }
376//!
377//! struct Ingest;
378//! impl DomainKey for Ingest { const NAME: &'static str = "ingest"; }
379//!
380//! let scheduler = Scheduler::builder()
381//!     .store_path("tasks.db")
382//!     .app_state(AppServices { /* ... */ })             // global — all domains
383//!     .domain(Domain::<Ingest>::new()
384//!         .task::<FetchTask>(FetchExecutor)
385//!         .state(IngestConfig { bucket: "...".into() }))  // domain-scoped
386//!     .build()
387//!     .await?;
388//!
389//! // Inside an ingest executor — domain state checked first, then global:
390//! async fn execute(&self, task: FetchTask, ctx: DomainTaskContext<'_, Ingest>) -> Result<(), TaskError> {
391//!     let cfg = ctx.state::<IngestConfig>().expect("IngestConfig not registered");
392//!     let svc = ctx.state::<AppServices>().expect("AppServices not registered");
393//!     svc.db.query("...").await?;
394//!     Ok(())
395//! }
396//! ```
397//!
398//! State can also be injected globally after construction via
399//! [`Scheduler::register_state`] — useful when a library
400//! receives a pre-built scheduler from a parent application.
401//!
402//! ## Backpressure
403//!
404//! Implement [`PressureSource`] to feed external signals into the scheduler's
405//! throttle decisions. The default [`ThrottlePolicy`] pauses `BACKGROUND`
406//! tasks above 50% pressure and `NORMAL` tasks above 75%:
407//!
408//! ```ignore
409//! use std::sync::atomic::{AtomicU32, Ordering};
410//! use taskmill::{PressureSource, Scheduler};
411//!
412//! struct ApiLoad { active: AtomicU32, max: u32 }
413//!
414//! impl PressureSource for ApiLoad {
415//!     fn pressure(&self) -> f32 {
416//!         self.active.load(Ordering::Relaxed) as f32 / self.max as f32
417//!     }
418//!     fn name(&self) -> &str { "api-load" }
419//! }
420//!
421//! let scheduler = Scheduler::builder()
422//!     .store_path("tasks.db")
423//!     .pressure_source(Box::new(ApiLoad { active: AtomicU32::new(0), max: 100 }))
424//!     // .throttle_policy(custom_policy)  // optional override
425//!     .build()
426//!     .await?;
427//! ```
428//!
429//! ## Events & progress
430//!
431//! Subscribe to [`SchedulerEvent`]s to drive a UI or collect metrics:
432//!
433//! ```ignore
434//! let mut rx = scheduler.subscribe();
435//! tokio::spawn(async move {
436//!     while let Ok(event) = rx.recv().await {
437//!         match event {
438//!             SchedulerEvent::Progress { header, percent, message } => {
439//!                 update_progress_bar(header.task_id, percent, message);
440//!             }
441//!             SchedulerEvent::Completed(header) => {
442//!                 mark_done(header.task_id);
443//!             }
444//!             _ => {}
445//!         }
446//!     }
447//! });
448//! ```
449//!
450//! For byte-level transfer progress with smoothed throughput and ETA,
451//! subscribe to the dedicated progress channel:
452//!
453//! ```ignore
454//! let mut progress_rx = scheduler.subscribe_progress();
455//! tokio::spawn(async move {
456//!     while let Ok(tp) = progress_rx.recv().await {
457//!         println!(
458//!             "{}: {}/{} bytes ({:.0} B/s, ETA {:?})",
459//!             tp.key, tp.bytes_completed, tp.bytes_total.unwrap_or(0),
460//!             tp.throughput_bps, tp.eta,
461//!         );
462//!     }
463//! });
464//! ```
465//!
466//! For a single-call dashboard snapshot, use [`Scheduler::snapshot`] which
467//! returns a serializable [`SchedulerSnapshot`] with queue depths, running
468//! tasks, progress estimates, byte-level progress, and backpressure.
469//!
470//! ## Group concurrency
471//!
472//! Limit concurrent tasks within a named group — for example, cap uploads
473//! per S3 bucket:
474//!
475//! ```ignore
476//! struct Uploads;
477//! impl DomainKey for Uploads { const NAME: &'static str = "uploads"; }
478//!
479//! let scheduler = Scheduler::builder()
480//!     .store_path("tasks.db")
481//!     .domain(Domain::<Uploads>::new()
482//!         .task::<UploadPart>(UploadPartExecutor))
483//!     .default_group_concurrency(4)               // default for all groups
484//!     .group_concurrency("s3://hot-bucket", 8)    // override for one group
485//!     .build()
486//!     .await?;
487//!
488//! // Tasks declare their group via TypedTask::config() or per-call override:
489//! let uploads: DomainHandle<Uploads> = scheduler.domain::<Uploads>();
490//! uploads.submit_with(UploadPart { etag: "abc".into() })
491//!     .group("s3://my-bucket")
492//!     .await?;
493//!
494//! // Adjust at runtime:
495//! scheduler.set_group_limit("s3://my-bucket", 2);
496//! scheduler.remove_group_limit("s3://my-bucket"); // fall back to default
497//! ```
498//!
499//! ## Batch submission
500//!
501//! Submit many tasks at once via [`DomainHandle::submit_batch`]:
502//!
503//! ```ignore
504//! let uploads: DomainHandle<Uploads> = scheduler.domain::<Uploads>();
505//! let tasks = vec![
506//!     UploadPart { etag: "file-1".into() },
507//!     UploadPart { etag: "file-2".into() },
508//! ];
509//! let outcomes = uploads.submit_batch(tasks).await?;
510//! ```
511//!
512//! For untyped batch submission with batch-wide defaults, use [`BatchSubmission`]
513//! and [`Scheduler::submit_built`].
514//!
515//! A [`SchedulerEvent::BatchSubmitted`] event is emitted for observability
516//! whenever at least one task in the batch was inserted.
517//!
518//! ## Child tasks
519//!
520//! Spawn child tasks from an executor to model fan-out work. The parent
521//! automatically waits for all children before its [`finalize`](TypedExecutor::finalize)
522//! method is called. `spawn_child` is domain-aware: the task type is
523//! auto-prefixed with the owning domain's namespace.
524//!
525//! ```ignore
526//! impl TypedExecutor<MultipartUpload> for MultipartUploadExecutor {
527//!     async fn execute(&self, upload: MultipartUpload, ctx: DomainTaskContext<'_, Uploads>) -> Result<(), TaskError> {
528//!         for part in &upload.parts {
529//!             ctx.spawn_child_with(UploadPart { etag: part.etag.clone(), size: part.size })
530//!                 .key(&part.etag)
531//!                 .priority(ctx.record().priority)
532//!                 .await?;
533//!         }
534//!         Ok(())
535//!     }
536//!
537//!     async fn finalize(&self, upload: MultipartUpload, ctx: DomainTaskContext<'_, Uploads>) -> Result<(), TaskError> {
538//!         // All parts uploaded — complete the multipart upload.
539//!         complete_multipart(&upload).await?;
540//!         Ok(())
541//!     }
542//! }
543//! ```
544//!
545//! For cross-domain children, use [`DomainSubmitBuilder::parent`] via `ctx.domain()`:
546//!
547//! ```ignore
548//! let storage = ctx.domain::<Storage>();
549//! storage.submit_with(Upload { ... })
550//!     .parent(ctx.record().id)
551//!     .await?;
552//! ```
553//!
554//! ## Cancellation & cleanup hooks
555//!
556//! Cancel tasks individually or in bulk. Implement
557//! [`on_cancel`](TypedExecutor::on_cancel) to clean up external resources:
558//!
559//! ```ignore
560//! impl TypedExecutor<Upload> for UploadExecutor {
561//!     async fn execute(&self, upload: Upload, ctx: DomainTaskContext<'_, Uploads>) -> Result<(), TaskError> {
562//!         // Cooperatively check for cancellation in long loops.
563//!         for chunk in upload.chunks() {
564//!             ctx.check_cancelled()?;
565//!             upload_chunk(chunk).await?;
566//!         }
567//!         Ok(())
568//!     }
569//!
570//!     async fn on_cancel(&self, upload: Upload, ctx: DomainTaskContext<'_, Uploads>) -> Result<(), TaskError> {
571//!         // Abort the in-progress multipart upload.
572//!         abort_multipart(&upload.upload_id).await?;
573//!         Ok(())
574//!     }
575//! }
576//!
577//! // Cancel by ID through the domain handle:
578//! let uploads: DomainHandle<Uploads> = scheduler.domain::<Uploads>();
579//! uploads.cancel(task_id).await?;
580//!
581//! // Bulk cancel all tasks in a domain:
582//! uploads.cancel_all().await?;
583//!
584//! // Cancel by predicate:
585//! uploads.cancel_where(|t| t.priority == Priority::BACKGROUND).await?;
586//! ```
587//!
588//! ## Task TTL
589//!
590//! Set a TTL on a task type via [`TypedTask::config()`], per-call via
591//! [`DomainSubmitBuilder::ttl`], or as a global default:
592//!
593//! ```ignore
594//! use std::time::Duration;
595//! use taskmill::{TypedTask, TaskTypeConfig, TtlFrom};
596//!
597//! // Per-type TTL — every Thumbnail task gets a 10-minute TTL.
598//! impl TypedTask for Thumbnail {
599//!     type Domain = Media;
600//!     const TASK_TYPE: &'static str = "thumbnail";
601//!
602//!     fn config() -> TaskTypeConfig {
603//!         TaskTypeConfig::new()
604//!             .ttl(Duration::from_secs(600))
605//!             .ttl_from(TtlFrom::Submission)
606//!     }
607//! }
608//!
609//! // Per-call override:
610//! media.submit_with(Thumbnail { path: "/img.jpg".into(), size: 256 })
611//!     .ttl(Duration::from_secs(300))
612//!     .await?;
613//!
614//! // Global default — catch-all for any task without a per-type TTL.
615//! let scheduler = Scheduler::builder()
616//!     .store_path("tasks.db")
617//!     .default_ttl(Duration::from_secs(3600)) // 1 hour
618//!     .build()
619//!     .await?;
620//! ```
621//!
622//! ## Scheduled tasks
623//!
624//! Delay a task or create recurring schedules:
625//!
626//! ```ignore
627//! use std::time::Duration;
628//! use taskmill::{TaskSubmission, RecurringSchedule};
629//!
630//! let media: DomainHandle<Media> = scheduler.domain::<Media>();
631//!
632//! // One-shot delay — dispatch after 30 seconds.
633//! media.submit_with(Cleanup { target: "stale-uploads".into() })
634//!     .run_after(Duration::from_secs(30))
635//!     .await?;
636//!
637//! // For recurring scheduling, configure it in `TypedTask::config()`:
638//! // impl TypedTask for Cleanup {
639//! //     fn config() -> TaskTypeConfig {
640//! //         TaskTypeConfig::new().recurring(RecurringSchedule {
641//! //             interval: Duration::from_secs(6 * 3600),
642//! //             initial_delay: None,
643//! //             max_executions: None,
644//! //         })
645//! //     }
646//! // }
647//! media.submit_with(Cleanup { target: "stale-uploads".into() })
648//!     .key("stale-uploads")
649//!     .await?;
650//!
651//! // Pause/resume/cancel recurring schedules via the domain handle.
652//! media.pause_recurring(task_id).await?;
653//! media.resume_recurring(task_id).await?;
654//! media.cancel_recurring(task_id).await?;
655//! ```
656//!
657//! ## Task chains
658//!
659//! Use [`DomainSubmitBuilder::depends_on`] to build dependency chains between
660//! independent tasks. Unlike parent-child relationships (which model
661//! fan-out from a single executor), chains connect separately submitted
662//! tasks into ordered workflows.
663//!
664//! ### Sequential chain
665//!
666//! Upload a file, verify its checksum, then delete the local copy:
667//!
668//! ```ignore
669//! let pipeline: DomainHandle<Pipeline> = scheduler.domain::<Pipeline>();
670//!
671//! let upload = pipeline.submit(UploadFile { key: "file-a".into() }).await?;
672//!
673//! let verify = pipeline.submit_with(VerifyChecksum { key: "file-a".into() })
674//!     .depends_on(upload.id().unwrap())
675//!     .await?;
676//!
677//! pipeline.submit_with(DeleteLocal { key: "file-a".into() })
678//!     .depends_on(verify.id().unwrap())
679//!     .await?;
680//! ```
681//!
682//! ### Fan-in
683//!
684//! Multiple uploads converging on a single finalize step:
685//!
686//! ```ignore
687//! let pipeline: DomainHandle<Pipeline> = scheduler.domain::<Pipeline>();
688//! let mut upload_ids = Vec::new();
689//! for part in &parts {
690//!     let outcome = pipeline.submit(part.clone()).await?;
691//!     upload_ids.push(outcome.id().unwrap());
692//! }
693//!
694//! pipeline.submit_with(FinalizeUpload { key: "finalize".into() })
695//!     .depends_on_all(upload_ids)
696//!     .await?;
697//! ```
698//!
699//! ### Diamond dependency
700//!
701//! Task A fans out to B and C, which both converge on D:
702//!
703//! ```ignore
704//! let pipeline: DomainHandle<Pipeline> = scheduler.domain::<Pipeline>();
705//!
706//! let a = pipeline.submit(Extract { key: "a".into() }).await?;
707//! let a_id = a.id().unwrap();
708//!
709//! let b = pipeline.submit_with(TransformX { key: "b".into() })
710//!     .depends_on(a_id)
711//!     .await?;
712//!
713//! let c = pipeline.submit_with(TransformY { key: "c".into() })
714//!     .depends_on(a_id)
715//!     .await?;
716//!
717//! pipeline.submit_with(Load { key: "d".into() })
718//!     .depends_on_all([b.id().unwrap(), c.id().unwrap()])
719//!     .await?;
720//! ```
721//!
722//! ## Task superseding
723//!
724//! Use [`DuplicateStrategy::Supersede`] for "latest-value-wins" scenarios
725//! like continuous file sync, where re-submitting an already-queued task
726//! should atomically cancel the old one and replace it. Set it via
727//! [`TypedTask::config()`]:
728//!
729//! ```ignore
730//! impl TypedTask for SyncFile {
731//!     type Domain = Sync;
732//!     const TASK_TYPE: &'static str = "sync-file";
733//!
734//!     fn config() -> TaskTypeConfig {
735//!         TaskTypeConfig::new().on_duplicate(DuplicateStrategy::Supersede)
736//!     }
737//!
738//!     fn key(&self) -> Option<String> { Some(self.path.clone()) }
739//! }
740//!
741//! let sync: DomainHandle<Sync> = scheduler.domain::<Sync>();
742//! let outcome = sync.submit(SyncFile { path: "path/to/file.txt".into() }).await?;
743//! // outcome is Superseded { new_task_id, replaced_task_id } if a duplicate existed,
744//! // or Inserted(id) if this was the first submission.
745//! ```
746//!
747//! # How the dispatch loop works
748//!
749//! Understanding the run loop helps when tuning [`SchedulerConfig`]:
750//!
751//! 1. The loop wakes on three conditions: a new task was submitted (via
752//!    [`Notify`](tokio::sync::Notify)), the
753//!    [`poll_interval`](SchedulerBuilder::poll_interval) elapsed (default
754//!    500ms), or the cancellation token fired.
755//! 2. Expired tasks are swept (if the sweep interval has elapsed).
756//! 3. Paused tasks are resumed if no active preemptors exist at their
757//!    priority level.
758//! 4. Pending finalizers (parents whose children all completed) are
759//!    dispatched first.
760//! 5. The highest-priority pending task is peeked (without claiming it).
761//!    If it has expired, it is moved to history and the next candidate is
762//!    tried.
763//! 6. The dispatch gate checks concurrency limits, group concurrency,
764//!    IO budget, and backpressure. If the gate rejects, no slot is consumed.
765//! 7. If admitted, the task is atomically claimed (`peek` → `pop_by_id`)
766//!    and spawned as a Tokio task.
767//! 8. Steps 5–7 repeat until the queue is empty or the gate rejects.
768//!
769//! # Feature flags
770//!
771//! - **`sysinfo-monitor`** (default): Enables the built-in [`SysinfoSampler`](resource::sysinfo_monitor::SysinfoSampler)
772//!   for cross-platform CPU and disk IO monitoring. Disable for mobile targets or
773//!   when providing a custom [`ResourceSampler`]. Without this feature, calling
774//!   [`SchedulerBuilder::with_resource_monitoring`] requires a custom sampler
775//!   via [`resource_sampler()`](SchedulerBuilder::resource_sampler).
776
777pub mod backpressure;
778pub mod domain;
779pub(crate) mod module;
780pub mod priority;
781pub mod registry;
782pub mod resource;
783pub mod scheduler;
784pub mod store;
785pub mod task;
786
787// ── Domain-centric API ───────────────────────────────────────────────
788pub use domain::{
789    Domain, DomainHandle, DomainKey, DomainSubmitBuilder, TaskEvent, TaskTypeConfig,
790    TaskTypeOptions, TypedEventStream, TypedExecutor,
791};
792pub use registry::{ChildSpawnBuilder, DomainTaskContext};
793
794// ── Core re-exports ──────────────────────────────────────────────────
795pub use backpressure::{CompositePressure, PressureSource, ThrottlePolicy};
796pub use priority::Priority;
797pub use resource::network_pressure::NetworkPressure;
798pub use resource::sampler::SamplerConfig;
799pub use resource::{ResourceReader, ResourceSampler, ResourceSnapshot};
800pub use scheduler::{
801    EstimatedProgress, GroupLimits, ProgressReporter, Scheduler, SchedulerBuilder, SchedulerConfig,
802    SchedulerEvent, SchedulerSnapshot, ShutdownMode, TaskEventHeader, TaskProgress,
803};
804pub use store::{RetentionPolicy, StoreConfig, StoreError, TaskStore};
805pub use task::{
806    generate_dedup_key, BackoffStrategy, BatchOutcome, BatchSubmission, DependencyFailurePolicy,
807    DuplicateStrategy, HistoryStatus, IoBudget, ParentResolution, RecurringSchedule,
808    RecurringScheduleInfo, RetryPolicy, SubmitOutcome, TaskError, TaskHistoryRecord, TaskLookup,
809    TaskRecord, TaskStatus, TaskSubmission, TtlFrom, TypeStats, TypedTask, MAX_TAGS_PER_TASK,
810    MAX_TAG_KEY_LEN, MAX_TAG_VALUE_LEN,
811};
812
813#[cfg(feature = "sysinfo-monitor")]
814pub use resource::platform_sampler;