From 38e68023fb99dee20b1e6669dc83954e5aa58f75 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:34:42 +0530 Subject: [PATCH 01/16] feat(java): add taskito-java JNI crate jni-rs (cdylib) shell over taskito-core, peer to the node/python shells. Producer surface (open/enqueue/get/cancel/stats) with a panic-safe FFI boundary. --- Cargo.lock | 199 +++++++++++++++++++++++++++-- Cargo.toml | 2 +- crates/taskito-java/Cargo.toml | 22 ++++ crates/taskito-java/src/backend.rs | 70 ++++++++++ crates/taskito-java/src/convert.rs | 154 ++++++++++++++++++++++ crates/taskito-java/src/error.rs | 36 ++++++ crates/taskito-java/src/ffi.rs | 52 ++++++++ crates/taskito-java/src/handle.rs | 29 +++++ crates/taskito-java/src/jvm.rs | 18 +++ crates/taskito-java/src/lib.rs | 30 +++++ crates/taskito-java/src/queue.rs | 133 +++++++++++++++++++ 11 files changed, 732 insertions(+), 13 deletions(-) create mode 100644 crates/taskito-java/Cargo.toml create mode 100644 crates/taskito-java/src/backend.rs create mode 100644 crates/taskito-java/src/convert.rs create mode 100644 crates/taskito-java/src/error.rs create mode 100644 crates/taskito-java/src/ffi.rs create mode 100644 crates/taskito-java/src/handle.rs create mode 100644 crates/taskito-java/src/jvm.rs create mode 100644 crates/taskito-java/src/lib.rs create mode 100644 crates/taskito-java/src/queue.rs diff --git a/Cargo.lock b/Cargo.lock index 54411f13..ce0ad8d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,6 +120,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -243,7 +249,7 @@ dependencies = [ "regex", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -653,6 +659,50 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "js-sys" version = "0.3.88" @@ -1232,7 +1282,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" dependencies = [ "hashbrown 0.16.1", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1260,6 +1310,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scheduled-thread-pool" version = "0.2.7" @@ -1439,11 +1498,22 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", "uuid", ] +[[package]] +name = "taskito-java" +version = "0.16.4" +dependencies = [ + "jni", + "once_cell", + "serde", + "serde_json", + "taskito-core", +] + [[package]] name = "taskito-mesh" version = "0.16.4" @@ -1525,13 +1595,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1673,6 +1763,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1776,6 +1876,15 @@ dependencies = [ "semver", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -1835,13 +1944,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -1853,6 +1971,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -1860,28 +1993,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.53.1" @@ -1894,24 +2045,48 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" diff --git a/Cargo.toml b/Cargo.toml index 1f68d960..a32eeef1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/taskito-core", "crates/taskito-python", "crates/taskito-node", "crates/taskito-workflows", "crates/taskito-mesh"] +members = ["crates/taskito-core", "crates/taskito-python", "crates/taskito-node", "crates/taskito-java", "crates/taskito-workflows", "crates/taskito-mesh"] resolver = "2" [workspace.dependencies] diff --git a/crates/taskito-java/Cargo.toml b/crates/taskito-java/Cargo.toml new file mode 100644 index 00000000..d65ba949 --- /dev/null +++ b/crates/taskito-java/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "taskito-java" +version = "0.16.4" +edition = "2021" +description = "Java (JNI) 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" } +jni = "0.21" +serde = { workspace = true } +serde_json = { workspace = true } +once_cell = "1" diff --git a/crates/taskito-java/src/backend.rs b/crates/taskito-java/src/backend.rs new file mode 100644 index 00000000..69688b0a --- /dev/null +++ b/crates/taskito-java/src/backend.rs @@ -0,0 +1,70 @@ +//! Construct a [`QueueHandle`] from caller options (mirrors the Node shell). + +use taskito_core::{SqliteStorage, StorageBackend}; + +use crate::convert::OpenOptions; +use crate::error::BindingError; + +const DEFAULT_SQLITE_POOL: u32 = 8; +#[cfg(feature = "postgres")] +const DEFAULT_POSTGRES_POOL: u32 = 10; +#[cfg(feature = "postgres")] +const DEFAULT_POSTGRES_SCHEMA: &str = "taskito"; + +/// An open queue: the storage backend plus the default namespace applied to +/// enqueues that do not specify their own. +pub struct QueueHandle { + pub storage: StorageBackend, + pub namespace: Option, +} + +/// Error for a backend that is unknown or whose cargo feature is not compiled in. +fn unknown_backend(name: &str) -> BindingError { + BindingError::new(format!( + "backend '{name}' is not available (unknown, or this build omits its cargo feature)" + )) +} + +/// Reject an explicit zero pool size — r2d2 panics when `max_size == 0`, which +/// would take down the whole JVM. +fn resolve_pool_size(pool_size: Option, default: u32) -> Result { + match pool_size { + Some(0) => Err(BindingError::new("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 library. +pub fn open(options: OpenOptions) -> Result { + let storage = match options.backend.as_deref().unwrap_or("sqlite") { + "sqlite" => { + let pool = resolve_pool_size(options.pool_size, DEFAULT_SQLITE_POOL)?; + StorageBackend::Sqlite(SqliteStorage::with_pool_size(&options.dsn, pool)?) + } + #[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)?; + StorageBackend::Postgres(taskito_core::PostgresStorage::with_schema_and_pool_size( + &options.dsn, + schema, + pool, + )?) + } + #[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), + }?; + StorageBackend::Redis(storage) + } + other => return Err(unknown_backend(other)), + }; + Ok(QueueHandle { + storage, + namespace: options.namespace, + }) +} diff --git a/crates/taskito-java/src/convert.rs b/crates/taskito-java/src/convert.rs new file mode 100644 index 00000000..7495974b --- /dev/null +++ b/crates/taskito-java/src/convert.rs @@ -0,0 +1,154 @@ +//! Serde views marshalled across the JNI boundary as JSON. +//! +//! Option and filter structs cross as JSON strings (decoded here); opaque job +//! payloads cross as raw `byte[]` and are never interpreted by the core. + +use serde::{Deserialize, Serialize}; +use taskito_core::job::{now_millis, Job, NewJob}; +use taskito_core::storage::QueueStats; + +use crate::error::BindingError; + +const DEFAULT_QUEUE: &str = "default"; + +/// Options accepted by `NativeQueue.open`. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenOptions { + pub backend: Option, + pub dsn: String, + pub pool_size: Option, + /// Postgres schema; read only by the `postgres` backend. + #[cfg_attr(not(feature = "postgres"), allow(dead_code))] + pub schema: Option, + /// Redis key prefix; read only by the `redis` backend. + #[cfg_attr(not(feature = "redis"), allow(dead_code))] + pub prefix: Option, + pub namespace: Option, +} + +/// Per-enqueue options. Every field is optional; absent fields take core +/// defaults. +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct EnqueueOptions { + pub queue: Option, + pub priority: Option, + pub max_retries: Option, + pub timeout_ms: Option, + pub delay_ms: Option, + pub unique_key: Option, + pub metadata: Option, + pub namespace: Option, +} + +/// Parse a JSON argument, attributing any failure to the named field. +pub fn parse_json<'a, T: Deserialize<'a>>(raw: &'a str, what: &str) -> Result { + serde_json::from_str(raw).map_err(|e| BindingError::new(format!("invalid {what} JSON: {e}"))) +} + +/// Build a [`NewJob`] from an enqueue request, applying the queue's default +/// namespace when the request does not set one. +pub fn build_new_job( + task_name: String, + payload: Vec, + options: EnqueueOptions, + default_namespace: Option<&str>, +) -> NewJob { + let delay = options.delay_ms.unwrap_or(0).max(0); + NewJob { + queue: options.queue.unwrap_or_else(|| DEFAULT_QUEUE.to_string()), + task_name, + payload, + priority: options.priority.unwrap_or(0), + scheduled_at: now_millis() + delay, + max_retries: options.max_retries.unwrap_or(0), + timeout_ms: options.timeout_ms.unwrap_or(0), + unique_key: options.unique_key, + metadata: options.metadata, + notes: None, + depends_on: Vec::new(), + expires_at: None, + result_ttl_ms: None, + namespace: options + .namespace + .or_else(|| default_namespace.map(str::to_string)), + } +} + +/// Job-count snapshot returned by `NativeQueue.stats`. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StatsView { + pub pending: i64, + pub running: i64, + pub completed: i64, + pub failed: i64, + pub dead: i64, + pub cancelled: i64, +} + +impl From for StatsView { + fn from(s: QueueStats) -> Self { + Self { + pending: s.pending, + running: s.running, + completed: s.completed, + failed: s.failed, + dead: s.dead, + cancelled: s.cancelled, + } + } +} + +/// Java-facing view of a job. Omits the opaque `payload`/`result` blobs; the +/// Java side deserializes those itself. `status` is the lowercase wire string +/// shared across SDKs. Timestamps are Unix milliseconds. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobView<'a> { + pub id: &'a str, + pub queue: &'a str, + pub task_name: &'a str, + pub status: &'static str, + pub priority: i32, + pub created_at: i64, + pub scheduled_at: i64, + pub started_at: Option, + pub completed_at: Option, + pub retry_count: i32, + pub max_retries: i32, + pub timeout_ms: i64, + pub progress: Option, + pub error: Option<&'a str>, + pub unique_key: Option<&'a str>, + pub namespace: Option<&'a str>, +} + +impl<'a> From<&'a Job> for JobView<'a> { + fn from(j: &'a Job) -> Self { + Self { + id: &j.id, + queue: &j.queue, + task_name: &j.task_name, + status: j.status.as_str(), + priority: j.priority, + created_at: j.created_at, + scheduled_at: j.scheduled_at, + started_at: j.started_at, + completed_at: j.completed_at, + retry_count: j.retry_count, + max_retries: j.max_retries, + timeout_ms: j.timeout_ms, + progress: j.progress, + error: j.error.as_deref(), + unique_key: j.unique_key.as_deref(), + namespace: j.namespace.as_deref(), + } + } +} + +/// Serialize a view to a JSON string for return across the boundary. +pub fn to_json(value: &T) -> Result { + serde_json::to_string(value).map_err(|e| BindingError::new(format!("serialize failed: {e}"))) +} diff --git a/crates/taskito-java/src/error.rs b/crates/taskito-java/src/error.rs new file mode 100644 index 00000000..930f203a --- /dev/null +++ b/crates/taskito-java/src/error.rs @@ -0,0 +1,36 @@ +//! Translate core errors into Java exceptions thrown across the JNI boundary. + +use jni::JNIEnv; +use taskito_core::QueueError; + +/// Fully-qualified JNI name (slashes, not dots) of the SDK's exception class. +pub const TASKITO_EXCEPTION: &str = "org/byteveda/taskito/TaskitoException"; + +/// A deferred Java exception. The binding builds one of these on failure and +/// throws it at the FFI boundary instead of unwinding a Rust panic across it. +pub struct BindingError { + class: &'static str, + message: String, +} + +impl BindingError { + /// A `TaskitoException` carrying `message`. + pub fn new(message: impl Into) -> Self { + Self { + class: TASKITO_EXCEPTION, + message: message.into(), + } + } + + /// Throw this as a pending Java exception. A failed throw means the JVM is + /// already in a bad state, so there is nothing further to do. + pub fn throw(&self, env: &mut JNIEnv) { + let _ = env.throw_new(self.class, &self.message); + } +} + +impl From for BindingError { + fn from(err: QueueError) -> Self { + Self::new(err.to_string()) + } +} diff --git a/crates/taskito-java/src/ffi.rs b/crates/taskito-java/src/ffi.rs new file mode 100644 index 00000000..f3b8bd87 --- /dev/null +++ b/crates/taskito-java/src/ffi.rs @@ -0,0 +1,52 @@ +//! JNI marshalling helpers and the panic-safety boundary. + +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use jni::objects::{JByteArray, JString}; +use jni::JNIEnv; + +use crate::error::BindingError; + +/// Run `body`, converting both `BindingError`s and Rust panics into thrown Java +/// exceptions and returning `fallback` in either case. +/// +/// A Rust panic must never unwind across the FFI boundary (undefined behaviour), +/// so every JNI entry point routes its work through this guard. +pub fn guard<'local, T>( + env: &mut JNIEnv<'local>, + fallback: T, + body: impl FnOnce(&mut JNIEnv<'local>) -> Result, +) -> T { + match catch_unwind(AssertUnwindSafe(|| body(env))) { + Ok(Ok(value)) => value, + Ok(Err(err)) => { + err.throw(env); + fallback + } + Err(_) => { + BindingError::new("taskito native panic").throw(env); + fallback + } + } +} + +/// Read a required Java `String` argument into an owned Rust `String`. +pub fn read_string(env: &mut JNIEnv, value: &JString) -> Result { + let java_str = env + .get_string(value) + .map_err(|e| BindingError::new(format!("invalid string argument: {e}")))?; + Ok(java_str.into()) +} + +/// Read a Java `byte[]` argument into an owned `Vec`. +pub fn read_bytes(env: &mut JNIEnv, value: &JByteArray) -> Result, BindingError> { + env.convert_byte_array(value) + .map_err(|e| BindingError::new(format!("invalid byte[] argument: {e}"))) +} + +/// Build a Java `String` return value from an owned Rust `String`. +pub fn new_string(env: &mut JNIEnv, value: String) -> Result { + env.new_string(value) + .map(|s| s.into_raw()) + .map_err(|e| BindingError::new(format!("failed to allocate Java string: {e}"))) +} diff --git a/crates/taskito-java/src/handle.rs b/crates/taskito-java/src/handle.rs new file mode 100644 index 00000000..7aedc6ce --- /dev/null +++ b/crates/taskito-java/src/handle.rs @@ -0,0 +1,29 @@ +//! Opaque Java `long` <-> heap-boxed Rust value. +//! +//! A handle is a `Box::into_raw` pointer as `jlong`; `close` reclaims it. Java +//! must not use a handle after `close`, nor `close` it concurrently with a call. + +use jni::sys::jlong; + +/// Box `value` on the heap and return its pointer as an opaque handle. +pub fn into_handle(value: T) -> jlong { + Box::into_raw(Box::new(value)) as jlong +} + +/// Borrow the value behind a handle. +/// +/// # Safety +/// `handle` must be a live pointer returned by [`into_handle`] for a `T` and not +/// yet passed to [`drop_handle`]. +pub unsafe fn borrow<'a, T>(handle: jlong) -> &'a T { + &*(handle as *const T) +} + +/// Reclaim and drop the value behind a handle. +/// +/// # Safety +/// `handle` must be a live pointer returned by [`into_handle`] for a `T`; it is +/// invalid afterward. +pub unsafe fn drop_handle(handle: jlong) { + drop(Box::from_raw(handle as *mut T)); +} diff --git a/crates/taskito-java/src/jvm.rs b/crates/taskito-java/src/jvm.rs new file mode 100644 index 00000000..c5e3395a --- /dev/null +++ b/crates/taskito-java/src/jvm.rs @@ -0,0 +1,18 @@ +//! Process-wide `JavaVM` handle, cached at `JNI_OnLoad`. + +use jni::JavaVM; +use once_cell::sync::OnceCell; + +static JAVA_VM: OnceCell = OnceCell::new(); + +/// Store the VM. Called once from `JNI_OnLoad`. +pub fn init(vm: JavaVM) { + let _ = JAVA_VM.set(vm); +} + +/// The cached VM (present after `JNI_OnLoad`). Used by the worker bridge to +/// attach worker threads that invoke Java callbacks. +#[allow(dead_code)] +pub fn vm() -> &'static JavaVM { + JAVA_VM.get().expect("JNI_OnLoad has not run") +} diff --git a/crates/taskito-java/src/lib.rs b/crates/taskito-java/src/lib.rs new file mode 100644 index 00000000..6fa1419a --- /dev/null +++ b/crates/taskito-java/src/lib.rs @@ -0,0 +1,30 @@ +//! Java (JNI) bindings for the Taskito task-queue core. +//! +//! A thin binding shell — peer to the Python (`taskito-python`) and Node +//! (`taskito-node`) shells. All scheduling and storage logic lives in +//! `taskito-core`; this crate only marshals between the JVM and the core. +//! +//! Each JNI entry point lives in [`queue`] and is named +//! `Java_org_byteveda_taskito_internal_NativeQueue_` so the JVM links it +//! to the matching `native` method on the `NativeQueue` Java class. + +mod backend; +mod convert; +mod error; +mod ffi; +mod handle; +mod jvm; +mod queue; + +use std::ffi::c_void; + +use jni::sys::{jint, JNI_VERSION_1_8}; +use jni::JavaVM; + +/// Cache the `JavaVM` so worker threads can later attach to invoke Java +/// callbacks. The JVM calls this before any native method here. +#[no_mangle] +pub extern "system" fn JNI_OnLoad(vm: JavaVM, _reserved: *mut c_void) -> jint { + jvm::init(vm); + JNI_VERSION_1_8 +} diff --git a/crates/taskito-java/src/queue.rs b/crates/taskito-java/src/queue.rs new file mode 100644 index 00000000..22e3c695 --- /dev/null +++ b/crates/taskito-java/src/queue.rs @@ -0,0 +1,133 @@ +//! JNI entry points backing `org.byteveda.taskito.internal.NativeQueue`. +//! +//! Each function name encodes the Java package, class, and method the JVM links +//! it to. Every body runs inside [`guard`] so failures surface as Java +//! exceptions rather than unwinding across the FFI boundary. + +use jni::objects::{JByteArray, JClass, JString}; +use jni::sys::{jboolean, jlong, jstring, JNI_FALSE, JNI_TRUE}; +use jni::JNIEnv; +use taskito_core::Storage; + +use crate::backend::{self, QueueHandle}; +use crate::convert::{ + build_new_job, parse_json, to_json, EnqueueOptions, JobView, OpenOptions, StatsView, +}; +use crate::ffi::{guard, new_string, read_bytes, read_string}; +use crate::handle::{borrow, drop_handle, into_handle}; + +/// Borrow the queue behind a handle. +/// +/// # Safety +/// `handle` must be a live `QueueHandle` pointer (see [`crate::handle`]). +unsafe fn queue<'a>(handle: jlong) -> &'a QueueHandle { + borrow::(handle) +} + +/// `long open(String optionsJson)` — open a backend, returning its handle. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_open<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + options_json: JString<'local>, +) -> jlong { + guard(&mut env, 0, |env| { + let raw = read_string(env, &options_json)?; + let options: OpenOptions = parse_json(&raw, "open options")?; + Ok(into_handle(backend::open(options)?)) + }) +} + +/// `void close(long handle)` — reclaim a handle. A zero handle is a no-op. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_close( + _env: JNIEnv, + _class: JClass, + handle: jlong, +) { + if handle != 0 { + unsafe { drop_handle::(handle) }; + } +} + +/// `String enqueue(long handle, String taskName, byte[] payload, String optionsJson)` +/// — enqueue a job and return its id. A set `uniqueKey` makes a duplicate +/// enqueue a no-op while the first job is pending/running (idempotency). +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_enqueue<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + task_name: JString<'local>, + payload: JByteArray<'local>, + options_json: JString<'local>, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { queue(handle) }; + let task = read_string(env, &task_name)?; + let bytes = read_bytes(env, &payload)?; + let raw_opts = read_string(env, &options_json)?; + let options: EnqueueOptions = parse_json(&raw_opts, "enqueue options")?; + let unique = options.unique_key.is_some(); + let new_job = build_new_job(task, bytes, options, queue.namespace.as_deref()); + let job = if unique { + queue.storage.enqueue_unique(new_job) + } else { + queue.storage.enqueue(new_job) + }?; + new_string(env, job.id) + }) +} + +/// `String getJob(long handle, String jobId)` — a JSON [`JobView`], or `null` +/// when no such job exists. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_getJob<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + job_id: JString<'local>, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { queue(handle) }; + let id = read_string(env, &job_id)?; + match queue.storage.get_job(&id)? { + Some(job) => new_string(env, to_json(&JobView::from(&job))?), + None => Ok(std::ptr::null_mut()), + } + }) +} + +/// `boolean cancel(long handle, String jobId)` — cancel a pending job. Returns +/// false when the job was not pending. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_cancel<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + job_id: JString<'local>, +) -> jboolean { + guard(&mut env, JNI_FALSE, |env| { + let queue = unsafe { queue(handle) }; + let id = read_string(env, &job_id)?; + Ok(if queue.storage.cancel_job(&id)? { + JNI_TRUE + } else { + JNI_FALSE + }) + }) +} + +/// `String stats(long handle)` — a JSON [`StatsView`] of job counts by status. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_stats<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { queue(handle) }; + let stats = queue.storage.stats()?; + new_string(env, to_json(&StatsView::from(stats))?) + }) +} From 8123283cc9cbfb381f25581ec4d957f25712d05a Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:34:42 +0530 Subject: [PATCH 02/16] feat(java): add layered Java SDK sources Taskito factory -> Queue interface (pkg-private impl), typed Task, JobStatus enum, Serializer SPI, and a QueueBackend seam decoupling the API from the JNI layer. --- .../org/byteveda/taskito/DefaultQueue.java | 74 +++++++++++++++ .../org/byteveda/taskito/EnqueueOptions.java | 91 +++++++++++++++++++ .../main/java/org/byteveda/taskito/Job.java | 62 +++++++++++++ .../java/org/byteveda/taskito/JobStatus.java | 25 +++++ .../main/java/org/byteveda/taskito/Queue.java | 23 +++++ .../java/org/byteveda/taskito/QueueStats.java | 32 +++++++ .../main/java/org/byteveda/taskito/Task.java | 35 +++++++ .../java/org/byteveda/taskito/Taskito.java | 83 +++++++++++++++++ .../byteveda/taskito/TaskitoException.java | 12 +++ .../taskito/internal/JniQueueBackend.java | 43 +++++++++ .../taskito/internal/NativeLoader.java | 75 +++++++++++++++ .../taskito/internal/NativeQueue.java | 30 ++++++ .../taskito/internal/package-info.java | 2 + .../org/byteveda/taskito/package-info.java | 11 +++ .../taskito/serialization/JsonSerializer.java | 35 +++++++ .../taskito/serialization/Serializer.java | 8 ++ .../taskito/serialization/package-info.java | 2 + .../byteveda/taskito/spi/QueueBackend.java | 24 +++++ .../byteveda/taskito/spi/package-info.java | 2 + .../java/org/byteveda/taskito/QueueTest.java | 56 ++++++++++++ 20 files changed, 725 insertions(+) create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/DefaultQueue.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/EnqueueOptions.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/Job.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/JobStatus.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/Queue.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/QueueStats.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/Task.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/Taskito.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/TaskitoException.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/internal/JniQueueBackend.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/internal/NativeLoader.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/internal/NativeQueue.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/internal/package-info.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/package-info.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/serialization/JsonSerializer.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/serialization/Serializer.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/serialization/package-info.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/spi/package-info.java create mode 100644 sdks/java/src/test/java/org/byteveda/taskito/QueueTest.java diff --git a/sdks/java/src/main/java/org/byteveda/taskito/DefaultQueue.java b/sdks/java/src/main/java/org/byteveda/taskito/DefaultQueue.java new file mode 100644 index 00000000..9d1829a5 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/DefaultQueue.java @@ -0,0 +1,74 @@ +package org.byteveda.taskito; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Optional; +import org.byteveda.taskito.serialization.Serializer; +import org.byteveda.taskito.spi.QueueBackend; + +/** + * Default {@link Queue}: maps the typed public API onto a {@link QueueBackend}, + * serializing payloads with the configured {@link Serializer} and decoding + * native JSON views with a private mapper. + */ +final class DefaultQueue implements Queue { + private static final ObjectMapper VIEWS = new ObjectMapper(); + + private final QueueBackend backend; + private final Serializer serializer; + + DefaultQueue(QueueBackend backend, Serializer serializer) { + this.backend = backend; + this.serializer = serializer; + } + + @Override + public String enqueue(Task task, T payload) { + return enqueue(task, payload, task.options()); + } + + @Override + public String enqueue(Task task, T payload, EnqueueOptions options) { + return backend.enqueue(task.name(), serializer.serialize(payload), encode(options)); + } + + @Override + public String enqueue(String taskName, Object payload) { + return backend.enqueue(taskName, serializer.serialize(payload), encode(EnqueueOptions.none())); + } + + @Override + public Optional getJob(String jobId) { + return backend.getJobJson(jobId).map(json -> decode(json, Job.class)); + } + + @Override + public boolean cancel(String jobId) { + return backend.cancel(jobId); + } + + @Override + public QueueStats stats() { + return decode(backend.statsJson(), QueueStats.class); + } + + @Override + public void close() { + backend.close(); + } + + private static String encode(EnqueueOptions options) { + try { + return VIEWS.writeValueAsString(options); + } catch (Exception e) { + throw new TaskitoException("failed to encode options", e); + } + } + + private static R decode(String json, Class type) { + try { + return VIEWS.readValue(json, type); + } catch (Exception e) { + throw new TaskitoException("failed to decode native response", e); + } + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/EnqueueOptions.java b/sdks/java/src/main/java/org/byteveda/taskito/EnqueueOptions.java new file mode 100644 index 00000000..4149ef35 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/EnqueueOptions.java @@ -0,0 +1,91 @@ +package org.byteveda.taskito; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Immutable per-enqueue options. Unset fields take core defaults. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class EnqueueOptions { + @JsonProperty("queue") private final String queue; + @JsonProperty("priority") private final Integer priority; + @JsonProperty("maxRetries") private final Integer maxRetries; + @JsonProperty("timeoutMs") private final Long timeoutMs; + @JsonProperty("delayMs") private final Long delayMs; + @JsonProperty("uniqueKey") private final String uniqueKey; + @JsonProperty("metadata") private final String metadata; + @JsonProperty("namespace") private final String namespace; + + private EnqueueOptions(Builder b) { + this.queue = b.queue; + this.priority = b.priority; + this.maxRetries = b.maxRetries; + this.timeoutMs = b.timeoutMs; + this.delayMs = b.delayMs; + this.uniqueKey = b.uniqueKey; + this.metadata = b.metadata; + this.namespace = b.namespace; + } + + public static EnqueueOptions none() { + return builder().build(); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String queue; + private Integer priority; + private Integer maxRetries; + private Long timeoutMs; + private Long delayMs; + private String uniqueKey; + private String metadata; + private String namespace; + + public Builder queue(String queue) { + this.queue = queue; + return this; + } + + public Builder priority(int priority) { + this.priority = priority; + return this; + } + + public Builder maxRetries(int maxRetries) { + this.maxRetries = maxRetries; + return this; + } + + public Builder timeoutMs(long timeoutMs) { + this.timeoutMs = timeoutMs; + return this; + } + + public Builder delayMs(long delayMs) { + this.delayMs = delayMs; + return this; + } + + public Builder uniqueKey(String uniqueKey) { + this.uniqueKey = uniqueKey; + return this; + } + + public Builder metadata(String metadata) { + this.metadata = metadata; + return this; + } + + public Builder namespace(String namespace) { + this.namespace = namespace; + return this; + } + + public EnqueueOptions build() { + return new EnqueueOptions(this); + } + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/Job.java b/sdks/java/src/main/java/org/byteveda/taskito/Job.java new file mode 100644 index 00000000..1d331556 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/Job.java @@ -0,0 +1,62 @@ +package org.byteveda.taskito; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Immutable view of a job. Timestamps are Unix milliseconds. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class Job { + public final String id; + public final String queue; + public final String taskName; + public final JobStatus status; + public final int priority; + public final long createdAt; + public final long scheduledAt; + public final Long startedAt; + public final Long completedAt; + public final int retryCount; + public final int maxRetries; + public final long timeoutMs; + public final Integer progress; + public final String error; + public final String uniqueKey; + public final String namespace; + + @JsonCreator + public Job( + @JsonProperty("id") String id, + @JsonProperty("queue") String queue, + @JsonProperty("taskName") String taskName, + @JsonProperty("status") JobStatus status, + @JsonProperty("priority") int priority, + @JsonProperty("createdAt") long createdAt, + @JsonProperty("scheduledAt") long scheduledAt, + @JsonProperty("startedAt") Long startedAt, + @JsonProperty("completedAt") Long completedAt, + @JsonProperty("retryCount") int retryCount, + @JsonProperty("maxRetries") int maxRetries, + @JsonProperty("timeoutMs") long timeoutMs, + @JsonProperty("progress") Integer progress, + @JsonProperty("error") String error, + @JsonProperty("uniqueKey") String uniqueKey, + @JsonProperty("namespace") String namespace) { + this.id = id; + this.queue = queue; + this.taskName = taskName; + this.status = status; + this.priority = priority; + this.createdAt = createdAt; + this.scheduledAt = scheduledAt; + this.startedAt = startedAt; + this.completedAt = completedAt; + this.retryCount = retryCount; + this.maxRetries = maxRetries; + this.timeoutMs = timeoutMs; + this.progress = progress; + this.error = error; + this.uniqueKey = uniqueKey; + this.namespace = namespace; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/JobStatus.java b/sdks/java/src/main/java/org/byteveda/taskito/JobStatus.java new file mode 100644 index 00000000..dcb7bd5f --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/JobStatus.java @@ -0,0 +1,25 @@ +package org.byteveda.taskito; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.Locale; + +/** Lifecycle state of a job. Wire form is the lowercase name, shared across SDKs. */ +public enum JobStatus { + PENDING, + RUNNING, + COMPLETE, + FAILED, + DEAD, + CANCELLED; + + @JsonValue + public String wire() { + return name().toLowerCase(Locale.ROOT); + } + + @JsonCreator + public static JobStatus fromWire(String wire) { + return valueOf(wire.toUpperCase(Locale.ROOT)); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/Queue.java b/sdks/java/src/main/java/org/byteveda/taskito/Queue.java new file mode 100644 index 00000000..d9954998 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/Queue.java @@ -0,0 +1,23 @@ +package org.byteveda.taskito; + +import java.util.Optional; + +/** A Taskito queue. Obtain one from {@link Taskito#builder()}. */ +public interface Queue extends AutoCloseable { + /** Enqueue a typed payload using the task's default options; returns the job id. */ + String enqueue(Task task, T payload); + + String enqueue(Task task, T payload, EnqueueOptions options); + + /** Enqueue by task name with an arbitrary payload and default options. */ + String enqueue(String taskName, Object payload); + + Optional getJob(String jobId); + + boolean cancel(String jobId); + + QueueStats stats(); + + @Override + void close(); +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/QueueStats.java b/sdks/java/src/main/java/org/byteveda/taskito/QueueStats.java new file mode 100644 index 00000000..d177a626 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/QueueStats.java @@ -0,0 +1,32 @@ +package org.byteveda.taskito; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Job counts by status across all queues. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class QueueStats { + public final long pending; + public final long running; + public final long completed; + public final long failed; + public final long dead; + public final long cancelled; + + @JsonCreator + public QueueStats( + @JsonProperty("pending") long pending, + @JsonProperty("running") long running, + @JsonProperty("completed") long completed, + @JsonProperty("failed") long failed, + @JsonProperty("dead") long dead, + @JsonProperty("cancelled") long cancelled) { + this.pending = pending; + this.running = running; + this.completed = completed; + this.failed = failed; + this.dead = dead; + this.cancelled = cancelled; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/Task.java b/sdks/java/src/main/java/org/byteveda/taskito/Task.java new file mode 100644 index 00000000..6b4968a7 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/Task.java @@ -0,0 +1,35 @@ +package org.byteveda.taskito; + +/** Typed task descriptor: a name, its payload type, and default enqueue options. */ +public final class Task { + private final String name; + private final Class payloadType; + private final EnqueueOptions options; + + private Task(String name, Class payloadType, EnqueueOptions options) { + this.name = name; + this.payloadType = payloadType; + this.options = options; + } + + public static Task of(String name, Class payloadType) { + return new Task<>(name, payloadType, EnqueueOptions.none()); + } + + /** A copy of this task with the given default options. */ + public Task withOptions(EnqueueOptions options) { + return new Task<>(name, payloadType, options); + } + + public String name() { + return name; + } + + public Class payloadType() { + return payloadType; + } + + public EnqueueOptions options() { + return options; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/Taskito.java b/sdks/java/src/main/java/org/byteveda/taskito/Taskito.java new file mode 100644 index 00000000..fd7f88b2 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/Taskito.java @@ -0,0 +1,83 @@ +package org.byteveda.taskito; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.LinkedHashMap; +import java.util.Map; +import org.byteveda.taskito.internal.JniQueueBackend; +import org.byteveda.taskito.serialization.JsonSerializer; +import org.byteveda.taskito.serialization.Serializer; +import org.byteveda.taskito.spi.QueueBackend; + +/** Entry point: open a {@link Queue} over a storage backend. */ +public final class Taskito { + private Taskito() {} + + public static Builder builder() { + return new Builder(); + } + + /** Configures and opens a {@link Queue}. */ + public static final class Builder { + private static final ObjectMapper JSON = new ObjectMapper(); + + private final Map options = new LinkedHashMap<>(); + private Serializer serializer = new JsonSerializer(); + + public Builder backend(String backend) { + options.put("backend", backend); + return this; + } + + /** Connection string: a file path for SQLite, a URL for Postgres/Redis. */ + public Builder url(String dsn) { + options.put("dsn", dsn); + return this; + } + + public Builder poolSize(int poolSize) { + options.put("poolSize", poolSize); + return this; + } + + public Builder schema(String schema) { + options.put("schema", schema); + return this; + } + + public Builder prefix(String prefix) { + options.put("prefix", prefix); + return this; + } + + public Builder namespace(String namespace) { + options.put("namespace", namespace); + return this; + } + + public Builder serializer(Serializer serializer) { + this.serializer = serializer; + return this; + } + + /** Open over an explicit backend, e.g. an in-memory fake in tests. */ + public Queue open(QueueBackend backend) { + return new DefaultQueue(backend, serializer); + } + + /** Open the native backend described by the configured options. */ + public Queue open() { + if (!options.containsKey("dsn")) { + throw new TaskitoException("url (dsn) is required"); + } + return new DefaultQueue(JniQueueBackend.open(encodeOptions()), serializer); + } + + private String encodeOptions() { + try { + return JSON.writeValueAsString(options); + } catch (Exception e) { + throw new TaskitoException("failed to encode open options", e); + } + } + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/TaskitoException.java b/sdks/java/src/main/java/org/byteveda/taskito/TaskitoException.java new file mode 100644 index 00000000..b8f9ccd5 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/TaskitoException.java @@ -0,0 +1,12 @@ +package org.byteveda.taskito; + +/** Unchecked exception for Taskito SDK and native errors. */ +public class TaskitoException extends RuntimeException { + public TaskitoException(String message) { + super(message); + } + + public TaskitoException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/internal/JniQueueBackend.java b/sdks/java/src/main/java/org/byteveda/taskito/internal/JniQueueBackend.java new file mode 100644 index 00000000..f36a7c66 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/internal/JniQueueBackend.java @@ -0,0 +1,43 @@ +package org.byteveda.taskito.internal; + +import java.util.Optional; +import org.byteveda.taskito.spi.QueueBackend; + +/** JNI-backed {@link QueueBackend} over a native queue handle. */ +public final class JniQueueBackend implements QueueBackend { + private final long handle; + + private JniQueueBackend(long handle) { + this.handle = handle; + } + + /** Open a native backend from its JSON options. */ + public static JniQueueBackend open(String optionsJson) { + return new JniQueueBackend(NativeQueue.open(optionsJson)); + } + + @Override + public String enqueue(String taskName, byte[] payload, String optionsJson) { + return NativeQueue.enqueue(handle, taskName, payload, optionsJson); + } + + @Override + public Optional getJobJson(String jobId) { + return Optional.ofNullable(NativeQueue.getJob(handle, jobId)); + } + + @Override + public boolean cancel(String jobId) { + return NativeQueue.cancel(handle, jobId); + } + + @Override + public String statsJson() { + return NativeQueue.stats(handle); + } + + @Override + public void close() { + NativeQueue.close(handle); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/internal/NativeLoader.java b/sdks/java/src/main/java/org/byteveda/taskito/internal/NativeLoader.java new file mode 100644 index 00000000..45a4c8db --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/internal/NativeLoader.java @@ -0,0 +1,75 @@ +package org.byteveda.taskito.internal; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; + +/** + * Loads the native Taskito library. + * + *

Order: an explicit {@code -Dtaskito.native.lib=/path} override, otherwise + * the platform binary bundled in the JAR is extracted to a temp file and loaded. + */ +public final class NativeLoader { + private static final String LIB = "taskito_java"; + private static boolean loaded; + + private NativeLoader() {} + + /** Load the library once per process. */ + public static synchronized void load() { + if (loaded) { + return; + } + String override = System.getProperty("taskito.native.lib"); + if (override != null) { + System.load(override); + } else { + System.load(extractBundled()); + } + loaded = true; + } + + /** Extract the bundled platform binary to a temp file; return its path. */ + private static String extractBundled() { + String resource = "/org/byteveda/taskito/native/" + platformDir() + "/" + System.mapLibraryName(LIB); + InputStream in = NativeLoader.class.getResourceAsStream(resource); + if (in == null) { + throw new UnsatisfiedLinkError( + "no bundled native library for platform '" + platformDir() + "' (" + resource + + "); set -Dtaskito.native.lib=/path/to/library"); + } + try { + Path dir = Files.createTempDirectory("taskito-native"); + dir.toFile().deleteOnExit(); + Path target = dir.resolve(System.mapLibraryName(LIB)); + try (InputStream src = in; OutputStream out = Files.newOutputStream(target)) { + byte[] buf = new byte[8192]; + int n; + while ((n = src.read(buf)) != -1) { + out.write(buf, 0, n); + } + } + target.toFile().deleteOnExit(); + return target.toAbsolutePath().toString(); + } catch (IOException e) { + throw new UnsatisfiedLinkError("failed to extract native library: " + e.getMessage()); + } + } + + /** Resource classifier directory, e.g. {@code linux-x86_64}. */ + static String platformDir() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + String arch = System.getProperty("os.arch", "").toLowerCase(Locale.ROOT); + String osDir = os.contains("win") ? "windows" + : (os.contains("mac") || os.contains("darwin")) ? "osx" + : "linux"; + String archDir = (arch.equals("amd64") || arch.equals("x86_64")) ? "x86_64" + : (arch.equals("aarch64") || arch.equals("arm64")) ? "aarch64" + : arch; + return osDir + "-" + archDir; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/internal/NativeQueue.java b/sdks/java/src/main/java/org/byteveda/taskito/internal/NativeQueue.java new file mode 100644 index 00000000..45cc1559 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/internal/NativeQueue.java @@ -0,0 +1,30 @@ +package org.byteveda.taskito.internal; + +/** + * JNI surface over the Taskito core. + * + *

Opaque job payloads cross as {@code byte[]}; options and views cross as + * JSON strings. Methods throw {@code org.byteveda.taskito.TaskitoException} on + * native failure. The {@code handle} is an opaque pointer from {@link #open}. + */ +public final class NativeQueue { + static { + NativeLoader.load(); + } + + private NativeQueue() {} + + public static native long open(String optionsJson); + + public static native void close(long handle); + + public static native String enqueue(long handle, String taskName, byte[] payload, String optionsJson); + + /** Returns a JSON job view, or {@code null} if absent. */ + public static native String getJob(long handle, String jobId); + + public static native boolean cancel(long handle, String jobId); + + /** Returns a JSON job-count snapshot. */ + public static native String stats(long handle); +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/internal/package-info.java b/sdks/java/src/main/java/org/byteveda/taskito/internal/package-info.java new file mode 100644 index 00000000..2c079164 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/internal/package-info.java @@ -0,0 +1,2 @@ +/** Implementation details: JNI bindings, native-library loading, and the default backend. Not public API. */ +package org.byteveda.taskito.internal; diff --git a/sdks/java/src/main/java/org/byteveda/taskito/package-info.java b/sdks/java/src/main/java/org/byteveda/taskito/package-info.java new file mode 100644 index 00000000..67e0d8b5 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/package-info.java @@ -0,0 +1,11 @@ +/** + * Taskito Java SDK — a typed client over the Rust task-queue core. + * + *

Open a {@link org.byteveda.taskito.Queue} via + * {@link org.byteveda.taskito.Taskito#builder()}. Enqueue typed payloads with a + * {@link org.byteveda.taskito.Task}; payloads serialize through a + * {@link org.byteveda.taskito.serialization.Serializer}. The implementation + * talks to the core through the {@link org.byteveda.taskito.spi.QueueBackend} + * seam, whose default implementation lives in {@code internal}. + */ +package org.byteveda.taskito; diff --git a/sdks/java/src/main/java/org/byteveda/taskito/serialization/JsonSerializer.java b/sdks/java/src/main/java/org/byteveda/taskito/serialization/JsonSerializer.java new file mode 100644 index 00000000..396edbd0 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/serialization/JsonSerializer.java @@ -0,0 +1,35 @@ +package org.byteveda.taskito.serialization; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.byteveda.taskito.TaskitoException; + +/** Default {@link Serializer}: JSON via Jackson. */ +public final class JsonSerializer implements Serializer { + private final ObjectMapper mapper; + + public JsonSerializer() { + this(new ObjectMapper()); + } + + public JsonSerializer(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public byte[] serialize(Object value) { + try { + return mapper.writeValueAsBytes(value); + } catch (Exception e) { + throw new TaskitoException("failed to serialize payload", e); + } + } + + @Override + public T deserialize(byte[] bytes, Class type) { + try { + return mapper.readValue(bytes, type); + } catch (Exception e) { + throw new TaskitoException("failed to deserialize payload", e); + } + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/serialization/Serializer.java b/sdks/java/src/main/java/org/byteveda/taskito/serialization/Serializer.java new file mode 100644 index 00000000..feedc94c --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/serialization/Serializer.java @@ -0,0 +1,8 @@ +package org.byteveda.taskito.serialization; + +/** Converts task payloads and results to and from the opaque bytes the core stores. */ +public interface Serializer { + byte[] serialize(Object value); + + T deserialize(byte[] bytes, Class type); +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/serialization/package-info.java b/sdks/java/src/main/java/org/byteveda/taskito/serialization/package-info.java new file mode 100644 index 00000000..030feb5d --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/serialization/package-info.java @@ -0,0 +1,2 @@ +/** Payload codec: the {@link org.byteveda.taskito.serialization.Serializer} SPI and its JSON default. */ +package org.byteveda.taskito.serialization; diff --git a/sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java b/sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java new file mode 100644 index 00000000..f9fdea22 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java @@ -0,0 +1,24 @@ +package org.byteveda.taskito.spi; + +import java.util.Optional; + +/** + * Low-level queue operations a backend provides, in native-shaped terms (opaque + * {@code byte[]} payloads, JSON strings for options and views). + * + *

This is the seam between the public API and its implementation. The default + * implementation is JNI-backed; alternatives (an FFM backend, or an in-memory + * fake for tests) can be supplied without touching the public API. + */ +public interface QueueBackend extends AutoCloseable { + String enqueue(String taskName, byte[] payload, String optionsJson); + + Optional getJobJson(String jobId); + + boolean cancel(String jobId); + + String statsJson(); + + @Override + void close(); +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/spi/package-info.java b/sdks/java/src/main/java/org/byteveda/taskito/spi/package-info.java new file mode 100644 index 00000000..5412d5f7 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/spi/package-info.java @@ -0,0 +1,2 @@ +/** Backend seam: {@link org.byteveda.taskito.spi.QueueBackend} decouples the public API from the JNI layer. */ +package org.byteveda.taskito.spi; diff --git a/sdks/java/src/test/java/org/byteveda/taskito/QueueTest.java b/sdks/java/src/test/java/org/byteveda/taskito/QueueTest.java new file mode 100644 index 00000000..d39978f5 --- /dev/null +++ b/sdks/java/src/test/java/org/byteveda/taskito/QueueTest.java @@ -0,0 +1,56 @@ +package org.byteveda.taskito; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class QueueTest { + + private static final Task> SEND_EMAIL = Task.of("send_email", mapType()); + + @SuppressWarnings("unchecked") + private static Class> mapType() { + return (Class>) (Class) Map.class; + } + + private Queue open(Path dir) { + return Taskito.builder().backend("sqlite").url(dir.resolve("t.db").toString()).open(); + } + + @Test + void enqueueGetCancelStats(@TempDir Path dir) { + try (Queue queue = open(dir)) { + String id = queue.enqueue( + SEND_EMAIL, + Collections.singletonMap("to", "a@b.c"), + EnqueueOptions.builder().priority(5).queue("emails").build()); + assertFalse(id.isEmpty()); + + Optional job = queue.getJob(id); + assertTrue(job.isPresent()); + assertEquals("send_email", job.get().taskName); + assertEquals(JobStatus.PENDING, job.get().status); + assertEquals(5, job.get().priority); + assertEquals("emails", job.get().queue); + + assertEquals(1, queue.stats().pending); + + assertTrue(queue.cancel(id)); + assertEquals(0, queue.stats().pending); + } + } + + @Test + void getMissingJobIsEmpty(@TempDir Path dir) { + try (Queue queue = open(dir)) { + assertTrue(queue.getJob("nope").isEmpty()); + } + } +} From bf61d148a6e94951b5250056694ab6c669658af4 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:34:42 +0530 Subject: [PATCH 03/16] build(java): add Gradle build and lint tooling Toolchain 11, cargo native build + classifier staging, Spotless (palantir) and Checkstyle. Wrapper pinned to 8.12. --- sdks/java/build.gradle.kts | 95 ++++++++++ sdks/java/config/checkstyle/checkstyle.xml | 39 ++++ sdks/java/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 56921 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + sdks/java/gradlew | 176 ++++++++++++++++++ sdks/java/gradlew.bat | 84 +++++++++ sdks/java/settings.gradle.kts | 1 + 7 files changed, 400 insertions(+) create mode 100644 sdks/java/build.gradle.kts create mode 100644 sdks/java/config/checkstyle/checkstyle.xml create mode 100644 sdks/java/gradle/wrapper/gradle-wrapper.jar create mode 100644 sdks/java/gradle/wrapper/gradle-wrapper.properties create mode 100755 sdks/java/gradlew create mode 100644 sdks/java/gradlew.bat create mode 100644 sdks/java/settings.gradle.kts diff --git a/sdks/java/build.gradle.kts b/sdks/java/build.gradle.kts new file mode 100644 index 00000000..a2a17239 --- /dev/null +++ b/sdks/java/build.gradle.kts @@ -0,0 +1,95 @@ +plugins { + `java-library` + checkstyle + id("com.diffplug.spotless") version "6.25.0" +} + +group = "org.byteveda" +version = "0.16.4" + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(11)) + } + withSourcesJar() + withJavadocJar() +} + +repositories { + mavenCentral() +} + +// --- Code integrity: formatting + static analysis ------------------------- + +spotless { + java { + target("src/**/*.java") + palantirJavaFormat("2.50.0") // modern 4-space formatter; `spotlessApply` to fix + removeUnusedImports() + trimTrailingWhitespace() + endWithNewline() + } +} + +checkstyle { + toolVersion = "10.21.4" + configFile = file("config/checkstyle/checkstyle.xml") + isIgnoreFailures = false +} +// Native staging copies binaries under build/resources; never lint those. +tasks.withType().configureEach { + source = fileTree("src") { include("**/*.java") } +} + +dependencies { + api("com.fasterxml.jackson.core:jackson-databind:2.17.2") + + testImplementation(platform("org.junit:junit-bom:5.10.3")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +// --- Native (Rust cdylib) ------------------------------------------------- + +val crateDir = layout.projectDirectory.dir("../../crates/taskito-java") +val cargoTargetDir = layout.projectDirectory.dir("../../target") +val nativeStaging = layout.buildDirectory.dir("native") + +// Build the native library for the local platform. +val cargoBuild by tasks.registering(Exec::class) { + workingDir = crateDir.asFile + commandLine("cargo", "build", "--release", "--features", "postgres,redis") +} + +// Stage the built library under its platform-classifier resource path. +val copyNative by tasks.registering(Copy::class) { + dependsOn(cargoBuild) + from(cargoTargetDir.dir("release")) { + include("libtaskito_java.so", "libtaskito_java.dylib", "taskito_java.dll") + } + into(nativeStaging.map { it.dir("org/byteveda/taskito/native/${platformClassifier()}") }) +} + +sourceSets["main"].resources.srcDir(nativeStaging) +tasks.named("processResources") { dependsOn(copyNative) } + +tasks.test { + useJUnitPlatform() +} + +/** Resource classifier for the local platform, e.g. "linux-x86_64". */ +fun platformClassifier(): String { + val os = System.getProperty("os.name").lowercase() + val arch = System.getProperty("os.arch").lowercase() + val osDir = when { + os.contains("win") -> "windows" + os.contains("mac") || os.contains("darwin") -> "osx" + else -> "linux" + } + val archDir = when (arch) { + "amd64", "x86_64" -> "x86_64" + "aarch64", "arm64" -> "aarch64" + else -> arch + } + return "$osDir-$archDir" +} diff --git a/sdks/java/config/checkstyle/checkstyle.xml b/sdks/java/config/checkstyle/checkstyle.xml new file mode 100644 index 00000000..47dc3584 --- /dev/null +++ b/sdks/java/config/checkstyle/checkstyle.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sdks/java/gradle/wrapper/gradle-wrapper.jar b/sdks/java/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b498d2444600d918cbd5949508e2ade6fbef0301 GIT binary patch literal 56921 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 literal 0 HcmV?d00001 diff --git a/sdks/java/gradle/wrapper/gradle-wrapper.properties b/sdks/java/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..d6e308a6 --- /dev/null +++ b/sdks/java/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/sdks/java/gradlew b/sdks/java/gradlew new file mode 100755 index 00000000..17a91706 --- /dev/null +++ b/sdks/java/gradlew @@ -0,0 +1,176 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then + DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS" +fi + +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/sdks/java/gradlew.bat b/sdks/java/gradlew.bat new file mode 100644 index 00000000..e95643d6 --- /dev/null +++ b/sdks/java/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/sdks/java/settings.gradle.kts b/sdks/java/settings.gradle.kts new file mode 100644 index 00000000..ca2d61ba --- /dev/null +++ b/sdks/java/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "taskito" From 76ac55a28833e8f8c56e83c39d739d340d2b6782 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:34:42 +0530 Subject: [PATCH 04/16] docs(java): add Java SDK README --- sdks/java/README.md | 71 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 sdks/java/README.md diff --git a/sdks/java/README.md b/sdks/java/README.md new file mode 100644 index 00000000..4d36ca47 --- /dev/null +++ b/sdks/java/README.md @@ -0,0 +1,71 @@ +# Taskito Java SDK + +A typed Java 11+ client over the Taskito Rust core, via a hand-written JNI shell +(`crates/taskito-java`). + +> Status: **early build-out** (Phase 0). Producer surface (enqueue / get / cancel +> / stats) is implemented and verified end-to-end against the native library. +> Worker execution, dashboard, webhooks, and the CLI follow in later phases. + +## Usage + +```java +import org.byteveda.taskito.*; +import java.util.Map; + +Task sendEmail = Task.of("send_email", Map.class) + .withOptions(EnqueueOptions.builder().queue("emails").priority(5).build()); + +try (Queue queue = Taskito.builder().backend("sqlite").url("taskito.db").open()) { + String id = queue.enqueue(sendEmail, Map.of("to", "a@b.c")); + Job job = queue.getJob(id).orElseThrow(); // job.status == JobStatus.PENDING + QueueStats stats = queue.stats(); + queue.cancel(id); +} +``` + +## Structure + +``` +org.byteveda.taskito +├── Taskito entry point — Taskito.builder()...open() +├── Queue public interface +├── DefaultQueue package-private impl (not exported) +├── Task / Job / JobStatus / QueueStats / EnqueueOptions typed model +├── TaskitoException unchecked error type +├── serialization/ Serializer SPI + JsonSerializer (Jackson) default +├── spi/ QueueBackend — seam between API and the native layer +└── internal/ JNI bindings (NativeQueue, NativeLoader, JniQueueBackend) +``` + +The `spi.QueueBackend` seam keeps the public API independent of JNI: it can be +backed by the native library (default) or an in-memory fake in tests, and leaves +room for a future FFM/Panama backend without touching the API. + +## Build & checks + +```bash +./gradlew build # cargoBuild → stage native → compile → test → jar +./gradlew test # JUnit 5 +./gradlew spotlessApply # format (palantir-java-format) +./gradlew spotlessCheck # verify formatting +./gradlew checkstyleMain # static analysis +./gradlew check # test + spotlessCheck + checkstyle +``` + +The Rust shell is built and checked with the workspace tooling: + +```bash +cargo build -p taskito-java --release --features postgres,redis +cargo fmt -p taskito-java -- --check +cargo clippy -p taskito-java -- -D warnings +``` + +### Native library resolution + +At runtime the platform binary is extracted from the JAR and loaded. For local +development against a freshly built library, point the loader at it directly: + +```bash +-Dtaskito.native.lib=/abs/path/to/target/release/libtaskito_java.so +``` From 69b69514fdf9603d1039445afb31e9d065639455 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:34:42 +0530 Subject: [PATCH 05/16] chore(java): ignore Gradle build artifacts --- sdks/java/.gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 sdks/java/.gitignore diff --git a/sdks/java/.gitignore b/sdks/java/.gitignore new file mode 100644 index 00000000..67bcc2f7 --- /dev/null +++ b/sdks/java/.gitignore @@ -0,0 +1,2 @@ +.gradle/ +build/ From e7deb2e831444a1125aa0694957c8cc0125057c1 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:34:43 +0530 Subject: [PATCH 06/16] feat(java): expand JNI surface, split queue module Add inspection/admin/log entry points (enqueueMany, listJobs, metrics, DLQ, settings, logs, ...) and split queue.rs into queue/{mod,inspect, admin,logs}.rs as the surface grew. --- crates/taskito-java/src/convert.rs | 166 +++++++++++++++- crates/taskito-java/src/ffi.rs | 60 +++++- crates/taskito-java/src/queue.rs | 133 ------------- crates/taskito-java/src/queue/admin.rs | 194 +++++++++++++++++++ crates/taskito-java/src/queue/inspect.rs | 148 ++++++++++++++ crates/taskito-java/src/queue/logs.rs | 53 ++++++ crates/taskito-java/src/queue/mod.rs | 233 +++++++++++++++++++++++ 7 files changed, 851 insertions(+), 136 deletions(-) delete mode 100644 crates/taskito-java/src/queue.rs create mode 100644 crates/taskito-java/src/queue/admin.rs create mode 100644 crates/taskito-java/src/queue/inspect.rs create mode 100644 crates/taskito-java/src/queue/logs.rs create mode 100644 crates/taskito-java/src/queue/mod.rs diff --git a/crates/taskito-java/src/convert.rs b/crates/taskito-java/src/convert.rs index 7495974b..0d532c22 100644 --- a/crates/taskito-java/src/convert.rs +++ b/crates/taskito-java/src/convert.rs @@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize}; use taskito_core::job::{now_millis, Job, NewJob}; -use taskito_core::storage::QueueStats; +use taskito_core::storage::models::{JobErrorRow, TaskLogRow, TaskMetricRow, WorkerRow}; +use taskito_core::storage::{DeadJob, QueueStats}; use crate::error::BindingError; @@ -148,6 +149,169 @@ impl<'a> From<&'a Job> for JobView<'a> { } } +/// Filter accepted by `NativeQueue.listJobs`. +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct JobFilter { + pub status: Option, + pub queue: Option, + pub task: Option, + pub limit: Option, + pub offset: Option, +} + +/// Map a lowercase status string to the core's `i32` status code. +pub fn status_code(status: &str) -> Option { + match status { + "pending" => Some(0), + "running" => Some(1), + "complete" | "completed" => Some(2), + "failed" => Some(3), + "dead" => Some(4), + "cancelled" => Some(5), + _ => None, + } +} + +/// Dead-letter entry view (omits the opaque payload). +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeadJobView<'a> { + pub id: &'a str, + pub original_job_id: &'a str, + pub queue: &'a str, + pub task_name: &'a str, + pub error: Option<&'a str>, + pub retry_count: i32, + pub failed_at: i64, + pub metadata: Option<&'a str>, + pub dlq_retry_count: i32, +} + +impl<'a> From<&'a DeadJob> for DeadJobView<'a> { + fn from(d: &'a DeadJob) -> Self { + Self { + id: &d.id, + original_job_id: &d.original_job_id, + queue: &d.queue, + task_name: &d.task_name, + error: d.error.as_deref(), + retry_count: d.retry_count, + failed_at: d.failed_at, + metadata: d.metadata.as_deref(), + dlq_retry_count: d.dlq_retry_count, + } + } +} + +/// One recorded error attempt for a job. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobErrorView<'a> { + pub id: &'a str, + pub job_id: &'a str, + pub attempt: i32, + pub error: &'a str, + pub failed_at: i64, +} + +impl<'a> From<&'a JobErrorRow> for JobErrorView<'a> { + fn from(e: &'a JobErrorRow) -> Self { + Self { + id: &e.id, + job_id: &e.job_id, + attempt: e.attempt, + error: &e.error, + failed_at: e.failed_at, + } + } +} + +/// A per-execution task metric. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MetricView<'a> { + pub task_name: &'a str, + pub job_id: &'a str, + pub wall_time_ns: i64, + pub memory_bytes: i64, + pub succeeded: bool, + pub recorded_at: i64, +} + +impl<'a> From<&'a TaskMetricRow> for MetricView<'a> { + fn from(m: &'a TaskMetricRow) -> Self { + Self { + task_name: &m.task_name, + job_id: &m.job_id, + wall_time_ns: m.wall_time_ns, + memory_bytes: m.memory_bytes, + succeeded: m.succeeded, + recorded_at: m.recorded_at, + } + } +} + +/// A registered worker (heartbeat + identity). +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkerView<'a> { + pub worker_id: &'a str, + pub queues: &'a str, + pub status: &'a str, + pub last_heartbeat: i64, + pub started_at: Option, + pub hostname: Option<&'a str>, + pub pid: Option, + pub pool_type: Option<&'a str>, + pub threads: i32, + pub tags: Option<&'a str>, +} + +impl<'a> From<&'a WorkerRow> for WorkerView<'a> { + fn from(w: &'a WorkerRow) -> Self { + Self { + worker_id: &w.worker_id, + queues: &w.queues, + status: &w.status, + last_heartbeat: w.last_heartbeat, + started_at: w.started_at, + hostname: w.hostname.as_deref(), + pid: w.pid, + pool_type: w.pool_type.as_deref(), + threads: w.threads, + tags: w.tags.as_deref(), + } + } +} + +/// A task log line. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LogView<'a> { + pub id: &'a str, + pub job_id: &'a str, + pub task_name: &'a str, + pub level: &'a str, + pub message: &'a str, + pub extra: Option<&'a str>, + pub logged_at: i64, +} + +impl<'a> From<&'a TaskLogRow> for LogView<'a> { + fn from(l: &'a TaskLogRow) -> Self { + Self { + id: &l.id, + job_id: &l.job_id, + task_name: &l.task_name, + level: &l.level, + message: &l.message, + extra: l.extra.as_deref(), + logged_at: l.logged_at, + } + } +} + /// Serialize a view to a JSON string for return across the boundary. pub fn to_json(value: &T) -> Result { serde_json::to_string(value).map_err(|e| BindingError::new(format!("serialize failed: {e}"))) diff --git a/crates/taskito-java/src/ffi.rs b/crates/taskito-java/src/ffi.rs index f3b8bd87..35f157d0 100644 --- a/crates/taskito-java/src/ffi.rs +++ b/crates/taskito-java/src/ffi.rs @@ -2,7 +2,8 @@ use std::panic::{catch_unwind, AssertUnwindSafe}; -use jni::objects::{JByteArray, JString}; +use jni::objects::{JByteArray, JObject, JObjectArray, JString}; +use jni::sys::{jbyteArray, jobjectArray, jstring}; use jni::JNIEnv; use crate::error::BindingError; @@ -38,15 +39,70 @@ pub fn read_string(env: &mut JNIEnv, value: &JString) -> Result Result, BindingError> { + if value.is_null() { + Ok(None) + } else { + read_string(env, value).map(Some) + } +} + /// Read a Java `byte[]` argument into an owned `Vec`. pub fn read_bytes(env: &mut JNIEnv, value: &JByteArray) -> Result, BindingError> { env.convert_byte_array(value) .map_err(|e| BindingError::new(format!("invalid byte[] argument: {e}"))) } +/// Read a Java `byte[][]` argument into owned bytes per element. +pub fn read_bytes_array( + env: &mut JNIEnv, + value: &JObjectArray, +) -> Result>, BindingError> { + let len = env + .get_array_length(value) + .map_err(|e| BindingError::new(format!("invalid byte[][] argument: {e}")))?; + let mut out = Vec::with_capacity(len as usize); + for index in 0..len { + let element = env + .get_object_array_element(value, index) + .map_err(|e| BindingError::new(format!("invalid byte[][] element: {e}")))?; + out.push(read_bytes(env, &JByteArray::from(element))?); + } + Ok(out) +} + /// Build a Java `String` return value from an owned Rust `String`. -pub fn new_string(env: &mut JNIEnv, value: String) -> Result { +pub fn new_string(env: &mut JNIEnv, value: String) -> Result { env.new_string(value) .map(|s| s.into_raw()) .map_err(|e| BindingError::new(format!("failed to allocate Java string: {e}"))) } + +/// Build a Java `byte[]` return value from a slice. +pub fn new_bytes(env: &mut JNIEnv, value: &[u8]) -> Result { + env.byte_array_from_slice(value) + .map(|a| a.into_raw()) + .map_err(|e| BindingError::new(format!("failed to allocate byte[]: {e}"))) +} + +/// Build a Java `String[]` return value from owned strings. +pub fn new_string_array(env: &mut JNIEnv, values: &[String]) -> Result { + let class = env + .find_class("java/lang/String") + .map_err(|e| BindingError::new(format!("String class lookup failed: {e}")))?; + let array = env + .new_object_array(values.len() as i32, &class, JObject::null()) + .map_err(|e| BindingError::new(format!("failed to allocate String[]: {e}")))?; + for (index, value) in values.iter().enumerate() { + let element = env + .new_string(value) + .map_err(|e| BindingError::new(format!("failed to allocate Java string: {e}")))?; + env.set_object_array_element(&array, index as i32, &element) + .map_err(|e| BindingError::new(format!("failed to set String[] element: {e}")))?; + } + Ok(array.into_raw()) +} diff --git a/crates/taskito-java/src/queue.rs b/crates/taskito-java/src/queue.rs deleted file mode 100644 index 22e3c695..00000000 --- a/crates/taskito-java/src/queue.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! JNI entry points backing `org.byteveda.taskito.internal.NativeQueue`. -//! -//! Each function name encodes the Java package, class, and method the JVM links -//! it to. Every body runs inside [`guard`] so failures surface as Java -//! exceptions rather than unwinding across the FFI boundary. - -use jni::objects::{JByteArray, JClass, JString}; -use jni::sys::{jboolean, jlong, jstring, JNI_FALSE, JNI_TRUE}; -use jni::JNIEnv; -use taskito_core::Storage; - -use crate::backend::{self, QueueHandle}; -use crate::convert::{ - build_new_job, parse_json, to_json, EnqueueOptions, JobView, OpenOptions, StatsView, -}; -use crate::ffi::{guard, new_string, read_bytes, read_string}; -use crate::handle::{borrow, drop_handle, into_handle}; - -/// Borrow the queue behind a handle. -/// -/// # Safety -/// `handle` must be a live `QueueHandle` pointer (see [`crate::handle`]). -unsafe fn queue<'a>(handle: jlong) -> &'a QueueHandle { - borrow::(handle) -} - -/// `long open(String optionsJson)` — open a backend, returning its handle. -#[no_mangle] -pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_open<'local>( - mut env: JNIEnv<'local>, - _class: JClass<'local>, - options_json: JString<'local>, -) -> jlong { - guard(&mut env, 0, |env| { - let raw = read_string(env, &options_json)?; - let options: OpenOptions = parse_json(&raw, "open options")?; - Ok(into_handle(backend::open(options)?)) - }) -} - -/// `void close(long handle)` — reclaim a handle. A zero handle is a no-op. -#[no_mangle] -pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_close( - _env: JNIEnv, - _class: JClass, - handle: jlong, -) { - if handle != 0 { - unsafe { drop_handle::(handle) }; - } -} - -/// `String enqueue(long handle, String taskName, byte[] payload, String optionsJson)` -/// — enqueue a job and return its id. A set `uniqueKey` makes a duplicate -/// enqueue a no-op while the first job is pending/running (idempotency). -#[no_mangle] -pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_enqueue<'local>( - mut env: JNIEnv<'local>, - _class: JClass<'local>, - handle: jlong, - task_name: JString<'local>, - payload: JByteArray<'local>, - options_json: JString<'local>, -) -> jstring { - guard(&mut env, std::ptr::null_mut(), |env| { - let queue = unsafe { queue(handle) }; - let task = read_string(env, &task_name)?; - let bytes = read_bytes(env, &payload)?; - let raw_opts = read_string(env, &options_json)?; - let options: EnqueueOptions = parse_json(&raw_opts, "enqueue options")?; - let unique = options.unique_key.is_some(); - let new_job = build_new_job(task, bytes, options, queue.namespace.as_deref()); - let job = if unique { - queue.storage.enqueue_unique(new_job) - } else { - queue.storage.enqueue(new_job) - }?; - new_string(env, job.id) - }) -} - -/// `String getJob(long handle, String jobId)` — a JSON [`JobView`], or `null` -/// when no such job exists. -#[no_mangle] -pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_getJob<'local>( - mut env: JNIEnv<'local>, - _class: JClass<'local>, - handle: jlong, - job_id: JString<'local>, -) -> jstring { - guard(&mut env, std::ptr::null_mut(), |env| { - let queue = unsafe { queue(handle) }; - let id = read_string(env, &job_id)?; - match queue.storage.get_job(&id)? { - Some(job) => new_string(env, to_json(&JobView::from(&job))?), - None => Ok(std::ptr::null_mut()), - } - }) -} - -/// `boolean cancel(long handle, String jobId)` — cancel a pending job. Returns -/// false when the job was not pending. -#[no_mangle] -pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_cancel<'local>( - mut env: JNIEnv<'local>, - _class: JClass<'local>, - handle: jlong, - job_id: JString<'local>, -) -> jboolean { - guard(&mut env, JNI_FALSE, |env| { - let queue = unsafe { queue(handle) }; - let id = read_string(env, &job_id)?; - Ok(if queue.storage.cancel_job(&id)? { - JNI_TRUE - } else { - JNI_FALSE - }) - }) -} - -/// `String stats(long handle)` — a JSON [`StatsView`] of job counts by status. -#[no_mangle] -pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_stats<'local>( - mut env: JNIEnv<'local>, - _class: JClass<'local>, - handle: jlong, -) -> jstring { - guard(&mut env, std::ptr::null_mut(), |env| { - let queue = unsafe { queue(handle) }; - let stats = queue.storage.stats()?; - new_string(env, to_json(&StatsView::from(stats))?) - }) -} diff --git a/crates/taskito-java/src/queue/admin.rs b/crates/taskito-java/src/queue/admin.rs new file mode 100644 index 00000000..91b6a479 --- /dev/null +++ b/crates/taskito-java/src/queue/admin.rs @@ -0,0 +1,194 @@ +//! Mutating administration entry points for `NativeQueue`. + +use jni::objects::{JClass, JString}; +use jni::sys::{jboolean, jlong, jstring, JNI_FALSE}; +use jni::JNIEnv; +use taskito_core::Storage; + +use super::borrow_queue; +use crate::convert::{to_json, DeadJobView}; +use crate::ffi::{guard, new_string, read_string}; + +/// `String listDead(long handle, long limit, long offset)` — dead-letter entries. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_listDead<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + limit: jlong, + offset: jlong, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + let dead = queue.storage.list_dead(limit.max(0), offset.max(0))?; + let views: Vec = dead.iter().map(DeadJobView::from).collect(); + new_string(env, to_json(&views)?) + }) +} + +/// `String retryDead(long handle, String deadId)` — re-enqueue; returns new id. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_retryDead<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + dead_id: JString<'local>, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + let id = read_string(env, &dead_id)?; + new_string(env, queue.storage.retry_dead(&id)?) + }) +} + +/// `boolean deleteDead(long handle, String deadId)`. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_deleteDead<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + dead_id: JString<'local>, +) -> jboolean { + guard(&mut env, JNI_FALSE, |env| { + let queue = unsafe { borrow_queue(handle) }; + let id = read_string(env, &dead_id)?; + Ok(super::to_jboolean(queue.storage.delete_dead(&id)?)) + }) +} + +/// `long purgeDead(long handle, long olderThanMs)` — returns rows removed. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_purgeDead<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + older_than_ms: jlong, +) -> jlong { + guard(&mut env, 0, |_env| { + let queue = unsafe { borrow_queue(handle) }; + Ok(queue.storage.purge_dead(older_than_ms)? as jlong) + }) +} + +/// `long purgeCompleted(long handle, long olderThanMs)` — returns rows removed. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_purgeCompleted<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + older_than_ms: jlong, +) -> jlong { + guard(&mut env, 0, |_env| { + let queue = unsafe { borrow_queue(handle) }; + Ok(queue.storage.purge_completed(older_than_ms)? as jlong) + }) +} + +/// `void pauseQueue(long handle, String queue)`. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_pauseQueue<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + queue_name: JString<'local>, +) { + guard(&mut env, (), |env| { + let queue = unsafe { borrow_queue(handle) }; + let name = read_string(env, &queue_name)?; + queue.storage.pause_queue(&name)?; + Ok(()) + }) +} + +/// `void resumeQueue(long handle, String queue)`. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_resumeQueue<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + queue_name: JString<'local>, +) { + guard(&mut env, (), |env| { + let queue = unsafe { borrow_queue(handle) }; + let name = read_string(env, &queue_name)?; + queue.storage.resume_queue(&name)?; + Ok(()) + }) +} + +/// `String listPausedQueues(long handle)` — a JSON array of queue names. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_listPausedQueues<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + new_string(env, to_json(&queue.storage.list_paused_queues()?)?) + }) +} + +/// `String getSetting(long handle, String key)` — value, or `null` if unset. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_getSetting<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + key: JString<'local>, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + let key = read_string(env, &key)?; + match queue.storage.get_setting(&key)? { + Some(value) => new_string(env, value), + None => Ok(std::ptr::null_mut()), + } + }) +} + +/// `void setSetting(long handle, String key, String value)`. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_setSetting<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + key: JString<'local>, + value: JString<'local>, +) { + guard(&mut env, (), |env| { + let queue = unsafe { borrow_queue(handle) }; + let key = read_string(env, &key)?; + let value = read_string(env, &value)?; + queue.storage.set_setting(&key, &value)?; + Ok(()) + }) +} + +/// `boolean deleteSetting(long handle, String key)`. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_deleteSetting<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + key: JString<'local>, +) -> jboolean { + guard(&mut env, JNI_FALSE, |env| { + let queue = unsafe { borrow_queue(handle) }; + let key = read_string(env, &key)?; + Ok(super::to_jboolean(queue.storage.delete_setting(&key)?)) + }) +} + +/// `String listSettings(long handle)` — a JSON map of key to value. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_listSettings<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + new_string(env, to_json(&queue.storage.list_settings()?)?) + }) +} diff --git a/crates/taskito-java/src/queue/inspect.rs b/crates/taskito-java/src/queue/inspect.rs new file mode 100644 index 00000000..26523c67 --- /dev/null +++ b/crates/taskito-java/src/queue/inspect.rs @@ -0,0 +1,148 @@ +//! Read-only inspection entry points for `NativeQueue`. + +use std::collections::HashMap; + +use jni::objects::{JClass, JString}; +use jni::sys::{jlong, jstring}; +use jni::JNIEnv; +use taskito_core::Storage; + +use super::borrow_queue; +use crate::convert::{ + status_code, to_json, JobErrorView, JobFilter, JobView, MetricView, StatsView, WorkerView, +}; +use crate::error::BindingError; +use crate::ffi::{guard, new_string, read_optional_string, read_string}; + +const DEFAULT_LIMIT: i64 = 50; + +/// `String stats(long handle)` — job counts by status across all queues. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_stats<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + new_string(env, to_json(&StatsView::from(queue.storage.stats()?))?) + }) +} + +/// `String statsByQueue(long handle, String queue)`. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_statsByQueue<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + queue_name: JString<'local>, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + let name = read_string(env, &queue_name)?; + let stats = queue.storage.stats_by_queue(&name)?; + new_string(env, to_json(&StatsView::from(stats))?) + }) +} + +/// `String statsAllQueues(long handle)` — a JSON map of queue name to counts. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_statsAllQueues<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + let all = queue.storage.stats_all_queues()?; + let mapped: HashMap = all + .into_iter() + .map(|(k, v)| (k, StatsView::from(v))) + .collect(); + new_string(env, to_json(&mapped)?) + }) +} + +/// `String listJobs(long handle, String filterJson)` — a JSON array of jobs. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_listJobs<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + filter_json: JString<'local>, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + let raw = read_string(env, &filter_json)?; + let filter: JobFilter = crate::convert::parse_json(&raw, "job filter")?; + // Reject an unknown status so a typo fails loudly instead of matching all. + let status = match filter.status.as_deref() { + Some(s) => Some( + status_code(s).ok_or_else(|| BindingError::new(format!("unknown status '{s}'")))?, + ), + None => None, + }; + let limit = filter.limit.unwrap_or(DEFAULT_LIMIT).max(0); + let offset = filter.offset.unwrap_or(0).max(0); + let jobs = queue.storage.list_jobs( + status, + filter.queue.as_deref(), + filter.task.as_deref(), + limit, + offset, + queue.namespace.as_deref(), + )?; + let views: Vec = jobs.iter().map(JobView::from).collect(); + new_string(env, to_json(&views)?) + }) +} + +/// `String jobErrors(long handle, String jobId)` — one entry per failed attempt. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_jobErrors<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + job_id: JString<'local>, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + let id = read_string(env, &job_id)?; + let errors = queue.storage.get_job_errors(&id)?; + let views: Vec = errors.iter().map(JobErrorView::from).collect(); + new_string(env, to_json(&views)?) + }) +} + +/// `String metrics(long handle, String taskNameOrNull, long sinceMs)`. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_metrics<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + task_name: JString<'local>, + since_ms: jlong, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + let task = read_optional_string(env, &task_name)?; + let metrics = queue.storage.get_metrics(task.as_deref(), since_ms)?; + let views: Vec = metrics.iter().map(MetricView::from).collect(); + new_string(env, to_json(&views)?) + }) +} + +/// `String listWorkers(long handle)` — registered workers. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_listWorkers<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + let workers = queue.storage.list_workers()?; + let views: Vec = workers.iter().map(WorkerView::from).collect(); + new_string(env, to_json(&views)?) + }) +} diff --git a/crates/taskito-java/src/queue/logs.rs b/crates/taskito-java/src/queue/logs.rs new file mode 100644 index 00000000..d6e418f4 --- /dev/null +++ b/crates/taskito-java/src/queue/logs.rs @@ -0,0 +1,53 @@ +//! Task-log entry points for `NativeQueue`. + +use jni::objects::{JClass, JString}; +use jni::sys::{jlong, jstring}; +use jni::JNIEnv; +use taskito_core::Storage; + +use super::borrow_queue; +use crate::convert::{to_json, LogView}; +use crate::ffi::{guard, new_string, read_optional_string, read_string}; + +/// `void writeTaskLog(long handle, String jobId, String taskName, String level, String message, String extraOrNull)`. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_writeTaskLog<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + job_id: JString<'local>, + task_name: JString<'local>, + level: JString<'local>, + message: JString<'local>, + extra: JString<'local>, +) { + guard(&mut env, (), |env| { + let queue = unsafe { borrow_queue(handle) }; + let job_id = read_string(env, &job_id)?; + let task_name = read_string(env, &task_name)?; + let level = read_string(env, &level)?; + let message = read_string(env, &message)?; + let extra = read_optional_string(env, &extra)?; + queue + .storage + .write_task_log(&job_id, &task_name, &level, &message, extra.as_deref())?; + Ok(()) + }) +} + +/// `String getTaskLogs(long handle, String jobId)` — a JSON array of log lines. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_getTaskLogs<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + job_id: JString<'local>, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + let id = read_string(env, &job_id)?; + let logs = queue.storage.get_task_logs(&id)?; + let views: Vec = logs.iter().map(LogView::from).collect(); + new_string(env, to_json(&views)?) + }) +} diff --git a/crates/taskito-java/src/queue/mod.rs b/crates/taskito-java/src/queue/mod.rs new file mode 100644 index 00000000..a684c854 --- /dev/null +++ b/crates/taskito-java/src/queue/mod.rs @@ -0,0 +1,233 @@ +//! JNI entry points backing `org.byteveda.taskito.internal.NativeQueue`. +//! +//! Each function name encodes the Java package, class, and method the JVM links +//! it to. Every body runs inside [`guard`] so failures surface as Java +//! exceptions rather than unwinding across the FFI boundary. This module holds +//! the producer surface; inspection, admin, and log methods live in the sibling +//! submodules. + +mod admin; +mod inspect; +mod logs; + +use jni::objects::{JByteArray, JClass, JObjectArray, JString}; +use jni::sys::{jboolean, jbyteArray, jint, jlong, jobjectArray, jstring, JNI_FALSE, JNI_TRUE}; +use jni::JNIEnv; +use taskito_core::Storage; + +use crate::backend::{self, QueueHandle}; +use crate::convert::{build_new_job, parse_json, EnqueueOptions, OpenOptions}; +use crate::ffi::{ + guard, new_bytes, new_string, new_string_array, read_bytes, read_bytes_array, read_string, +}; +use crate::handle::{self, drop_handle, into_handle}; + +/// Borrow the queue behind a handle. +/// +/// # Safety +/// `handle` must be a live `QueueHandle` pointer (see [`crate::handle`]). +unsafe fn borrow_queue<'a>(handle: jlong) -> &'a QueueHandle { + handle::borrow::(handle) +} + +/// `long open(String optionsJson)` — open a backend, returning its handle. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_open<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + options_json: JString<'local>, +) -> jlong { + guard(&mut env, 0, |env| { + let raw = read_string(env, &options_json)?; + let options: OpenOptions = parse_json(&raw, "open options")?; + Ok(into_handle(backend::open(options)?)) + }) +} + +/// `void close(long handle)` — reclaim a handle. A zero handle is a no-op. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_close( + _env: JNIEnv, + _class: JClass, + handle: jlong, +) { + if handle != 0 { + unsafe { drop_handle::(handle) }; + } +} + +/// `String enqueue(long handle, String taskName, byte[] payload, String optionsJson)` +/// — enqueue a job and return its id. A set `uniqueKey` makes a duplicate +/// enqueue a no-op while the first job is pending/running (idempotency). +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_enqueue<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + task_name: JString<'local>, + payload: JByteArray<'local>, + options_json: JString<'local>, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + let task = read_string(env, &task_name)?; + let bytes = read_bytes(env, &payload)?; + let raw_opts = read_string(env, &options_json)?; + let options: EnqueueOptions = parse_json(&raw_opts, "enqueue options")?; + let unique = options.unique_key.is_some(); + let new_job = build_new_job(task, bytes, options, queue.namespace.as_deref()); + let job = if unique { + queue.storage.enqueue_unique(new_job) + } else { + queue.storage.enqueue(new_job) + }?; + new_string(env, job.id) + }) +} + +/// `String[] enqueueMany(long handle, String taskName, byte[][] payloads, String optionsJson)` +/// — enqueue a batch in one storage transaction. `optionsJson` is a JSON array +/// of per-job options, the same length as `payloads`. Returns the new job ids. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_enqueueMany<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + task_name: JString<'local>, + payloads: JObjectArray<'local>, + options_json: JString<'local>, +) -> jobjectArray { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + let task = read_string(env, &task_name)?; + let payload_list = read_bytes_array(env, &payloads)?; + let raw_opts = read_string(env, &options_json)?; + let option_list: Vec = parse_json(&raw_opts, "enqueue batch options")?; + if option_list.len() != payload_list.len() { + return Err(crate::error::BindingError::new( + "payloads and options must have the same length", + )); + } + let namespace = queue.namespace.as_deref(); + let new_jobs = payload_list + .into_iter() + .zip(option_list) + .map(|(payload, options)| build_new_job(task.clone(), payload, options, namespace)) + .collect(); + let created = queue.storage.enqueue_batch(new_jobs)?; + let ids: Vec = created.into_iter().map(|job| job.id).collect(); + new_string_array(env, &ids) + }) +} + +/// `String getJob(long handle, String jobId)` — a JSON job view, or `null`. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_getJob<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + job_id: JString<'local>, +) -> jstring { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + let id = read_string(env, &job_id)?; + match queue.storage.get_job(&id)? { + Some(job) => new_string( + env, + crate::convert::to_json(&crate::convert::JobView::from(&job))?, + ), + None => Ok(std::ptr::null_mut()), + } + }) +} + +/// `byte[] getResult(long handle, String jobId)` — the job's serialized result, +/// or `null` if absent or not yet complete. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_getResult<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + job_id: JString<'local>, +) -> jbyteArray { + guard(&mut env, std::ptr::null_mut(), |env| { + let queue = unsafe { borrow_queue(handle) }; + let id = read_string(env, &job_id)?; + match queue.storage.get_job(&id)?.and_then(|job| job.result) { + Some(bytes) => new_bytes(env, &bytes), + None => Ok(std::ptr::null_mut()), + } + }) +} + +/// `boolean cancel(long handle, String jobId)` — cancel a pending job. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_cancel<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + job_id: JString<'local>, +) -> jboolean { + guard(&mut env, JNI_FALSE, |env| { + let queue = unsafe { borrow_queue(handle) }; + let id = read_string(env, &job_id)?; + Ok(to_jboolean(queue.storage.cancel_job(&id)?)) + }) +} + +/// `boolean requestCancel(long handle, String jobId)` — cooperatively cancel a +/// running job. Returns false if no such running job exists. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_requestCancel<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + job_id: JString<'local>, +) -> jboolean { + guard(&mut env, JNI_FALSE, |env| { + let queue = unsafe { borrow_queue(handle) }; + let id = read_string(env, &job_id)?; + Ok(to_jboolean(queue.storage.request_cancel(&id)?)) + }) +} + +/// `boolean isCancelRequested(long handle, String jobId)`. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_isCancelRequested<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + job_id: JString<'local>, +) -> jboolean { + guard(&mut env, JNI_FALSE, |env| { + let queue = unsafe { borrow_queue(handle) }; + let id = read_string(env, &job_id)?; + Ok(to_jboolean(queue.storage.is_cancel_requested(&id)?)) + }) +} + +/// `void setProgress(long handle, String jobId, int progress)` — clamp to 0..100. +#[no_mangle] +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_setProgress<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + job_id: JString<'local>, + progress: jint, +) { + guard(&mut env, (), |env| { + let queue = unsafe { borrow_queue(handle) }; + let id = read_string(env, &job_id)?; + queue.storage.update_progress(&id, progress.clamp(0, 100))?; + Ok(()) + }) +} + +/// Map a Rust `bool` onto a JNI `jboolean`. +fn to_jboolean(value: bool) -> jboolean { + if value { + JNI_TRUE + } else { + JNI_FALSE + } +} From 1dc84c1f8664b1ba990b1b3f2d82c3c5042dc481 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:34:43 +0530 Subject: [PATCH 07/16] feat(java): expand SDK API for inspection/admin/logs Typed methods over the new surface plus DeadJob/JobError/TaskMetric/ Worker/TaskLog/JobFilter models; the QueueBackend SPI mirrors the native ops. --- .../java/org/byteveda/taskito/DeadJob.java | 41 ++++ .../org/byteveda/taskito/DefaultQueue.java | 185 +++++++++++++++++- .../java/org/byteveda/taskito/JobError.java | 29 +++ .../java/org/byteveda/taskito/JobFilter.java | 68 +++++++ .../main/java/org/byteveda/taskito/Queue.java | 71 +++++++ .../java/org/byteveda/taskito/TaskLog.java | 35 ++++ .../java/org/byteveda/taskito/TaskMetric.java | 32 +++ .../java/org/byteveda/taskito/Worker.java | 44 +++++ .../taskito/internal/JniQueueBackend.java | 125 ++++++++++++ .../taskito/internal/NativeQueue.java | 65 +++++- .../byteveda/taskito/spi/QueueBackend.java | 58 +++++- 11 files changed, 745 insertions(+), 8 deletions(-) create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/DeadJob.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/JobError.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/JobFilter.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/TaskLog.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/TaskMetric.java create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/Worker.java diff --git a/sdks/java/src/main/java/org/byteveda/taskito/DeadJob.java b/sdks/java/src/main/java/org/byteveda/taskito/DeadJob.java new file mode 100644 index 00000000..ffe43fa1 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/DeadJob.java @@ -0,0 +1,41 @@ +package org.byteveda.taskito; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Immutable view of a dead-letter entry. Timestamps are Unix milliseconds. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class DeadJob { + public final String id; + public final String originalJobId; + public final String queue; + public final String taskName; + public final String error; + public final int retryCount; + public final long failedAt; + public final String metadata; + public final int dlqRetryCount; + + @JsonCreator + public DeadJob( + @JsonProperty("id") String id, + @JsonProperty("originalJobId") String originalJobId, + @JsonProperty("queue") String queue, + @JsonProperty("taskName") String taskName, + @JsonProperty("error") String error, + @JsonProperty("retryCount") int retryCount, + @JsonProperty("failedAt") long failedAt, + @JsonProperty("metadata") String metadata, + @JsonProperty("dlqRetryCount") int dlqRetryCount) { + this.id = id; + this.originalJobId = originalJobId; + this.queue = queue; + this.taskName = taskName; + this.error = error; + this.retryCount = retryCount; + this.failedAt = failedAt; + this.metadata = metadata; + this.dlqRetryCount = dlqRetryCount; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/DefaultQueue.java b/sdks/java/src/main/java/org/byteveda/taskito/DefaultQueue.java index 9d1829a5..b3e64f81 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/DefaultQueue.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/DefaultQueue.java @@ -1,6 +1,11 @@ package org.byteveda.taskito; +import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; import java.util.Optional; import org.byteveda.taskito.serialization.Serializer; import org.byteveda.taskito.spi.QueueBackend; @@ -21,6 +26,8 @@ final class DefaultQueue implements Queue { this.serializer = serializer; } + // ── Producer ──────────────────────────────────────────────────── + @Override public String enqueue(Task task, T payload) { return enqueue(task, payload, task.options()); @@ -36,31 +43,185 @@ public String enqueue(String taskName, Object payload) { return backend.enqueue(taskName, serializer.serialize(payload), encode(EnqueueOptions.none())); } + @Override + public List enqueueMany(Task task, List payloads) { + return enqueueMany(task, payloads, task.options()); + } + + @Override + public List enqueueMany(Task task, List payloads, EnqueueOptions options) { + byte[][] bytes = new byte[payloads.size()][]; + List perJob = new ArrayList<>(payloads.size()); + for (int i = 0; i < payloads.size(); i++) { + bytes[i] = serializer.serialize(payloads.get(i)); + perJob.add(options); + } + return Arrays.asList(backend.enqueueMany(task.name(), bytes, encode(perJob))); + } + @Override public Optional getJob(String jobId) { return backend.getJobJson(jobId).map(json -> decode(json, Job.class)); } + @Override + public Optional getResult(String jobId) { + return backend.getResult(jobId); + } + + @Override + public Optional getResult(String jobId, Class type) { + return backend.getResult(jobId).map(bytes -> serializer.deserialize(bytes, type)); + } + @Override public boolean cancel(String jobId) { return backend.cancel(jobId); } + @Override + public boolean requestCancel(String jobId) { + return backend.requestCancel(jobId); + } + + @Override + public boolean isCancelRequested(String jobId) { + return backend.isCancelRequested(jobId); + } + + @Override + public void setProgress(String jobId, int progress) { + backend.setProgress(jobId, progress); + } + + // ── Inspection ────────────────────────────────────────────────── + @Override public QueueStats stats() { return decode(backend.statsJson(), QueueStats.class); } + @Override + public QueueStats statsByQueue(String queue) { + return decode(backend.statsByQueueJson(queue), QueueStats.class); + } + + @Override + public Map statsAllQueues() { + return decodeMap(backend.statsAllQueuesJson(), QueueStats.class); + } + + @Override + public List listJobs(JobFilter filter) { + return decodeList(backend.listJobsJson(encode(filter)), Job.class); + } + + @Override + public List jobErrors(String jobId) { + return decodeList(backend.jobErrorsJson(jobId), JobError.class); + } + + @Override + public List metrics(String taskName, long sinceMs) { + return decodeList(backend.metricsJson(taskName, sinceMs), TaskMetric.class); + } + + @Override + public List listWorkers() { + return decodeList(backend.listWorkersJson(), Worker.class); + } + + // ── Admin ─────────────────────────────────────────────────────── + + @Override + public List listDead(long limit, long offset) { + return decodeList(backend.listDeadJson(limit, offset), DeadJob.class); + } + + @Override + public String retryDead(String deadId) { + return backend.retryDead(deadId); + } + + @Override + public boolean deleteDead(String deadId) { + return backend.deleteDead(deadId); + } + + @Override + public long purgeDead(long olderThanMs) { + return backend.purgeDead(olderThanMs); + } + + @Override + public long purgeCompleted(long olderThanMs) { + return backend.purgeCompleted(olderThanMs); + } + + @Override + public void pauseQueue(String queue) { + backend.pauseQueue(queue); + } + + @Override + public void resumeQueue(String queue) { + backend.resumeQueue(queue); + } + + @Override + public List listPausedQueues() { + return decodeList(backend.listPausedQueuesJson(), String.class); + } + + @Override + public Optional getSetting(String key) { + return backend.getSetting(key); + } + + @Override + public void setSetting(String key, String value) { + backend.setSetting(key, value); + } + + @Override + public boolean deleteSetting(String key) { + return backend.deleteSetting(key); + } + + @Override + public Map listSettings() { + return decodeMap(backend.listSettingsJson(), String.class); + } + + // ── Logs ──────────────────────────────────────────────────────── + + @Override + public void writeTaskLog(String jobId, String taskName, String level, String message) { + backend.writeTaskLog(jobId, taskName, level, message, null); + } + + @Override + public void writeTaskLog(String jobId, String taskName, String level, String message, String extra) { + backend.writeTaskLog(jobId, taskName, level, message, extra); + } + + @Override + public List getTaskLogs(String jobId) { + return decodeList(backend.getTaskLogsJson(jobId), TaskLog.class); + } + @Override public void close() { backend.close(); } - private static String encode(EnqueueOptions options) { + // ── JSON helpers ──────────────────────────────────────────────── + + private static String encode(Object value) { try { - return VIEWS.writeValueAsString(options); + return VIEWS.writeValueAsString(value); } catch (Exception e) { - throw new TaskitoException("failed to encode options", e); + throw new TaskitoException("failed to encode request", e); } } @@ -71,4 +232,22 @@ private static R decode(String json, Class type) { throw new TaskitoException("failed to decode native response", e); } } + + private static List decodeList(String json, Class element) { + JavaType type = VIEWS.getTypeFactory().constructCollectionType(List.class, element); + try { + return VIEWS.readValue(json, type); + } catch (Exception e) { + throw new TaskitoException("failed to decode native response", e); + } + } + + private static Map decodeMap(String json, Class value) { + JavaType type = VIEWS.getTypeFactory().constructMapType(Map.class, String.class, value); + try { + return VIEWS.readValue(json, type); + } catch (Exception e) { + throw new TaskitoException("failed to decode native response", e); + } + } } diff --git a/sdks/java/src/main/java/org/byteveda/taskito/JobError.java b/sdks/java/src/main/java/org/byteveda/taskito/JobError.java new file mode 100644 index 00000000..5f81dad5 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/JobError.java @@ -0,0 +1,29 @@ +package org.byteveda.taskito; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** One recorded error attempt for a job. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class JobError { + public final String id; + public final String jobId; + public final int attempt; + public final String error; + public final long failedAt; + + @JsonCreator + public JobError( + @JsonProperty("id") String id, + @JsonProperty("jobId") String jobId, + @JsonProperty("attempt") int attempt, + @JsonProperty("error") String error, + @JsonProperty("failedAt") long failedAt) { + this.id = id; + this.jobId = jobId; + this.attempt = attempt; + this.error = error; + this.failedAt = failedAt; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/JobFilter.java b/sdks/java/src/main/java/org/byteveda/taskito/JobFilter.java new file mode 100644 index 00000000..2c6f3183 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/JobFilter.java @@ -0,0 +1,68 @@ +package org.byteveda.taskito; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Immutable filter for {@link Queue#listJobs(JobFilter)}. Unset fields are ignored. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class JobFilter { + @JsonProperty("status") private final String status; + @JsonProperty("queue") private final String queue; + @JsonProperty("task") private final String task; + @JsonProperty("limit") private final Integer limit; + @JsonProperty("offset") private final Integer offset; + + private JobFilter(Builder b) { + this.status = b.status; + this.queue = b.queue; + this.task = b.task; + this.limit = b.limit; + this.offset = b.offset; + } + + public static JobFilter all() { + return builder().build(); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String status; + private String queue; + private String task; + private Integer limit; + private Integer offset; + + /** Lowercase wire status: pending/running/complete/failed/dead/cancelled. */ + public Builder status(JobStatus status) { + this.status = status.wire(); + return this; + } + + public Builder queue(String queue) { + this.queue = queue; + return this; + } + + public Builder task(String task) { + this.task = task; + return this; + } + + public Builder limit(int limit) { + this.limit = limit; + return this; + } + + public Builder offset(int offset) { + this.offset = offset; + return this; + } + + public JobFilter build() { + return new JobFilter(this); + } + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/Queue.java b/sdks/java/src/main/java/org/byteveda/taskito/Queue.java index d9954998..60fae7f2 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/Queue.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/Queue.java @@ -1,9 +1,13 @@ package org.byteveda.taskito; +import java.util.List; +import java.util.Map; import java.util.Optional; /** A Taskito queue. Obtain one from {@link Taskito#builder()}. */ public interface Queue extends AutoCloseable { + // ── Producer ──────────────────────────────────────────────────── + /** Enqueue a typed payload using the task's default options; returns the job id. */ String enqueue(Task task, T payload); @@ -12,12 +16,79 @@ public interface Queue extends AutoCloseable { /** Enqueue by task name with an arbitrary payload and default options. */ String enqueue(String taskName, Object payload); + /** Enqueue a batch in one storage call; returns ids in input order. */ + List enqueueMany(Task task, List payloads); + + List enqueueMany(Task task, List payloads, EnqueueOptions options); + Optional getJob(String jobId); + /** The job's raw serialized result, if complete. */ + Optional getResult(String jobId); + + /** The job's result deserialized to {@code type}, if complete. */ + Optional getResult(String jobId, Class type); + boolean cancel(String jobId); + boolean requestCancel(String jobId); + + boolean isCancelRequested(String jobId); + + void setProgress(String jobId, int progress); + + // ── Inspection ────────────────────────────────────────────────── + QueueStats stats(); + QueueStats statsByQueue(String queue); + + Map statsAllQueues(); + + List listJobs(JobFilter filter); + + List jobErrors(String jobId); + + /** Per-execution metrics within the last {@code sinceMs}; null task = all. */ + List metrics(String taskName, long sinceMs); + + List listWorkers(); + + // ── Admin ─────────────────────────────────────────────────────── + + List listDead(long limit, long offset); + + /** Re-enqueue a dead-letter entry; returns the new job id. */ + String retryDead(String deadId); + + boolean deleteDead(String deadId); + + long purgeDead(long olderThanMs); + + long purgeCompleted(long olderThanMs); + + void pauseQueue(String queue); + + void resumeQueue(String queue); + + List listPausedQueues(); + + Optional getSetting(String key); + + void setSetting(String key, String value); + + boolean deleteSetting(String key); + + Map listSettings(); + + // ── Logs ──────────────────────────────────────────────────────── + + void writeTaskLog(String jobId, String taskName, String level, String message); + + void writeTaskLog(String jobId, String taskName, String level, String message, String extra); + + List getTaskLogs(String jobId); + @Override void close(); } diff --git a/sdks/java/src/main/java/org/byteveda/taskito/TaskLog.java b/sdks/java/src/main/java/org/byteveda/taskito/TaskLog.java new file mode 100644 index 00000000..09f7c4f6 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/TaskLog.java @@ -0,0 +1,35 @@ +package org.byteveda.taskito; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** A task log line. Timestamps are Unix milliseconds. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class TaskLog { + public final String id; + public final String jobId; + public final String taskName; + public final String level; + public final String message; + public final String extra; + public final long loggedAt; + + @JsonCreator + public TaskLog( + @JsonProperty("id") String id, + @JsonProperty("jobId") String jobId, + @JsonProperty("taskName") String taskName, + @JsonProperty("level") String level, + @JsonProperty("message") String message, + @JsonProperty("extra") String extra, + @JsonProperty("loggedAt") long loggedAt) { + this.id = id; + this.jobId = jobId; + this.taskName = taskName; + this.level = level; + this.message = message; + this.extra = extra; + this.loggedAt = loggedAt; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/TaskMetric.java b/sdks/java/src/main/java/org/byteveda/taskito/TaskMetric.java new file mode 100644 index 00000000..037639d9 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/TaskMetric.java @@ -0,0 +1,32 @@ +package org.byteveda.taskito; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** A per-execution task metric. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class TaskMetric { + public final String taskName; + public final String jobId; + public final long wallTimeNs; + public final long memoryBytes; + public final boolean succeeded; + public final long recordedAt; + + @JsonCreator + public TaskMetric( + @JsonProperty("taskName") String taskName, + @JsonProperty("jobId") String jobId, + @JsonProperty("wallTimeNs") long wallTimeNs, + @JsonProperty("memoryBytes") long memoryBytes, + @JsonProperty("succeeded") boolean succeeded, + @JsonProperty("recordedAt") long recordedAt) { + this.taskName = taskName; + this.jobId = jobId; + this.wallTimeNs = wallTimeNs; + this.memoryBytes = memoryBytes; + this.succeeded = succeeded; + this.recordedAt = recordedAt; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/Worker.java b/sdks/java/src/main/java/org/byteveda/taskito/Worker.java new file mode 100644 index 00000000..6c01df5e --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/Worker.java @@ -0,0 +1,44 @@ +package org.byteveda.taskito; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** A registered worker (heartbeat + identity). Timestamps are Unix milliseconds. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class Worker { + public final String workerId; + public final String queues; + public final String status; + public final long lastHeartbeat; + public final Long startedAt; + public final String hostname; + public final Integer pid; + public final String poolType; + public final int threads; + public final String tags; + + @JsonCreator + public Worker( + @JsonProperty("workerId") String workerId, + @JsonProperty("queues") String queues, + @JsonProperty("status") String status, + @JsonProperty("lastHeartbeat") long lastHeartbeat, + @JsonProperty("startedAt") Long startedAt, + @JsonProperty("hostname") String hostname, + @JsonProperty("pid") Integer pid, + @JsonProperty("poolType") String poolType, + @JsonProperty("threads") int threads, + @JsonProperty("tags") String tags) { + this.workerId = workerId; + this.queues = queues; + this.status = status; + this.lastHeartbeat = lastHeartbeat; + this.startedAt = startedAt; + this.hostname = hostname; + this.pid = pid; + this.poolType = poolType; + this.threads = threads; + this.tags = tags; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/internal/JniQueueBackend.java b/sdks/java/src/main/java/org/byteveda/taskito/internal/JniQueueBackend.java index f36a7c66..cb20db1b 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/internal/JniQueueBackend.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/internal/JniQueueBackend.java @@ -21,21 +21,146 @@ public String enqueue(String taskName, byte[] payload, String optionsJson) { return NativeQueue.enqueue(handle, taskName, payload, optionsJson); } + @Override + public String[] enqueueMany(String taskName, byte[][] payloads, String optionsJson) { + return NativeQueue.enqueueMany(handle, taskName, payloads, optionsJson); + } + @Override public Optional getJobJson(String jobId) { return Optional.ofNullable(NativeQueue.getJob(handle, jobId)); } + @Override + public Optional getResult(String jobId) { + return Optional.ofNullable(NativeQueue.getResult(handle, jobId)); + } + @Override public boolean cancel(String jobId) { return NativeQueue.cancel(handle, jobId); } + @Override + public boolean requestCancel(String jobId) { + return NativeQueue.requestCancel(handle, jobId); + } + + @Override + public boolean isCancelRequested(String jobId) { + return NativeQueue.isCancelRequested(handle, jobId); + } + + @Override + public void setProgress(String jobId, int progress) { + NativeQueue.setProgress(handle, jobId, progress); + } + @Override public String statsJson() { return NativeQueue.stats(handle); } + @Override + public String statsByQueueJson(String queue) { + return NativeQueue.statsByQueue(handle, queue); + } + + @Override + public String statsAllQueuesJson() { + return NativeQueue.statsAllQueues(handle); + } + + @Override + public String listJobsJson(String filterJson) { + return NativeQueue.listJobs(handle, filterJson); + } + + @Override + public String jobErrorsJson(String jobId) { + return NativeQueue.jobErrors(handle, jobId); + } + + @Override + public String metricsJson(String taskNameOrNull, long sinceMs) { + return NativeQueue.metrics(handle, taskNameOrNull, sinceMs); + } + + @Override + public String listWorkersJson() { + return NativeQueue.listWorkers(handle); + } + + @Override + public String listDeadJson(long limit, long offset) { + return NativeQueue.listDead(handle, limit, offset); + } + + @Override + public String retryDead(String deadId) { + return NativeQueue.retryDead(handle, deadId); + } + + @Override + public boolean deleteDead(String deadId) { + return NativeQueue.deleteDead(handle, deadId); + } + + @Override + public long purgeDead(long olderThanMs) { + return NativeQueue.purgeDead(handle, olderThanMs); + } + + @Override + public long purgeCompleted(long olderThanMs) { + return NativeQueue.purgeCompleted(handle, olderThanMs); + } + + @Override + public void pauseQueue(String queue) { + NativeQueue.pauseQueue(handle, queue); + } + + @Override + public void resumeQueue(String queue) { + NativeQueue.resumeQueue(handle, queue); + } + + @Override + public String listPausedQueuesJson() { + return NativeQueue.listPausedQueues(handle); + } + + @Override + public Optional getSetting(String key) { + return Optional.ofNullable(NativeQueue.getSetting(handle, key)); + } + + @Override + public void setSetting(String key, String value) { + NativeQueue.setSetting(handle, key, value); + } + + @Override + public boolean deleteSetting(String key) { + return NativeQueue.deleteSetting(handle, key); + } + + @Override + public String listSettingsJson() { + return NativeQueue.listSettings(handle); + } + + @Override + public void writeTaskLog(String jobId, String taskName, String level, String message, String extraOrNull) { + NativeQueue.writeTaskLog(handle, jobId, taskName, level, message, extraOrNull); + } + + @Override + public String getTaskLogsJson(String jobId) { + return NativeQueue.getTaskLogs(handle, jobId); + } + @Override public void close() { NativeQueue.close(handle); diff --git a/sdks/java/src/main/java/org/byteveda/taskito/internal/NativeQueue.java b/sdks/java/src/main/java/org/byteveda/taskito/internal/NativeQueue.java index 45cc1559..1139afd1 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/internal/NativeQueue.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/internal/NativeQueue.java @@ -3,9 +3,9 @@ /** * JNI surface over the Taskito core. * - *

Opaque job payloads cross as {@code byte[]}; options and views cross as - * JSON strings. Methods throw {@code org.byteveda.taskito.TaskitoException} on - * native failure. The {@code handle} is an opaque pointer from {@link #open}. + *

Opaque job payloads cross as {@code byte[]}; options, filters, and views + * cross as JSON strings. Methods throw {@link org.byteveda.taskito.TaskitoException} + * on native failure. The {@code handle} is an opaque pointer from {@link #open}. */ public final class NativeQueue { static { @@ -14,17 +14,74 @@ public final class NativeQueue { private NativeQueue() {} + // ── Lifecycle ─────────────────────────────────────────────────── public static native long open(String optionsJson); public static native void close(long handle); + // ── Producer ──────────────────────────────────────────────────── public static native String enqueue(long handle, String taskName, byte[] payload, String optionsJson); + public static native String[] enqueueMany(long handle, String taskName, byte[][] payloads, String optionsJson); + /** Returns a JSON job view, or {@code null} if absent. */ public static native String getJob(long handle, String jobId); + /** Returns the job's serialized result, or {@code null} if absent/incomplete. */ + public static native byte[] getResult(long handle, String jobId); + public static native boolean cancel(long handle, String jobId); - /** Returns a JSON job-count snapshot. */ + public static native boolean requestCancel(long handle, String jobId); + + public static native boolean isCancelRequested(long handle, String jobId); + + public static native void setProgress(long handle, String jobId, int progress); + + // ── Inspection ────────────────────────────────────────────────── public static native String stats(long handle); + + public static native String statsByQueue(long handle, String queue); + + public static native String statsAllQueues(long handle); + + public static native String listJobs(long handle, String filterJson); + + public static native String jobErrors(long handle, String jobId); + + public static native String metrics(long handle, String taskNameOrNull, long sinceMs); + + public static native String listWorkers(long handle); + + // ── Admin ─────────────────────────────────────────────────────── + public static native String listDead(long handle, long limit, long offset); + + public static native String retryDead(long handle, String deadId); + + public static native boolean deleteDead(long handle, String deadId); + + public static native long purgeDead(long handle, long olderThanMs); + + public static native long purgeCompleted(long handle, long olderThanMs); + + public static native void pauseQueue(long handle, String queue); + + public static native void resumeQueue(long handle, String queue); + + public static native String listPausedQueues(long handle); + + /** Returns the value, or {@code null} if unset. */ + public static native String getSetting(long handle, String key); + + public static native void setSetting(long handle, String key, String value); + + public static native boolean deleteSetting(long handle, String key); + + public static native String listSettings(long handle); + + // ── Logs ──────────────────────────────────────────────────────── + public static native void writeTaskLog( + long handle, String jobId, String taskName, String level, String message, String extraOrNull); + + public static native String getTaskLogs(long handle, String jobId); } diff --git a/sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java b/sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java index f9fdea22..cf4e91fb 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java @@ -8,17 +8,73 @@ * *

This is the seam between the public API and its implementation. The default * implementation is JNI-backed; alternatives (an FFM backend, or an in-memory - * fake for tests) can be supplied without touching the public API. + * fake for tests) can be supplied without touching the public API. Methods that + * return a JSON collection never return {@code null}; nullable scalars are + * returned as {@link Optional}. */ public interface QueueBackend extends AutoCloseable { + // ── Producer ──────────────────────────────────────────────────── String enqueue(String taskName, byte[] payload, String optionsJson); + String[] enqueueMany(String taskName, byte[][] payloads, String optionsJson); + Optional getJobJson(String jobId); + Optional getResult(String jobId); + boolean cancel(String jobId); + boolean requestCancel(String jobId); + + boolean isCancelRequested(String jobId); + + void setProgress(String jobId, int progress); + + // ── Inspection ────────────────────────────────────────────────── String statsJson(); + String statsByQueueJson(String queue); + + String statsAllQueuesJson(); + + String listJobsJson(String filterJson); + + String jobErrorsJson(String jobId); + + String metricsJson(String taskNameOrNull, long sinceMs); + + String listWorkersJson(); + + // ── Admin ─────────────────────────────────────────────────────── + String listDeadJson(long limit, long offset); + + String retryDead(String deadId); + + boolean deleteDead(String deadId); + + long purgeDead(long olderThanMs); + + long purgeCompleted(long olderThanMs); + + void pauseQueue(String queue); + + void resumeQueue(String queue); + + String listPausedQueuesJson(); + + Optional getSetting(String key); + + void setSetting(String key, String value); + + boolean deleteSetting(String key); + + String listSettingsJson(); + + // ── Logs ──────────────────────────────────────────────────────── + void writeTaskLog(String jobId, String taskName, String level, String message, String extraOrNull); + + String getTaskLogsJson(String jobId); + @Override void close(); } From 186a5cd44fb77f4370eafb414d1fda2d12d5f19b Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:34:43 +0530 Subject: [PATCH 08/16] test(java): cover enqueueMany, settings, logs, pause --- .../java/org/byteveda/taskito/QueueTest.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/sdks/java/src/test/java/org/byteveda/taskito/QueueTest.java b/sdks/java/src/test/java/org/byteveda/taskito/QueueTest.java index d39978f5..c04418dd 100644 --- a/sdks/java/src/test/java/org/byteveda/taskito/QueueTest.java +++ b/sdks/java/src/test/java/org/byteveda/taskito/QueueTest.java @@ -5,7 +5,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.file.Path; +import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -53,4 +55,54 @@ void getMissingJobIsEmpty(@TempDir Path dir) { assertTrue(queue.getJob("nope").isEmpty()); } } + + @Test + void enqueueManyAndFilteredList(@TempDir Path dir) { + try (Queue queue = open(dir)) { + List ids = queue.enqueueMany( + SEND_EMAIL.withOptions(EnqueueOptions.builder().queue("emails").build()), + Arrays.asList( + Collections.singletonMap("to", "a"), + Collections.singletonMap("to", "b"), + Collections.singletonMap("to", "c"))); + assertEquals(3, ids.size()); + assertEquals(3, queue.statsByQueue("emails").pending); + + List jobs = queue.listJobs( + JobFilter.builder().queue("emails").status(JobStatus.PENDING).build()); + assertEquals(3, jobs.size()); + } + } + + @Test + void settingsRoundTrip(@TempDir Path dir) { + try (Queue queue = open(dir)) { + queue.setSetting("theme", "dark"); + assertEquals("dark", queue.getSetting("theme").orElse("")); + assertEquals("dark", queue.listSettings().get("theme")); + assertTrue(queue.deleteSetting("theme")); + assertTrue(queue.getSetting("theme").isEmpty()); + } + } + + @Test + void pauseAndResumeQueue(@TempDir Path dir) { + try (Queue queue = open(dir)) { + queue.pauseQueue("emails"); + assertTrue(queue.listPausedQueues().contains("emails")); + queue.resumeQueue("emails"); + assertFalse(queue.listPausedQueues().contains("emails")); + } + } + + @Test + void taskLogsRoundTrip(@TempDir Path dir) { + try (Queue queue = open(dir)) { + String id = queue.enqueue("send_email", Collections.singletonMap("to", "a")); + queue.writeTaskLog(id, "send_email", "info", "hello"); + List logs = queue.getTaskLogs(id); + assertEquals(1, logs.size()); + assertEquals("hello", logs.get(0).message); + } + } } From 962bb8430c91802361f7bd24cb6d8dbf2ec955ac Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:13:58 +0530 Subject: [PATCH 09/16] fix(java): saturate scheduled_at against delayMs overflow --- crates/taskito-java/src/convert.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/taskito-java/src/convert.rs b/crates/taskito-java/src/convert.rs index 0d532c22..fac7b047 100644 --- a/crates/taskito-java/src/convert.rs +++ b/crates/taskito-java/src/convert.rs @@ -62,7 +62,8 @@ pub fn build_new_job( task_name, payload, priority: options.priority.unwrap_or(0), - scheduled_at: now_millis() + delay, + // Saturate rather than wrap/panic on an absurd delay. + scheduled_at: now_millis().saturating_add(delay), max_retries: options.max_retries.unwrap_or(0), timeout_ms: options.timeout_ms.unwrap_or(0), unique_key: options.unique_key, From 63fcd2947e99600ff6c3e9b31c920be361579d89 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:14:02 +0530 Subject: [PATCH 10/16] fix(java): release per-element JNI local refs in array marshalling --- crates/taskito-java/src/ffi.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/taskito-java/src/ffi.rs b/crates/taskito-java/src/ffi.rs index 35f157d0..c366defd 100644 --- a/crates/taskito-java/src/ffi.rs +++ b/crates/taskito-java/src/ffi.rs @@ -70,7 +70,10 @@ pub fn read_bytes_array( let element = env .get_object_array_element(value, index) .map_err(|e| BindingError::new(format!("invalid byte[][] element: {e}")))?; - out.push(read_bytes(env, &JByteArray::from(element))?); + // Auto-release the per-element local ref each iteration so a large array + // can't exhaust the JNI local-reference table before we return. + let element = env.auto_local(JByteArray::from(element)); + out.push(read_bytes(env, &element)?); } Ok(out) } @@ -98,9 +101,12 @@ pub fn new_string_array(env: &mut JNIEnv, values: &[String]) -> Result Date: Thu, 25 Jun 2026 15:14:03 +0530 Subject: [PATCH 11/16] fix(java): make native close panic-safe and idempotent --- crates/taskito-java/src/queue/mod.rs | 17 +++++++++++------ .../taskito/internal/JniQueueBackend.java | 10 ++++++++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/taskito-java/src/queue/mod.rs b/crates/taskito-java/src/queue/mod.rs index a684c854..146d3cab 100644 --- a/crates/taskito-java/src/queue/mod.rs +++ b/crates/taskito-java/src/queue/mod.rs @@ -46,14 +46,19 @@ pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_open<'loca /// `void close(long handle)` — reclaim a handle. A zero handle is a no-op. #[no_mangle] -pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_close( - _env: JNIEnv, - _class: JClass, +pub extern "system" fn Java_org_byteveda_taskito_internal_NativeQueue_close<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, handle: jlong, ) { - if handle != 0 { - unsafe { drop_handle::(handle) }; - } + // Routed through `guard` like every other entry point: a panic in a + // destructor must not unwind across the FFI boundary. + guard(&mut env, (), |_env| { + if handle != 0 { + unsafe { drop_handle::(handle) }; + } + Ok(()) + }) } /// `String enqueue(long handle, String taskName, byte[] payload, String optionsJson)` diff --git a/sdks/java/src/main/java/org/byteveda/taskito/internal/JniQueueBackend.java b/sdks/java/src/main/java/org/byteveda/taskito/internal/JniQueueBackend.java index cb20db1b..d189e355 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/internal/JniQueueBackend.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/internal/JniQueueBackend.java @@ -6,6 +6,7 @@ /** JNI-backed {@link QueueBackend} over a native queue handle. */ public final class JniQueueBackend implements QueueBackend { private final long handle; + private volatile boolean closed; private JniQueueBackend(long handle) { this.handle = handle; @@ -161,8 +162,13 @@ public String getTaskLogsJson(String jobId) { return NativeQueue.getTaskLogs(handle, jobId); } + /** Idempotent: the native handle is freed exactly once, so a second {@code close()} + * (e.g. an explicit call plus try-with-resources) can't double-free. */ @Override - public void close() { - NativeQueue.close(handle); + public synchronized void close() { + if (!closed) { + closed = true; + NativeQueue.close(handle); + } } } From b9111f087ba65aace8e24bac84a22de97fa14973 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:14:04 +0530 Subject: [PATCH 12/16] fix(java): validate Task constructor inputs --- .../java/src/main/java/org/byteveda/taskito/Task.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/sdks/java/src/main/java/org/byteveda/taskito/Task.java b/sdks/java/src/main/java/org/byteveda/taskito/Task.java index 6b4968a7..7aec0ef5 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/Task.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/Task.java @@ -1,5 +1,7 @@ package org.byteveda.taskito; +import java.util.Objects; + /** Typed task descriptor: a name, its payload type, and default enqueue options. */ public final class Task { private final String name; @@ -7,9 +9,12 @@ public final class Task { private final EnqueueOptions options; private Task(String name, Class payloadType, EnqueueOptions options) { - this.name = name; - this.payloadType = payloadType; - this.options = options; + this.name = Objects.requireNonNull(name, "task name must not be null"); + if (name.trim().isEmpty()) { + throw new IllegalArgumentException("task name must not be blank"); + } + this.payloadType = Objects.requireNonNull(payloadType, "payloadType must not be null"); + this.options = Objects.requireNonNull(options, "options must not be null"); } public static Task of(String name, Class payloadType) { From 44e9659f3a574656eef955c6dc015f6ae226ae20 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:14:04 +0530 Subject: [PATCH 13/16] fix(java): harden JobStatus.fromWire against null and unknown values --- .../src/main/java/org/byteveda/taskito/JobStatus.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/sdks/java/src/main/java/org/byteveda/taskito/JobStatus.java b/sdks/java/src/main/java/org/byteveda/taskito/JobStatus.java index dcb7bd5f..85195385 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/JobStatus.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/JobStatus.java @@ -20,6 +20,13 @@ public String wire() { @JsonCreator public static JobStatus fromWire(String wire) { - return valueOf(wire.toUpperCase(Locale.ROOT)); + if (wire == null) { + throw new TaskitoException("job status is null"); + } + try { + return valueOf(wire.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new TaskitoException("unknown job status: " + wire, e); + } } } From 8558b4456d59df08bcae8bcc7d64c058b511a6f8 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:14:04 +0530 Subject: [PATCH 14/16] fix(java): reject negative enqueue option values --- .../main/java/org/byteveda/taskito/EnqueueOptions.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sdks/java/src/main/java/org/byteveda/taskito/EnqueueOptions.java b/sdks/java/src/main/java/org/byteveda/taskito/EnqueueOptions.java index 4149ef35..96667661 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/EnqueueOptions.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/EnqueueOptions.java @@ -55,16 +55,25 @@ public Builder priority(int priority) { } public Builder maxRetries(int maxRetries) { + if (maxRetries < 0) { + throw new IllegalArgumentException("maxRetries must be >= 0"); + } this.maxRetries = maxRetries; return this; } public Builder timeoutMs(long timeoutMs) { + if (timeoutMs < 0) { + throw new IllegalArgumentException("timeoutMs must be >= 0"); + } this.timeoutMs = timeoutMs; return this; } public Builder delayMs(long delayMs) { + if (delayMs < 0) { + throw new IllegalArgumentException("delayMs must be >= 0"); + } this.delayMs = delayMs; return this; } From 00611ed978944d895d86e65a50f28cdf85a7ec98 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:14:04 +0530 Subject: [PATCH 15/16] docs(java): clarify enqueueMany options and tag README fence --- sdks/java/README.md | 2 +- .../main/java/org/byteveda/taskito/spi/QueueBackend.java | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/sdks/java/README.md b/sdks/java/README.md index 4d36ca47..caef584d 100644 --- a/sdks/java/README.md +++ b/sdks/java/README.md @@ -26,7 +26,7 @@ try (Queue queue = Taskito.builder().backend("sqlite").url("taskito.db").open()) ## Structure -``` +```text org.byteveda.taskito ├── Taskito entry point — Taskito.builder()...open() ├── Queue public interface diff --git a/sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java b/sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java index cf4e91fb..f7d0b3e5 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java @@ -14,8 +14,15 @@ */ public interface QueueBackend extends AutoCloseable { // ── Producer ──────────────────────────────────────────────────── + + /** Enqueue one job; {@code optionsJson} is a single {@code EnqueueOptions} object. */ String enqueue(String taskName, byte[] payload, String optionsJson); + /** + * Enqueue a batch. Unlike {@link #enqueue}, {@code optionsJson} is a JSON + * array of per-job {@code EnqueueOptions}, the same length as + * {@code payloads}. Returns the job ids in input order. + */ String[] enqueueMany(String taskName, byte[][] payloads, String optionsJson); Optional getJobJson(String jobId); From 7a10961f5578350e6abe8e41e14acbbfa7c839be Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:15:28 +0530 Subject: [PATCH 16/16] build(java): pin Gradle distribution checksum --- sdks/java/gradle/wrapper/gradle-wrapper.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/sdks/java/gradle/wrapper/gradle-wrapper.properties b/sdks/java/gradle/wrapper/gradle-wrapper.properties index d6e308a6..00cde9d2 100644 --- a/sdks/java/gradle/wrapper/gradle-wrapper.properties +++ b/sdks/java/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip +distributionSha256Sum=7a00d51fb93147819aab76024feece20b6b84e420694101f276be952e08bef03 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists