-
Notifications
You must be signed in to change notification settings - Fork 0
feat(node): core SDK — napi crate, TS Queue/Worker, serializers, CLI #268
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
30 commits
Select commit
Hold shift + click to select a range
5be9670
feat(node): scaffold taskito-node napi crate (enqueue + getJob)
pratyush618 b4341e8
chore(node): node package + napi build wiring
pratyush618 21146df
feat(node): worker dispatcher via threadsafe fn
pratyush618 23a8a7a
feat(node): typescript Queue/Worker api + json serializer
pratyush618 38400a7
test(node): e2e round-trip + serializer unit tests
pratyush618 eb2ecb1
refactor(node): split conversion into convert/ module
pratyush618 fe8de8b
chore(node): tsup dual esm/cjs build + serializers layout
pratyush618 4af8f74
feat(node): backends, enqueue options, task config, timeout
pratyush618 0752546
feat(node): backend/options/config typescript api
pratyush618 cdce792
test(node): delay, idempotency, retry, timeout
pratyush618 9e55449
feat(node): cancellation-aware dispatch + cancel/progress
pratyush618 2098da2
feat(node): cooperative cancellation + job context
pratyush618 dd49bc5
test(node): cooperative cancellation
pratyush618 5a58555
feat(node): inspection + management methods
pratyush618 2944c52
feat(node): inspection/management api + result waiter
pratyush618 4215f33
test(node): stats, listing, dead-letter, pause/resume
pratyush618 5c070e4
feat(node): msgpack serializer
pratyush618 81de66b
test(node): msgpack round-trip + e2e
pratyush618 d04794f
docs(node): document the full SDK surface
pratyush618 88c629e
feat(node): standalone cli (run, enqueue, inspect, manage)
pratyush618 745a114
test(node): cli integration (spawn bin)
pratyush618 058ca32
refactor(node): cli/commands barrel
pratyush618 50c7442
docs(node): document the cli
pratyush618 6c2ec4e
chore(node): use local biome schema path
pratyush618 57f6f8e
test(node): pass cli app paths via env, not code interpolation
pratyush618 04aa226
feat(node): harden N-API input validation
pratyush618 ab8e8fe
docs(node): note JS task timeout cannot force-kill
pratyush618 0dedcdf
feat(node): validate CLI flags and args; drain on run shutdown
pratyush618 4f021d5
fix(node): honor --json in cancel output
pratyush618 a14ee92
test(node): cover N-API boundary validation
pratyush618 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| [package] | ||
| name = "taskito-node" | ||
| version = "0.16.3" | ||
| edition = "2021" | ||
| description = "Node.js (napi-rs) bindings for the Taskito task-queue core" | ||
| license = "MIT" | ||
| publish = false | ||
|
|
||
| [lib] | ||
| crate-type = ["cdylib"] | ||
|
|
||
| [features] | ||
| default = [] | ||
| postgres = ["taskito-core/postgres"] | ||
| redis = ["taskito-core/redis"] | ||
|
|
||
| [dependencies] | ||
| taskito-core = { path = "../taskito-core" } | ||
| napi = { version = "2", default-features = false, features = ["napi8", "tokio_rt"] } | ||
| napi-derive = "2" | ||
| tokio = { workspace = true } | ||
| crossbeam-channel = { workspace = true } | ||
| async-trait = { workspace = true } | ||
| log = { workspace = true } | ||
|
|
||
| [build-dependencies] | ||
| napi-build = "2" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| fn main() { | ||
| napi_build::setup(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| //! Feature-gated construction of a [`StorageBackend`] from JS [`OpenOptions`]. | ||
|
|
||
| use napi::bindgen_prelude::{Error, Result, Status}; | ||
| use taskito_core::{SqliteStorage, StorageBackend}; | ||
|
|
||
| use crate::config::OpenOptions; | ||
| use crate::error::{invalid_arg, to_napi_err}; | ||
|
|
||
| const DEFAULT_SQLITE_POOL: u32 = 8; | ||
| #[cfg(feature = "postgres")] | ||
| const DEFAULT_POSTGRES_POOL: u32 = 10; | ||
| #[cfg(feature = "postgres")] | ||
| const DEFAULT_POSTGRES_SCHEMA: &str = "taskito"; | ||
|
|
||
| /// Resolve a connection pool size, rejecting an explicit zero — r2d2 requires | ||
| /// `max_size > 0` and panics otherwise, which would crash the whole process. | ||
| fn resolve_pool_size(pool_size: Option<u32>, default: u32) -> Result<u32> { | ||
| match pool_size { | ||
| Some(0) => Err(invalid_arg("poolSize must be greater than 0")), | ||
| Some(n) => Ok(n), | ||
| None => Ok(default), | ||
| } | ||
| } | ||
|
|
||
| /// Open the storage backend named by `options.backend` (default `"sqlite"`). | ||
| /// Returns an error if a requested backend was not compiled into this addon. | ||
| pub fn open(options: &OpenOptions) -> Result<StorageBackend> { | ||
| match options.backend.as_deref().unwrap_or("sqlite") { | ||
| "sqlite" => { | ||
| let pool = resolve_pool_size(options.pool_size, DEFAULT_SQLITE_POOL)?; | ||
| let storage = SqliteStorage::with_pool_size(&options.dsn, pool).map_err(to_napi_err)?; | ||
| Ok(StorageBackend::Sqlite(storage)) | ||
| } | ||
| #[cfg(feature = "postgres")] | ||
| "postgres" => { | ||
| let schema = options.schema.as_deref().unwrap_or(DEFAULT_POSTGRES_SCHEMA); | ||
| let pool = resolve_pool_size(options.pool_size, DEFAULT_POSTGRES_POOL)?; | ||
| let storage = | ||
| taskito_core::PostgresStorage::with_schema_and_pool_size(&options.dsn, schema, pool) | ||
| .map_err(to_napi_err)?; | ||
| Ok(StorageBackend::Postgres(storage)) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| #[cfg(feature = "redis")] | ||
| "redis" => { | ||
| let storage = match options.prefix.as_deref() { | ||
| Some(prefix) => taskito_core::RedisStorage::with_prefix(&options.dsn, prefix), | ||
| None => taskito_core::RedisStorage::new(&options.dsn), | ||
| } | ||
| .map_err(to_napi_err)?; | ||
| Ok(StorageBackend::Redis(storage)) | ||
| } | ||
| other => Err(Error::new( | ||
| Status::InvalidArg, | ||
| format!("backend '{other}' is not available (not a known backend, or this addon was built without its cargo feature)"), | ||
| )), | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| //! Plain option objects passed from JavaScript. napi maps snake_case Rust | ||
| //! fields to camelCase JS keys (`maxRetries`, `timeoutMs`). | ||
|
|
||
| use napi_derive::napi; | ||
|
|
||
| /// How to open a queue's storage backend. | ||
| #[napi(object)] | ||
| pub struct OpenOptions { | ||
| /// `"sqlite"` (default), `"postgres"`, or `"redis"`. The latter two require | ||
| /// the addon to be built with the matching cargo feature. | ||
| pub backend: Option<String>, | ||
| /// SQLite file path, Postgres URL, or Redis URL. | ||
| pub dsn: String, | ||
| /// Connection pool size (SQLite/Postgres). | ||
| pub pool_size: Option<u32>, | ||
| /// Postgres schema (default `"taskito"`). | ||
| pub schema: Option<String>, | ||
| /// Redis key prefix. | ||
| pub prefix: Option<String>, | ||
| /// Optional namespace applied to enqueued jobs and the worker scheduler. | ||
| pub namespace: Option<String>, | ||
| } | ||
|
|
||
| /// Per-enqueue overrides. All optional — omitted fields fall back to defaults | ||
| /// in [`crate::convert::build_new_job`]. | ||
| #[napi(object)] | ||
| #[derive(Default)] | ||
| pub struct EnqueueOptions { | ||
| pub queue: Option<String>, | ||
| pub priority: Option<i32>, | ||
| pub max_retries: Option<i32>, | ||
| pub timeout_ms: Option<i64>, | ||
| /// Run no earlier than `now + delayMs` (delayed/scheduled job). | ||
| pub delay_ms: Option<i64>, | ||
| /// Dedup key — a second enqueue with the same key is a no-op while the | ||
| /// first is pending/running (idempotency). | ||
| pub unique_key: Option<String>, | ||
| /// Free-form metadata string stored with the job. | ||
| pub metadata: Option<String>, | ||
| /// Namespace override for this job. | ||
| pub namespace: Option<String>, | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /// Filter for [`crate::queue::JsQueue::list_jobs`]. All fields optional. | ||
| #[napi(object)] | ||
| #[derive(Default)] | ||
| pub struct JobFilter { | ||
| /// Lowercase status (`"pending"`, `"running"`, `"complete"`, `"failed"`, `"dead"`, `"cancelled"`). | ||
| pub status: Option<String>, | ||
| pub queue: Option<String>, | ||
| pub task: Option<String>, | ||
| pub limit: Option<i64>, | ||
| pub offset: Option<i64>, | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /// Per-task configuration registered on the scheduler before the worker runs. | ||
| #[napi(object)] | ||
| pub struct TaskConfigInput { | ||
| pub name: String, | ||
| pub max_retries: Option<i32>, | ||
| pub retry_base_delay_ms: Option<i64>, | ||
| pub retry_max_delay_ms: Option<i64>, | ||
| pub max_concurrent: Option<i32>, | ||
| /// Rate-limit spec like `"100/m"`, `"50/s"`, `"3600/h"`. | ||
| pub rate_limit: Option<String>, | ||
| } | ||
|
|
||
| /// Per-queue configuration registered on the scheduler before the worker runs. | ||
| #[napi(object)] | ||
| pub struct QueueConfigInput { | ||
| pub name: String, | ||
| pub max_concurrent: Option<i32>, | ||
| pub rate_limit: Option<String>, | ||
| } | ||
|
|
||
| /// Options for a running worker. `queues` defaults to `["default"]`. | ||
| #[napi(object)] | ||
| #[derive(Default)] | ||
| pub struct WorkerOptions { | ||
| pub queues: Option<Vec<String>>, | ||
| pub channel_capacity: Option<u32>, | ||
| /// Jobs claimed per scheduler poll (default 1). | ||
| pub batch_size: Option<u32>, | ||
| pub task_configs: Option<Vec<TaskConfigInput>>, | ||
| pub queue_configs: Option<Vec<QueueConfigInput>>, | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| //! Marshalling between core job types and JS-facing shapes. Kept out of the | ||
| //! logic modules so `queue`/`worker` read as intent, not plumbing. | ||
|
|
||
| use napi::bindgen_prelude::{Buffer, Result}; | ||
| use napi_derive::napi; | ||
| use taskito_core::job::now_millis; | ||
| use taskito_core::{Job, NewJob}; | ||
|
|
||
| use crate::config::EnqueueOptions; | ||
| use crate::error::non_negative; | ||
|
|
||
| const DEFAULT_QUEUE: &str = "default"; | ||
| const DEFAULT_MAX_RETRIES: i32 = 3; | ||
| const DEFAULT_TIMEOUT_MS: i64 = 300_000; | ||
|
|
||
| /// Build a [`NewJob`] from a task name, opaque payload bytes, and JS options. | ||
| /// The core never interprets `payload` — the shell owns (de)serialization. | ||
| /// `queue_namespace` is the queue-level default applied when the enqueue call | ||
| /// doesn't override it. | ||
| pub fn build_new_job( | ||
| task_name: String, | ||
| payload: Vec<u8>, | ||
| opts: EnqueueOptions, | ||
| queue_namespace: Option<&str>, | ||
| ) -> Result<NewJob> { | ||
| // Signed types reach us from JS; reject the negatives that would silently | ||
| // corrupt scheduling (premature timeout, retries disabled, past schedule). | ||
| let delay = non_negative(opts.delay_ms.unwrap_or(0), "delayMs")?; | ||
| let max_retries = match opts.max_retries { | ||
| Some(n) => non_negative(n as i64, "maxRetries")? as i32, | ||
| None => DEFAULT_MAX_RETRIES, | ||
| }; | ||
| let timeout_ms = match opts.timeout_ms { | ||
| Some(n) => non_negative(n, "timeoutMs")?, | ||
| None => DEFAULT_TIMEOUT_MS, | ||
| }; | ||
| Ok(NewJob { | ||
| queue: opts.queue.unwrap_or_else(|| DEFAULT_QUEUE.to_string()), | ||
| task_name, | ||
| payload, | ||
| priority: opts.priority.unwrap_or(0), | ||
| // Saturate so an extreme delay can't overflow into a past schedule. | ||
| scheduled_at: now_millis().saturating_add(delay), | ||
| max_retries, | ||
| timeout_ms, | ||
| unique_key: opts.unique_key, | ||
| metadata: opts.metadata, | ||
| notes: None, | ||
| depends_on: Vec::new(), | ||
| expires_at: None, | ||
| result_ttl_ms: None, | ||
| namespace: opts | ||
| .namespace | ||
| .or_else(|| queue_namespace.map(str::to_string)), | ||
| }) | ||
| } | ||
|
|
||
| /// Passed to the JS task callback for each dispatched job. `payload` is the | ||
| /// opaque arg blob the shell deserializes before running the task. | ||
| #[napi(object)] | ||
| pub struct JsTaskInvocation { | ||
| pub id: String, | ||
| pub task_name: String, | ||
| pub payload: Buffer, | ||
| } | ||
|
|
||
| /// JS-facing view of a stored [`Job`]. `result` is the opaque result blob (or | ||
| /// `null`); the shell deserializes it. | ||
| #[napi(object)] | ||
| pub struct JsJob { | ||
| pub id: String, | ||
| pub queue: String, | ||
| pub task_name: String, | ||
| pub status: String, | ||
| pub priority: i32, | ||
| pub retry_count: i32, | ||
| pub max_retries: i32, | ||
| pub result: Option<Buffer>, | ||
| pub error: Option<String>, | ||
| pub created_at: i64, | ||
| pub completed_at: Option<i64>, | ||
| } | ||
|
|
||
| /// Convert a core [`Job`] into its JS-facing shape. | ||
| pub fn job_to_js(job: Job) -> JsJob { | ||
| JsJob { | ||
| id: job.id, | ||
| queue: job.queue, | ||
| task_name: job.task_name, | ||
| status: job.status.as_str().to_string(), | ||
| priority: job.priority, | ||
| retry_count: job.retry_count, | ||
| max_retries: job.max_retries, | ||
| result: job.result.map(Buffer::from), | ||
| error: job.error, | ||
| created_at: job.created_at, | ||
| completed_at: job.completed_at, | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| //! Marshalling between core types and JS-facing shapes. One submodule per | ||
| //! concern; kept out of the logic modules so they read as intent, not plumbing. | ||
|
|
||
| mod job; | ||
| mod stats; | ||
| mod task_config; | ||
|
|
||
| pub use job::{build_new_job, job_to_js, JsJob, JsTaskInvocation}; | ||
| pub use stats::{ | ||
| dead_job_to_js, job_error_to_js, metric_to_js, stats_to_js, status_code, JsDeadJob, JsJobError, | ||
| JsMetric, JsStats, | ||
| }; | ||
| pub use task_config::{queue_config, task_config}; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.