diff --git a/crates/taskito-node/src/config.rs b/crates/taskito-node/src/config.rs index 2b315c31..053bae5d 100644 --- a/crates/taskito-node/src/config.rs +++ b/crates/taskito-node/src/config.rs @@ -1,6 +1,7 @@ //! Plain option objects passed from JavaScript. napi maps snake_case Rust //! fields to camelCase JS keys (`maxRetries`, `timeoutMs`). +use napi::bindgen_prelude::Buffer; use napi_derive::napi; /// How to open a queue's storage backend. @@ -37,10 +38,20 @@ pub struct EnqueueOptions { pub unique_key: Option, /// Free-form metadata string stored with the job. pub metadata: Option, + /// Structured notes — a pre-encoded canonical JSON object (the TS shell + /// validates the bounds and stringifies before passing it here). + pub notes: Option, /// Namespace override for this job. pub namespace: Option, } +/// One entry in a batch enqueue: an opaque payload plus its own options. +#[napi(object)] +pub struct EnqueueJob { + pub payload: Buffer, + pub options: Option, +} + /// Filter for [`crate::queue::JsQueue::list_jobs`]. All fields optional. #[napi(object)] #[derive(Default)] diff --git a/crates/taskito-node/src/convert/job.rs b/crates/taskito-node/src/convert/job.rs index 7879729f..4135bc85 100644 --- a/crates/taskito-node/src/convert/job.rs +++ b/crates/taskito-node/src/convert/job.rs @@ -45,7 +45,7 @@ pub fn build_new_job( timeout_ms, unique_key: opts.unique_key, metadata: opts.metadata, - notes: None, + notes: opts.notes, depends_on: Vec::new(), expires_at: None, result_ttl_ms: None, diff --git a/crates/taskito-node/src/convert/log.rs b/crates/taskito-node/src/convert/log.rs new file mode 100644 index 00000000..c1b95290 --- /dev/null +++ b/crates/taskito-node/src/convert/log.rs @@ -0,0 +1,29 @@ +//! Marshalling for task logs / published partial results. + +use napi_derive::napi; +use taskito_core::storage::models::TaskLogRow; + +/// JS-facing view of a task log entry. A published partial result is a log with +/// `level === "result"` and the value in `extra`. +#[napi(object)] +pub struct JsTaskLog { + pub id: String, + pub job_id: String, + pub task_name: String, + pub level: String, + pub message: String, + pub extra: Option, + pub logged_at: i64, +} + +pub fn log_to_js(row: TaskLogRow) -> JsTaskLog { + JsTaskLog { + id: row.id, + job_id: row.job_id, + task_name: row.task_name, + level: row.level, + message: row.message, + extra: row.extra, + logged_at: row.logged_at, + } +} diff --git a/crates/taskito-node/src/convert/mod.rs b/crates/taskito-node/src/convert/mod.rs index b3720d9b..69ae8e97 100644 --- a/crates/taskito-node/src/convert/mod.rs +++ b/crates/taskito-node/src/convert/mod.rs @@ -3,6 +3,7 @@ mod job; mod lock; +mod log; mod outcome; mod stats; mod task_config; @@ -11,6 +12,7 @@ mod workflow; pub use job::{build_new_job, job_to_js, JsJob, JsTaskInvocation}; pub use lock::{lock_info_to_js, JsLockInfo}; +pub use log::{log_to_js, JsTaskLog}; pub use outcome::{outcome_to_js, JsOutcome}; pub use stats::{ dead_job_to_js, job_error_to_js, metric_to_js, stats_to_js, status_code, worker_to_js, diff --git a/crates/taskito-node/src/queue/logs.rs b/crates/taskito-node/src/queue/logs.rs new file mode 100644 index 00000000..834f012a --- /dev/null +++ b/crates/taskito-node/src/queue/logs.rs @@ -0,0 +1,36 @@ +//! Task-log methods on `JsQueue`: the storage behind published partial results +//! (`currentJob().publish()`) and the stream consumer (`queue.stream()`). + +use napi::bindgen_prelude::Result; +use napi_derive::napi; +use taskito_core::Storage; + +use super::JsQueue; +use crate::convert::{log_to_js, JsTaskLog}; +use crate::error::to_napi_err; + +#[napi] +impl JsQueue { + /// Append a task-log entry for a job. A published partial result uses + /// `level = "result"` with the value JSON-encoded in `extra`. + #[napi] + pub fn write_task_log( + &self, + job_id: String, + task_name: String, + level: String, + message: String, + extra: Option, + ) -> Result<()> { + self.storage + .write_task_log(&job_id, &task_name, &level, &message, extra.as_deref()) + .map_err(to_napi_err) + } + + /// All task-log entries for a job, oldest first. + #[napi] + pub fn get_task_logs(&self, job_id: String) -> Result> { + let rows = self.storage.get_task_logs(&job_id).map_err(to_napi_err)?; + Ok(rows.into_iter().map(log_to_js).collect()) + } +} diff --git a/crates/taskito-node/src/queue/mod.rs b/crates/taskito-node/src/queue/mod.rs index 181b540d..7025e08b 100644 --- a/crates/taskito-node/src/queue/mod.rs +++ b/crates/taskito-node/src/queue/mod.rs @@ -5,7 +5,7 @@ use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction}; use napi_derive::napi; use taskito_core::{Storage, StorageBackend}; -use crate::config::{EnqueueOptions, OpenOptions, WorkerOptions}; +use crate::config::{EnqueueJob, EnqueueOptions, OpenOptions, WorkerOptions}; use crate::convert::{build_new_job, job_to_js, JsJob, JsOutcome, JsTaskInvocation}; use crate::error::to_napi_err; use crate::worker::{start_worker, JsWorker}; @@ -13,6 +13,7 @@ use crate::worker::{start_worker, JsWorker}; mod admin; mod inspect; mod locks; +mod logs; mod periodic; #[cfg(feature = "workflows")] mod workflows; @@ -65,6 +66,24 @@ impl JsQueue { Ok(job.id) } + /// Enqueue a batch of jobs for one `task_name` in a single storage call. + /// Each entry carries its own payload and options. Returns the new job ids + /// in input order. Unlike `enqueue`, the batch path does not apply + /// `uniqueKey` dedup — it is a plain bulk insert for throughput. + #[napi] + pub fn enqueue_many(&self, task_name: String, jobs: Vec) -> Result> { + let namespace = self.namespace.as_deref(); + let new_jobs = jobs + .into_iter() + .map(|job| { + let opts = job.options.unwrap_or_default(); + build_new_job(task_name.clone(), job.payload.to_vec(), opts, namespace) + }) + .collect::>>()?; + let created = self.storage.enqueue_batch(new_jobs).map_err(to_napi_err)?; + Ok(created.into_iter().map(|job| job.id).collect()) + } + /// Fetch a job by id, or `null` if no such job exists. #[napi] pub fn get_job(&self, id: String) -> Result> { diff --git a/crates/taskito-workflows/src/definition.rs b/crates/taskito-workflows/src/definition.rs index 77bbedf3..2339322a 100644 --- a/crates/taskito-workflows/src/definition.rs +++ b/crates/taskito-workflows/src/definition.rs @@ -37,6 +37,11 @@ pub struct StepMetadata { /// (in reverse-dependency order) by running this task with the node's result. #[serde(default, skip_serializing_if = "Option::is_none")] pub compensate: Option, + /// JSON `{ttlMs}` marking a cacheable node — its result is reused across runs + /// when its task, args, and upstream results are unchanged. Opaque to the + /// core; the shell's tracker reads it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache: Option, } /// A persisted workflow definition: the DAG structure plus per-step metadata. diff --git a/docs/content/docs/node/api-reference/context.mdx b/docs/content/docs/node/api-reference/context.mdx index 10c8adce..d9a8b870 100644 --- a/docs/content/docs/node/api-reference/context.mdx +++ b/docs/content/docs/node/api-reference/context.mdx @@ -19,6 +19,7 @@ Returns the running task's context, or `undefined` outside a task. Backed by | `jobId` | `string` | The running job's id. | | `signal` | `AbortSignal` | Aborted on cancel or timeout — pass to `fetch`/streams. | | `setProgress(n)` | `(0–100) => void` | Report progress for inspection / dashboard. | +| `publish(value)` | `(unknown) => void` | Emit a partial result for [`queue.stream`](/node/guides/core/streaming) (JSON-serializable). | ```ts queue.task("download", async (url: string) => { @@ -31,3 +32,15 @@ queue.task("download", async (url: string) => { See [Cancellation](/node/guides/core/cancellation) for the cooperative cancellation flow. + +## `useResource` + +```ts +import { useResource } from "taskito"; + +const db = await useResource("db"); // inside a running task +``` + +Resolves a [registered resource](/node/guides/resources/dependency-injection) by +name against the current job's scope. Also `AsyncLocalStorage`-backed, so it +works across `await` boundaries. Throws if called outside a task. diff --git a/docs/content/docs/node/api-reference/errors.mdx b/docs/content/docs/node/api-reference/errors.mdx new file mode 100644 index 00000000..e447af6c --- /dev/null +++ b/docs/content/docs/node/api-reference/errors.mdx @@ -0,0 +1,45 @@ +--- +title: Errors +description: "The Taskito error hierarchy — catch by specific type or by the TaskitoError base." +--- + +Every error the SDK throws extends `TaskitoError`, so a single `catch` can scope +all of them; the specific subclasses let you branch on what went wrong. + +```ts +import { TaskitoError, ResourceNotFoundError } from "taskito"; + +try { + await runJob(); +} catch (err) { + if (err instanceof ResourceNotFoundError) reRegister(err.resourceName); + else if (err instanceof TaskitoError) report(err); + else throw err; +} +``` + +## Hierarchy + +| Class | Extends | Thrown when | +|---|---|---| +| `TaskitoError` | `Error` | Base — never thrown directly. | +| `TaskNotRegisteredError` | `TaskitoError` | A worker dequeues a job whose task name isn't registered. | +| `JobFailedError` | `TaskitoError` | `result()` awaits a job that failed or dead-lettered. | +| `JobCancelledError` | `TaskitoError` | `result()` awaits a cancelled job. | +| `ResultTimeoutError` | `TaskitoError` | `result()` times out before the job settles. | +| `QueueError` | `TaskitoError` | Queue construction / operational error (e.g. no `dbPath`/`dsn`). | +| `LockNotAcquiredError` | `TaskitoError` | `withLock` can't acquire a held lock. | +| `SerializationError` | `TaskitoError` | (De)serialization or payload-integrity failure (e.g. a bad `SignedSerializer` signature). | +| `NotesValidationError` | `TaskitoError` | A `notes` object breaks the contract (>15 fields or >4 KiB). | +| `WorkflowError` | `TaskitoError` | Workflow definition, submission, or query error. | +| `PredicateRejectedError` | `TaskitoError` | An enqueue-time [gate](/node/guides/core/predicates) rejected the submission. | +| `ResourceError` | `TaskitoError` | Base for resource dependency-injection errors. | +| `ResourceNotFoundError` | `ResourceError` | Resolving a resource name that was never registered. | +| `ResourceScopeError` | `ResourceError` | Resolving a task-scoped resource at worker scope. | + +Selected errors carry context: `TaskNotRegisteredError.taskName`, +`JobFailedError.jobId`, `ResultTimeoutError.timeoutMs`, +`LockNotAcquiredError.lockName`, `ResourceNotFoundError.resourceName`. + +See [error handling](/node/guides/reliability/error-handling) for retry / timeout +/ dead-letter behavior around a failing task. diff --git a/docs/content/docs/node/api-reference/index.mdx b/docs/content/docs/node/api-reference/index.mdx index 4c07a1e0..5277b039 100644 --- a/docs/content/docs/node/api-reference/index.mdx +++ b/docs/content/docs/node/api-reference/index.mdx @@ -15,4 +15,5 @@ The public API exported from the `taskito` barrel. Contrib helpers live under | [Context](/node/api-reference/context) | `currentJob()` — signal, progress, ids. | | [Serializers](/node/api-reference/serializers) | `JsonSerializer`, `MsgpackSerializer`, the `Serializer` interface. | | [Workflows](/node/api-reference/workflows) | The workflow builder + run handle. | +| [Errors](/node/api-reference/errors) | The `TaskitoError` hierarchy. | | [CLI](/node/api-reference/cli) | The `taskito` command. | diff --git a/docs/content/docs/node/api-reference/meta.json b/docs/content/docs/node/api-reference/meta.json index 04724d64..a8692ac6 100644 --- a/docs/content/docs/node/api-reference/meta.json +++ b/docs/content/docs/node/api-reference/meta.json @@ -1 +1 @@ -{ "title": "API Reference", "root": true, "pages": ["index", "queue", "task", "worker", "result", "context", "serializers", "workflows", "cli"] } +{ "title": "API Reference", "root": true, "pages": ["index", "queue", "task", "worker", "result", "context", "serializers", "workflows", "errors", "cli"] } diff --git a/docs/content/docs/node/api-reference/queue.mdx b/docs/content/docs/node/api-reference/queue.mdx index dc950ffe..9bb7c1ff 100644 --- a/docs/content/docs/node/api-reference/queue.mdx +++ b/docs/content/docs/node/api-reference/queue.mdx @@ -16,8 +16,10 @@ const queue = new Queue(options); | `dbPath` | `string` | SQLite file path (default backend). | | `backend` | `"sqlite" \| "postgres" \| "redis"` | Storage backend. | | `dsn` | `string` | Connection string for postgres/redis. | +| `poolSize` | `number` | Connection pool size (sqlite/postgres). | | `schema` | `string` | Postgres schema (default `taskito`). | | `prefix` | `string` | Redis key prefix. | +| `namespace` | `string` | Namespace applied to enqueued jobs + the worker scheduler. | | `serializer` | `Serializer` | Arg/result serializer (default `JsonSerializer`). | ## Tasks & enqueue @@ -27,13 +29,15 @@ const queue = new Queue(options); | `task(name, fn, config?)` | Register a task. See [Task](/node/api-reference/task). | | `configureQueue(name, config)` | Set per-queue rate limit / concurrency. | | `enqueue(task, args?, options?)` | Enqueue a job → job id. | -| `enqueueMany(task, jobs[])` | Enqueue a batch. | +| `enqueueMany(task, jobs[])` | Batch-enqueue `{ args?, options? }[]` → job ids (no dedup). | ## Results & cancellation | Method | Description | |---|---| | `result(id, { timeoutMs? })` | Await a job's result. | +| `stream(id, { timeoutMs?, pollMs? })` | Async-iterate a job's [published partials](/node/guides/core/streaming). | +| `taskLogs(id)` | Raw task-log entries for a job. | | `requestCancel(id)` | Abort a running job's signal. | | `cancelJob(id)` | Cancel a pending job. | @@ -56,7 +60,8 @@ const queue = new Queue(options); |---|---| | `runWorker(options?)` | Start a [worker](/node/api-reference/worker). | | `on(event, handler)` | [Events](/node/guides/extensibility/events). | -| `use(middleware)` | [Middleware](/node/guides/extensibility/middleware). | +| `use(middleware)` | [Middleware](/node/guides/extensibility/middleware) (incl. the `onEnqueue` [interception](/node/guides/resources/interception) hook). | +| `resource(name, factory, opts?)` | Register an injectable [resource](/node/guides/resources/dependency-injection) (`scope`, `dispose`). | | `webhooks` | [Webhooks](/node/guides/extensibility/webhooks) manager. | | `workflows` | [Workflow](/node/api-reference/workflows) builder + queries. | | `lock(name, opts)` / `withLock(name, fn)` | [Distributed locks](/node/guides/reliability/locks). | diff --git a/docs/content/docs/node/api-reference/result.mdx b/docs/content/docs/node/api-reference/result.mdx index fa42fa9a..dfd939a1 100644 --- a/docs/content/docs/node/api-reference/result.mdx +++ b/docs/content/docs/node/api-reference/result.mdx @@ -4,11 +4,15 @@ description: "Awaiting job results." --- ```ts -const value = await queue.result(id: string, options?: { timeoutMs?: number }): Promise; +const value = await queue.result( + id: string, + options?: { timeoutMs?: number; pollMs?: number }, +): Promise; ``` Resolves with the task's return value once the job finishes. Rejects if the job -dead-letters or the optional `timeoutMs` elapses before completion. +dead-letters or the optional `timeoutMs` (default 30000) elapses before +completion. `pollMs` (default 50) sets the store poll interval. ```ts const id = queue.enqueue("add", [2, 3]); @@ -21,6 +25,7 @@ The result is deserialized with the queue's can await a result by id — the waiter polls the store. - `result` is for awaiting one job. To scan many jobs or read outcomes in bulk, - use [`listJobs` / `getMetrics`](/node/guides/operations/inspection). + `result` is for awaiting one job. To scan many jobs use + [`listJobs`](/node/guides/operations/inspection); for aggregate outcomes use + [`getMetrics`](/node/guides/observability/monitoring). diff --git a/docs/content/docs/node/api-reference/serializers.mdx b/docs/content/docs/node/api-reference/serializers.mdx index f9eec93e..f347f10b 100644 --- a/docs/content/docs/node/api-reference/serializers.mdx +++ b/docs/content/docs/node/api-reference/serializers.mdx @@ -14,17 +14,32 @@ import type { Serializer } from "taskito"; |---|---| | `JsonSerializer` | Default. Human-readable JSON bytes. | | `MsgpackSerializer` | Compact binary (msgpack). | +| `SignedSerializer` | Wraps another codec with an HMAC-SHA256 tag; rejects tampered payloads. | +| `EncryptedSerializer` | Wraps another codec with AES-256-GCM; encrypts + authenticates. | ```ts new Queue({ dbPath: "taskito.db", serializer: new MsgpackSerializer() }); ``` +`SignedSerializer(secret, inner?)` prefixes each payload with a 32-byte HMAC tag +and verifies it (timing-safe) on read — authentication, not encryption. +`EncryptedSerializer(secret, inner?)` goes further: AES-256-GCM gives +confidentiality **and** integrity. Both derive their key from `secret`, so +producers and workers must share it. + +```ts +new Queue({ + dbPath: "taskito.db", + serializer: new EncryptedSerializer(process.env.TASKITO_SECRET!), +}); +``` + ## `Serializer` interface ```ts interface Serializer { - dumps(value: unknown): Uint8Array | Buffer; - loads(bytes: Uint8Array): unknown; + serialize(value: unknown): Uint8Array; + deserialize(bytes: Uint8Array): unknown; } ``` diff --git a/docs/content/docs/node/api-reference/task.mdx b/docs/content/docs/node/api-reference/task.mdx index 87971e99..582351b0 100644 --- a/docs/content/docs/node/api-reference/task.mdx +++ b/docs/content/docs/node/api-reference/task.mdx @@ -18,8 +18,9 @@ result. Re-registering a name replaces the function and config. | `retryBackoff` | `{ baseMs, maxMs }` | Exponential backoff bounds. | | `timeoutMs` | `number` | Per-attempt timeout. | | `maxConcurrent` | `number` | Max simultaneous running jobs of this task. | -| `rateLimit` | `{ limit, windowMs }` | Token-bucket rate limit. | +| `rateLimit` | `` `"100/m"` `` | Rate limit as `count/unit`, unit `s` \| `m` \| `h`. | | `circuitBreaker` | `{ threshold, windowMs, cooldownMs }` | Trip after repeated failures. | +| `inject` | `string[]` | Resource names injected as a trailing `deps` argument. See [Dependency injection](/node/guides/resources/dependency-injection). | ```ts queue.task("add", (a: number, b: number) => a + b, { diff --git a/docs/content/docs/node/api-reference/worker.mdx b/docs/content/docs/node/api-reference/worker.mdx index ad5c5e00..f25d0ca9 100644 --- a/docs/content/docs/node/api-reference/worker.mdx +++ b/docs/content/docs/node/api-reference/worker.mdx @@ -4,20 +4,22 @@ description: "runWorker options and the worker handle." --- ```ts -const worker = queue.runWorker(options?: WorkerOptions): Worker; +const worker = queue.runWorker(options?: WorkerRunOptions): Worker; ``` Starts a background worker over the Rust core and returns a handle. -## `WorkerOptions` +## `WorkerRunOptions` | Option | Type | Default | Description | |---|---|---|---| | `queues` | `string[]` | `["default"]` | Queue names to serve. | +| `channelCapacity` | `number` | `128` | In-flight dispatch channel capacity. | +| `batchSize` | `number` | `1` | Jobs claimed per scheduler poll. | | `advanceWorkflows` | `boolean` | `true` | Drive workflow bookkeeping. | -| `mesh` | `MeshOptions` | — | Join a [mesh](/node/guides/operations/mesh). | +| `mesh` | `MeshWorkerConfig` | — | Join a [mesh](/node/guides/operations/mesh). | -## `MeshOptions` +## `MeshWorkerConfig` `port`, `seeds`, `steal`, `encryptionKey`, `bindAddr`, `advertiseAddr`, `affinityWeight`, `localBuffer`, `stealBatch`, `stealThreshold`, `virtualNodes`, diff --git a/docs/content/docs/node/api-reference/workflows.mdx b/docs/content/docs/node/api-reference/workflows.mdx index c0758260..d4f2d123 100644 --- a/docs/content/docs/node/api-reference/workflows.mdx +++ b/docs/content/docs/node/api-reference/workflows.mdx @@ -14,14 +14,19 @@ queue.workflows | Method | Description | |---|---| -| `step(name, task, opts?)` | A task node. `opts`: `after`, `args`, `queue`, `maxRetries`, `timeoutMs`, `priority`, `condition`, `compensate`. | +| `step(name, task, opts?)` | A task node. `opts`: `after`, `args`, `queue`, `maxRetries`, `timeoutMs`, `priority`, `condition`, `compensate`, `cache`. | | `fanOut(name, { after, task, itemsFrom })` | Expand into one child per item. | | `fanIn(name, { after, task })` | Collect children's results into an array. | | `gate(name, { after, timeoutMs?, onTimeout? })` | Pause for [approval](/node/guides/workflows/gates). | | `subWorkflow(name, { after, workflow })` | Run a child workflow as a node. | +| `chain(steps[], { after? })` | Canvas: wire `steps` sequentially. | +| `group(steps[], { after? })` | Canvas: run `steps` in parallel. | +| `chord(steps[], callback, { after? })` | Canvas: a parallel group joined by `callback`. | | `submit()` | Pre-enqueue the DAG → `WorkflowHandle`. | | `build()` | Build (don't submit) — for use as a sub-workflow. | +Canvas `steps` are `{ name, task, ...stepOptions }` (`after` is managed by the helper). + `condition`: `"on_success"` (default) · `"on_failure"` · `"always"`. ## `WorkflowHandle` @@ -29,6 +34,7 @@ queue.workflows | Member | Description | |---|---| | `runId` | The run's id. | +| `status()` | The current `WorkflowRun` snapshot, or `undefined`. | | `wait()` | Resolves with the `WorkflowRun` when terminal. | | `nodes()` | Per-step status (fan-out parents carry `fanOutCount`). | @@ -36,9 +42,31 @@ queue.workflows ```ts queue.workflows.list({ state: "running" }); -queue.workflows.children(runId); // spawned sub-workflow runs +queue.workflows.run(runId); // a single run snapshot +queue.workflows.nodes(runId); // per-step status +queue.workflows.dag(runId); // the DAG graph (nodes + edges) +queue.workflows.children(runId); // spawned sub-workflow runs queue.workflows.approveGate(runId, "review"); queue.workflows.rejectGate(runId, "review", reason); +queue.workflows.resolveGate(runId, "review", approved); +queue.workflows.clearCache(); // drop all cached step results +``` + +## Analysis + +`analyze(runId)` returns a `WorkflowAnalysis` over the run's DAG + node statuses +(or `undefined` if the run is unknown), for graph introspection: + +```ts +const a = queue.workflows.analyze(runId); +a?.roots(); // entry nodes +a?.leaves(); // exit nodes +a?.ancestors("load"); // transitive upstream deps +a?.descendants("extract"); // transitive downstream +a?.topologicalOrder(); // a valid run order (throws on a cycle) +a?.topologicalLevels(); // nodes grouped by dependency depth +a?.criticalPath(); // longest dependency chain (root → leaf) +a?.stats(); // { total, byStatus, completed, failed, running, pending } ``` See the [Workflows guides](/node/guides/workflows) for fan-out, conditions, diff --git a/docs/content/docs/node/getting-started/capabilities.mdx b/docs/content/docs/node/getting-started/capabilities.mdx new file mode 100644 index 00000000..a25793d4 --- /dev/null +++ b/docs/content/docs/node/getting-started/capabilities.mdx @@ -0,0 +1,68 @@ +--- +title: Capabilities +description: "Everything the Node SDK can do, with a link to each guide." +--- + +A map of the Node SDK's features. Everything here runs over the shared Rust core +with zero Python — pick a backend, register tasks, run workers. + +## Core + +| Feature | Guide | +|---|---| +| Typed task registry, enqueue, await results | [Tasks](/node/guides/core/tasks) · [Result](/node/api-reference/result) | +| SQLite / Postgres / Redis backends | [Backends](/node/guides/operations/backends) | +| Named queues, priority, pause/resume | [Queues](/node/guides/core/queues) | +| Cron-scheduled & delayed jobs | [Scheduling](/node/guides/core/scheduling) | +| Cooperative cancellation (`AbortSignal`) | [Cancellation](/node/guides/core/cancellation) | +| Streaming partial results (`publish` / `stream`) | [Streaming](/node/guides/core/streaming) | +| Execution model (async, concurrency) | [Execution model](/node/guides/core/execution-model) | + +## Reliability + +| Feature | Guide | +|---|---| +| Retries with backoff, dead-letter queue | [Retries](/node/guides/reliability/retries) · [Dead-letter](/node/guides/reliability/dead-letter) | +| Per-attempt timeouts | [Timeouts](/node/guides/reliability/timeouts) | +| Concurrency caps & rate limits | [Concurrency](/node/guides/reliability/concurrency) · [Rate limiting](/node/guides/reliability/rate-limiting) | +| Circuit breakers | [Circuit breakers](/node/guides/reliability/circuit-breakers) | +| Idempotent enqueue (`uniqueKey`) | [Idempotency](/node/guides/reliability/idempotency) | +| Distributed locks | [Locks](/node/guides/reliability/locks) | +| At-least-once delivery | [Guarantees](/node/guides/reliability/guarantees) | + +## Orchestration + +| Feature | Guide | +|---|---| +| DAG workflows: fan-out/in, conditions, gates, sub-workflows, saga | [Workflows](/node/guides/workflows) | +| Decentralized work-stealing mesh | [Mesh](/node/guides/operations/mesh) | + +## Extensibility + +| Feature | Guide | +|---|---| +| Lifecycle events & middleware | [Events](/node/guides/extensibility/events) · [Middleware](/node/guides/extensibility/middleware) | +| Signed webhook delivery | [Webhooks](/node/guides/extensibility/webhooks) | +| Resource injection & enqueue interception | [Resources](/node/guides/resources) | +| Pluggable serializers (JSON / msgpack / signed / encrypted) | [Serializers](/node/guides/extensibility/serializers) | + +## Observability & ops + +| Feature | Guide | +|---|---| +| Stats, metrics, progress, worker heartbeats | [Monitoring](/node/guides/observability/monitoring) | +| Leveled logger | [Logging](/node/guides/observability/logging) | +| OpenTelemetry / Prometheus / Sentry | [Integrations](/node/guides/integrations) | +| React dashboard + REST API | [Dashboard](/node/guides/operations/dashboard) · [REST API](/node/guides/operations/dashboard-api) | +| KEDA autoscaling (queue-depth scaler) | [KEDA](/node/guides/operations/keda) | +| Standalone CLI | [CLI](/node/guides/operations/cli) | + +## Framework integrations + +[Express](/node/guides/integrations/express) · [Fastify](/node/guides/integrations/fastify) · [NestJS](/node/guides/integrations/nest) + + + Some features need the native addon built with optional cargo features — + workflows and mesh ship with `pnpm build:native`. See + [installation](/node/getting-started/installation). + diff --git a/docs/content/docs/node/getting-started/meta.json b/docs/content/docs/node/getting-started/meta.json index 35f890a4..c18d0a61 100644 --- a/docs/content/docs/node/getting-started/meta.json +++ b/docs/content/docs/node/getting-started/meta.json @@ -1 +1 @@ -{ "title": "Getting Started", "root": true, "pages": ["installation", "quickstart", "concepts"] } +{ "title": "Getting Started", "root": true, "pages": ["installation", "quickstart", "concepts", "capabilities"] } diff --git a/docs/content/docs/node/guides/core/enqueue-options.mdx b/docs/content/docs/node/guides/core/enqueue-options.mdx index 16e811e2..7f88ee50 100644 --- a/docs/content/docs/node/guides/core/enqueue-options.mdx +++ b/docs/content/docs/node/guides/core/enqueue-options.mdx @@ -10,7 +10,8 @@ queue.enqueue("send_email", [user], { priority: 5, delayMs: 30_000, uniqueKey: `welcome:${user.id}`, - metadata: { source: "signup" }, + metadata: "source=signup", + notes: { campaign: "welcome", attempt: 1 }, namespace: "emails", }); ``` @@ -22,7 +23,8 @@ queue.enqueue("send_email", [user], { | `timeoutMs` | Override the per-attempt timeout for this job. | | `delayMs` | Delay first execution by this many ms (scheduled run). | | `uniqueKey` | Idempotency key — a duplicate enqueue is a no-op while the first job is pending/running. | -| `metadata` | Arbitrary JSON attached to the job (surfaces on the dashboard / inspection). | +| `metadata` | Free-form string stored with the job (surfaces on the dashboard / inspection). | +| `notes` | Structured annotations — a JSON object, ≤15 fields and ≤4 KiB encoded. | | `namespace` | The queue name to route to. | ## Idempotency @@ -36,10 +38,27 @@ while an earlier one is still pending or running. See `delayMs` schedules the first attempt in the future; the scheduler picks it up when due. For recurring schedules use [periodic tasks](/node/guides/core/scheduling). +## Structured notes + +`notes` attaches a small, structured object to the job — distinct from the +free-form `metadata` string. It is validated (≤15 top-level fields, ≤4 KiB +encoded) and rendered on the dashboard. It survives dead-letter and replay. + +```ts +queue.enqueue("import", [fileId], { notes: { rows: 1200, source: "s3" } }); +``` + ## Batch enqueue -`enqueueMany` enqueues an array in one call: +`enqueueMany` inserts many jobs of one task in a single storage round-trip. Each +entry has its own `args` and `options`; it returns the new ids in order. ```ts -queue.enqueueMany("resize", urls.map((url) => ({ args: [url] }))); +const ids = queue.enqueueMany("resize", [ + { args: ["a.png"], options: { priority: 5 } }, + { args: ["b.png"] }, +]); ``` + +Unlike `enqueue`, the batch path does not apply `uniqueKey` dedup — it is a plain +bulk insert for throughput. diff --git a/docs/content/docs/node/guides/core/execution-model.mdx b/docs/content/docs/node/guides/core/execution-model.mdx new file mode 100644 index 00000000..713e8a51 --- /dev/null +++ b/docs/content/docs/node/guides/core/execution-model.mdx @@ -0,0 +1,52 @@ +--- +title: Execution model +description: "How the Node worker runs jobs — the event loop, concurrency, and CPU-bound work." +--- + +The Rust core owns scheduling — it claims due jobs from storage and dispatches +them. Execution happens back in **your Node process**: the core hands each job to +a JavaScript callback and awaits the returned promise. Handlers run on the +single Node event loop, just like the rest of your app. + +## Concurrency + +Jobs run **concurrently**, not one at a time. Each dispatched job is an +independent async invocation, so while one handler awaits I/O another can run. +In-flight work is bounded by: + +- `channelCapacity` (default 128) — the worker's in-flight dispatch buffer. +- per-task `maxConcurrent` and queue concurrency — explicit + [caps](/node/guides/reliability/concurrency). +- per-task / per-queue [rate limits](/node/guides/reliability/rate-limiting). + +`batchSize` (default 1) controls how many jobs the scheduler claims per poll — +raise it to amortize polling under high throughput. + +```ts +queue.runWorker({ queues: ["default"], channelCapacity: 256, batchSize: 16 }); +``` + +There is no GIL, thread-pool sizing, or prefork choice to make — concurrency is +ordinary event-loop async. + +## CPU-bound work + +Because handlers share the event loop, a long synchronous computation blocks +*every* in-flight job. For CPU-heavy tasks: + +- Offload to a [`worker_threads`](https://nodejs.org/api/worker_threads.html) + pool or a native addon, and `await` it. +- Or run several worker **processes** against the same storage — the core hands + each job to exactly one of them. + +## Scaling out + +Horizontal scaling is just more worker processes (or machines) pointed at shared +[Postgres or Redis](/node/guides/operations/backends) storage. The core claims +each job for a single worker, so adding workers adds throughput without +duplicate execution — see [guarantees](/node/guides/reliability/guarantees). + + + Async handlers are first-class: return a promise and the worker awaits it. A + sync handler works too — it just can't yield the loop, so keep it short. + diff --git a/docs/content/docs/node/guides/core/meta.json b/docs/content/docs/node/guides/core/meta.json index d2af37ad..13277466 100644 --- a/docs/content/docs/node/guides/core/meta.json +++ b/docs/content/docs/node/guides/core/meta.json @@ -1 +1 @@ -{ "title": "Core", "pages": ["tasks", "queues", "workers", "enqueue-options", "scheduling", "cancellation"] } +{ "title": "Core", "pages": ["tasks", "queues", "workers", "execution-model", "enqueue-options", "predicates", "scheduling", "cancellation", "streaming"] } diff --git a/docs/content/docs/node/guides/core/predicates.mdx b/docs/content/docs/node/guides/core/predicates.mdx new file mode 100644 index 00000000..b93beaf9 --- /dev/null +++ b/docs/content/docs/node/guides/core/predicates.mdx @@ -0,0 +1,59 @@ +--- +title: Predicate gates +description: "Gate enqueues with composable predicates evaluated at submit time." +--- + +A gate is a predicate evaluated when a job is enqueued. If it returns `false`, +the enqueue is rejected with a `PredicateRejectedError` and no job is created — +a declarative way to say "only enqueue this under condition X". + +```ts +queue.task("charge", (amount: number) => settle(amount)); + +queue.gate("charge", ({ args }) => args[0] > 0); // never enqueue a non-positive charge + +queue.enqueue("charge", [50]); // ok +queue.enqueue("charge", [-1]); // throws PredicateRejectedError +``` + +The predicate receives the task name and the (already +[`onEnqueue`](/node/guides/resources/interception)-rewritten) `args`, typed to the +task's signature. + +## Multiple gates + +Register more than one — they all must pass: + +```ts +queue.gate("charge", ({ args }) => args[0] > 0); +queue.gate("charge", ({ args }) => args[0] <= 10_000); +``` + +## Composition + +Combine predicates with `allOf`, `anyOf`, and `not`: + +```ts +import { allOf, anyOf, not } from "taskito"; + +const businessHours = () => { + const h = new Date().getHours(); + return h >= 9 && h < 17; +}; +const highPriority = ({ args }) => args[0].priority === "high"; + +queue.gate("dispatch", anyOf(businessHours, highPriority)); +queue.gate("dispatch", not(isHoliday)); +``` + +## Batches + +`enqueueMany` gates each entry; a single rejection throws (no jobs are enqueued). +Pre-filter the batch if you'd rather drop rejected entries silently. + + + Gates run on the **producer** side, so they decide whether work is created at + all. To react once a job is already running, use + [middleware](/node/guides/extensibility/middleware) or + [conditions](/node/guides/workflows/conditions) inside a workflow. + diff --git a/docs/content/docs/node/guides/core/queues.mdx b/docs/content/docs/node/guides/core/queues.mdx index f177026f..389e13ce 100644 --- a/docs/content/docs/node/guides/core/queues.mdx +++ b/docs/content/docs/node/guides/core/queues.mdx @@ -35,7 +35,7 @@ queue.resumeQueue("emails"); ```ts queue.configureQueue("emails", { - rateLimit: { limit: 50, windowMs: 1000 }, + rateLimit: "50/s", maxConcurrent: 10, }); ``` diff --git a/docs/content/docs/node/guides/core/streaming.mdx b/docs/content/docs/node/guides/core/streaming.mdx new file mode 100644 index 00000000..7f7b42ac --- /dev/null +++ b/docs/content/docs/node/guides/core/streaming.mdx @@ -0,0 +1,56 @@ +--- +title: Streaming partial results +description: "Publish intermediate values from a task and consume them live." +--- + +A long-running task can publish intermediate results as it works; a consumer +streams them as they arrive — useful for ETL, ML training steps, or batch +progress. + +## Publish + +Call `currentJob().publish(value)` from inside the task. The value must be +JSON-serializable. + +```ts +import { currentJob } from "taskito"; + +queue.task("train", async (epochs: number) => { + const job = currentJob(); + for (let epoch = 1; epoch <= epochs; epoch++) { + const loss = await trainEpoch(epoch); + job?.publish({ epoch, loss }); + } + return "trained"; +}); +``` + +## Consume + +`queue.stream(jobId)` is an async iterator that yields each published value in +order, then ends when the job reaches a terminal state. + +```ts +const id = queue.enqueue("train", [10]); + +for await (const update of queue.stream(id)) { + console.log(`epoch ${update.epoch}: loss ${update.loss}`); +} + +const result = await queue.result(id); // "trained" +``` + +`stream(jobId, { timeoutMs, pollMs })` bounds the wait (default 60 s) and poll +interval (default 200 ms). It works from any process sharing the storage, and a +late consumer still drains every value already published. + +## Raw logs + +`queue.taskLogs(jobId)` returns the raw log entries (published partials have +`level: "result"`). + + + Partials are stored as task-log rows, so they survive until the log is purged. + Pipe a stream straight to an SSE / WebSocket endpoint to push progress to a + browser. + diff --git a/docs/content/docs/node/guides/core/tasks.mdx b/docs/content/docs/node/guides/core/tasks.mdx index bc921cf0..64b75b04 100644 --- a/docs/content/docs/node/guides/core/tasks.mdx +++ b/docs/content/docs/node/guides/core/tasks.mdx @@ -23,7 +23,7 @@ queue.task("add", (a: number, b: number) => a + b, { retryBackoff: { baseMs: 1000, maxMs: 60_000 }, timeoutMs: 30_000, maxConcurrent: 4, - rateLimit: { limit: 100, windowMs: 60_000 }, + rateLimit: "100/m", circuitBreaker: { threshold: 5, windowMs: 60_000, cooldownMs: 30_000 }, }); ``` @@ -34,7 +34,7 @@ queue.task("add", (a: number, b: number) => a + b, { | `retryBackoff` | Exponential backoff bounds (`baseMs`, `maxMs`). | | `timeoutMs` | Per-attempt [timeout](/node/guides/reliability/timeouts). | | `maxConcurrent` | Max simultaneously-running jobs of this task ([concurrency](/node/guides/reliability/concurrency)). | -| `rateLimit` | Token-bucket [rate limit](/node/guides/reliability/rate-limiting). | +| `rateLimit` | [Rate limit](/node/guides/reliability/rate-limiting) string, `count/unit` (e.g. `"100/m"`). | | `circuitBreaker` | Trip after repeated failures ([circuit breakers](/node/guides/reliability/circuit-breakers)). | @@ -47,5 +47,5 @@ queue.task("add", (a: number, b: number) => a + b, { Beyond per-task config, you can set defaults for an entire named queue: ```ts -queue.configureQueue("emails", { rateLimit: { limit: 50, windowMs: 1000 } }); +queue.configureQueue("emails", { rateLimit: "50/s" }); ``` diff --git a/docs/content/docs/node/guides/extensibility/middleware.mdx b/docs/content/docs/node/guides/extensibility/middleware.mdx index 87020f14..f0c59278 100644 --- a/docs/content/docs/node/guides/extensibility/middleware.mdx +++ b/docs/content/docs/node/guides/extensibility/middleware.mdx @@ -21,16 +21,22 @@ queue.use({ | Hook | When | Awaited | |---|---|---| +| `onEnqueue(ctx)` | Producer-side, inside `enqueue`, before serialization. | no (sync) | | `before(ctx)` | Before each execution attempt. | yes | | `after(ctx, result)` | After a successful attempt. | yes | -| `onError(ctx, err)` | When an attempt throws (before retry/dead decision). | yes | +| `onError(ctx, err)` | When an attempt throws (before the retry/dead decision). | yes | +| `onCompleted(e)` | After a job completes successfully. | no | | `onRetry(e)` | After the core schedules a retry. | no | | `onDeadLetter(e)` | After a job dead-letters. | no | -| `onTimeout(e)` | After an attempt times out. | no | +| `onCancel(e)` | After a job is cancelled. | no | -`ctx` carries `taskName`, `jobId`, `queue`, and `retryCount`. Multiple -middlewares run in registration order; `before` outermost-first, `after` -in reverse. +The execution hooks (`before`/`after`/`onError`) receive a context with +`taskName`, `jobId`, and `args`. The outcome hooks receive an event with +`taskName`, `jobId`, `queue`, `retryCount`, and `timedOut` (which separates a +timeout from other failures). `onEnqueue` receives a *mutable* enqueue context — +see [Enqueue interception](/node/guides/resources/interception). Multiple +middlewares run in registration order; `before` outermost-first, `after` in +reverse. Middleware wraps execution and can do async work in the hot path — keep it diff --git a/docs/content/docs/node/guides/extensibility/serializers.mdx b/docs/content/docs/node/guides/extensibility/serializers.mdx index f69600d8..e6dc2a95 100644 --- a/docs/content/docs/node/guides/extensibility/serializers.mdx +++ b/docs/content/docs/node/guides/extensibility/serializers.mdx @@ -9,6 +9,10 @@ concern. - `JsonSerializer` — default; human-readable, debuggable. - `MsgpackSerializer` — compact binary, smaller payloads. +- `SignedSerializer(secret, inner?)` — wraps another codec with an HMAC-SHA256 + tag and rejects tampered payloads on read (authentication, not encryption). +- `EncryptedSerializer(secret, inner?)` — wraps another codec with AES-256-GCM + for confidentiality **and** integrity. ```ts import { Queue, MsgpackSerializer } from "taskito"; @@ -18,14 +22,14 @@ new Queue({ dbPath: "taskito.db", serializer: new MsgpackSerializer() }); ## Custom serializer -Implement the `Serializer` interface — `dumps` to bytes, `loads` back: +Implement the `Serializer` interface — `serialize` to bytes, `deserialize` back: ```ts import type { Serializer } from "taskito"; const mySerializer: Serializer = { - dumps: (value) => Buffer.from(JSON.stringify(value)), - loads: (bytes) => JSON.parse(Buffer.from(bytes).toString()), + serialize: (value) => Buffer.from(JSON.stringify(value)), + deserialize: (bytes) => JSON.parse(Buffer.from(bytes).toString()), }; new Queue({ dbPath: "taskito.db", serializer: mySerializer }); diff --git a/docs/content/docs/node/guides/meta.json b/docs/content/docs/node/guides/meta.json index 84548fd0..d884b213 100644 --- a/docs/content/docs/node/guides/meta.json +++ b/docs/content/docs/node/guides/meta.json @@ -1 +1 @@ -{ "title": "Guides", "root": true, "pages": ["core", "reliability", "workflows", "extensibility", "integrations", "operations"] } +{ "title": "Guides", "root": true, "pages": ["core", "reliability", "workflows", "extensibility", "resources", "integrations", "operations", "observability"] } diff --git a/docs/content/docs/node/guides/observability/index.mdx b/docs/content/docs/node/guides/observability/index.mdx new file mode 100644 index 00000000..2c342e77 --- /dev/null +++ b/docs/content/docs/node/guides/observability/index.mdx @@ -0,0 +1,19 @@ +--- +title: Observability +description: "Monitor queue health and emit structured logs from the Node SDK." +--- + +See what the queue is doing — counts, per-task latency, job progress, live +workers — and emit structured logs, all from the `Queue` handle with no extra +service. + +| Page | What it covers | +|---|---| +| [Monitoring](/node/guides/observability/monitoring) | Stats, per-task metrics, progress, worker heartbeats, Prometheus/OTel/Sentry pointers | +| [Logging](/node/guides/observability/logging) | The built-in leveled logger — levels, namespaces, pluggable sink | + + + Lifecycle [events](/node/guides/extensibility/events) and + [middleware](/node/guides/extensibility/middleware) are the extension points + for pushing these signals to any external system. + diff --git a/docs/content/docs/node/guides/observability/logging.mdx b/docs/content/docs/node/guides/observability/logging.mdx new file mode 100644 index 00000000..6b28356c --- /dev/null +++ b/docs/content/docs/node/guides/observability/logging.mdx @@ -0,0 +1,72 @@ +--- +title: Logging +description: "The built-in leveled logger — levels, namespaces, and a pluggable sink." +--- + +The SDK ships a tiny zero-dependency leveled logger. It writes to **stderr** by +default, so it never pollutes stdout (the CLI's `--json` output and piped data +stay clean). Taskito uses it internally; it is exported for your own use too. + +```ts +import { createLogger } from "taskito"; + +const log = createLogger("billing"); // tagged [taskito:billing] + +queue.task("charge", async (amount: number) => { + log.info("charging", { amount }); + // ... +}); +``` + +## Levels + +`debug < info < warn < error < silent`. The threshold defaults to `warn` and is +read once from `TASKITO_LOG_LEVEL`; override it at runtime: + +```ts +import { setLogLevel } from "taskito"; + +setLogLevel("debug"); // global, immediate +``` + +A message can be a string or a **thunk** that is only evaluated when the level +passes the threshold — so expensive log lines cost nothing when filtered out: + +```ts +log.debug(() => `payload=${JSON.stringify(bigObject)}`); +``` + +Extra arguments are appended: `Error`s render their stack, objects are +JSON-stringified. + +```ts +log.error("delivery failed", err, { webhookId }); +``` + +## Namespaces + +`createLogger(ns)` tags every line `[taskito:ns]`; `child()` nests further: + +```ts +const log = createLogger("worker"); // [taskito:worker] +const poolLog = log.child("pool"); // [taskito:worker:pool] +``` + +## Custom sink + +Replace the output sink to route logs to a file, JSON transport, or a test +buffer — globally and immediately: + +```ts +import { setLogSink } from "taskito"; + +setLogSink((level, line) => myTransport.write({ level, line })); +``` + + + This is a process-level logger, not per-job structured logging — it is not + attached to a job or queryable from the dashboard. For per-job signal, use + [progress](/node/guides/observability/monitoring), job + [metadata](/node/guides/core/enqueue-options), or + [events](/node/guides/extensibility/events). + diff --git a/docs/content/docs/node/guides/observability/meta.json b/docs/content/docs/node/guides/observability/meta.json new file mode 100644 index 00000000..c6d9beae --- /dev/null +++ b/docs/content/docs/node/guides/observability/meta.json @@ -0,0 +1 @@ +{ "title": "Observability", "pages": ["monitoring", "logging"] } diff --git a/docs/content/docs/node/guides/observability/monitoring.mdx b/docs/content/docs/node/guides/observability/monitoring.mdx new file mode 100644 index 00000000..044fcf44 --- /dev/null +++ b/docs/content/docs/node/guides/observability/monitoring.mdx @@ -0,0 +1,75 @@ +--- +title: Monitoring +description: "Queue stats, per-task metrics, job progress, and worker heartbeats." +--- + +Read live queue health straight from the `Queue` handle — no extra service. The +same data powers the [dashboard](/node/guides/operations/dashboard) and the +[CLI](/node/guides/operations/cli) read commands. + +## Stats + +Counts by status, globally or per queue: + +```ts +queue.stats(); // { pending, running, completed, failed, dead, cancelled } +queue.statsByQueue("default"); +queue.statsAllQueues(); // Record +``` + +## Metrics + +`getMetrics(windowMs, task?)` aggregates finished jobs over a trailing window +into per-task counts, a success/failure split, and latency percentiles +(p50/p95/p99): + +```ts +queue.getMetrics(3_600_000); // last hour, all tasks +queue.getMetrics(3_600_000, "add"); // one task +``` + +## Progress + +A running task reports progress through its job context; inspection and the +dashboard surface it live: + +```ts +import { currentJob } from "taskito"; + +queue.task("import", async (rows: Row[]) => { + const job = currentJob(); + for (let i = 0; i < rows.length; i++) { + await importRow(rows[i]); + job?.setProgress(Math.round(((i + 1) / rows.length) * 100)); + } +}); +``` + +## Workers + +Each running worker registers and heartbeats every 5 seconds; list the live +fleet: + +```ts +queue.listWorkers(); +// [{ workerId, hostname, pid, queues, status, lastHeartbeat, threads, ... }] +``` + +A worker appears on the dashboard Workers panel while it heartbeats; `stop()` +unregisters it. See [Workers](/node/guides/core/workers) for the worker +lifecycle. + +## Prometheus & tracing + +For metric scraping and distributed traces, the contrib integrations wrap the +events layer: + +- [Prometheus](/node/guides/integrations/prometheus) — counters and histograms behind a `/metrics` endpoint. +- [OpenTelemetry](/node/guides/integrations/otel) — a span per task. +- [Sentry](/node/guides/integrations/sentry) — error capture. + + + Lifecycle [events](/node/guides/extensibility/events) and + [middleware](/node/guides/extensibility/middleware) hooks let you push the same + signals to any backend. + diff --git a/docs/content/docs/node/guides/operations/cli.mdx b/docs/content/docs/node/guides/operations/cli.mdx index 29faa37f..ba4e11e9 100644 --- a/docs/content/docs/node/guides/operations/cli.mdx +++ b/docs/content/docs/node/guides/operations/cli.mdx @@ -36,6 +36,14 @@ over them. Use it as your container entrypoint for worker processes. taskito --db taskito.db dashboard --port 8787 ``` +## KEDA scaler + +```bash +taskito --backend postgres --dsn "$DATABASE_URL" scaler --port 9091 +``` + +Serves the queue-depth metric for [KEDA](/node/guides/operations/keda). + The CLI ships in the package `bin`. Commands operate over `--db`/`--backend`/ `--dsn`, except `run` and `dashboard`, which load your app module and serve diff --git a/docs/content/docs/node/guides/operations/dashboard-api.mdx b/docs/content/docs/node/guides/operations/dashboard-api.mdx new file mode 100644 index 00000000..2d49d327 --- /dev/null +++ b/docs/content/docs/node/guides/operations/dashboard-api.mdx @@ -0,0 +1,54 @@ +--- +title: Dashboard REST API +description: "The /api/* endpoints the Node dashboard serves over the queue." +--- + +[`serveDashboard`](/node/guides/operations/dashboard) mounts a JSON REST API at +`/api/*` over the queue. The bundled React SPA consumes it; you can call it +directly too. All timestamps are Unix milliseconds. + +## Read + +| Method · Path | Returns | +|---|---| +| `GET /api/stats` | Counts by status across all queues. | +| `GET /api/stats/queues` | Counts per queue. | +| `GET /api/queues/paused` | Names of paused queues. | +| `GET /api/jobs` | Job list (filtered via query params). | +| `GET /api/jobs/:id` | A single job. | +| `GET /api/dead-letters` | Dead-letter entries. | +| `GET /api/metrics` | Aggregated per-task metrics. | +| `GET /api/metrics/timeseries` | Metrics bucketed over time. | +| `GET /api/workers` | Registered workers + heartbeats. | +| `GET /api/event-types` | Known job event names. | +| `GET /api/workflows/runs` · `/:id` | Workflow runs, and one run. | +| `GET /api/workflows/runs/:id/dag` · `/children` | A run's DAG graph and child runs. | +| `GET /api/webhooks` · `/:id` · `/:id/deliveries` | Webhook config and delivery log. | + +## Write + +| Method · Path | Effect | +|---|---| +| `POST /api/jobs/:id/cancel` | Cancel a job. | +| `POST /api/dead-letters/:id/retry` | Re-enqueue a dead-letter entry. | +| `POST /api/queues/:id/pause` · `/resume` | Pause / resume a queue. | +| `POST /api/webhooks` · `PUT /:id` · `DELETE /:id` | Manage webhook subscriptions. | +| `POST /api/webhooks/:id/test` | Send a test delivery. | + +## Auth + +By default the dashboard runs in **open mode**: `GET /api/auth/status` reports +`{ setup_required: false }` and `GET /api/auth/whoami` returns an admin identity. + +Pass `auth: { token }` to [`serveDashboard`](/node/guides/operations/dashboard) to +require a bearer token. Then every `/api/*` request except `GET /api/auth/status` +must present the token via `Authorization: Bearer `, an `X-Taskito-Token` +header, a `?token=` query, or the `taskito_token` cookie; otherwise it returns +`401`. This is a single shared token — there is no per-user login, RBAC, or SSO. + + + Open mode means anyone who can reach the port has full control. Don't expose it + on an untrusted network — bind to localhost, or mount it behind your own auth + with the [Express](/node/guides/integrations/express#dashboard) / + [Fastify](/node/guides/integrations/fastify) helpers. + diff --git a/docs/content/docs/node/guides/operations/dashboard.mdx b/docs/content/docs/node/guides/operations/dashboard.mdx index 93409abb..4ad5f154 100644 --- a/docs/content/docs/node/guides/operations/dashboard.mdx +++ b/docs/content/docs/node/guides/operations/dashboard.mdx @@ -28,8 +28,22 @@ It serves the SPA plus the `/api/*` REST contract over the queue: - metrics and time-series, workers - webhooks, workflow runs -Auth runs **open** (localhost). To mount the dashboard inside an existing app -instead, use the [Express](/node/guides/integrations/express#dashboard) or +See the [REST API reference](/node/guides/operations/dashboard-api) for the full +endpoint list. + +Auth runs **open** by default. Pass `auth: { token }` to require a bearer token +on every `/api/*` request (except `/api/auth/status`): + +```ts +serveDashboard(queue, { port: 8787, auth: { token: process.env.DASH_TOKEN! } }); +``` + +Requests authenticate via `Authorization: Bearer `, an `X-Taskito-Token` +header, or `?token=`. Opening `/?token=` once sets an httpOnly +cookie so the SPA works for the rest of the session. The token is compared in +constant time. For full login / RBAC / SSO, mount the dashboard inside an existing +app behind your own auth via the +[Express](/node/guides/integrations/express#dashboard) or [Fastify](/node/guides/integrations/fastify) helpers. diff --git a/docs/content/docs/node/guides/operations/inspection.mdx b/docs/content/docs/node/guides/operations/inspection.mdx index c03c1c06..6547abaf 100644 --- a/docs/content/docs/node/guides/operations/inspection.mdx +++ b/docs/content/docs/node/guides/operations/inspection.mdx @@ -1,29 +1,19 @@ --- -title: Inspection & management -description: "Stats, job listing, metrics, and queue pause/resume." +title: Job management +description: "List and inspect jobs, manage the dead-letter queue, and pause queues." --- -Read and manage queue state directly from the `Queue` handle. +Read and manage individual jobs directly from the `Queue` handle. For +queue-wide health — stats, per-task metrics, progress, workers — see +[Monitoring](/node/guides/observability/monitoring). -## Stats - -```ts -queue.stats(); // { pending, running, completed, failed, dead, cancelled } -queue.statsByQueue("default"); -queue.statsAllQueues(); -``` - -## Jobs & metrics +## Jobs ```ts queue.listJobs({ status: "failed", limit: 50 }); -queue.getJobErrors(id); // per-attempt error history -queue.getMetrics(3600_000, "add"); // window (ms), optional task filter +queue.getJobErrors(id); // per-attempt error history ``` -`getMetrics(windowMs, task?)` returns per-task counts, success/failure split, -and latency percentiles (p50/p95/p99) over the window. - ## Dead letters ```ts diff --git a/docs/content/docs/node/guides/operations/keda.mdx b/docs/content/docs/node/guides/operations/keda.mdx new file mode 100644 index 00000000..1b5de8f0 --- /dev/null +++ b/docs/content/docs/node/guides/operations/keda.mdx @@ -0,0 +1,68 @@ +--- +title: KEDA autoscaling +description: "Scale Node workers on Kubernetes by queue depth via the bundled scaler server." +--- + +[KEDA](https://keda.sh) scales a worker Deployment up and down based on queue +depth. The SDK ships a scaler server that KEDA's `metrics-api` scaler polls. + +## Scaler server + +```ts +import { Queue, serveScaler } from "taskito"; + +const queue = new Queue({ backend: "postgres", dsn: process.env.DATABASE_URL }); +serveScaler(queue, { port: 9091, targetQueueDepth: 10 }); +``` + +Or from the CLI, over a backend: + +```bash +taskito --backend postgres --dsn "$DATABASE_URL" scaler --port 9091 +``` + +| Option | Default | Description | +|---|---|---| +| `port` | `9091` | Listen port | +| `host` | `0.0.0.0` | Bind address (reachable in-cluster) | +| `targetQueueDepth` | `10` | Target depth per replica, returned to KEDA | +| `queue` | all | Restrict the metric to one queue | + +## Endpoints + +| Endpoint | Returns | +|---|---| +| `GET /api/scaler[?queue=]` | `{ metricValue, targetValue, queueName }` | +| `GET /health` | `{ status: "ok" }` | + +`metricValue` is the current pending count; KEDA scales toward +`ceil(metricValue / targetValue)` replicas. + +```json +{ "metricValue": 42, "targetValue": 10, "queueName": "*" } +``` + +## ScaledObject + +```yaml +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: taskito-workers +spec: + scaleTargetRef: + name: taskito-worker + minReplicaCount: 1 + maxReplicaCount: 20 + triggers: + - type: metrics-api + metadata: + url: "http://taskito-scaler:9091/api/scaler" + valueLocation: "metricValue" + targetValue: "10" +``` + + + Run the scaler as its own small Deployment + Service (not in the worker pod) so + scaling to zero workers doesn't take the metric source down with it. + diff --git a/docs/content/docs/node/guides/operations/meta.json b/docs/content/docs/node/guides/operations/meta.json index 59420dbe..adb52714 100644 --- a/docs/content/docs/node/guides/operations/meta.json +++ b/docs/content/docs/node/guides/operations/meta.json @@ -1 +1 @@ -{ "title": "Operations", "pages": ["backends", "inspection", "dashboard", "mesh", "cli", "deployment"] } +{ "title": "Operations", "pages": ["backends", "inspection", "dashboard", "dashboard-api", "mesh", "keda", "cli", "testing", "security", "troubleshooting", "deployment", "migration"] } diff --git a/docs/content/docs/node/guides/operations/migration.mdx b/docs/content/docs/node/guides/operations/migration.mdx new file mode 100644 index 00000000..949c7953 --- /dev/null +++ b/docs/content/docs/node/guides/operations/migration.mdx @@ -0,0 +1,59 @@ +--- +title: Migrating from BullMQ +description: "Concept mapping from BullMQ to the Taskito Node SDK." +--- + +If you're coming from BullMQ, the moving parts map closely. The biggest shifts: +Taskito is **storage-backed** (SQLite, Postgres, *or* Redis — not Redis-only), +handlers are **registered by name** on the queue rather than wired to a separate +`Worker` processor, and orchestration is built in. + +## Concept map + +| BullMQ | Taskito | +|---|---| +| `new Queue(name, { connection })` | `new Queue({ backend, dsn })` — one object is producer + admin | +| `new Worker(name, processor, { connection })` | `queue.task(name, fn)` to register, then `queue.runWorker()` | +| `queue.add("job", data, opts)` | `queue.task("job", fn)` once, then `queue.enqueue("job", args, opts)` | +| `job.data` | the positional `args` passed to the handler | +| `job.id`, progress | `currentJob()` → `jobId`, `setProgress()` | +| `attempts` + `backoff` | `maxRetries` + `retryBackoff` | +| `rateLimiter` | per-task / per-queue `rateLimit: "100/m"` | +| `QueueEvents` | `queue.on(event, …)` + [middleware](/node/guides/extensibility/middleware) | +| Repeatable jobs (cron) | `queue.registerPeriodic(name, task, cron)` | +| Flows (parent/child) | [Workflows](/node/guides/workflows) (DAG, fan-out, gates, saga) | +| `queue.getJobCounts()` | `queue.stats()` / `statsAllQueues()` | +| Bull Board | the built-in [dashboard](/node/guides/operations/dashboard) | + +## Side by side + +```ts +// BullMQ +const queue = new Queue("emails", { connection }); +await queue.add("welcome", { userId: 1 }); +new Worker("emails", async (job) => send(job.data.userId), { connection }); + +// Taskito +const queue = new Queue({ backend: "redis", dsn: "redis://localhost" }); +queue.task("welcome", (userId: number) => send(userId)); +queue.enqueue("welcome", [1], { queue: "emails" }); +queue.runWorker({ queues: ["emails"] }); +``` + +## What changes + +- **Backend choice** — start on SQLite with zero infrastructure; move to Postgres + or Redis for multi-machine workers without code changes. See + [backends](/node/guides/operations/backends). +- **Typed args** — `enqueue` infers argument types from the registered task; data + is positional `args`, not a single `data` object. +- **Registration on the worker** — a worker runs only the tasks registered in its + process. Producers need only the task *name*. +- **Built-in orchestration** — DAG [workflows](/node/guides/workflows) and a + [mesh](/node/guides/operations/mesh) replace hand-rolled flows and external + schedulers. + + + Migrating incrementally? Point Taskito at the same Redis you already run, move + one task family at a time, and keep both systems until cut over. + diff --git a/docs/content/docs/node/guides/operations/security.mdx b/docs/content/docs/node/guides/operations/security.mdx new file mode 100644 index 00000000..44b2d692 --- /dev/null +++ b/docs/content/docs/node/guides/operations/security.mdx @@ -0,0 +1,65 @@ +--- +title: Security +description: "Trust model, payload exposure, webhook signing, and dashboard access." +--- + +## Trust model: storage is code execution + +A worker runs whatever task name a job names, with whatever payload it carries. +So **anyone who can write to the storage can run code on your workers.** Treat +the database / Redis credentials as code-execution credentials: + +- Don't expose the storage to untrusted clients — enqueue through your own + authenticated service, not directly from a browser. +- Use least-privilege storage credentials and a private network. + +## Payloads are not encrypted + +Task arguments and results are serialized with [JSON or +msgpack](/node/guides/extensibility/serializers) and stored as plain bytes — by +default **not encrypted.** Anyone who can read the storage can read them. + +- Don't put secrets (tokens, PII) directly in task args. Pass an id and fetch the + secret inside the handler — for example via a + [resource](/node/guides/resources/dependency-injection). +- To detect tampering, wrap your codec in + [`SignedSerializer`](/node/api-reference/serializers) — it HMAC-signs each + payload so a worker rejects anything altered or forged (integrity, not + confidentiality). +- To hide payloads at rest, use + [`EncryptedSerializer`](/node/api-reference/serializers) — AES-256-GCM gives + both confidentiality and integrity. Share the secret across producers/workers. + +## Webhooks + +Webhook deliveries are **HMAC-SHA256 signed** when the subscription has a secret; +the signature rides in `x-taskito-signature: sha256=…`. Verify it on the +receiver before trusting the body. + +```ts +const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex"); +// timing-safe compare against the x-taskito-signature header +``` + +The deliverer fetches the configured URL directly — there is **no SSRF allowlist**. +Only register webhook URLs you control, and validate them before storing if they +come from users. + +## Dashboard + +The [dashboard](/node/guides/operations/dashboard) runs in **open mode** by +default — anyone who reaches the port has full control. Pass `auth: { token }` to +gate the API with a shared bearer token, bind it to localhost, or mount it behind +your own auth (login / RBAC / SSO) via the +[Express](/node/guides/integrations/express#dashboard) / +[Fastify](/node/guides/integrations/fastify) helpers. + +## Hardening checklist + +- [ ] Storage reachable only from trusted services, on a private network. +- [ ] Enqueue behind your own authn/authz — never expose storage to clients. +- [ ] No secrets in task args; fetch them inside the handler. +- [ ] Webhook receivers verify the HMAC signature; webhook URLs are trusted. +- [ ] Dashboard bound to localhost or behind auth. +- [ ] Logs don't echo sensitive payloads (use `onEnqueue` to + [redact](/node/guides/resources/interception)). diff --git a/docs/content/docs/node/guides/operations/testing.mdx b/docs/content/docs/node/guides/operations/testing.mdx new file mode 100644 index 00000000..29240b8c --- /dev/null +++ b/docs/content/docs/node/guides/operations/testing.mdx @@ -0,0 +1,75 @@ +--- +title: Testing +description: "Test tasks and workers against a real queue with vitest." +--- + +Tasks are plain functions — unit-test them directly. For integration coverage, +run a real worker against a throwaway SQLite database; the whole enqueue → +execute → result path runs in-process, no mocks of the core needed. + +```ts +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { Queue, type Worker } from "taskito"; + +let worker: Worker | undefined; +afterEach(() => { + worker?.stop(); // always stop — a leaked worker keeps polling across tests + worker = undefined; +}); + +function newQueue(): Queue { + // a fresh temp DB per test keeps them independent + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "test-")), "q.db") }); +} + +it("runs a task end to end", async () => { + const queue = newQueue(); + queue.task("add", (a: number, b: number) => a + b); + + const id = queue.enqueue("add", [2, 3]); + worker = queue.runWorker(); + + expect(await queue.result(id)).toBe(5); +}); +``` + +## Polling for side effects + +When you assert on a side effect rather than a return value, poll until it +settles instead of sleeping a fixed time: + +```ts +async function waitFor(predicate: () => boolean, timeoutMs = 4000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return true; + await new Promise((r) => setTimeout(r, 20)); + } + return false; +} +``` + +## Faking dependencies + +Register a [resource](/node/guides/resources/dependency-injection) with a stub in +the test — the worker injects whatever is registered, so no real connection is +opened: + +```ts +import { useResource } from "taskito"; + +queue.resource("db", () => fakeDb); +queue.task("sync", async () => { + const db = await useResource("db"); + // ... +}); +``` + + + Awaiting `queue.result(id)` is the simplest synchronization point — it resolves + when the job reaches a terminal state and rejects on failure, so a test rarely + needs an explicit sleep. + diff --git a/docs/content/docs/node/guides/operations/troubleshooting.mdx b/docs/content/docs/node/guides/operations/troubleshooting.mdx new file mode 100644 index 00000000..de5bd9bf --- /dev/null +++ b/docs/content/docs/node/guides/operations/troubleshooting.mdx @@ -0,0 +1,64 @@ +--- +title: Troubleshooting +description: "Common Node SDK issues and how to diagnose them." +--- + +Turn on debug logs first — they show the worker claiming, running, and settling +jobs: + +```ts +import { setLogLevel } from "taskito"; +setLogLevel("debug"); +// or: TASKITO_LOG_LEVEL=debug node app.js +``` + +## A job stays `pending` and never runs + +- **No worker running** — `queue.runWorker()` must be called in a live process. +- **Queue mismatch** — the worker only serves its `queues` (default + `["default"]`); a job enqueued to another `namespace`/queue is ignored. Match + them. +- **Queue paused** — check `queue.listPausedQueues()`; `resumeQueue(name)`. +- **Producer and worker use different storage** — they must point at the same + `dbPath`/`dsn`. A relative SQLite path resolves per working directory. + +## Worker runs but throws `TaskNotRegisteredError` + +The worker dequeued a job whose task name was never registered on it. Register +the handler (`queue.task(name, fn)`) on the **worker** process — task functions +don't travel through the queue, only their names and args do. + +## `SQLite database is locked` + +SQLite allows one writer at a time. Under many concurrent workers this surfaces +as lock contention. Options: + +- Keep workers on one machine and moderate concurrency, or +- Move to [Postgres or Redis](/node/guides/operations/backends) for multi-process + / multi-machine deployments. + +## `Cannot find module '.../native/index.js'` + +The native addon isn't built. Run `pnpm build:native`. Workflows and mesh also +require the addon to be built **with their cargo features** (included in +`build:native`) — a "feature not enabled" error at `queue.workflows` means the +addon was built without them. + +## Jobs run, but latency is high + +- Raise `batchSize` and `channelCapacity` on `runWorker` to claim and buffer more + per poll — see the [execution model](/node/guides/core/execution-model). +- Add more worker processes against shared storage. +- Check you aren't capped by a per-task `maxConcurrent` or + [rate limit](/node/guides/reliability/rate-limiting). + +## A handler hangs and blocks others + +A long **synchronous** computation blocks the event loop and stalls every +in-flight job. Offload CPU-bound work to `worker_threads` and `await` it, or set +a [`timeoutMs`](/node/guides/reliability/timeouts) so a stuck attempt is aborted. + + + Inspect failures with `queue.getJobErrors(id)` and `queue.deadLetters()`; see + [job management](/node/guides/operations/inspection). + diff --git a/docs/content/docs/node/guides/reliability/error-handling.mdx b/docs/content/docs/node/guides/reliability/error-handling.mdx new file mode 100644 index 00000000..acd86635 --- /dev/null +++ b/docs/content/docs/node/guides/reliability/error-handling.mdx @@ -0,0 +1,63 @@ +--- +title: Error handling +description: "What happens when a task throws — retries, timeouts, and the dead-letter queue." +--- + +When a task throws (or its promise rejects), Taskito decides the job's fate from +its retry budget. Each failing attempt runs the `onError` middleware hook, then: + +1. **Retry** — if attempts remain, the job is rescheduled with + [backoff](/node/guides/reliability/retries). +2. **Dead-letter** — once the budget is exhausted, the job moves to the + [dead-letter queue](/node/guides/reliability/dead-letter). + +```ts +queue.task("fetch", fetchUrl, { + maxRetries: 3, + retryBackoff: { baseMs: 1000, maxMs: 60_000 }, +}); +``` + +Returning normally is success; an uncaught throw is failure. There is no +in-handler "stop retrying" signal — set `maxRetries: 0` for fire-once tasks. + +## Timeouts + +A per-attempt [timeout](/node/guides/reliability/timeouts) aborts the job's +`AbortSignal` and counts as a failure; the outcome carries `timedOut: true` so +hooks can tell timeouts apart from other errors. + +```ts +queue.task("slow", handler, { timeoutMs: 30_000 }); +``` + +## Inspecting failures + +Every attempt's error is recorded: + +```ts +queue.getJobErrors(id); // one entry per failed attempt +queue.listJobs({ status: "failed" }); +queue.deadLetters(); // exhausted jobs +``` + +## Reacting to outcomes + +[Middleware](/node/guides/extensibility/middleware) and +[events](/node/guides/extensibility/events) fire as the core decides each +outcome: + +```ts +queue.use({ + onError: (ctx, err) => log.error(ctx.taskName, err), // each throwing attempt + onRetry: (e) => metrics.inc("retry", e.taskName), + onDeadLetter: (e) => alertOps(e), +}); +queue.on("job.dead", (e) => pageOncall(e)); +``` + + + A throw fails only the current *attempt*. To fail fast with no retries, set + `maxRetries: 0`; to make failures safe to retry, keep handlers + [idempotent](/node/guides/reliability/idempotency). + diff --git a/docs/content/docs/node/guides/reliability/guarantees.mdx b/docs/content/docs/node/guides/reliability/guarantees.mdx new file mode 100644 index 00000000..69c78081 --- /dev/null +++ b/docs/content/docs/node/guides/reliability/guarantees.mdx @@ -0,0 +1,47 @@ +--- +title: Delivery guarantees +description: "At-least-once execution, job claiming, and staying correct under retries." +--- + +Taskito gives **at-least-once** execution: every enqueued job runs to completion +at least once. A job can also run *more* than once — a worker that crashes +mid-task (after claiming it, before recording the result) leaves the job to be +picked up again. Design handlers to tolerate that. + +## How a job is claimed + +A worker atomically claims a pending job — marking it running in a single +guarded update — before executing, so two workers never run the same job +concurrently. If that worker dies, the claim lapses and the scheduler dispatches +the job again. + +## Staying correct: idempotency + +Because a job may run more than once, make side effects idempotent: + +- Upsert / conditional-write keyed by a stable id rather than blind inserts. +- Dedupe external calls with the job id from `currentJob()`. +- Collapse duplicate *enqueues* into one job with + [`uniqueKey`](/node/guides/reliability/idempotency). + +```ts +import { currentJob } from "taskito"; + +queue.task("charge", async (orderId: string) => { + const job = currentJob(); + await payments.charge(orderId, { idempotencyKey: job?.jobId }); +}); +``` + +## Not guaranteed + +- **Exactly-once** — not offered anywhere in the system; pair at-least-once with + idempotent handlers instead. +- **Strict ordering** — within a queue, jobs dequeue by priority then age, but + concurrent workers run them in parallel; don't rely on one finishing before + another. Use a [workflow](/node/guides/workflows) when order matters. + + + A completed job's result is stored and can be awaited by id from any process + sharing the storage — see [result](/node/api-reference/result). + diff --git a/docs/content/docs/node/guides/reliability/meta.json b/docs/content/docs/node/guides/reliability/meta.json index f49fcbcc..3e095df5 100644 --- a/docs/content/docs/node/guides/reliability/meta.json +++ b/docs/content/docs/node/guides/reliability/meta.json @@ -1 +1 @@ -{ "title": "Reliability", "pages": ["retries", "timeouts", "idempotency", "rate-limiting", "concurrency", "circuit-breakers", "dead-letter", "locks"] } +{ "title": "Reliability", "pages": ["error-handling", "guarantees", "retries", "timeouts", "idempotency", "rate-limiting", "concurrency", "circuit-breakers", "dead-letter", "locks"] } diff --git a/docs/content/docs/node/guides/reliability/rate-limiting.mdx b/docs/content/docs/node/guides/reliability/rate-limiting.mdx index 8f9b3511..7323608a 100644 --- a/docs/content/docs/node/guides/reliability/rate-limiting.mdx +++ b/docs/content/docs/node/guides/reliability/rate-limiting.mdx @@ -9,20 +9,18 @@ failing it. ```ts queue.task("call_api", callApi, { - rateLimit: { limit: 100, windowMs: 60_000 }, // 100 / minute + rateLimit: "100/m", // 100 per minute }); ``` Apply a limit to a whole queue instead: ```ts -queue.configureQueue("emails", { rateLimit: { limit: 50, windowMs: 1000 } }); +queue.configureQueue("emails", { rateLimit: "50/s" }); ``` -| Field | Description | -|---|---| -| `limit` | Tokens (executions) per window. | -| `windowMs` | Window length in milliseconds. | +`rateLimit` is a string `"/"` where unit is `s` (second), `m` +(minute), or `h` (hour) — e.g. `"100/m"`, `"50/s"`, `"3600/h"`. Queue-level limits are evaluated before per-task limits. Rate-limited jobs stay `pending` and retry on the next tick — they do not consume the retry budget. diff --git a/docs/content/docs/node/guides/resources/dependency-injection.mdx b/docs/content/docs/node/guides/resources/dependency-injection.mdx new file mode 100644 index 00000000..a8f45eed --- /dev/null +++ b/docs/content/docs/node/guides/resources/dependency-injection.mdx @@ -0,0 +1,153 @@ +--- +title: Dependency injection +description: "Register external dependencies once and inject them into tasks by scope." +--- + +Tasks often need a database pool, an HTTP client, or a cloud SDK. Rather than +constructing those inside every handler, register them once as **resources** and +let the worker build, share, and tear them down with the right lifetime. + +```ts +import { Queue, useResource } from "taskito"; +import { Pool } from "pg"; + +const queue = new Queue({ dbPath: "tasks.db" }); + +queue.resource("db", () => new Pool({ connectionString: process.env.DATABASE_URL }), { + dispose: (pool) => pool.end(), +}); + +queue.task("sync-user", async (userId: string) => { + const db = await useResource("db"); + await db.query("UPDATE users SET synced = now() WHERE id = $1", [userId]); +}); +``` + +The pool is created once when the worker first needs it, shared across every +job, and closed when the worker stops. No connection is ever serialized into the +queue. + +## Scopes + +A resource's scope decides its lifetime: + +| Scope | Built | Lifetime | Use for | +|---|---|---|---| +| `worker` (default) | Lazily, on first use | The worker — a shared singleton | Connection pools, HTTP clients, SDK clients | +| `task` | Once per job invocation | That job — disposed when it finishes | Per-job transactions, request-scoped clients | + +```ts +queue.resource("tx", async () => db.begin(), { + scope: "task", + dispose: (tx) => tx.rollback(), // no-op if already committed +}); +``` + +Worker-scoped resources are built once even under concurrency — concurrent +initialization is de-duplicated. A factory that throws is **not** cached, so the +next job retries it. + +## Injecting resources + +Two equivalent ways to reach a resource from a handler. + +### Accessor — `useResource` + +Call `useResource(name)` anywhere inside a running task. It resolves against the +current job's scope and is typed through its generic: + +```ts +queue.task("report", async () => { + const db = await useResource("db"); + const cache = await useResource("cache"); + // ... +}); +``` + +Calling `useResource` outside a task throws — it is only available while a +handler runs. + +### Declarative — `inject` + +List resources on the task and receive them as a trailing `deps` object. The +`deps` argument is stripped from the typed `enqueue` signature, so producers +still call the task with its real arguments only: + +```ts +queue.task( + "sync-user", + async (userId: string, deps: { db: Pool }) => { + await deps.db.query("UPDATE users SET synced = now() WHERE id = $1", [userId]); + }, + { inject: ["db"] }, +); + +queue.enqueue("sync-user", ["u_123"]); // db is injected, not passed +``` + + + Annotate the `deps` parameter with the resource types you injected — that + annotation is what types `deps.db`. The names in `inject` must match the keys + you read off `deps`. + + +## Resources that depend on resources + +A factory receives a context whose `use` resolves another resource, so you can +compose them: + +```ts +queue.resource("config", async () => loadConfig()); +queue.resource("db", async (ctx) => { + const config = await ctx.use("config"); + return new Pool({ connectionString: config.databaseUrl }); +}); +``` + +A worker-scoped factory may only depend on other worker-scoped resources; +reaching for a task-scoped one throws (it has no job to bind to). Task-scoped +factories may depend on either. + +## Teardown + +Pass `dispose` to release a resource when its scope ends — worker resources on +`worker.stop()`, task resources when the job finishes. Disposal runs in +**reverse order** of construction (LIFO), so a resource is always torn down +before anything it depended on: + +```ts +queue.resource("db", () => openPool(), { dispose: (p) => p.end() }); +``` + +Disposal errors are logged, never thrown — they cannot fail an already-settled +job. + +## Metrics + +`queue.resourceMetrics()` returns per-resource lifecycle counters — how many +instances were built, disposed, and are currently live: + +```ts +queue.resourceMetrics(); +// { db: { created: 1, disposed: 0, active: 1 }, tx: { created: 12, disposed: 12, active: 0 } } +``` + +A worker-scoped resource shows `active: 1` while the worker runs and `0` after +`stop()`; a task-scoped resource's `created`/`disposed` climb per job with +`active` near zero. + +## Testing + +Register a stub factory to swap a real dependency in tests. `mockResource(value)` +wraps a value as a factory and records how often it was built: + +```ts +import { mockResource } from "taskito"; + +const db = mockResource({ query: async () => fakeRows }); +queue.resource("db", db.factory); +// ... run the task ... +expect(db.resolutions).toBe(1); +``` + +See [testing](/node/guides/operations/testing) for the full worker test setup. diff --git a/docs/content/docs/node/guides/resources/index.mdx b/docs/content/docs/node/guides/resources/index.mdx new file mode 100644 index 00000000..e9ef28e5 --- /dev/null +++ b/docs/content/docs/node/guides/resources/index.mdx @@ -0,0 +1,34 @@ +--- +title: Resources +description: "Inject external dependencies into tasks and intercept jobs before they enqueue." +--- + +Task arguments cross a process boundary, so they must be serializable — but most +real dependencies (database pools, HTTP clients, cloud SDKs) are not. The Node +SDK keeps those on the worker and passes only plain data through the queue. + +```ts +import { Queue, useResource } from "taskito"; +import { Pool } from "pg"; + +const queue = new Queue({ dbPath: "tasks.db" }); + +queue.resource("db", () => new Pool(), { dispose: (p) => p.end() }); + +queue.task("sync", async (id: string) => { + const db = await useResource("db"); + await db.query("UPDATE users SET synced = now() WHERE id = $1", [id]); +}); +``` + +| Page | What it covers | +|---|---| +| [Dependency injection](/node/guides/resources/dependency-injection) | `queue.resource()`, worker/task scopes, `useResource`, declarative `inject`, teardown | +| [Enqueue interception](/node/guides/resources/interception) | The `onEnqueue` hook — validate, redact, and rewrite jobs before serialization | + + + Coming from Python? Node's resource system is intentionally lighter — no + argument-interception *strategies* and no object *proxies*, because JavaScript + has no pickling problem. Worker-side dependency injection plus a producer-side + `onEnqueue` hook cover the same ground. + diff --git a/docs/content/docs/node/guides/resources/interception.mdx b/docs/content/docs/node/guides/resources/interception.mdx new file mode 100644 index 00000000..8ed7e884 --- /dev/null +++ b/docs/content/docs/node/guides/resources/interception.mdx @@ -0,0 +1,72 @@ +--- +title: Enqueue interception +description: "Validate, redact, or rewrite jobs before they are serialized." +--- + +The `onEnqueue` middleware hook runs on the **producer side**, inside +`queue.enqueue`, before the arguments are serialized. Use it to validate inputs, +redact secrets, or rewrite enqueue options across every task — in one place +instead of at every call site. + +```ts +queue.use({ + onEnqueue: (ctx) => { + // redact anything that looks like a secret before it reaches storage + ctx.args = ctx.args.map((a) => + typeof a === "string" && a.startsWith("pw:") ? "***" : a, + ); + }, +}); +``` + +## The context + +`onEnqueue` receives a mutable context: + +| Field | Description | +|---|---| +| `taskName` | The task being enqueued (read-only). | +| `args` | The positional arguments — reassign or mutate before serialization. | +| `options` | The enqueue options — set `metadata`, `priority`, `namespace`, and so on. | + +Mutations take effect: `args` is what gets serialized, `options` is what reaches +the core. + +```ts +queue.use({ + onEnqueue: (ctx) => { + ctx.options.metadata = JSON.stringify({ enqueuedBy: currentUser() }); + }, +}); +``` + +## Validation + +Throwing from `onEnqueue` aborts the enqueue — the job is never created and the +error propagates to the caller: + +```ts +queue.use({ + onEnqueue: (ctx) => { + if (ctx.taskName === "charge" && (ctx.args[0] as number) < 0) { + throw new Error("charge amount must be non-negative"); + } + }, +}); + +queue.enqueue("charge", [-5]); // throws — nothing is enqueued +``` + + + `onEnqueue` is synchronous (so is `enqueue`) and runs in middleware + registration order. It is the producer-side counterpart to the execution hooks + in [Middleware](/node/guides/extensibility/middleware). + + +## Why no proxies? + +Python's SDK also ships object *proxies* — a system for shipping non-serializable +objects (file handles, sessions) through the queue. Node doesn't need it: a +handler closes over its own resources, or pulls them from +[dependency injection](/node/guides/resources/dependency-injection). Keep +non-serializable state on the worker and pass plain data through the queue. diff --git a/docs/content/docs/node/guides/resources/meta.json b/docs/content/docs/node/guides/resources/meta.json new file mode 100644 index 00000000..8e49d3b4 --- /dev/null +++ b/docs/content/docs/node/guides/resources/meta.json @@ -0,0 +1 @@ +{ "title": "Resources", "pages": ["dependency-injection", "interception"] } diff --git a/docs/content/docs/node/guides/workflows/analysis.mdx b/docs/content/docs/node/guides/workflows/analysis.mdx new file mode 100644 index 00000000..efd55253 --- /dev/null +++ b/docs/content/docs/node/guides/workflows/analysis.mdx @@ -0,0 +1,50 @@ +--- +title: Analysis +description: "Introspect a workflow run's DAG — dependencies, levels, critical path, and stats." +--- + +`queue.workflows.analyze(runId)` returns a `WorkflowAnalysis` — a read-only view +over the run's graph (`dag()`) and per-node statuses (`nodes()`). Every method is +pure graph computation over that snapshot, so it works whether the run is pending, +in flight, or finished. + +```ts +const a = queue.workflows.analyze(runId); +if (!a) throw new Error("unknown run"); +``` + +## Structure + +```ts +a.roots(); // ["extract"] — nodes with no dependencies +a.leaves(); // ["load"] — nodes nothing depends on +a.ancestors("load"); // upstream — transitive predecessors +a.descendants("extract"); // downstream +a.topologicalOrder(); // a valid execution order (throws on a cycle) +``` + +## Levels & critical path + +`topologicalLevels()` groups nodes by dependency depth — everything in one level +can run in parallel. `criticalPath()` returns the longest dependency chain from a +root to a leaf (the structural lower bound on how many sequential steps the run +takes). + +```ts +a.topologicalLevels(); // [["extract"], ["transform-a", "transform-b"], ["load"]] +a.criticalPath(); // ["extract", "transform-a", "load"] +``` + +## Status stats + +```ts +a.stats(); +// { total: 4, byStatus: { completed: 3, running: 1 }, completed: 3, failed: 0, running: 1, pending: 0 } +``` + + + `analyze` and [`wait`](/node/api-reference/workflows) are separate calls, not + chainable: `analyze(runId)` returns a snapshot now, while `handle.wait()` + resolves when the run finishes. Re-call `analyze(runId)` after `wait()` to see + the slowest chain on the completed run. + diff --git a/docs/content/docs/node/guides/workflows/caching.mdx b/docs/content/docs/node/guides/workflows/caching.mdx new file mode 100644 index 00000000..7a9879bf --- /dev/null +++ b/docs/content/docs/node/guides/workflows/caching.mdx @@ -0,0 +1,50 @@ +--- +title: Caching +description: "Reuse a workflow step's result across runs — incremental runs with a dirty set." +--- + +Mark a step `cache: true` and its result is reused whenever its task, args, and +upstream results are unchanged. Re-running a workflow then skips the expensive +work and reuses the prior result. + +```ts +queue.workflows + .define("report") + .step("fetch", "fetchData") + .step("crunch", "crunchNumbers", { after: "fetch", cache: true }) + .step("render", "renderReport", { after: "crunch" }) + .submit(); +``` + +On the second run, if `fetch` produces the same data, `crunch` is a **cache hit** +and isn't recomputed; `render` still runs. + +## The cache key (dirty set) + +A cached step is keyed by a content hash of its **task name**, its **args**, and +each **predecessor's result**. So a change anywhere upstream changes the key and +re-runs the affected step — only the genuinely-dirty subtree recomputes, the rest +is reused. + +Results are stored in the queue's shared settings store, so a cache survives +across processes and restarts that share the same storage. + +## TTL + +Expire a cache entry with `{ ttlMs }`: + +```ts +.step("crunch", "crunchNumbers", { after: "fetch", cache: { ttlMs: 3_600_000 } }) +``` + +## Clearing + +```ts +queue.workflows.clearCache(); // drop every cached step result → next run recomputes +``` + + + A cacheable step must have at least one predecessor — a cacheable *root* would + have nothing to trigger it. Cache hits complete the node with the stored result, + so downstream steps and fan-in see it exactly as a fresh run. + diff --git a/docs/content/docs/node/guides/workflows/canvas.mdx b/docs/content/docs/node/guides/workflows/canvas.mdx new file mode 100644 index 00000000..59a53606 --- /dev/null +++ b/docs/content/docs/node/guides/workflows/canvas.mdx @@ -0,0 +1,71 @@ +--- +title: Canvas +description: "Chain, group, and chord shorthands for common workflow shapes." +--- + +The builder's canvas helpers express common DAG shapes without hand-wiring +`after` edges. Each takes `steps` of the form `{ name, task, ...stepOptions }` +and returns the builder, so they chain with `.step()` and one another. + +## chain + +Run steps in sequence — each after the previous one: + +```ts +queue.workflows + .define("etl") + .chain([ + { name: "extract", task: "extract" }, + { name: "transform", task: "transform" }, + { name: "load", task: "load", maxRetries: 5 }, + ]) + .submit(); +// extract → transform → load +``` + +Pass `{ after }` to chain onto an existing step. + +## group + +Run steps in parallel (all after `options.after`, or as roots): + +```ts +queue.workflows + .define("notify") + .step("prepare", "prepare") + .group( + [ + { name: "email", task: "sendEmail" }, + { name: "sms", task: "sendSms" }, + { name: "push", task: "sendPush" }, + ], + { after: "prepare" }, + ) + .submit(); +// prepare → (email | sms | push) +``` + +## chord + +A parallel group joined by a callback that runs once every member completes: + +```ts +queue.workflows + .define("report") + .chord( + [ + { name: "q1", task: "queryRegion" }, + { name: "q2", task: "queryRegion" }, + ], + { name: "merge", task: "merge" }, + ) + .submit(); +// (q1 | q2) → merge +``` + + + `chord`'s callback runs *after* the group but receives its own `args` — the + members' results are not auto-passed. To aggregate results into one step, use + [fan-out / fan-in](/node/guides/workflows/fan-out), which collects child + results into the combiner's argument. + diff --git a/docs/content/docs/node/guides/workflows/conditions.mdx b/docs/content/docs/node/guides/workflows/conditions.mdx index 9357b547..fa1b6a75 100644 --- a/docs/content/docs/node/guides/workflows/conditions.mdx +++ b/docs/content/docs/node/guides/workflows/conditions.mdx @@ -33,7 +33,7 @@ console.log(run.state); // "failed" — even if recover ran (see below) | Value | When the step runs | |---|---| | `"on_success"` | All predecessors completed successfully (default) | -| `"on_failure"` | At least one predecessor dead-lettered | +| `"on_failure"` | At least one predecessor failed | | `"always"` | Regardless of predecessor outcome | A step whose condition is not met transitions to `skipped`. Skipped steps diff --git a/docs/content/docs/node/guides/workflows/meta.json b/docs/content/docs/node/guides/workflows/meta.json index 78b4fc06..55117da3 100644 --- a/docs/content/docs/node/guides/workflows/meta.json +++ b/docs/content/docs/node/guides/workflows/meta.json @@ -1,4 +1,4 @@ { "title": "Workflows", - "pages": ["index", "fan-out", "conditions", "gates", "sub-workflows", "saga"] + "pages": ["index", "fan-out", "conditions", "gates", "sub-workflows", "saga", "canvas", "caching", "analysis"] } diff --git a/sdks/node/src/cli/commands/index.ts b/sdks/node/src/cli/commands/index.ts index b19c1656..3a08b457 100644 --- a/sdks/node/src/cli/commands/index.ts +++ b/sdks/node/src/cli/commands/index.ts @@ -5,4 +5,5 @@ export { registerEnqueue } from "./enqueue"; export { registerJobs } from "./jobs"; export { registerQueues } from "./queues"; export { registerRun } from "./run"; +export { registerScaler } from "./scaler"; export { registerStats } from "./stats"; diff --git a/sdks/node/src/cli/commands/scaler.ts b/sdks/node/src/cli/commands/scaler.ts new file mode 100644 index 00000000..8f6fc790 --- /dev/null +++ b/sdks/node/src/cli/commands/scaler.ts @@ -0,0 +1,31 @@ +import type { Command } from "commander"; +import { serveScaler } from "../../scaler"; +import { connect, type GlobalOptions } from "../connect"; +import { positiveIntFlag } from "../parse"; + +export function registerScaler(program: Command): void { + program + .command("scaler") + .description("Serve the KEDA scaler endpoint (queue-depth metric)") + .option("-p, --port ", "port to listen on", "9091") + .option("--host ", "host to bind", "0.0.0.0") + .option("--target-queue-depth ", "target queue depth per replica", "10") + .option("--queue ", "restrict the metric to one queue") + .action( + ( + options: { port?: string; host?: string; targetQueueDepth?: string; queue?: string }, + command: Command, + ) => { + const queue = connect(command.optsWithGlobals() as GlobalOptions); + const host = options.host ?? "0.0.0.0"; + const port = positiveIntFlag(options.port, "port") ?? 9091; + serveScaler(queue, { + port, + host, + targetQueueDepth: positiveIntFlag(options.targetQueueDepth, "target-queue-depth"), + queue: options.queue, + }); + process.stdout.write(`taskito scaler on http://${host}:${port}/api/scaler\n`); + }, + ); +} diff --git a/sdks/node/src/cli/index.ts b/sdks/node/src/cli/index.ts index 9f5d2ef6..1d79b59d 100644 --- a/sdks/node/src/cli/index.ts +++ b/sdks/node/src/cli/index.ts @@ -8,6 +8,7 @@ import { registerJobs, registerQueues, registerRun, + registerScaler, registerStats, } from "./commands"; @@ -34,6 +35,7 @@ registerQueues(program); registerDlq(program); registerRun(program); registerDashboard(program); +registerScaler(program); program.parseAsync(process.argv).catch((error: unknown) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); diff --git a/sdks/node/src/context.ts b/sdks/node/src/context.ts index 2cda4e7d..5dfabeb8 100644 --- a/sdks/node/src/context.ts +++ b/sdks/node/src/context.ts @@ -11,6 +11,12 @@ export interface JobContext { readonly signal: AbortSignal; /** Report progress (0–100) for observability. */ setProgress(progress: number): void; + /** + * Publish a partial result, consumable live via `queue.stream(jobId)`. The + * value must be JSON-serializable. Use to stream progress from a long-running + * task (ETL, ML steps, batch processing). + */ + publish(value: unknown): void; } const store = new AsyncLocalStorage(); diff --git a/sdks/node/src/contrib/rest.ts b/sdks/node/src/contrib/rest.ts index bb366897..2eb80945 100644 --- a/sdks/node/src/contrib/rest.ts +++ b/sdks/node/src/contrib/rest.ts @@ -2,8 +2,8 @@ // Each route maps a plain request (params/query/body) to a status + JSON body, so the // adapters only translate framework request/response objects — no logic duplicated. -import type { EnqueueOptions } from "../native"; import type { Queue } from "../queue"; +import type { EnqueueOptions } from "../types"; /** Options controlling which routes a helper exposes. */ export interface RestOptions { diff --git a/sdks/node/src/dashboard/auth.ts b/sdks/node/src/dashboard/auth.ts new file mode 100644 index 00000000..55901867 --- /dev/null +++ b/sdks/node/src/dashboard/auth.ts @@ -0,0 +1,71 @@ +import { timingSafeEqual } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +/** Bearer-token protection for the dashboard. A single shared token gates the API. */ +export interface DashboardAuth { + /** Shared token required on every `/api/*` request (except `/api/auth/status`). */ + token: string; +} + +/** Cookie the SPA carries once a valid `?token=` has bootstrapped a session. */ +const TOKEN_COOKIE = "taskito_token"; + +/** API paths reachable without a token, so the SPA can detect that auth is on. */ +const PUBLIC_API_PATHS = new Set(["/api/auth/status"]); + +export function isPublicApiPath(path: string): boolean { + return PUBLIC_API_PATHS.has(path); +} + +/** + * The token presented on a request, from (in order) the `Authorization: Bearer` + * header, an `X-Taskito-Token` header, a `?token=` query param, or the + * `taskito_token` cookie. + */ +export function presentedToken(req: IncomingMessage, url: URL): string | undefined { + const header = req.headers.authorization; + if (header?.startsWith("Bearer ")) { + return header.slice("Bearer ".length); + } + const custom = req.headers["x-taskito-token"]; + if (typeof custom === "string" && custom.length > 0) { + return custom; + } + const query = url.searchParams.get("token"); + if (query) { + return query; + } + return readCookie(req, TOKEN_COOKIE); +} + +/** Constant-time comparison of the expected token against what was presented. */ +export function tokenMatches(expected: string, presented: string | undefined): boolean { + if (!presented) { + return false; + } + const a = Buffer.from(expected); + const b = Buffer.from(presented); + return a.length === b.length && timingSafeEqual(a, b); +} + +/** Persist a valid token as an httpOnly cookie so the SPA's later calls authenticate. */ +export function setTokenCookie(res: ServerResponse, token: string): void { + res.setHeader( + "set-cookie", + `${TOKEN_COOKIE}=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400`, + ); +} + +function readCookie(req: IncomingMessage, name: string): string | undefined { + const raw = req.headers.cookie; + if (!raw) { + return undefined; + } + for (const part of raw.split(";")) { + const [key, ...rest] = part.trim().split("="); + if (key === name) { + return decodeURIComponent(rest.join("=")); + } + } + return undefined; +} diff --git a/sdks/node/src/dashboard/index.ts b/sdks/node/src/dashboard/index.ts index 01399bb3..522464c8 100644 --- a/sdks/node/src/dashboard/index.ts +++ b/sdks/node/src/dashboard/index.ts @@ -1,6 +1,7 @@ import type { Server } from "node:http"; import { fileURLToPath } from "node:url"; import type { Queue } from "../index"; +import type { DashboardAuth } from "./auth"; import { createDashboardServer } from "./server"; export interface DashboardOptions { @@ -10,6 +11,11 @@ export interface DashboardOptions { host?: string; /** Path to the built SPA assets. Defaults to the package's bundled `static/dashboard`. */ staticDir?: string; + /** + * Require a bearer token on every `/api/*` request (except `/api/auth/status`). + * Omit for open mode. Open `/?token=` once to use the SPA behind it. + */ + auth?: DashboardAuth; } // Built relative to dist/ at runtime. The path is assembled dynamically so the @@ -22,9 +28,14 @@ const defaultStaticDir = (): string => fileURLToPath(new URL(STATIC_REL, import. * Returns the listening HTTP server; call `.close()` to stop it. */ export function serveDashboard(queue: Queue, options: DashboardOptions = {}): Server { - const server = createDashboardServer(queue, options.staticDir ?? defaultStaticDir()); + const server = createDashboardServer( + queue, + options.staticDir ?? defaultStaticDir(), + options.auth, + ); server.listen(options.port ?? 8787, options.host ?? "127.0.0.1"); return server; } +export type { DashboardAuth } from "./auth"; export { createDashboardHandler, createDashboardServer } from "./server"; diff --git a/sdks/node/src/dashboard/server.ts b/sdks/node/src/dashboard/server.ts index add41982..7ea954d8 100644 --- a/sdks/node/src/dashboard/server.ts +++ b/sdks/node/src/dashboard/server.ts @@ -4,6 +4,13 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import type { Queue } from "../index"; import { createLogger } from "../utils"; import { WebhookValidationError } from "../webhooks"; +import { + type DashboardAuth, + isPublicApiPath, + presentedToken, + setTokenCookie, + tokenMatches, +} from "./auth"; import { routes } from "./routes"; import { StaticAssets } from "./static"; @@ -16,15 +23,17 @@ const MAX_BODY_BYTES = 1024 * 1024; * Build a Node `http` request handler that serves the dashboard SPA from `staticDir` * plus the `/api/*` JSON contract over `queue`. Use this to mount the dashboard into an * existing server (e.g. an Express or Fastify app); {@link createDashboardServer} wraps - * it in a standalone server. + * it in a standalone server. When `auth` is given, every `/api/*` request (except + * `/api/auth/status`) requires the token. */ export function createDashboardHandler( queue: Queue, staticDir: string, + auth?: DashboardAuth, ): (req: IncomingMessage, res: ServerResponse) => void { const assets = new StaticAssets(staticDir); return (req, res) => { - void dispatch(queue, assets, req, res).catch((error) => { + void dispatch(queue, assets, req, res, auth).catch((error) => { log.error(() => "dashboard dispatch failed", error); if (!res.headersSent) { sendJson(res, 500, { error: "internal server error" }); @@ -34,8 +43,12 @@ export function createDashboardHandler( } /** Build (but do not start) the dashboard server over `queue`, serving the SPA from `staticDir`. */ -export function createDashboardServer(queue: Queue, staticDir: string): Server { - return createServer(createDashboardHandler(queue, staticDir)); +export function createDashboardServer( + queue: Queue, + staticDir: string, + auth?: DashboardAuth, +): Server { + return createServer(createDashboardHandler(queue, staticDir, auth)); } async function dispatch( @@ -43,10 +56,17 @@ async function dispatch( assets: StaticAssets, req: IncomingMessage, res: ServerResponse, + auth: DashboardAuth | undefined, ): Promise { const url = new URL(req.url ?? "/", "http://localhost"); const path = url.pathname; + // A valid `?token=` bootstraps an httpOnly cookie so the SPA's later API calls + // authenticate without re-passing the token. + if (auth && url.searchParams.get("token") && tokenMatches(auth.token, presentedToken(req, url))) { + setTokenCookie(res, auth.token); + } + if (!path.startsWith("/api/")) { if (assets.serve(path, res)) { return; @@ -55,6 +75,11 @@ async function dispatch( return; } + if (auth && !isPublicApiPath(path) && !tokenMatches(auth.token, presentedToken(req, url))) { + sendJson(res, 401, { error: "unauthorized" }); + return; + } + for (const route of routes) { if (route.method !== req.method) { continue; diff --git a/sdks/node/src/errors.ts b/sdks/node/src/errors.ts index b25140ee..f56eabf0 100644 --- a/sdks/node/src/errors.ts +++ b/sdks/node/src/errors.ts @@ -1,4 +1,4 @@ -/** Base class for all Taskito SDK errors. */ +/** Base class for all Taskito SDK errors. Every error below extends this. */ export class TaskitoError extends Error { constructor(message: string) { super(message); @@ -6,6 +6,8 @@ export class TaskitoError extends Error { } } +// ── Tasks & jobs ──────────────────────────────────────────────────────────── + /** Thrown when a worker dequeues a job whose task name was never registered. */ export class TaskNotRegisteredError extends TaskitoError { constructor(readonly taskName: string) { @@ -33,6 +35,27 @@ export class JobCancelledError extends TaskitoError { } } +/** Thrown by {@link Queue.result} when a job doesn't settle within the timeout. */ +export class ResultTimeoutError extends TaskitoError { + constructor( + readonly jobId: string, + readonly timeoutMs: number, + ) { + super(`timed out after ${timeoutMs}ms waiting for job ${jobId}`); + this.name = "ResultTimeoutError"; + } +} + +// ── Queue & locks ─────────────────────────────────────────────────────────── + +/** Thrown on queue-level configuration or operational errors. */ +export class QueueError extends TaskitoError { + constructor(message: string) { + super(message); + this.name = "QueueError"; + } +} + /** Thrown by {@link Queue.withLock} when the lock is held by another owner. */ export class LockNotAcquiredError extends TaskitoError { constructor(readonly lockName: string) { @@ -48,3 +71,74 @@ export class LockLostError extends TaskitoError { this.name = "LockLostError"; } } + +// ── Serialization & validation ────────────────────────────────────────────── + +/** Thrown on serialization, deserialization, or payload-integrity failures. */ +export class SerializationError extends TaskitoError { + constructor(message: string) { + super(message); + this.name = "SerializationError"; + } +} + +/** Thrown when a `notes` object violates the structured-notes contract. */ +export class NotesValidationError extends TaskitoError { + constructor(message: string) { + super(message); + this.name = "NotesValidationError"; + } +} + +// ── Resources ─────────────────────────────────────────────────────────────── + +/** Base class for resource dependency-injection errors. */ +export class ResourceError extends TaskitoError { + constructor(message: string) { + super(message); + this.name = "ResourceError"; + } +} + +/** Thrown when resolving a resource name that was never registered. */ +export class ResourceNotFoundError extends ResourceError { + constructor(readonly resourceName: string) { + super(`No resource registered with name "${resourceName}"`); + this.name = "ResourceNotFoundError"; + } +} + +/** Thrown when a task-scoped resource is resolved at worker scope. */ +export class ResourceScopeError extends ResourceError { + constructor(readonly resourceName: string) { + super(`Resource "${resourceName}" is task-scoped and cannot be resolved at worker scope`); + this.name = "ResourceScopeError"; + } +} + +// ── Workflows ─────────────────────────────────────────────────────────────── + +/** Thrown on workflow definition, submission, or query errors. */ +export class WorkflowError extends TaskitoError { + constructor(message: string) { + super(message); + this.name = "WorkflowError"; + } +} + +// ── Predicates ────────────────────────────────────────────────────────────── + +/** Thrown when an enqueue-time predicate gate rejects the submission. */ +export class PredicateRejectedError extends TaskitoError { + constructor( + readonly taskName: string, + readonly reason?: string, + ) { + super( + reason + ? `predicate rejected enqueue of "${taskName}": ${reason}` + : `predicate rejected enqueue of "${taskName}"`, + ); + this.name = "PredicateRejectedError"; + } +} diff --git a/sdks/node/src/index.ts b/sdks/node/src/index.ts index 9733a129..65ec9ce2 100644 --- a/sdks/node/src/index.ts +++ b/sdks/node/src/index.ts @@ -1,18 +1,51 @@ export { currentJob, type JobContext } from "./context"; -export { type DashboardOptions, serveDashboard } from "./dashboard"; +export { type DashboardAuth, type DashboardOptions, serveDashboard } from "./dashboard"; export { JobCancelledError, JobFailedError, LockLostError, LockNotAcquiredError, + NotesValidationError, + PredicateRejectedError, + QueueError, + ResourceError, + ResourceNotFoundError, + ResourceScopeError, + ResultTimeoutError, + SerializationError, TaskitoError, TaskNotRegisteredError, + WorkflowError, } from "./errors"; export type { EventHandler, EventName, OutcomeEvent } from "./events"; export { Lock, type LockInfo, type LockOptions } from "./locks"; -export type { Middleware, TaskContext } from "./middleware"; +export type { EnqueueContext, Middleware, TaskContext } from "./middleware"; +export { + allOf, + anyOf, + not, + type Predicate, + type PredicateContext, +} from "./predicates"; export { Queue, type QueueOptions } from "./queue"; -export { JsonSerializer, MsgpackSerializer, type Serializer } from "./serializers"; +export { + type MockResource, + mockResource, + type ResourceContext, + type ResourceDefinition, + type ResourceMetrics, + type ResourceScope, + type ResourceStat, + useResource, +} from "./resources"; +export { type ScalerOptions, serveScaler } from "./scaler"; +export { + EncryptedSerializer, + JsonSerializer, + MsgpackSerializer, + type Serializer, + SignedSerializer, +} from "./serializers"; export type { AnyHandler, CircuitBreakerOptions, @@ -28,7 +61,9 @@ export type { RateLimit, ResultOptions, Stats, + StreamOptions, TaskHandler, + TaskLog, TaskMap, TaskOptions, WorkerInfo, @@ -48,14 +83,19 @@ export type { Delivery, Webhook, WebhookInput } from "./webhooks"; export { WebhookManager, WebhookValidationError } from "./webhooks"; export { Worker } from "./worker"; export type { + CanvasStep, + GraphEdge, + GraphNode, WorkflowAdvance, + WorkflowGraph, WorkflowHandle, WorkflowNode, WorkflowRun, WorkflowRunState, WorkflowSpec, + WorkflowStats, WorkflowStepOptions, WorkflowSubmitOptions, WorkflowWaitOptions, } from "./workflows"; -export { WorkflowBuilder, WorkflowManager } from "./workflows"; +export { WorkflowAnalysis, WorkflowBuilder, WorkflowManager } from "./workflows"; diff --git a/sdks/node/src/middleware.ts b/sdks/node/src/middleware.ts index aa09c85e..42833566 100644 --- a/sdks/node/src/middleware.ts +++ b/sdks/node/src/middleware.ts @@ -1,4 +1,5 @@ import type { OutcomeEvent } from "./events"; +import type { EnqueueOptions } from "./types"; /** Context for a task as it executes on a worker. */ export interface TaskContext { @@ -7,12 +8,27 @@ export interface TaskContext { args: unknown[]; } +/** + * Context for a job being enqueued, passed to {@link Middleware.onEnqueue} before + * serialization. Mutate `args`/`options` in place to validate, redact, or rewrite + * the job; throw to abort the enqueue. + */ +export interface EnqueueContext { + readonly taskName: string; + /** Positional args, mutable before they are serialized. */ + args: unknown[]; + /** Enqueue options, mutable before they reach the core. */ + options: EnqueueOptions; +} + /** * Cross-cutting hooks around task execution and job outcomes. Register with - * {@link Queue.use}. `before`/`after`/`onError` wrap execution (awaited, counted + * {@link Queue.use}. `onEnqueue` runs (sync) on the enqueuing side before + * serialization; `before`/`after`/`onError` wrap execution (awaited, counted * toward the timeout); the outcome hooks fire after the core decides the result. */ export interface Middleware { + onEnqueue?(ctx: EnqueueContext): void; before?(ctx: TaskContext): void | Promise; after?(ctx: TaskContext, result: unknown): void | Promise; onError?(ctx: TaskContext, error: unknown): void | Promise; diff --git a/sdks/node/src/native.ts b/sdks/node/src/native.ts index 22c2f963..44cd3938 100644 --- a/sdks/node/src/native.ts +++ b/sdks/node/src/native.ts @@ -27,6 +27,7 @@ export type { JsOutcome, JsStats, JsTaskInvocation, + JsTaskLog, JsWorkerRow, JsWorkflowAdvance, JsWorkflowNode, diff --git a/sdks/node/src/notes.ts b/sdks/node/src/notes.ts new file mode 100644 index 00000000..5f8ea024 --- /dev/null +++ b/sdks/node/src/notes.ts @@ -0,0 +1,36 @@ +import { NotesValidationError } from "./errors"; + +/** Max top-level fields in a notes object (matches the storage contract). */ +const MAX_FIELDS = 15; +/** Max encoded size of a notes object in bytes. */ +const MAX_BYTES = 4096; + +/** + * Validate structured job notes against the storage contract and return their + * canonical JSON encoding. Bounds: at most {@link MAX_FIELDS} top-level fields + * and {@link MAX_BYTES} bytes encoded. Throws {@link TaskitoError} on violation. + */ +export function encodeNotes(notes: Record): string { + const fields = Object.keys(notes).length; + if (fields > MAX_FIELDS) { + throw new NotesValidationError(`notes: at most ${MAX_FIELDS} top-level fields (got ${fields})`); + } + // JSON.stringify throws on circular refs / BigInt and returns undefined for + // unsupported roots — normalize both into the typed validation error. + let encoded: string | undefined; + try { + encoded = JSON.stringify(notes); + } catch (error) { + throw new NotesValidationError( + `notes: not serializable (${error instanceof Error ? error.message : String(error)})`, + ); + } + if (encoded === undefined) { + throw new NotesValidationError("notes: not serializable"); + } + const bytes = Buffer.byteLength(encoded, "utf8"); + if (bytes > MAX_BYTES) { + throw new NotesValidationError(`notes: encoded size ${bytes} exceeds ${MAX_BYTES} bytes`); + } + return encoded; +} diff --git a/sdks/node/src/predicates.ts b/sdks/node/src/predicates.ts new file mode 100644 index 00000000..d2ad6471 --- /dev/null +++ b/sdks/node/src/predicates.ts @@ -0,0 +1,27 @@ +/** What a predicate sees when a job is being enqueued. */ +export interface PredicateContext { + readonly taskName: string; + /** The positional args (after any `onEnqueue` rewrites). */ + readonly args: readonly unknown[]; +} + +/** + * A gate evaluated at enqueue time. Returning `false` rejects the submission + * with a {@link PredicateRejectedError}. Register one with {@link Queue.gate}. + */ +export type Predicate = (ctx: PredicateContext) => boolean; + +/** A predicate that passes only when every `predicates` member passes. */ +export function allOf(...predicates: Predicate[]): Predicate { + return (ctx) => predicates.every((predicate) => predicate(ctx)); +} + +/** A predicate that passes when any `predicates` member passes. */ +export function anyOf(...predicates: Predicate[]): Predicate { + return (ctx) => predicates.some((predicate) => predicate(ctx)); +} + +/** A predicate that passes when `predicate` fails. */ +export function not(predicate: Predicate): Predicate { + return (ctx) => !predicate(ctx); +} diff --git a/sdks/node/src/queue.ts b/sdks/node/src/queue.ts index 1d413af8..a6266199 100644 --- a/sdks/node/src/queue.ts +++ b/sdks/node/src/queue.ts @@ -3,12 +3,28 @@ import { JobFailedError, LockLostError, LockNotAcquiredError, + PredicateRejectedError, + QueueError, + ResultTimeoutError, TaskitoError, } from "./errors"; import { Emitter, type EventHandler, type EventName } from "./events"; import { Lock, type LockOptions } from "./locks"; -import type { Middleware } from "./middleware"; -import { JsQueue, type NativeQueue, type OpenOptions } from "./native"; +import type { EnqueueContext, Middleware } from "./middleware"; +import { + JsQueue, + type EnqueueOptions as NativeEnqueueOptions, + type NativeQueue, + type OpenOptions, +} from "./native"; +import { encodeNotes } from "./notes"; +import type { Predicate } from "./predicates"; +import { + type ResourceContext, + type ResourceMetrics, + ResourceRuntime, + type ResourceScope, +} from "./resources"; import { JsonSerializer, type Serializer } from "./serializers"; import type { AnyHandler, @@ -23,6 +39,8 @@ import type { RegisteredTask, ResultOptions, Stats, + StreamOptions, + TaskLog, TaskMap, TaskOptions, WorkerInfo, @@ -62,7 +80,9 @@ export class Queue { private readonly tasks = new Map(); private readonly queueLimits = new Map(); private readonly middleware: Middleware[] = []; + private readonly gates = new Map(); private readonly emitter = new Emitter(); + private readonly resources = new ResourceRuntime(); private readonly webhookManager: WebhookManager; /** Built lazily — its constructor throws on addons lacking the `workflows` feature. */ private workflowManager?: WorkflowManager; @@ -138,6 +158,16 @@ export class Queue { ); } + /** + * Register a task that receives injected resources as a trailing `deps` object + * (`handler(...args, deps)`). The `deps` param is stripped from the typed + * {@link Queue.enqueue} args. Annotate `deps` to type the injected resources. + */ + task( + name: Name, + handler: (...args: [...A, deps: D]) => R | Promise, + options: TaskOptions & { inject: readonly string[] }, + ): Queue R>>; /** * Register a task handler under `name`. Chain calls to build a typed registry — * {@link Queue.enqueue} then infers each task's argument types. @@ -146,9 +176,34 @@ export class Queue { name: Name, handler: Handler, options?: TaskOptions, - ): Queue> { + ): Queue>; + task(name: string, handler: AnyHandler, options?: TaskOptions): Queue { this.tasks.set(name, { handler, options }); - return this as unknown as Queue>; + return this as unknown as Queue; + } + + /** + * Register an injectable resource. Worker-scoped (default) values are built + * once and shared across the worker's lifetime; task-scoped values are built + * per job invocation. Reach them from a handler via `useResource(name)` or the + * declarative `inject` option on {@link Queue.task}. + */ + resource( + name: string, + factory: (ctx: ResourceContext) => T | Promise, + options?: { scope?: ResourceScope; dispose?: (value: T) => void | Promise }, + ): this { + this.resources.register(name, { + factory, + scope: options?.scope ?? "worker", + dispose: options?.dispose, + }); + return this; + } + + /** Per-resource lifecycle metrics (created / disposed / active), keyed by name. */ + resourceMetrics(): ResourceMetrics { + return this.resources.metrics(); } /** Set per-queue concurrency / rate-limit applied when a worker runs. */ @@ -161,6 +216,21 @@ export class Queue { this.middleware.push(middleware); } + /** + * Gate enqueues of `name` with a predicate evaluated at enqueue time (after + * `onEnqueue`). If it returns `false`, the enqueue throws + * {@link PredicateRejectedError}. Multiple gates on a task all must pass. + */ + gate( + name: Name, + predicate: (ctx: { taskName: Name; args: Parameters }) => boolean, + ): this { + const list = this.gates.get(name) ?? []; + list.push(predicate as Predicate); + this.gates.set(name, list); + return this; + } + /** Subscribe to a job lifecycle event. */ on(event: EventName, handler: EventHandler): void { this.emitter.on(event, handler); @@ -179,14 +249,54 @@ export class Queue { args?: Parameters, options?: EnqueueOptions, ): string { + const { payload, options: nativeOpts } = this.prepareEnqueue(name, args, options); + return this.native.enqueue(name, payload, nativeOpts); + } + + /** + * Enqueue many jobs of `name` in one storage round-trip. Each entry is its own + * typed `args` + `options`. Returns the new job ids in input order. Unlike + * {@link Queue.enqueue}, the batch path does not apply `uniqueKey` dedup. + */ + enqueueMany( + name: Name, + jobs: ReadonlyArray<{ args?: Parameters; options?: EnqueueOptions }>, + ): string[] { + const prepared = jobs.map((job) => this.prepareEnqueue(name, job.args, job.options)); + return this.native.enqueueMany(name, prepared); + } + + /** + * Merge per-task defaults, run the `onEnqueue` interception hooks, then + * serialize the args and encode the options — the shared path for + * {@link Queue.enqueue} and {@link Queue.enqueueMany}. + */ + private prepareEnqueue( + name: Name, + args: Parameters | undefined, + options: EnqueueOptions | undefined, + ): { payload: Buffer; options: NativeEnqueueOptions } { const defaults = this.tasks.get(name)?.options; const merged: EnqueueOptions = { ...options, maxRetries: options?.maxRetries ?? defaults?.maxRetries, timeoutMs: options?.timeoutMs ?? defaults?.timeoutMs, }; - const payload = Buffer.from(this.serializer.serialize(args ?? [])); - return this.native.enqueue(name, payload, merged); + // Interception seam: let middleware validate/redact/rewrite before serializing. + const ctx: EnqueueContext = { taskName: name, args: [...(args ?? [])], options: merged }; + for (const mw of this.middleware) { + mw.onEnqueue?.(ctx); + } + // Gate: predicates see the (possibly rewritten) args and may reject the enqueue. + for (const gate of this.gates.get(name) ?? []) { + if (!gate({ taskName: name, args: ctx.args })) { + throw new PredicateRejectedError(name); + } + } + return { + payload: Buffer.from(this.serializer.serialize(ctx.args)), + options: toNativeEnqueueOptions(ctx.options), + }; } /** Fetch a job by id, or `null` if unknown. */ @@ -242,7 +352,47 @@ export class Queue { } await new Promise((resolve) => setTimeout(resolve, pollMs)); } - throw new TaskitoError(`timed out waiting for job ${id}`); + throw new ResultTimeoutError(id, timeoutMs); + } + + /** + * Async-iterate the partial results a job publishes via `currentJob().publish()`, + * in order, until the job terminates (or the timeout elapses). Each value is the + * JSON-deserialized argument passed to `publish`. + */ + async *stream(id: string, options?: StreamOptions): AsyncIterableIterator { + const timeoutMs = options?.timeoutMs ?? 60_000; + const pollMs = options?.pollMs ?? 200; + const deadline = Date.now() + timeoutMs; + const seen = new Set(); + for (;;) { + yield* this.newPartials(id, seen); + const job = this.native.getJob(id); + if (job && TERMINAL_STATUSES.has(job.status)) { + yield* this.newPartials(id, seen); // final drain for values committed at completion + return; + } + if (Date.now() >= deadline) { + return; + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + } + + /** Raw task-log entries for a job (oldest first), including published partials. */ + taskLogs(id: string): TaskLog[] { + return this.native.getTaskLogs(id); + } + + /** Yield not-yet-seen partial-result entries (dedup by id, robust to same-ms writes). */ + private *newPartials(id: string, seen: Set): Generator { + for (const log of this.native.getTaskLogs(id)) { + if (log.level !== STREAM_LEVEL || seen.has(log.id)) { + continue; + } + seen.add(log.id); + yield decodePartial(log.extra); + } } /** Job counts by status across all queues. */ @@ -328,16 +478,43 @@ export class Queue { serializer: this.serializer, middleware: this.middleware, emitter: this.emitter, + resources: this.resources, run: options, }); } } +/** Log level used for published partial results (matches the cross-SDK contract). */ +const STREAM_LEVEL = "result"; +/** Job statuses at which a stream stops. */ +const TERMINAL_STATUSES = new Set(["complete", "failed", "dead", "cancelled"]); + +/** Decode a partial-result log's `extra` (JSON, falling back to the raw string). */ +function decodePartial(extra: string | null | undefined): unknown { + if (!extra) { + return undefined; + } + try { + return JSON.parse(extra); + } catch { + return extra; + } +} + +/** + * Convert public enqueue options to the native shape: structured `notes` is + * validated and encoded to canonical JSON; all other fields pass through. + */ +function toNativeEnqueueOptions(options: EnqueueOptions): NativeEnqueueOptions { + const { notes, ...rest } = options; + return notes === undefined ? rest : { ...rest, notes: encodeNotes(notes) }; +} + /** Resolve a {@link QueueOptions} into the native open options. */ function toOpenOptions(options: QueueOptions): OpenOptions { const dsn = options.dsn ?? options.dbPath; if (!dsn) { - throw new TaskitoError("Queue requires `dbPath` (SQLite) or `dsn`"); + throw new QueueError("Queue requires `dbPath` (SQLite) or `dsn`"); } return { backend: options.backend ?? (options.dbPath ? "sqlite" : undefined), diff --git a/sdks/node/src/resources/context.ts b/sdks/node/src/resources/context.ts new file mode 100644 index 00000000..faed1b03 --- /dev/null +++ b/sdks/node/src/resources/context.ts @@ -0,0 +1,25 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { ResourceError } from "../errors"; +import type { ResourceResolver } from "./types"; + +const store = new AsyncLocalStorage(); + +/** + * Resolve a registered resource from inside a running task. Worker-scoped + * resources are shared singletons; task-scoped ones are built per invocation and + * cached for the rest of it. Throws if called outside a task. + */ +export function useResource(name: string): Promise { + const resolver = store.getStore(); + if (!resolver) { + throw new ResourceError( + `useResource("${name}") called outside a task — only available while a handler runs`, + ); + } + return resolver(name) as Promise; +} + +/** Bind `resolver` as the ambient resource resolver while `fn` runs. @internal */ +export function runWithResolver(resolver: ResourceResolver, fn: () => T): T { + return store.run(resolver, fn); +} diff --git a/sdks/node/src/resources/index.ts b/sdks/node/src/resources/index.ts new file mode 100644 index 00000000..3ccc5fc8 --- /dev/null +++ b/sdks/node/src/resources/index.ts @@ -0,0 +1,11 @@ +export { runWithResolver, useResource } from "./context"; +export { type MockResource, mockResource } from "./mock"; +export { ResourceRuntime, type TaskScope } from "./runtime"; +export type { + ResourceContext, + ResourceDefinition, + ResourceMetrics, + ResourceResolver, + ResourceScope, + ResourceStat, +} from "./types"; diff --git a/sdks/node/src/resources/mock.ts b/sdks/node/src/resources/mock.ts new file mode 100644 index 00000000..2e065b82 --- /dev/null +++ b/sdks/node/src/resources/mock.ts @@ -0,0 +1,26 @@ +/** A test double for a resource: a fixed value plus a factory that records use. */ +export interface MockResource { + /** The value the factory returns. */ + value: T; + /** Pass to `queue.resource(name, mock.factory)`. */ + factory: () => T; + /** How many times the factory was invoked (i.e. the resource was built). */ + resolutions: number; +} + +/** + * Build a {@link MockResource} wrapping `value`, for swapping a real resource for + * a stub in tests. Register it with `queue.resource(name, mock.factory)` and + * assert on `mock.resolutions` to confirm the resource was built. + */ +export function mockResource(value: T): MockResource { + const mock: MockResource = { + value, + resolutions: 0, + factory: () => { + mock.resolutions += 1; + return mock.value; + }, + }; + return mock; +} diff --git a/sdks/node/src/resources/runtime.ts b/sdks/node/src/resources/runtime.ts new file mode 100644 index 00000000..d9e6d858 --- /dev/null +++ b/sdks/node/src/resources/runtime.ts @@ -0,0 +1,219 @@ +import { ResourceNotFoundError, ResourceScopeError } from "../errors"; +import type { + ResourceContext, + ResourceDefinition, + ResourceMetrics, + ResourceResolver, +} from "./types"; + +/** A disposal thunk plus the resource name, for error context. */ +interface Teardown { + name: string; + run: () => void | Promise; +} + +/** Per-invocation task scope: caches task-scoped resources and disposes them. */ +export interface TaskScope { + /** Resolve a resource for this invocation (task cache first, then worker). */ + readonly resolver: ResourceResolver; + /** Dispose the task-scoped resources built during this invocation, LIFO. */ + teardown(): Promise; +} + +/** + * Registry + lifecycle for injectable resources. Worker-scoped values are built + * once and shared; task-scoped values are built per invocation. The worker wires + * this into task execution; tasks reach values via `useResource` or declarative + * `inject`. + * + * Every resolver always returns a promise — guard and factory failures surface as + * rejections, never synchronous throws — so a failed build is awaited and retried, + * not cached. + */ +export class ResourceRuntime { + private readonly defs = new Map(); + private readonly workerCache = new Map>(); + private readonly workerTeardown: Teardown[] = []; + private readonly counters = new Map(); + /** Active worker leases sharing this runtime; teardown disposes only at zero. */ + private workerLeases = 0; + + /** Register (or replace) a resource definition. */ + register(name: string, definition: ResourceDefinition): void { + this.defs.set(name, definition as ResourceDefinition); + } + + /** True when nothing is registered — lets the worker skip resource wiring. */ + get isEmpty(): boolean { + return this.defs.size === 0; + } + + /** Snapshot of per-resource lifecycle counters (created / disposed / active). */ + metrics(): ResourceMetrics { + const out: ResourceMetrics = {}; + for (const [name, c] of this.counters) { + out[name] = { created: c.created, disposed: c.disposed, active: c.created - c.disposed }; + } + return out; + } + + /** Get-or-create the mutable counter for a resource. */ + private counter(name: string): { created: number; disposed: number } { + let c = this.counters.get(name); + if (!c) { + c = { created: 0, disposed: 0 }; + this.counters.set(name, c); + } + return c; + } + + /** Build a worker-scoped resource once, memoizing the promise (dedups concurrent init). */ + private resolveWorker(name: string): Promise { + const cached = this.workerCache.get(name); + if (cached) { + return cached; + } + const def = this.defs.get(name); + if (!def) { + return Promise.reject(unregistered(name)); + } + if (def.scope !== "worker") { + return Promise.reject(new ResourceScopeError(name)); + } + const ctx: ResourceContext = { + scope: "worker", + use: (dep: string) => this.resolveWorker(dep) as Promise, + }; + const counter = this.counter(name); + counter.created += 1; + // Register the pending promise BEFORE the factory body runs (deferred a + // microtask) so a resource that resolves a dependency on itself reuses this + // promise instead of recursing into resolveWorker and overflowing the stack. + const built = Promise.resolve() + .then(() => startFactory(def, ctx)) + .catch((error) => { + counter.created -= 1; // a failed build is not a live resource + this.workerCache.delete(name); // failed build is retryable, not cached + throw error; + }); + this.workerCache.set(name, built); + this.trackResource(this.workerTeardown, name, def, built); + return built; + } + + /** Begin a per-invocation task scope. */ + createTaskScope(): TaskScope { + const taskCache = new Map>(); + const taskTeardown: Teardown[] = []; + + const resolve: ResourceResolver = (name) => { + const def = this.defs.get(name); + if (!def) { + return Promise.reject(unregistered(name)); + } + if (def.scope === "worker") { + return this.resolveWorker(name); // shared singleton, even when first reached here + } + const cached = taskCache.get(name); + if (cached) { + return cached; + } + const ctx: ResourceContext = { + scope: "task", + use: (dep: string) => resolve(dep) as Promise, + }; + const counter = this.counter(name); + counter.created += 1; + // Cache before the factory runs (see resolveWorker) so a self-referential + // task resource reuses this promise instead of recursing. + const built = Promise.resolve() + .then(() => startFactory(def, ctx)) + .catch((error) => { + counter.created -= 1; // a failed build is not a live resource + taskCache.delete(name); // failed build is retryable, not cached + throw error; + }); + taskCache.set(name, built); + this.trackResource(taskTeardown, name, def, built); + return built; + }; + + return { resolver: resolve, teardown: () => runTeardown(taskTeardown) }; + } + + /** Register a worker that shares this runtime's worker-scoped resources. */ + acquireWorker(): void { + this.workerLeases += 1; + } + + /** + * Release one worker lease; dispose worker-scoped resources (LIFO) and reset + * the caches only once the last sharing worker has stopped. This keeps a + * second `runWorker()` on the same queue from tearing down resources the + * first worker is still using. + */ + async teardownWorker(): Promise { + if (this.workerLeases > 0) { + this.workerLeases -= 1; + } + if (this.workerLeases > 0) { + return; + } + const pending = this.workerTeardown.splice(0); + this.workerCache.clear(); + await runTeardown(pending); + } + + /** + * Queue a teardown thunk for a resource: run its `dispose` (if any) and record + * the disposal. A build that failed is skipped — there's nothing to tear down. + */ + private trackResource( + stack: Teardown[], + name: string, + def: ResourceDefinition, + built: Promise, + ): void { + const { dispose } = def; + const counter = this.counter(name); + stack.push({ + name, + run: async () => { + let value: unknown; + try { + value = await built; + } catch { + return; // build failed (and was already un-counted) — nothing to dispose + } + if (dispose) { + await dispose(value); + } + counter.disposed += 1; + }, + }); + } +} + +/** Run a factory, converting a synchronous throw into a rejected promise. */ +function startFactory(def: ResourceDefinition, ctx: ResourceContext): Promise { + return new Promise((resolve) => resolve(def.factory(ctx))); +} + +function unregistered(name: string): ResourceNotFoundError { + return new ResourceNotFoundError(name); +} + +/** Run disposal thunks in reverse (LIFO) order; surface the first error after all run. */ +async function runTeardown(stack: Teardown[]): Promise { + let firstError: unknown; + for (let i = stack.length - 1; i >= 0; i--) { + try { + await stack[i]?.run(); + } catch (error) { + firstError ??= error; + } + } + if (firstError !== undefined) { + throw firstError; + } +} diff --git a/sdks/node/src/resources/types.ts b/sdks/node/src/resources/types.ts new file mode 100644 index 00000000..446d3c3d --- /dev/null +++ b/sdks/node/src/resources/types.ts @@ -0,0 +1,40 @@ +/** Lifetime of a registered resource. */ +export type ResourceScope = "worker" | "task"; + +/** + * Passed to a resource factory so it can depend on other resources. A + * worker-scoped factory may only depend on other worker-scoped resources; a + * task-scoped factory may depend on either. + */ +export interface ResourceContext { + /** Scope of the resource currently being built. */ + readonly scope: ResourceScope; + /** Resolve a dependency resource by name. */ + use(name: string): Promise; +} + +/** How to build — and optionally tear down — a resource of type `T`. */ +export interface ResourceDefinition { + /** Builds the value. May be async and may depend on other resources via `ctx.use`. */ + factory: (ctx: ResourceContext) => T | Promise; + /** When the value is created and disposed. */ + scope: ResourceScope; + /** Tear-down hook, run LIFO when the scope ends (worker stop / job finish). */ + dispose?: (value: T) => void | Promise; +} + +/** Resolves a resource by name within the current scope. @internal */ +export type ResourceResolver = (name: string) => Promise; + +/** Lifecycle counters for one resource. */ +export interface ResourceStat { + /** Successful factory builds so far. */ + created: number; + /** Disposals run so far. */ + disposed: number; + /** Currently-live instances (`created - disposed`). */ + active: number; +} + +/** Per-resource lifecycle metrics, keyed by resource name. */ +export type ResourceMetrics = Record; diff --git a/sdks/node/src/scaler/index.ts b/sdks/node/src/scaler/index.ts new file mode 100644 index 00000000..69252d1a --- /dev/null +++ b/sdks/node/src/scaler/index.ts @@ -0,0 +1 @@ +export { type ScalerOptions, serveScaler } from "./server"; diff --git a/sdks/node/src/scaler/server.ts b/sdks/node/src/scaler/server.ts new file mode 100644 index 00000000..908cc1f9 --- /dev/null +++ b/sdks/node/src/scaler/server.ts @@ -0,0 +1,69 @@ +import { createServer, type Server, type ServerResponse } from "node:http"; +import type { Queue } from "../queue"; +import { createLogger } from "../utils"; + +const log = createLogger("scaler"); + +/** Options for {@link serveScaler}. */ +export interface ScalerOptions { + /** Port to listen on (default 9091). */ + port?: number; + /** Host to bind (default `0.0.0.0`, so KEDA in-cluster can reach it). */ + host?: string; + /** Target queue depth per replica returned to KEDA (default 10). */ + targetQueueDepth?: number; + /** Restrict the metric to one queue (default: all queues). Overridable per request via `?queue=`. */ + queue?: string; +} + +/** + * Start the KEDA scaler server over `queue`. KEDA's `metrics-api` scaler polls + * `GET /api/scaler` for the current queue depth and target, then scales the + * worker deployment toward `ceil(metricValue / targetValue)` replicas. + * + * Endpoints: + * - `GET /api/scaler[?queue=]` → `{ metricValue, targetValue, queueName }` + * - `GET /health` → `{ status: "ok" }` + * + * Returns the listening server; call `.close()` to stop it. + */ +export function serveScaler(queue: Queue, options: ScalerOptions = {}): Server { + const targetValue = options.targetQueueDepth ?? 10; + // A non-positive target makes KEDA's ceil(metric / target) divide-by-zero or + // scale unboundedly, so reject it up front rather than serving a bad metric. + if (!Number.isFinite(targetValue) || targetValue <= 0) { + throw new RangeError(`targetQueueDepth must be a positive number, got ${targetValue}`); + } + const defaultQueue = options.queue; + + const server = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://localhost"); + if (url.pathname === "/health") { + sendJson(res, 200, { status: "ok" }); + return; + } + if (url.pathname === "/api/scaler" && req.method === "GET") { + const queueName = url.searchParams.get("queue") ?? defaultQueue; + try { + const metricValue = queueName + ? queue.statsByQueue(queueName).pending + : queue.stats().pending; + sendJson(res, 200, { metricValue, targetValue, queueName: queueName ?? "*" }); + } catch (error) { + // Keep backend/path details in the logs; never echo them to the caller. + log.error(() => "scaler metric read failed", error); + sendJson(res, 500, { error: "internal server error" }); + } + return; + } + sendJson(res, 404, { error: "not found" }); + }); + + server.listen(options.port ?? 9091, options.host ?? "0.0.0.0"); + return server; +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} diff --git a/sdks/node/src/serializers/encrypted.ts b/sdks/node/src/serializers/encrypted.ts new file mode 100644 index 00000000..4adc4960 --- /dev/null +++ b/sdks/node/src/serializers/encrypted.ts @@ -0,0 +1,62 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; +import { SerializationError } from "../errors"; +import { JsonSerializer } from "./json"; +import type { Serializer } from "./serializer"; + +const ALGORITHM = "aes-256-gcm"; +/** GCM nonce length in bytes. */ +const IV_BYTES = 12; +/** GCM authentication-tag length in bytes. */ +const TAG_BYTES = 16; + +/** + * Wraps another serializer with AES-256-GCM authenticated encryption. Each + * payload is laid out as `[iv | authTag | ciphertext]`; {@link deserialize} + * decrypts and verifies the GCM tag, rejecting tampered or wrong-key payloads. + * The 32-byte key is derived from `secret` via SHA-256, so producers and workers + * need only share the secret string. + * + * Provides confidentiality **and** integrity — a superset of + * {@link SignedSerializer}, which authenticates without hiding the body. + */ +export class EncryptedSerializer implements Serializer { + private readonly key: Buffer; + private readonly inner: Serializer; + + constructor(secret: string, inner: Serializer = new JsonSerializer()) { + if (!secret) { + throw new SerializationError("EncryptedSerializer requires a non-empty secret"); + } + this.key = createHash("sha256").update(secret).digest(); + this.inner = inner; + } + + serialize(value: unknown): Uint8Array { + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(ALGORITHM, this.key, iv); + const body = Buffer.from(this.inner.serialize(value)); + const ciphertext = Buffer.concat([cipher.update(body), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]); + } + + deserialize(bytes: Uint8Array): unknown { + const buf = Buffer.from(bytes); + if (buf.length < IV_BYTES + TAG_BYTES) { + throw new SerializationError("EncryptedSerializer: payload too short to be encrypted"); + } + const iv = buf.subarray(0, IV_BYTES); + const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES); + const ciphertext = buf.subarray(IV_BYTES + TAG_BYTES); + const decipher = createDecipheriv(ALGORITHM, this.key, iv); + decipher.setAuthTag(tag); + let body: Buffer; + try { + body = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + } catch { + throw new SerializationError( + "EncryptedSerializer: decryption failed (tampered payload or wrong secret)", + ); + } + return this.inner.deserialize(body); + } +} diff --git a/sdks/node/src/serializers/index.ts b/sdks/node/src/serializers/index.ts index b9603b3e..bf994963 100644 --- a/sdks/node/src/serializers/index.ts +++ b/sdks/node/src/serializers/index.ts @@ -1,3 +1,5 @@ +export { EncryptedSerializer } from "./encrypted"; export { JsonSerializer } from "./json"; export { MsgpackSerializer } from "./msgpack"; export type { Serializer } from "./serializer"; +export { SignedSerializer } from "./signed"; diff --git a/sdks/node/src/serializers/signed.ts b/sdks/node/src/serializers/signed.ts new file mode 100644 index 00000000..48341ff9 --- /dev/null +++ b/sdks/node/src/serializers/signed.ts @@ -0,0 +1,55 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { SerializationError } from "../errors"; +import { JsonSerializer } from "./json"; +import type { Serializer } from "./serializer"; + +/** HMAC-SHA256 digest length in bytes — the fixed-size tag prefixed to each payload. */ +const TAG_BYTES = 32; + +/** + * Wraps another serializer, prefixing every payload with an HMAC-SHA256 tag + * keyed by a shared secret. {@link deserialize} recomputes and verifies the tag + * (timing-safe) before delegating, so a tampered, truncated, or wrong-secret + * payload is rejected instead of decoded. + * + * Producers and workers must share the same secret. This authenticates payload + * integrity and origin — it does **not** encrypt; the body is still readable by + * anyone with storage access. + */ +export class SignedSerializer implements Serializer { + private readonly secret: string; + private readonly inner: Serializer; + + constructor(secret: string, inner: Serializer = new JsonSerializer()) { + if (!secret) { + throw new SerializationError("SignedSerializer requires a non-empty secret"); + } + this.secret = secret; + this.inner = inner; + } + + serialize(value: unknown): Uint8Array { + const body = Buffer.from(this.inner.serialize(value)); + const tag = this.tagFor(body); + return Buffer.concat([tag, body]); + } + + deserialize(bytes: Uint8Array): unknown { + const buf = Buffer.from(bytes); + if (buf.length < TAG_BYTES) { + throw new SerializationError("SignedSerializer: payload too short to be signed"); + } + const tag = buf.subarray(0, TAG_BYTES); + const body = buf.subarray(TAG_BYTES); + if (!timingSafeEqual(tag, this.tagFor(body))) { + throw new SerializationError( + "SignedSerializer: signature mismatch (tampered or wrong secret)", + ); + } + return this.inner.deserialize(body); + } + + private tagFor(body: Buffer): Buffer { + return createHmac("sha256", this.secret).update(body).digest(); + } +} diff --git a/sdks/node/src/types.ts b/sdks/node/src/types.ts index ca1e098e..a1887fd4 100644 --- a/sdks/node/src/types.ts +++ b/sdks/node/src/types.ts @@ -1,18 +1,32 @@ -import type { CircuitBreakerInput, MeshWorkerConfig } from "./native"; +import type { + CircuitBreakerInput, + MeshWorkerConfig, + EnqueueOptions as NativeEnqueueOptions, +} from "./native"; export type { CircuitBreakerInput as CircuitBreakerOptions, - EnqueueOptions, JobFilter, JsDeadJob as DeadJob, JsJob as Job, JsJobError as JobError, JsMetric as Metric, JsStats as Stats, + JsTaskLog as TaskLog, JsWorkerRow as WorkerInfo, MeshWorkerConfig, } from "./native"; +/** + * Per-job enqueue options. Mirrors the native options, but `notes` is a + * structured object here (validated and JSON-encoded before it reaches the + * core) rather than a pre-encoded string. + */ +export interface EnqueueOptions extends Omit { + /** Structured annotations stored on the job — at most 15 fields, 4 KiB encoded. */ + notes?: Record; +} + /** Options for {@link Queue.result}. */ export interface ResultOptions { /** Max time to wait for a terminal state (ms). Default 30000. */ @@ -21,6 +35,14 @@ export interface ResultOptions { pollMs?: number; } +/** Options for {@link Queue.stream}. */ +export interface StreamOptions { + /** Max time to wait for the job to terminate (ms). Default 60000. */ + timeoutMs?: number; + /** Poll interval (ms). Default 200. */ + pollMs?: number; +} + /** * A registered task: receives the deserialized positional args and returns a * (possibly async) result. @@ -53,6 +75,12 @@ export interface TaskOptions { rateLimit?: RateLimit; /** Trip the task's circuit breaker after repeated failures. */ circuitBreaker?: CircuitBreakerInput; + /** + * Resource names injected as a trailing `deps` object: the handler is called + * `handler(...args, deps)`. Use the {@link Queue.task} `inject` overload so the + * `deps` param is stripped from the typed `enqueue` args. + */ + inject?: readonly string[]; } /** Options for {@link Queue.registerPeriodic}. */ diff --git a/sdks/node/src/worker.ts b/sdks/node/src/worker.ts index bd4f05c0..4cd15c79 100644 --- a/sdks/node/src/worker.ts +++ b/sdks/node/src/worker.ts @@ -11,10 +11,12 @@ import type { QueueConfigInput, TaskConfigInput, } from "./native"; +import { type ResourceRuntime, runWithResolver } from "./resources"; import type { Serializer } from "./serializers"; import type { QueueLimits, RegisteredTask, WorkerRunOptions } from "./types"; import { createLogger } from "./utils"; import { WorkflowTracker } from "./workflows"; +import { CACHE_TASK } from "./workflows/cache"; const log = createLogger("worker"); @@ -36,12 +38,16 @@ export interface WorkerStartParams { serializer: Serializer; middleware: readonly Middleware[]; emitter: Emitter; + resources: ResourceRuntime; run?: WorkerRunOptions; } /** A running worker. Hold it for the worker's lifetime; call {@link Worker.stop}. */ export class Worker { - private constructor(private readonly native: NativeWorker) {} + private constructor( + private readonly native: NativeWorker, + private readonly resources: ResourceRuntime, + ) {} /** * Start a worker from a queue's task registry. Use {@link Queue.runWorker} @@ -50,7 +56,7 @@ export class Worker { * @internal */ static start(queue: NativeQueue, params: WorkerStartParams): Worker { - const { tasks, queueLimits, serializer, middleware, emitter, run } = params; + const { tasks, queueLimits, serializer, middleware, emitter, resources, run } = params; // Advance workflow runs as node-jobs settle, unless disabled or unsupported. const tracker = @@ -59,6 +65,11 @@ export class Worker { : null; const taskCallback = async (invocation: JsTaskInvocation): Promise => { + // Built-in workflow cache-return: echo the single (cached) arg as the result. + if (invocation.taskName === CACHE_TASK) { + const [value] = serializer.deserialize(invocation.payload) as unknown[]; + return Buffer.from(serializer.serialize(value)); + } const task = tasks.get(invocation.taskName); if (!task) { throw new TaskNotRegisteredError(invocation.taskName); @@ -72,6 +83,14 @@ export class Worker { jobId: invocation.id, signal: controller.signal, setProgress: (progress) => queue.updateProgress(invocation.id, progress), + publish: (value) => + queue.writeTaskLog( + invocation.id, + invocation.taskName, + "result", + "", + JSON.stringify(value), + ), }; const poller = setInterval(() => { try { @@ -85,11 +104,25 @@ export class Worker { }, CANCEL_POLL_INTERVAL_MS); poller.unref(); + // Per-invocation resource scope; `useResource`/`inject` resolve against it. + const scope = resources.createTaskScope(); + const invoke = async (): Promise => { + const inject = task.options?.inject; + if (inject && inject.length > 0) { + const deps: Record = {}; + for (const name of inject) { + deps[name] = await scope.resolver(name); + } + return task.handler(...args, deps); + } + return task.handler(...args); + }; + try { for (const mw of middleware) { await mw.before?.(ctx); } - const result = await runInContext(context, () => task.handler(...args)); + const result = await runWithResolver(scope.resolver, () => runInContext(context, invoke)); for (const mw of middleware) { await mw.after?.(ctx, result); } @@ -105,6 +138,12 @@ export class Worker { throw error; } finally { clearInterval(poller); + try { + await scope.teardown(); + } catch (error) { + // dispose errors must not fail an already-settled job + log.debug(() => `task-scope teardown for ${invocation.id} failed`, error); + } } }; @@ -142,12 +181,22 @@ export class Worker { queueConfigs: buildQueueConfigs(queueLimits), mesh: run?.mesh, }; - return new Worker(queue.runWorker(taskCallback, outcomeCallback, nativeOptions)); + const native = queue.runWorker(taskCallback, outcomeCallback, nativeOptions); + // Lease the shared resource runtime only once the native worker actually + // started, so its worker-scoped values survive until the last worker on this + // queue stops (see ResourceRuntime). A failed start leaks no lease. + resources.acquireWorker(); + return new Worker(native, resources); } /** Stop the worker; in-flight results drain before background tasks exit. */ stop(): void { this.native.stop(); + // Dispose worker-scoped resources after the native worker quiesces. Best + // effort: lazy resources mean this is a no-op when none were built. + void this.resources.teardownWorker().catch((error) => { + log.debug(() => "worker-scope resource teardown failed", error); + }); } } diff --git a/sdks/node/src/workflows/analysis.ts b/sdks/node/src/workflows/analysis.ts new file mode 100644 index 00000000..cbc5ad19 --- /dev/null +++ b/sdks/node/src/workflows/analysis.ts @@ -0,0 +1,225 @@ +import type { WorkflowNode } from "./types"; + +/** A node in the serialized workflow graph. */ +export interface GraphNode { + name: string; +} + +/** A directed dependency edge (`from` runs before `to`). */ +export interface GraphEdge { + from: string; + to: string; + weight?: number; +} + +/** The serialized workflow graph, as returned (JSON) by `workflows.dag()`. */ +export interface WorkflowGraph { + nodes: GraphNode[]; + edges: GraphEdge[]; +} + +/** Aggregate counts for a run's nodes. */ +export interface WorkflowStats { + /** Total node count. */ + total: number; + /** Count keyed by lowercase node status. */ + byStatus: Record; + completed: number; + failed: number; + running: number; + /** Nodes neither running nor terminal (pending/ready/waiting). */ + pending: number; +} + +const TERMINAL_FAILURE = new Set(["failed", "compensation_failed"]); + +/** + * Structural and status analysis of a workflow run's DAG. Built from the graph + * (`workflows.dag()`) plus per-node statuses (`workflows.nodes()`); all methods + * are pure graph computation over that snapshot. + * + * Obtain one via `queue.workflows.analyze(runId)`. + */ +export class WorkflowAnalysis { + /** Node names in graph order. */ + readonly nodeNames: readonly string[]; + private readonly predecessors = new Map(); + private readonly successors = new Map(); + private readonly nodeByName = new Map(); + + constructor(graph: WorkflowGraph, nodes: readonly WorkflowNode[]) { + this.nodeNames = graph.nodes.map((node) => node.name); + for (const name of this.nodeNames) { + this.predecessors.set(name, []); + this.successors.set(name, []); + } + for (const edge of graph.edges) { + this.successors.get(edge.from)?.push(edge.to); + this.predecessors.get(edge.to)?.push(edge.from); + } + for (const node of nodes) { + this.nodeByName.set(node.nodeName, node); + } + } + + /** The status record for a node, or `undefined` if it has none yet. */ + node(name: string): WorkflowNode | undefined { + return this.nodeByName.get(name); + } + + /** Entry nodes — those with no predecessors. */ + roots(): string[] { + return this.nodeNames.filter((name) => this.predecessors.get(name)?.length === 0); + } + + /** Exit nodes — those with no successors. */ + leaves(): string[] { + return this.nodeNames.filter((name) => this.successors.get(name)?.length === 0); + } + + /** All transitive predecessors of `name` (its upstream dependencies). */ + ancestors(name: string): string[] { + return this.reachable(name, this.predecessors); + } + + /** All transitive successors of `name` (everything that depends on it). */ + descendants(name: string): string[] { + return this.reachable(name, this.successors); + } + + /** A topological ordering (Kahn's algorithm); throws on a cycle. */ + topologicalOrder(): string[] { + const indegree = new Map(); + for (const name of this.nodeNames) { + indegree.set(name, this.predecessors.get(name)?.length ?? 0); + } + const queue = this.nodeNames.filter((name) => indegree.get(name) === 0); + const order: string[] = []; + while (queue.length > 0) { + const name = queue.shift() as string; + order.push(name); + for (const next of this.successors.get(name) ?? []) { + const remaining = (indegree.get(next) ?? 0) - 1; + indegree.set(next, remaining); + if (remaining === 0) { + queue.push(next); + } + } + } + if (order.length !== this.nodeNames.length) { + throw new Error("workflow graph has a cycle"); + } + return order; + } + + /** Nodes grouped by dependency depth: level 0 = roots, level n = longest path from a root. */ + topologicalLevels(): string[][] { + const depth = this.longestDepth(); + const levels: string[][] = []; + for (const name of this.nodeNames) { + const d = depth.get(name) ?? 0; + let bucket = levels[d]; + if (!bucket) { + bucket = []; + levels[d] = bucket; + } + bucket.push(name); + } + return levels.map((level) => level ?? []); + } + + /** + * The structural critical path — the longest chain of dependencies, by node + * count. Returned as the node sequence from a root to a leaf. + */ + criticalPath(): string[] { + const order = this.topologicalOrder(); + const length = new Map(); + const parent = new Map(); + let endNode = order[0] ?? null; + let best = 0; + for (const name of order) { + let bestPred: string | null = null; + let bestLen = 0; + for (const pred of this.predecessors.get(name) ?? []) { + const len = length.get(pred) ?? 0; + if (len > bestLen) { + bestLen = len; + bestPred = pred; + } + } + length.set(name, bestLen + 1); + parent.set(name, bestPred); + if (bestLen + 1 > best) { + best = bestLen + 1; + endNode = name; + } + } + const path: string[] = []; + for (let at = endNode; at !== null; at = parent.get(at) ?? null) { + path.push(at); + } + return path.reverse(); + } + + /** Aggregate node counts by status. */ + stats(): WorkflowStats { + const byStatus: Record = {}; + let completed = 0; + let failed = 0; + let running = 0; + // `skipped` is terminal (a pruned branch never runs), so it must not fall + // into `pending` via the remainder below. + let skipped = 0; + for (const name of this.nodeNames) { + const status = this.nodeByName.get(name)?.status ?? "pending"; + byStatus[status] = (byStatus[status] ?? 0) + 1; + if (status === "completed") { + completed += 1; + } else if (TERMINAL_FAILURE.has(status)) { + failed += 1; + } else if (status === "running") { + running += 1; + } else if (status === "skipped") { + skipped += 1; + } + } + const total = this.nodeNames.length; + return { + total, + byStatus, + completed, + failed, + running, + pending: total - completed - failed - running - skipped, + }; + } + + /** Transitive closure of `name` over `edges`, excluding `name` itself. */ + private reachable(name: string, edges: Map): string[] { + const seen = new Set(); + const stack = [...(edges.get(name) ?? [])]; + while (stack.length > 0) { + const next = stack.pop() as string; + if (seen.has(next)) { + continue; + } + seen.add(next); + stack.push(...(edges.get(next) ?? [])); + } + return [...seen]; + } + + /** Longest-path depth from any root for every node (memoized over topo order). */ + private longestDepth(): Map { + const depth = new Map(); + for (const name of this.topologicalOrder()) { + let max = 0; + for (const pred of this.predecessors.get(name) ?? []) { + max = Math.max(max, (depth.get(pred) ?? 0) + 1); + } + depth.set(name, max); + } + return depth; + } +} diff --git a/sdks/node/src/workflows/builder.ts b/sdks/node/src/workflows/builder.ts index c57cdf30..7872c312 100644 --- a/sdks/node/src/workflows/builder.ts +++ b/sdks/node/src/workflows/builder.ts @@ -1,5 +1,7 @@ +import { WorkflowError } from "../errors"; import { successorsOf, transitiveDeferred } from "./plan"; import type { + CanvasStep, FanInStepOptions, FanOutStepOptions, GateStepOptions, @@ -52,11 +54,15 @@ export class WorkflowBuilder { priority: options.priority, condition: options.condition, compensate: options.compensate, + cache: options.cache + ? JSON.stringify(options.cache === true ? {} : options.cache) + : undefined, }; this.stepArgs[name] = options.args ?? []; - // A conditioned step is enqueued by the tracker once its predecessors - // settle (so it can evaluate the condition), not statically at submit. - if (options.condition) { + // Conditioned and cacheable steps are enqueued by the tracker once their + // predecessors settle (to evaluate the condition / check the cache), not + // statically at submit. + if (options.condition || options.cache) { this.deferredSeeds.add(name); } return this; @@ -134,24 +140,78 @@ export class WorkflowBuilder { return this; } + /** + * Canvas shorthand — add a sequential chain where each step runs after the + * previous one. The first runs after `options.after` (or as a root). + */ + chain(steps: CanvasStep[], options: { after?: string | string[] } = {}): this { + let after = options.after; + for (const { name, task, ...stepOptions } of steps) { + this.step(name, task, { ...stepOptions, after }); + after = name; + } + return this; + } + + /** + * Canvas shorthand — add a parallel group where every step runs after + * `options.after` (or as roots). + */ + group(steps: CanvasStep[], options: { after?: string | string[] } = {}): this { + for (const { name, task, ...stepOptions } of steps) { + this.step(name, task, { ...stepOptions, after: options.after }); + } + return this; + } + + /** + * Canvas shorthand — a parallel `steps` group joined by `callback`, which runs + * once every group member completes. The callback gets its own `args`; to + * aggregate the members' *results*, use {@link WorkflowBuilder.fanOut} / + * {@link WorkflowBuilder.fanIn} instead. + */ + chord( + steps: CanvasStep[], + callback: CanvasStep, + options: { after?: string | string[] } = {}, + ): this { + this.group(steps, options); + const { name, task, ...callbackOptions } = callback; + // Depend on every group member; fall back to the caller's `after` so an + // empty group still chains after its declared prerequisites. + const extra = options.after + ? Array.isArray(options.after) + ? options.after + : [options.after] + : []; + const after = [...steps.map((step) => step.name), ...extra]; + this.step(name, task, { ...callbackOptions, after }); + return this; + } + /** Materialize the validated spec without submitting. */ build(): WorkflowSpec { + const hasPredecessor = new Set(this.edges.map((edge) => edge.to)); for (const edge of this.edges) { if (!this.seen.has(edge.from)) { - throw new Error(`step '${edge.to}' depends on unknown step '${edge.from}'`); + throw new WorkflowError(`step '${edge.to}' depends on unknown step '${edge.from}'`); } } - // A fan-in node is only ever enqueued by its fan-out's completion, so its - // `after` must point at a fan-out step — otherwise the run would hang. for (const [name, meta] of Object.entries(this.stepMetadata)) { - if (!meta.fan_in) { - continue; + // A fan-in node is only ever enqueued by its fan-out's completion, so its + // `after` must point at a fan-out step — otherwise the run would hang. + if (meta.fan_in) { + const { from } = JSON.parse(meta.fan_in) as { from: string }; + if (!this.stepMetadata[from]?.fan_out) { + throw new WorkflowError( + `fan-in step '${name}' must target a fan-out step, but '${from}' is not one`, + ); + } } - const { from } = JSON.parse(meta.fan_in) as { from: string }; - if (!this.stepMetadata[from]?.fan_out) { - throw new Error( - `fan-in step '${name}' must target a fan-out step, but '${from}' is not one`, - ); + // A cacheable root has no job at submit and nothing to trigger it, so it + // would stall; require an upstream step. + if (meta.cache && !hasPredecessor.has(name)) { + throw new WorkflowError(`cacheable step '${name}' must have at least one predecessor`); } } const deferred = transitiveDeferred(this.deferredSeeds, successorsOf(this.edges)); @@ -175,7 +235,7 @@ export class WorkflowBuilder { /** Register a node + its incoming edges, rejecting duplicate names. */ private addNode(name: string, after: string | string[] | undefined): void { if (this.seen.has(name)) { - throw new Error(`duplicate workflow step '${name}'`); + throw new WorkflowError(`duplicate workflow step '${name}'`); } this.seen.add(name); this.nodes.push(name); diff --git a/sdks/node/src/workflows/cache.ts b/sdks/node/src/workflows/cache.ts new file mode 100644 index 00000000..9a6db1fc --- /dev/null +++ b/sdks/node/src/workflows/cache.ts @@ -0,0 +1,80 @@ +/** + * Built-in task that a cache hit runs instead of the real step: it returns its + * single argument (the cached value) verbatim. Handled inline by the worker, so + * it never needs registering. The double-underscore name avoids user collisions. + */ +export const CACHE_TASK = "__taskito_cache__"; + +/** Settings-KV prefix for stored workflow step results. */ +const KEY_PREFIX = "wf:cache:"; + +/** The native settings KV surface the cache store needs. */ +interface SettingsKv { + getSetting(key: string): string | null; + setSetting(key: string, value: string): void; + deleteSetting(key: string): boolean; + listSettings(): Record; +} + +/** A stored cache entry: base64 result bytes + write time (for TTL). */ +interface CacheEntry { + r: string; + t: number; +} + +/** + * Cross-run store for cacheable workflow step results, backed by the shared + * settings KV so an incremental re-run of a workflow reuses prior results. Keys + * are content hashes (task + args + upstream results), so any upstream change + * misses and re-runs the affected subtree. + */ +export class WorkflowCacheStore { + constructor( + private readonly kv: SettingsKv, + private readonly now: () => number = Date.now, + ) {} + + /** Cached result bytes for `key`, or `undefined` on miss / expiry. */ + get(key: string, ttlMs?: number): Buffer | undefined { + const storageKey = KEY_PREFIX + key; + const raw = this.kv.getSetting(storageKey); + if (!raw) { + return undefined; + } + // A corrupt entry must never stall workflow advancement: drop it and treat + // the read as a miss so the step simply re-runs. + let entry: CacheEntry; + try { + entry = JSON.parse(raw) as CacheEntry; + } catch { + this.kv.deleteSetting(storageKey); + return undefined; + } + if (typeof entry?.r !== "string" || typeof entry?.t !== "number") { + this.kv.deleteSetting(storageKey); + return undefined; + } + if (ttlMs !== undefined && this.now() - entry.t > ttlMs) { + return undefined; + } + return Buffer.from(entry.r, "base64"); + } + + /** Store `value` under `key`. */ + set(key: string, value: Buffer): void { + const entry: CacheEntry = { r: value.toString("base64"), t: this.now() }; + this.kv.setSetting(KEY_PREFIX + key, JSON.stringify(entry)); + } + + /** Remove every cached workflow result. Returns the number of entries dropped. */ + clear(): number { + let dropped = 0; + for (const key of Object.keys(this.kv.listSettings())) { + if (key.startsWith(KEY_PREFIX)) { + this.kv.deleteSetting(key); + dropped += 1; + } + } + return dropped; + } +} diff --git a/sdks/node/src/workflows/index.ts b/sdks/node/src/workflows/index.ts index ad2876b7..d4c16961 100644 --- a/sdks/node/src/workflows/index.ts +++ b/sdks/node/src/workflows/index.ts @@ -1,7 +1,15 @@ +export { + type GraphEdge, + type GraphNode, + WorkflowAnalysis, + type WorkflowGraph, + type WorkflowStats, +} from "./analysis"; export { WorkflowBuilder } from "./builder"; export { WorkflowManager } from "./manager"; export { WorkflowTracker } from "./tracker"; export type { + CanvasStep, FanInStepOptions, FanOutStepOptions, GateStepOptions, diff --git a/sdks/node/src/workflows/manager.ts b/sdks/node/src/workflows/manager.ts index 74b4f7ec..5622823d 100644 --- a/sdks/node/src/workflows/manager.ts +++ b/sdks/node/src/workflows/manager.ts @@ -1,6 +1,9 @@ +import { WorkflowError } from "../errors"; import type { NativeQueue } from "../native"; import type { Serializer } from "../serializers"; +import { WorkflowAnalysis, type WorkflowGraph } from "./analysis"; import { WorkflowBuilder } from "./builder"; +import { WorkflowCacheStore } from "./cache"; import { WorkflowTracker } from "./tracker"; import type { StepMetadataJson, @@ -40,7 +43,7 @@ export class WorkflowManager { private readonly serializer: Serializer, ) { if (typeof this.native.submitWorkflow !== "function") { - throw new Error("the native addon was built without the 'workflows' feature"); + throw new WorkflowError("the native addon was built without the 'workflows' feature"); } } @@ -69,11 +72,38 @@ export class WorkflowManager { return this.native.getWorkflowDag(runId) ?? undefined; } + /** + * Structural + status analysis of a run (ancestors, descendants, topological + * levels, critical path, stats). Returns `undefined` if the run is unknown or + * its DAG can't be parsed. + */ + analyze(runId: string): WorkflowAnalysis | undefined { + const dagJson = this.dag(runId); + if (dagJson === undefined) { + return undefined; + } + let graph: WorkflowGraph; + try { + graph = JSON.parse(dagJson) as WorkflowGraph; + } catch { + return undefined; + } + if (!Array.isArray(graph?.nodes) || !Array.isArray(graph?.edges)) { + return undefined; + } + return new WorkflowAnalysis(graph, this.nodes(runId)); + } + /** Sub-workflow runs spawned by a run (empty for Node-submitted runs). */ children(runId: string): WorkflowRun[] { return this.native.getWorkflowChildren(runId); } + /** Drop every cached cacheable-step result. Returns the number removed. */ + clearCache(): number { + return new WorkflowCacheStore(this.native).clear(); + } + /** * Resolve a workflow gate that is `waiting_approval`, then advance (or skip) * its successors. Works from any process — the run plan is read from storage. @@ -147,7 +177,7 @@ export class WorkflowManager { for (const name of spec.nodes) { const base = spec.stepMetadata[name]; if (!base) { - throw new Error(`workflow step '${name}' is missing metadata`); + throw new WorkflowError(`workflow step '${name}' is missing metadata`); } const b64 = Buffer.from(this.serializer.serialize(spec.stepArgs[name] ?? [])).toString( "base64", @@ -195,13 +225,13 @@ export class WorkflowManager { for (;;) { const run = this.native.getWorkflowRun(runId); if (!run) { - throw new Error(`workflow run '${runId}' not found`); + throw new WorkflowError(`workflow run '${runId}' not found`); } if (TERMINAL_STATES.has(run.state)) { return run; } if (Date.now() >= deadline) { - throw new Error(`workflow run '${runId}' did not finish within ${timeoutMs}ms`); + throw new WorkflowError(`workflow run '${runId}' did not finish within ${timeoutMs}ms`); } await new Promise((resolve) => setTimeout(resolve, pollMs)); } diff --git a/sdks/node/src/workflows/tracker.ts b/sdks/node/src/workflows/tracker.ts index 8085e2e3..e8149bc5 100644 --- a/sdks/node/src/workflows/tracker.ts +++ b/sdks/node/src/workflows/tracker.ts @@ -1,6 +1,8 @@ +import { createHash } from "node:crypto"; import type { JsOutcome, NativeQueue } from "../native"; import type { Serializer } from "../serializers"; import { createLogger } from "../utils"; +import { CACHE_TASK, WorkflowCacheStore } from "./cache"; import { predecessorsOf, successorsOf, transitiveDeferred } from "./plan"; import type { SubWorkflowTransport } from "./types"; @@ -44,6 +46,8 @@ interface StepMeta { sub_workflow?: string; /** Rollback task name run if the workflow fails (saga compensation). */ compensate?: string; + /** JSON `{ttlMs?}` marking a cacheable node (result reused across runs). */ + cache?: string; } /** Parsed gate config from a node's `gate` metadata. */ @@ -84,11 +88,15 @@ export class WorkflowTracker { private readonly plans = new Map(); /** Pending gate-timeout timers, keyed `runId\0node` (unref'd). */ private readonly gateTimers = new Map>(); + /** Cross-run store for cacheable step results. */ + private readonly cache: WorkflowCacheStore; constructor( private readonly native: NativeQueue, private readonly serializer: Serializer, - ) {} + ) { + this.cache = new WorkflowCacheStore(native); + } /** Handle one worker outcome. A no-op for non-workflow jobs. */ onOutcome(outcome: JsOutcome): void { @@ -134,6 +142,11 @@ export class WorkflowTracker { // Record the node's outcome. For plain DAGs the core also cascades + finalizes. const advance = this.native.markWorkflowNodeResult(jobId, succeeded, error, managed); + // Persist a successful cacheable node's result for future runs (idempotent on + // a hit, whose job already carries the cached bytes). + if (succeeded && plan.meta.get(nodeName)?.cache) { + this.storeCache(runId, nodeName, jobId, plan); + } if (!managed) { this.settle(runId, advance?.finalState ?? null); return; @@ -455,6 +468,8 @@ export class WorkflowTracker { this.submitSubWorkflow(runId, succ, meta); } else if (meta?.fan_out) { this.expandFanOut(runId, succ, plan); + } else if (meta?.cache) { + this.cacheOrEnqueue(runId, succ, plan, meta); } else { this.createDeferredJob(runId, succ, plan); } @@ -574,6 +589,72 @@ export class WorkflowTracker { ); } + /** + * Run a cacheable node — or, on a cache hit, enqueue the built-in cache task + * carrying the stored result so the node completes through the normal path + * (no downstream special-casing). A miss runs the real task. + */ + private cacheOrEnqueue(runId: string, node: string, plan: RunPlan, meta: StepMeta): void { + let ttlMs: number | undefined; + if (meta.cache) { + try { + ttlMs = (JSON.parse(meta.cache) as { ttlMs?: number }).ttlMs; + } catch { + // Corrupt cache metadata must not stall the run: skip the cache and + // run the real task so deferred successors still get enqueued. + this.createDeferredJob(runId, node, plan); + return; + } + } + const cached = this.cache.get(this.cacheKey(runId, node, plan), ttlMs); + if (!cached) { + this.createDeferredJob(runId, node, plan); // miss → run the real task + return; + } + const value = this.serializer.deserialize(cached); + const payload = Buffer.from(this.serializer.serialize([value])); + this.native.createDeferredJob( + runId, + node, + payload, + CACHE_TASK, + meta.queue ?? DEFAULT_QUEUE, + 0, // a cache return never retries + meta.timeout_ms ?? DEFAULT_TIMEOUT_MS, + meta.priority ?? 0, + ); + } + + /** Store a node's job result under its content-addressed cache key. */ + private storeCache(runId: string, node: string, jobId: string, plan: RunPlan): void { + const job = this.native.getJob(jobId); + if (job?.result) { + this.cache.set(this.cacheKey(runId, node, plan), Buffer.from(job.result)); + } + } + + /** Content-addressed key: task + args + each predecessor's result hash (the dirty set). */ + private cacheKey(runId: string, node: string, plan: RunPlan): string { + const meta = plan.meta.get(node); + const hash = createHash("sha256"); + hash.update(meta?.task_name ?? ""); + hash.update("\0"); + hash.update(meta?.args_template ?? ""); + for (const pred of [...(plan.predecessors.get(node) ?? [])].sort()) { + hash.update("\0"); + hash.update(pred); + hash.update(this.resultHashOf(runId, pred)); + } + return hash.digest("hex"); + } + + /** SHA-256 of a node's job-result bytes, or `""` when it has no result. */ + private resultHashOf(runId: string, node: string): string { + const ref = this.native.getWorkflowNodes(runId).find((n) => n.nodeName === node); + const job = ref?.jobId ? this.native.getJob(ref.jobId) : null; + return job?.result ? createHash("sha256").update(Buffer.from(job.result)).digest("hex") : ""; + } + private allPredecessorsTerminal(runId: string, node: string, plan: RunPlan): boolean { const preds = plan.predecessors.get(node) ?? []; if (preds.length === 0) { @@ -635,7 +716,9 @@ export class WorkflowTracker { ); const successors = successorsOf(edges); const seeds = [...meta] - .filter(([, m]) => m.fan_out || m.fan_in || m.condition || m.gate || m.sub_workflow) + .filter( + ([, m]) => m.fan_out || m.fan_in || m.condition || m.gate || m.sub_workflow || m.cache, + ) .map(([name]) => name); const plan: RunPlan = { successors, diff --git a/sdks/node/src/workflows/types.ts b/sdks/node/src/workflows/types.ts index bb75fc3d..5f4b133e 100644 --- a/sdks/node/src/workflows/types.ts +++ b/sdks/node/src/workflows/types.ts @@ -46,6 +46,21 @@ export interface WorkflowStepOptions { * with the step's result as its single argument, in reverse-dependency order. */ compensate?: string; + /** + * Reuse this step's result across runs when its task, args, and upstream + * results are unchanged. `{ ttlMs }` expires the cache. A cacheable step must + * have at least one predecessor. + */ + cache?: boolean | { ttlMs?: number }; +} + +/** + * A step in a canvas helper ({@link WorkflowBuilder.chain} / `group` / `chord`): + * a node name plus its task and step options. `after` is managed by the helper. + */ +export interface CanvasStep extends Omit { + name: string; + task: string; } /** Common per-step task config shared by every step kind. */ @@ -120,6 +135,8 @@ export interface StepMetadataJson { sub_workflow?: string; /** Rollback task name for saga compensation. */ compensate?: string; + /** JSON `{ttlMs?}` marking a cacheable node (result reused across runs). */ + cache?: string; } /** diff --git a/sdks/node/test/core/batch.test.ts b/sdks/node/test/core/batch.test.ts new file mode 100644 index 00000000..325cd39d --- /dev/null +++ b/sdks/node/test/core/batch.test.ts @@ -0,0 +1,86 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { Queue, type Worker } from "../../src/index"; + +let worker: Worker | undefined; +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-batch-")), "q.db") }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((r) => setTimeout(r, 20)); + } + return false; +} + +it("enqueueMany inserts a batch and returns ids in input order", () => { + const queue = newQueue(); + queue.task("double", (n: number) => n * 2); + + // Tag each row with a distinct queue so we can prove ids[i] maps to input[i]. + const ids = queue.enqueueMany("double", [ + { args: [1], options: { queue: "q0" } }, + { args: [2], options: { queue: "q1" } }, + { args: [3], options: { queue: "q2" } }, + ]); + expect(ids).toHaveLength(3); + expect(new Set(ids).size).toBe(3); // distinct ids + expect(queue.stats().pending).toBe(3); + + // Returned ids must line up with the input rows, not just be present. + expect(ids.map((id) => queue.getJob(id)?.queue)).toEqual(["q0", "q1", "q2"]); +}); + +it("runs every job in the batch", async () => { + const queue = newQueue(); + const seen: number[] = []; + queue.task("collect", (n: number) => { + seen.push(n); + return n; + }); + + const ids = queue.enqueueMany("collect", [{ args: [10] }, { args: [20] }, { args: [30] }]); + worker = queue.runWorker(); + + expect(await waitFor(() => seen.length === 3)).toBe(true); + expect([...seen].sort((a, b) => a - b)).toEqual([10, 20, 30]); + expect(await queue.result(ids[0] as string)).toBe(10); +}); + +it("applies per-job options across the batch", () => { + const queue = newQueue(); + queue.task("noop", () => undefined); + + const ids = queue.enqueueMany("noop", [ + { options: { queue: "a", priority: 5 } }, + { options: { queue: "b" } }, + ]); + expect(queue.getJob(ids[0] as string)?.queue).toBe("a"); + expect(queue.getJob(ids[1] as string)?.queue).toBe("b"); +}); + +it("runs onEnqueue interception for each batched job", () => { + const queue = newQueue(); + let calls = 0; + queue.use({ + onEnqueue: () => { + calls += 1; + }, + }); + queue.task("noop", () => undefined); + + queue.enqueueMany("noop", [{}, {}, {}]); + expect(calls).toBe(3); +}); diff --git a/sdks/node/test/core/encrypted-serializer.test.ts b/sdks/node/test/core/encrypted-serializer.test.ts new file mode 100644 index 00000000..a78eb04f --- /dev/null +++ b/sdks/node/test/core/encrypted-serializer.test.ts @@ -0,0 +1,77 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { + EncryptedSerializer, + MsgpackSerializer, + Queue, + SerializationError, + type Worker, +} from "../../src/index"; + +let worker: Worker | undefined; +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +it("round-trips an encrypted payload", () => { + const s = new EncryptedSerializer("secret"); + const bytes = s.serialize({ a: 1, b: "x", c: [1, 2, 3] }); + expect(s.deserialize(bytes)).toEqual({ a: 1, b: "x", c: [1, 2, 3] }); +}); + +it("hides the plaintext in the ciphertext", () => { + const s = new EncryptedSerializer("secret"); + const bytes = Buffer.from(s.serialize({ password: "hunter2" })); + expect(bytes.toString("utf8")).not.toContain("hunter2"); +}); + +it("uses a fresh IV per call (same value → different bytes)", () => { + const s = new EncryptedSerializer("secret"); + const a = Buffer.from(s.serialize({ a: 1 })); + const b = Buffer.from(s.serialize({ a: 1 })); + expect(a.equals(b)).toBe(false); +}); + +it("rejects a tampered payload", () => { + const s = new EncryptedSerializer("secret"); + const bytes = Buffer.from(s.serialize({ a: 1 })); + const last = bytes.length - 1; + bytes[last] = (bytes[last] ?? 0) ^ 0xff; + expect(() => s.deserialize(bytes)).toThrow(SerializationError); +}); + +it("rejects a payload encrypted with a different secret", () => { + const a = new EncryptedSerializer("secret-a"); + const b = new EncryptedSerializer("secret-b"); + expect(() => b.deserialize(a.serialize({ a: 1 }))).toThrow(SerializationError); +}); + +it("rejects a payload too short to be encrypted", () => { + const s = new EncryptedSerializer("secret"); + expect(() => s.deserialize(new Uint8Array(8))).toThrow(/too short/); +}); + +it("requires a non-empty secret", () => { + expect(() => new EncryptedSerializer("")).toThrow(SerializationError); +}); + +it("wraps any inner serializer", () => { + const s = new EncryptedSerializer("secret", new MsgpackSerializer()); + expect(s.deserialize(s.serialize({ a: 1 }))).toEqual({ a: 1 }); +}); + +it("works end to end as the queue serializer", async () => { + const queue = new Queue({ + dbPath: join(mkdtempSync(join(tmpdir(), "taskito-enc-")), "q.db"), + serializer: new EncryptedSerializer("shared-key"), + }); + queue.task("echo", (x: number) => x + 1); + + const id = queue.enqueue("echo", [41]); + worker = queue.runWorker(); + + expect(await queue.result(id)).toBe(42); +}); diff --git a/sdks/node/test/core/errors.test.ts b/sdks/node/test/core/errors.test.ts new file mode 100644 index 00000000..5c338018 --- /dev/null +++ b/sdks/node/test/core/errors.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { + NotesValidationError, + Queue, + QueueError, + ResourceError, + ResourceNotFoundError, + ResourceScopeError, + ResultTimeoutError, + SerializationError, + SignedSerializer, + TaskitoError, + type Worker, + WorkflowError, +} from "../../src/index"; + +let worker: Worker | undefined; +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-err-")), "q.db") }); +} + +it("specific errors extend the right bases and carry a name", () => { + for (const err of [ + new QueueError("x"), + new NotesValidationError("x"), + new SerializationError("x"), + new WorkflowError("x"), + new ResultTimeoutError("id", 1), + new ResourceError("x"), + ]) { + expect(err).toBeInstanceOf(TaskitoError); + } + // resource errors nest under ResourceError + expect(new ResourceNotFoundError("db")).toBeInstanceOf(ResourceError); + expect(new ResourceScopeError("db")).toBeInstanceOf(ResourceError); + expect(new ResourceNotFoundError("db")).toBeInstanceOf(TaskitoError); + + expect(new QueueError("x").name).toBe("QueueError"); + expect(new ResourceNotFoundError("db").resourceName).toBe("db"); + expect(new ResultTimeoutError("j", 50).timeoutMs).toBe(50); +}); + +it("Queue construction without storage throws QueueError", () => { + expect(() => new Queue({})).toThrow(QueueError); +}); + +it("invalid notes throw NotesValidationError", () => { + const queue = newQueue(); + queue.task("noop", () => undefined); + expect(() => queue.enqueue("noop", undefined, { notes: { blob: "x".repeat(5000) } })).toThrow( + NotesValidationError, + ); +}); + +it("SignedSerializer surfaces SerializationError", () => { + const s = new SignedSerializer("k"); + expect(() => s.deserialize(new Uint8Array(4))).toThrow(SerializationError); +}); + +it("result() times out with ResultTimeoutError", async () => { + const queue = newQueue(); + queue.task("slow", () => 1); + const id = queue.enqueue("slow"); // no worker started → never runs + await expect(queue.result(id, { timeoutMs: 120, pollMs: 20 })).rejects.toBeInstanceOf( + ResultTimeoutError, + ); +}); + +it("duplicate workflow step throws WorkflowError", () => { + const queue = newQueue(); + expect(() => queue.workflows.define("w").step("a", "t").step("a", "t")).toThrow(WorkflowError); +}); diff --git a/sdks/node/test/core/notes.test.ts b/sdks/node/test/core/notes.test.ts new file mode 100644 index 00000000..878ea63e --- /dev/null +++ b/sdks/node/test/core/notes.test.ts @@ -0,0 +1,57 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { Queue } from "../../src/index"; + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-notes-")), "q.db") }); +} + +it("stores structured notes at enqueue and reads them back", () => { + const queue = newQueue(); + queue.task("noop", () => undefined); + + const id = queue.enqueue("noop", undefined, { notes: { source: "signup", attempt: 1 } }); + const job = queue.getJob(id); + + expect(job?.notes).toBeDefined(); + expect(JSON.parse(job?.notes as string)).toEqual({ source: "signup", attempt: 1 }); +}); + +it("leaves notes unset when not provided", () => { + const queue = newQueue(); + queue.task("noop", () => undefined); + + const id = queue.enqueue("noop"); + expect(queue.getJob(id)?.notes ?? null).toBeNull(); +}); + +it("rejects notes with more than 15 top-level fields", () => { + const queue = newQueue(); + queue.task("noop", () => undefined); + + const tooMany = Object.fromEntries(Array.from({ length: 16 }, (_, i) => [`k${i}`, i])); + expect(() => queue.enqueue("noop", undefined, { notes: tooMany })).toThrow(/15 top-level/); +}); + +it("rejects notes larger than 4 KiB encoded", () => { + const queue = newQueue(); + queue.task("noop", () => undefined); + + const big = { blob: "x".repeat(5000) }; + expect(() => queue.enqueue("noop", undefined, { notes: big })).toThrow(/exceeds/); +}); + +it("onEnqueue can rewrite notes before validation", () => { + const queue = newQueue(); + queue.use({ + onEnqueue: (ctx) => { + ctx.options.notes = { ...ctx.options.notes, tagged: true }; + }, + }); + queue.task("noop", () => undefined); + + const id = queue.enqueue("noop", undefined, { notes: { a: 1 } }); + expect(JSON.parse(queue.getJob(id)?.notes as string)).toEqual({ a: 1, tagged: true }); +}); diff --git a/sdks/node/test/core/predicates.test.ts b/sdks/node/test/core/predicates.test.ts new file mode 100644 index 00000000..b846bcb2 --- /dev/null +++ b/sdks/node/test/core/predicates.test.ts @@ -0,0 +1,63 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { allOf, anyOf, not, PredicateRejectedError, Queue } from "../../src/index"; + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-pred-")), "q.db") }); +} + +it("enqueues when the gate passes", () => { + const queue = newQueue().task("charge", (n: number) => n); + queue.gate("charge", ({ args }) => args[0] > 0); + expect(typeof queue.enqueue("charge", [5])).toBe("string"); +}); + +it("rejects the enqueue when the gate fails", () => { + const queue = newQueue().task("charge", (n: number) => n); + queue.gate("charge", ({ args }) => args[0] > 0); + expect(() => queue.enqueue("charge", [-1])).toThrow(PredicateRejectedError); + expect(queue.stats().pending).toBe(0); +}); + +it("requires every gate to pass", () => { + const queue = newQueue().task("t", (n: number) => n); + queue.gate("t", ({ args }) => args[0] > 0); + queue.gate("t", ({ args }) => args[0] < 100); + expect(typeof queue.enqueue("t", [50])).toBe("string"); + expect(() => queue.enqueue("t", [200])).toThrow(PredicateRejectedError); +}); + +it("sees args after onEnqueue rewrites them", () => { + const queue = newQueue().task("t", (n: number) => n); + queue.use({ + onEnqueue: (ctx) => { + ctx.args = [Math.abs(ctx.args[0] as number)]; + }, + }); + queue.gate("t", ({ args }) => args[0] > 0); + expect(typeof queue.enqueue("t", [-5])).toBe("string"); // rewritten to 5 → passes +}); + +it("composes predicates with allOf / anyOf / not", () => { + const queue = newQueue().task("t", (n: number) => n); + const positive = ({ args }: { args: readonly unknown[] }) => (args[0] as number) > 0; + const even = ({ args }: { args: readonly unknown[] }) => (args[0] as number) % 2 === 0; + queue.gate("t", allOf(positive, not(even))); + expect(typeof queue.enqueue("t", [3])).toBe("string"); // positive, odd + expect(() => queue.enqueue("t", [4])).toThrow(PredicateRejectedError); // even + expect(() => queue.enqueue("t", [-3])).toThrow(PredicateRejectedError); // not positive + + const q2 = newQueue().task("t", (n: number) => n); + q2.gate("t", anyOf(positive, even)); + expect(typeof q2.enqueue("t", [-2])).toBe("string"); // negative but even +}); + +it("gates each job in a batch", () => { + const queue = newQueue().task("t", (n: number) => n); + queue.gate("t", ({ args }) => args[0] > 0); + expect(() => queue.enqueueMany("t", [{ args: [1] }, { args: [-1] }])).toThrow( + PredicateRejectedError, + ); +}); diff --git a/sdks/node/test/core/signed-serializer.test.ts b/sdks/node/test/core/signed-serializer.test.ts new file mode 100644 index 00000000..02d92a6a --- /dev/null +++ b/sdks/node/test/core/signed-serializer.test.ts @@ -0,0 +1,71 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { + JsonSerializer, + MsgpackSerializer, + Queue, + SignedSerializer, + TaskitoError, + type Worker, +} from "../../src/index"; + +let worker: Worker | undefined; +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +it("round-trips a signed payload", () => { + const s = new SignedSerializer("secret"); + const bytes = s.serialize({ a: 1, b: "x", c: [1, 2, 3] }); + expect(s.deserialize(bytes)).toEqual({ a: 1, b: "x", c: [1, 2, 3] }); +}); + +it("prefixes a 32-byte HMAC tag", () => { + const s = new SignedSerializer("secret"); + const body = new JsonSerializer().serialize({ a: 1 }); + expect(s.serialize({ a: 1 }).length).toBe(32 + body.length); +}); + +it("rejects a tampered payload", () => { + const s = new SignedSerializer("secret"); + const bytes = Buffer.from(s.serialize({ a: 1 })); + const last = bytes.length - 1; + bytes[last] = (bytes[last] ?? 0) ^ 0xff; // flip a body byte + expect(() => s.deserialize(bytes)).toThrow(/signature mismatch/); +}); + +it("rejects a payload signed with a different secret", () => { + const a = new SignedSerializer("secret-a"); + const b = new SignedSerializer("secret-b"); + expect(() => b.deserialize(a.serialize({ a: 1 }))).toThrow(/signature mismatch/); +}); + +it("rejects a payload too short to be signed", () => { + const s = new SignedSerializer("secret"); + expect(() => s.deserialize(new Uint8Array(8))).toThrow(/too short/); +}); + +it("requires a non-empty secret", () => { + expect(() => new SignedSerializer("")).toThrow(TaskitoError); +}); + +it("wraps any inner serializer", () => { + const s = new SignedSerializer("secret", new MsgpackSerializer()); + expect(s.deserialize(s.serialize({ a: 1 }))).toEqual({ a: 1 }); +}); + +it("works end to end as the queue serializer", async () => { + const queue = new Queue({ + dbPath: join(mkdtempSync(join(tmpdir(), "taskito-signed-")), "q.db"), + serializer: new SignedSerializer("shared-key"), + }); + queue.task("echo", (x: number) => x + 1); + + const id = queue.enqueue("echo", [41]); + worker = queue.runWorker(); + + expect(await queue.result(id)).toBe(42); +}); diff --git a/sdks/node/test/dashboard/auth.test.ts b/sdks/node/test/dashboard/auth.test.ts new file mode 100644 index 00000000..04d0fcc9 --- /dev/null +++ b/sdks/node/test/dashboard/auth.test.ts @@ -0,0 +1,67 @@ +import { execSync } from "node:child_process"; +import { existsSync, mkdtempSync } from "node:fs"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { Queue, serveDashboard } from "../../src/index"; + +const pkgRoot = fileURLToPath(new URL("../..", import.meta.url)); +const staticDir = join(pkgRoot, "static", "dashboard"); +const TOKEN = "s3cret-token"; + +beforeAll(() => { + if (!existsSync(join(staticDir, "index.html"))) { + execSync("pnpm run build:dashboard", { cwd: pkgRoot, stdio: "ignore" }); + } +}, 120_000); + +let server: Server | undefined; +let base = ""; + +beforeEach(async () => { + const db = join(mkdtempSync(join(tmpdir(), "taskito-dashauth-")), "q.db"); + const queue = new Queue({ dbPath: db }); + queue.task("add", (a: number, b: number) => a + b); + server = serveDashboard(queue, { port: 0, staticDir, auth: { token: TOKEN } }); + await new Promise((resolve) => setTimeout(resolve, 80)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterEach(() => { + server?.close(); + server = undefined; +}); + +it("rejects an API request without a token", async () => { + const res = await fetch(`${base}/api/stats`); + expect(res.status).toBe(401); +}); + +it("rejects a wrong token", async () => { + const res = await fetch(`${base}/api/stats`, { headers: { authorization: "Bearer nope" } }); + expect(res.status).toBe(401); +}); + +it("accepts a Bearer token", async () => { + const res = await fetch(`${base}/api/stats`, { headers: { authorization: `Bearer ${TOKEN}` } }); + expect(res.status).toBe(200); +}); + +it("accepts an X-Taskito-Token header", async () => { + const res = await fetch(`${base}/api/stats`, { headers: { "x-taskito-token": TOKEN } }); + expect(res.status).toBe(200); +}); + +it("accepts a ?token= query and sets a session cookie", async () => { + const res = await fetch(`${base}/api/stats?token=${TOKEN}`); + expect(res.status).toBe(200); + expect(res.headers.get("set-cookie")).toContain("taskito_token="); +}); + +it("leaves /api/auth/status public", async () => { + const res = await fetch(`${base}/api/auth/status`); + expect(res.status).toBe(200); +}); diff --git a/sdks/node/test/observability/scaler.test.ts b/sdks/node/test/observability/scaler.test.ts new file mode 100644 index 00000000..fef9cfbd --- /dev/null +++ b/sdks/node/test/observability/scaler.test.ts @@ -0,0 +1,63 @@ +import { mkdtempSync } from "node:fs"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { Queue, serveScaler } from "../../src/index"; + +let server: Server | undefined; +let base = ""; +let queue: Queue; + +beforeEach(async () => { + queue = new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-scaler-")), "q.db") }); + queue.task("work", () => undefined); + server = serveScaler(queue, { port: 0, host: "127.0.0.1", targetQueueDepth: 5 }); + await new Promise((r) => setTimeout(r, 60)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterEach(() => { + server?.close(); + server = undefined; +}); + +it("reports global queue depth and target for KEDA", async () => { + queue.enqueue("work"); + queue.enqueue("work"); + const body = (await (await fetch(`${base}/api/scaler`)).json()) as { + metricValue: number; + targetValue: number; + queueName: string; + }; + expect(body.metricValue).toBe(2); + expect(body.targetValue).toBe(5); + expect(body.queueName).toBe("*"); +}); + +it("filters the metric to a queue via ?queue=", async () => { + queue.enqueue("work", undefined, { queue: "emails" }); + const all = (await (await fetch(`${base}/api/scaler`)).json()) as { metricValue: number }; + expect(all.metricValue).toBe(1); + const emails = (await (await fetch(`${base}/api/scaler?queue=emails`)).json()) as { + metricValue: number; + queueName: string; + }; + expect(emails.metricValue).toBe(1); + expect(emails.queueName).toBe("emails"); + const other = (await (await fetch(`${base}/api/scaler?queue=other`)).json()) as { + metricValue: number; + }; + expect(other.metricValue).toBe(0); +}); + +it("serves a health check", async () => { + const res = await fetch(`${base}/health`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "ok" }); +}); + +it("404s an unknown path", async () => { + expect((await fetch(`${base}/nope`)).status).toBe(404); +}); diff --git a/sdks/node/test/resources/interception.test.ts b/sdks/node/test/resources/interception.test.ts new file mode 100644 index 00000000..cb7657a6 --- /dev/null +++ b/sdks/node/test/resources/interception.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { type Middleware, Queue, type Worker } from "../../src/index"; + +let worker: Worker | undefined; + +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-int-")), "q.db") }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} + +it("onEnqueue can redact args before serialization", async () => { + const queue = newQueue(); + const redact: Middleware = { + onEnqueue: (ctx) => { + ctx.args = ctx.args.map((a) => (typeof a === "string" && a.startsWith("pw:") ? "***" : a)); + }, + }; + queue.use(redact); + let seen: unknown[] = []; + queue.task("login", (...args: unknown[]) => { + seen = args; + }); + + queue.enqueue("login", ["alice", "pw:hunter2"]); + worker = queue.runWorker(); + + expect(await waitFor(() => seen.length > 0)).toBe(true); + expect(seen).toEqual(["alice", "***"]); +}); + +it("onEnqueue can rewrite options before they reach the core", () => { + const queue = newQueue(); + queue.use({ + onEnqueue: (ctx) => { + ctx.options.metadata = "tagged-by-mw"; + }, + }); + queue.task("noop", () => undefined); + + const id = queue.enqueue("noop"); + const job = queue.getJob(id); + expect(job?.metadata).toBe("tagged-by-mw"); +}); + +it("a throwing onEnqueue aborts the enqueue", () => { + const queue = newQueue(); + queue.use({ + onEnqueue: (ctx) => { + if ((ctx.args[0] as number) < 0) { + throw new Error("amount must be non-negative"); + } + }, + }); + queue.task("charge", (_amount: number) => undefined); + + expect(() => queue.enqueue("charge", [-5])).toThrow("amount must be non-negative"); + expect(queue.stats().pending).toBe(0); // nothing was enqueued +}); + +it("runs onEnqueue hooks in registration order", () => { + const queue = newQueue(); + const order: string[] = []; + queue.use({ onEnqueue: () => void order.push("first") }); + queue.use({ onEnqueue: () => void order.push("second") }); + queue.task("noop", () => undefined); + + queue.enqueue("noop"); + expect(order).toEqual(["first", "second"]); +}); diff --git a/sdks/node/test/resources/metrics.test.ts b/sdks/node/test/resources/metrics.test.ts new file mode 100644 index 00000000..e162b381 --- /dev/null +++ b/sdks/node/test/resources/metrics.test.ts @@ -0,0 +1,83 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { mockResource, Queue, useResource, type Worker } from "../../src/index"; + +let worker: Worker | undefined; +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-rmetrics-")), "q.db") }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((r) => setTimeout(r, 20)); + } + return false; +} + +it("counts a worker-scoped resource as created once, active until stop", async () => { + const queue = newQueue(); + queue.resource("db", () => ({ ok: true }), { dispose: () => undefined }); + let seen = 0; + queue.task("use", async () => { + await useResource("db"); + seen += 1; + }); + + queue.enqueue("use"); + queue.enqueue("use"); + worker = queue.runWorker(); + + expect(await waitFor(() => seen === 2)).toBe(true); + let m = queue.resourceMetrics().db; + expect(m).toEqual({ created: 1, disposed: 0, active: 1 }); // singleton, still alive + + worker.stop(); + worker = undefined; + expect(await waitFor(() => queue.resourceMetrics().db?.disposed === 1)).toBe(true); + m = queue.resourceMetrics().db; + expect(m).toEqual({ created: 1, disposed: 1, active: 0 }); +}); + +it("counts a task-scoped resource per job, all disposed", async () => { + const queue = newQueue(); + queue.resource("tx", () => ({}), { scope: "task", dispose: () => undefined }); + queue.task("use", async () => { + await useResource("tx"); + }); + + queue.enqueue("use"); + queue.enqueue("use"); + worker = queue.runWorker(); + + expect(await waitFor(() => queue.resourceMetrics().tx?.disposed === 2)).toBe(true); + expect(queue.resourceMetrics().tx).toEqual({ created: 2, disposed: 2, active: 0 }); +}); + +it("mockResource records resolutions and injects its value", async () => { + const queue = newQueue(); + const db = mockResource({ query: () => 7 }); + queue.resource("db", db.factory); + let result = 0; + queue.task("read", async () => { + const conn = await useResource<{ query: () => number }>("db"); + result = conn.query(); + }); + + queue.enqueue("read"); + queue.enqueue("read"); + worker = queue.runWorker(); + + expect(await waitFor(() => result === 7)).toBe(true); + expect(db.resolutions).toBe(1); // worker-scoped singleton, built once +}); diff --git a/sdks/node/test/resources/resources.test.ts b/sdks/node/test/resources/resources.test.ts new file mode 100644 index 00000000..9d0c30d6 --- /dev/null +++ b/sdks/node/test/resources/resources.test.ts @@ -0,0 +1,260 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { Queue, TaskitoError, useResource, type Worker } from "../../src/index"; +import { ResourceRuntime } from "../../src/resources/runtime"; + +let worker: Worker | undefined; + +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-res-")), "q.db") }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} + +describe("ResourceRuntime", () => { + it("builds a worker-scoped resource once across many resolutions", async () => { + const rt = new ResourceRuntime(); + let builds = 0; + rt.register("cfg", { + scope: "worker", + factory: () => { + builds += 1; + return { n: builds }; + }, + }); + const a = rt.createTaskScope(); + const b = rt.createTaskScope(); + expect(await a.resolver("cfg")).toEqual({ n: 1 }); + expect(await a.resolver("cfg")).toEqual({ n: 1 }); + expect(await b.resolver("cfg")).toEqual({ n: 1 }); + expect(builds).toBe(1); + }); + + it("builds a task-scoped resource once per scope, fresh across scopes", async () => { + const rt = new ResourceRuntime(); + let builds = 0; + rt.register("conn", { + scope: "task", + factory: () => { + builds += 1; + return builds; + }, + }); + const a = rt.createTaskScope(); + expect(await a.resolver("conn")).toBe(1); + expect(await a.resolver("conn")).toBe(1); // same scope → cached + const b = rt.createTaskScope(); + expect(await b.resolver("conn")).toBe(2); // new scope → rebuilt + expect(builds).toBe(2); + }); + + it("disposes worker-scoped resources LIFO on teardown", async () => { + const rt = new ResourceRuntime(); + const order: string[] = []; + rt.register("a", { scope: "worker", factory: () => "a", dispose: () => void order.push("a") }); + rt.register("b", { scope: "worker", factory: () => "b", dispose: () => void order.push("b") }); + const s = rt.createTaskScope(); + await s.resolver("a"); + await s.resolver("b"); + await rt.teardownWorker(); + expect(order).toEqual(["b", "a"]); // reverse of build order + }); + + it("disposes worker resources only when the last sharing worker stops", async () => { + const rt = new ResourceRuntime(); + let disposed = 0; + rt.register("conn", { + scope: "worker", + factory: () => "c", + dispose: () => { + disposed += 1; + }, + }); + rt.acquireWorker(); + rt.acquireWorker(); + await rt.createTaskScope().resolver("conn"); + + await rt.teardownWorker(); // one of two workers stopped + expect(disposed).toBe(0); // still in use by the sibling worker + + await rt.teardownWorker(); // last worker stopped + expect(disposed).toBe(1); + }); + + it("disposes task-scoped resources LIFO when the scope ends", async () => { + const rt = new ResourceRuntime(); + const order: string[] = []; + rt.register("x", { scope: "task", factory: () => "x", dispose: () => void order.push("x") }); + rt.register("y", { scope: "task", factory: () => "y", dispose: () => void order.push("y") }); + const s = rt.createTaskScope(); + await s.resolver("x"); + await s.resolver("y"); + await s.teardown(); + expect(order).toEqual(["y", "x"]); + }); + + it("lets a factory depend on another resource via ctx.use", async () => { + const rt = new ResourceRuntime(); + rt.register("base", { scope: "worker", factory: () => 10 }); + rt.register("derived", { + scope: "worker", + factory: async (ctx) => (await ctx.use("base")) * 2, + }); + const s = rt.createTaskScope(); + expect(await s.resolver("derived")).toBe(20); + }); + + it("rejects a worker factory depending on a task-scoped resource", async () => { + const rt = new ResourceRuntime(); + rt.register("perJob", { scope: "task", factory: () => "x" }); + rt.register("singleton", { + scope: "worker", + factory: (ctx) => ctx.use("perJob"), + }); + const s = rt.createTaskScope(); + await expect(s.resolver("singleton")).rejects.toThrow(/task-scoped/); + }); + + it("throws for an unregistered resource", async () => { + const rt = new ResourceRuntime(); + const s = rt.createTaskScope(); + await expect(s.resolver("nope")).rejects.toThrow(/No resource registered/); + }); + + it("retries a failed build instead of caching the rejection", async () => { + const rt = new ResourceRuntime(); + let attempts = 0; + rt.register("flaky", { + scope: "worker", + factory: () => { + attempts += 1; + if (attempts === 1) { + throw new Error("transient"); + } + return "ok"; + }, + }); + const s = rt.createTaskScope(); + await expect(s.resolver("flaky")).rejects.toThrow("transient"); + expect(await s.resolver("flaky")).toBe("ok"); // second attempt succeeds + }); +}); + +describe("resource injection in a worker", () => { + it("resolves a worker-scoped resource via useResource(), shared across jobs", async () => { + const queue = newQueue(); + let builds = 0; + let disposed = false; + queue.resource( + "config", + () => { + builds += 1; + return { token: "secret" }; + }, + { + dispose: () => { + disposed = true; + }, + }, + ); + const seen: string[] = []; + queue.task("read", async () => { + const cfg = await useResource<{ token: string }>("config"); + seen.push(cfg.token); + }); + + queue.enqueue("read"); + queue.enqueue("read"); + worker = queue.runWorker(); + + expect(await waitFor(() => seen.length === 2)).toBe(true); + expect(seen).toEqual(["secret", "secret"]); + expect(builds).toBe(1); // singleton across both jobs + + worker.stop(); + worker = undefined; + expect(await waitFor(() => disposed)).toBe(true); + }); + + it("injects declared resources as a trailing deps argument", async () => { + const queue = newQueue(); + queue.resource("db", () => ({ query: () => 7 })); + let got = 0; + queue.task( + "use-db", + (factor: number, deps: { db: { query: () => number } }) => { + got = deps.db.query() * factor; + }, + { inject: ["db"] }, + ); + + queue.enqueue("use-db", [3]); + worker = queue.runWorker(); + + expect(await waitFor(() => got !== 0)).toBe(true); + expect(got).toBe(21); + }); + + it("builds and disposes a task-scoped resource per job", async () => { + const queue = newQueue(); + let builds = 0; + let disposals = 0; + queue.resource( + "tx", + () => { + builds += 1; + return builds; + }, + { + scope: "task", + dispose: () => { + disposals += 1; + }, + }, + ); + queue.task("work", async () => { + await useResource("tx"); + }); + + queue.enqueue("work"); + queue.enqueue("work"); + worker = queue.runWorker(); + + expect(await waitFor(() => disposals === 2)).toBe(true); + expect(builds).toBe(2); // one per invocation + }); + + it("throws when useResource is called outside a task", () => { + expect(() => useResource("anything")).toThrow(TaskitoError); + }); + + it("strips the injected deps arg from typed enqueue", () => { + const q = newQueue().task("inj", (id: string, deps: { db: number }) => `${id}:${deps.db}`, { + inject: ["db"], + }); + // Type-only: never executed. Asserts `deps` is not part of the enqueue args. + const _typeCheck = () => { + q.enqueue("inj", ["a"]); + // @ts-expect-error deps must not be passed as an enqueue arg + q.enqueue("inj", ["a", { db: 1 }]); + }; + void _typeCheck; + expect(true).toBe(true); + }); +}); diff --git a/sdks/node/test/worker/streaming.test.ts b/sdks/node/test/worker/streaming.test.ts new file mode 100644 index 00000000..abc68f8b --- /dev/null +++ b/sdks/node/test/worker/streaming.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { currentJob, Queue, type Worker } from "../../src/index"; + +let worker: Worker | undefined; +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-stream-")), "q.db") }); +} + +it("streams partial results published by a task, in order", async () => { + const queue = newQueue(); + queue.task("crunch", async () => { + const job = currentJob(); + for (let i = 1; i <= 3; i += 1) { + job?.publish({ step: i }); + await new Promise((r) => setTimeout(r, 15)); + } + return "done"; + }); + + const id = queue.enqueue("crunch"); + worker = queue.runWorker(); + + const got: unknown[] = []; + for await (const value of queue.stream(id, { pollMs: 10, timeoutMs: 5000 })) { + got.push(value); + } + expect(got).toEqual([{ step: 1 }, { step: 2 }, { step: 3 }]); + expect(await queue.result(id)).toBe("done"); +}); + +it("drains partials already published before the consumer attaches", async () => { + const queue = newQueue(); + queue.task("emit", () => { + const job = currentJob(); + job?.publish("a"); + job?.publish("b"); + return null; + }); + + const id = queue.enqueue("emit"); + worker = queue.runWorker(); + await queue.result(id); // let the job finish first + + const got: unknown[] = []; + for await (const value of queue.stream(id, { pollMs: 10, timeoutMs: 2000 })) { + got.push(value); + } + expect(got).toEqual(["a", "b"]); +}); + +it("terminates an empty stream when the job has no partials", async () => { + const queue = newQueue(); + queue.task("quiet", () => 1); + + const id = queue.enqueue("quiet"); + worker = queue.runWorker(); + + const got: unknown[] = []; + for await (const value of queue.stream(id, { pollMs: 10, timeoutMs: 2000 })) { + got.push(value); + } + expect(got).toEqual([]); +}); + +it("exposes raw task logs", async () => { + const queue = newQueue(); + queue.task("emit", () => { + currentJob()?.publish({ x: 1 }); + return null; + }); + + const id = queue.enqueue("emit"); + worker = queue.runWorker(); + await queue.result(id); + + const logs = queue.taskLogs(id).filter((l) => l.level === "result"); + expect(logs).toHaveLength(1); + expect(JSON.parse(logs[0]?.extra as string)).toEqual({ x: 1 }); +}); diff --git a/sdks/node/test/workflows/analysis.test.ts b/sdks/node/test/workflows/analysis.test.ts new file mode 100644 index 00000000..a9fc39b7 --- /dev/null +++ b/sdks/node/test/workflows/analysis.test.ts @@ -0,0 +1,69 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { Queue, WorkflowAnalysis } from "../../src/index"; + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-wf-an-")), "q.db") }); +} + +/** Diamond: a → b, a → c, b → d, c → d. */ +function submitDiamond(queue: Queue): string { + queue.task("noop", () => undefined); + const handle = queue.workflows + .define("diamond") + .step("a", "noop") + .step("b", "noop", { after: "a" }) + .step("c", "noop", { after: "a" }) + .step("d", "noop", { after: ["b", "c"] }) + .submit(); + return handle.runId; +} + +it("returns undefined for an unknown run", () => { + const queue = newQueue(); + expect(queue.workflows.analyze("nope")).toBeUndefined(); +}); + +it("computes roots, leaves, ancestors and descendants", () => { + const queue = newQueue(); + const analysis = queue.workflows.analyze(submitDiamond(queue)); + expect(analysis).toBeInstanceOf(WorkflowAnalysis); + if (!analysis) { + return; + } + expect(analysis.roots()).toEqual(["a"]); + expect(analysis.leaves()).toEqual(["d"]); + expect(analysis.ancestors("d").sort()).toEqual(["a", "b", "c"]); + expect(analysis.descendants("a").sort()).toEqual(["b", "c", "d"]); + expect(analysis.ancestors("a")).toEqual([]); +}); + +it("groups nodes into topological levels", () => { + const queue = newQueue(); + const analysis = queue.workflows.analyze(submitDiamond(queue)); + const levels = analysis?.topologicalLevels() ?? []; + expect(levels[0]).toEqual(["a"]); + expect(levels[1]?.sort()).toEqual(["b", "c"]); + expect(levels[2]).toEqual(["d"]); +}); + +it("finds the critical path (longest dependency chain)", () => { + const queue = newQueue(); + const analysis = queue.workflows.analyze(submitDiamond(queue)); + const path = analysis?.criticalPath() ?? []; + expect(path).toHaveLength(3); + expect(path[0]).toBe("a"); + expect(path[2]).toBe("d"); + expect(["b", "c"]).toContain(path[1]); +}); + +it("reports node-status stats", () => { + const queue = newQueue(); + const analysis = queue.workflows.analyze(submitDiamond(queue)); + const stats = analysis?.stats(); + expect(stats?.total).toBe(4); + expect(stats?.completed).toBe(0); // no worker ran + expect(stats?.failed).toBe(0); +}); diff --git a/sdks/node/test/workflows/cache.test.ts b/sdks/node/test/workflows/cache.test.ts new file mode 100644 index 00000000..5748bef3 --- /dev/null +++ b/sdks/node/test/workflows/cache.test.ts @@ -0,0 +1,97 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { Queue, type Worker, WorkflowError } from "../../src/index"; + +let worker: Worker | undefined; +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-wfcache-")), "q.db") }); +} + +it("reuses a cacheable step's result across runs", async () => { + const queue = newQueue(); + let runs = 0; + queue.task("seed", () => 10); + queue.task("expensive", (x: number) => { + runs += 1; + return x * 2; + }); + worker = queue.runWorker(); + + const build = () => + queue.workflows + .define("wf") + .step("seed", "seed") + .step("expensive", "expensive", { after: "seed", args: [10], cache: true }); + + await build().submit().wait(); + expect(runs).toBe(1); + + const handle = build().submit(); + const run = await handle.wait(); + expect(run.state).toBe("completed"); + expect(runs).toBe(1); // cache hit — `expensive` did not re-run + + const node = queue.workflows.nodes(handle.runId).find((n) => n.nodeName === "expensive"); + expect(queue.getResult(node?.jobId as string)).toBe(20); // result intact on a hit +}); + +it("misses when an upstream result changes (dirty set)", async () => { + const queue = newQueue(); + let runs = 0; + queue.task("seed", (n: number) => n); + queue.task("expensive", (x: number) => { + runs += 1; + return x; + }); + worker = queue.runWorker(); + + const run = (seedValue: number) => + queue.workflows + .define("wf") + .step("seed", "seed", { args: [seedValue] }) + .step("expensive", "expensive", { after: "seed", args: [0], cache: true }) + .submit() + .wait(); + + await run(1); + expect(runs).toBe(1); + await run(2); // seed result changed → `expensive`'s key differs → miss + expect(runs).toBe(2); +}); + +it("re-runs after clearCache", async () => { + const queue = newQueue(); + let runs = 0; + queue.task("seed", () => 1); + queue.task("expensive", () => { + runs += 1; + return 1; + }); + worker = queue.runWorker(); + + const build = () => + queue.workflows + .define("wf") + .step("seed", "seed") + .step("expensive", "expensive", { after: "seed", cache: true }); + + await build().submit().wait(); + expect(runs).toBe(1); + expect(queue.workflows.clearCache()).toBeGreaterThan(0); + await build().submit().wait(); + expect(runs).toBe(2); // cache cleared → re-run +}); + +it("rejects a cacheable root step at build time", () => { + const queue = newQueue(); + expect(() => queue.workflows.define("wf").step("a", "noop", { cache: true }).submit()).toThrow( + WorkflowError, + ); +}); diff --git a/sdks/node/test/workflows/canvas.test.ts b/sdks/node/test/workflows/canvas.test.ts new file mode 100644 index 00000000..f1d574ea --- /dev/null +++ b/sdks/node/test/workflows/canvas.test.ts @@ -0,0 +1,78 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { Queue } from "../../src/index"; + +function newQueue(): Queue { + const queue = new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-canvas-")), "q.db") }); + queue.task("noop", () => undefined); + return queue; +} + +it("chain wires steps sequentially", () => { + const queue = newQueue(); + const handle = queue.workflows + .define("c") + .chain([ + { name: "a", task: "noop" }, + { name: "b", task: "noop" }, + { name: "d", task: "noop" }, + ]) + .submit(); + const a = queue.workflows.analyze(handle.runId); + expect(a?.roots()).toEqual(["a"]); + expect(a?.leaves()).toEqual(["d"]); + expect(a?.topologicalLevels()).toEqual([["a"], ["b"], ["d"]]); +}); + +it("group runs steps in parallel", () => { + const queue = newQueue(); + const handle = queue.workflows + .define("g") + .group([ + { name: "a", task: "noop" }, + { name: "b", task: "noop" }, + { name: "d", task: "noop" }, + ]) + .submit(); + const a = queue.workflows.analyze(handle.runId); + expect(a?.roots().sort()).toEqual(["a", "b", "d"]); + expect(a?.leaves().sort()).toEqual(["a", "b", "d"]); +}); + +it("chord joins a group with a callback", () => { + const queue = newQueue(); + const handle = queue.workflows + .define("ch") + .chord( + [ + { name: "a", task: "noop" }, + { name: "b", task: "noop" }, + ], + { name: "cb", task: "noop" }, + ) + .submit(); + const a = queue.workflows.analyze(handle.runId); + expect(a?.roots().sort()).toEqual(["a", "b"]); + expect(a?.leaves()).toEqual(["cb"]); + expect(a?.ancestors("cb").sort()).toEqual(["a", "b"]); +}); + +it("chains after an existing step", () => { + const queue = newQueue(); + const handle = queue.workflows + .define("seeded") + .step("seed", "noop") + .chain( + [ + { name: "a", task: "noop" }, + { name: "b", task: "noop" }, + ], + { after: "seed" }, + ) + .submit(); + const a = queue.workflows.analyze(handle.runId); + expect(a?.roots()).toEqual(["seed"]); + expect(a?.criticalPath()).toEqual(["seed", "a", "b"]); +});