From ab3cdc96caf31758d14e90a536f6431b27563226 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sun, 5 Jul 2026 09:29:10 +0530
Subject: [PATCH 1/9] fix(java): guard native handles against use-after-close
---
.../taskito/internal/JniQueueBackend.java | 251 ++++++++++++------
.../taskito/internal/JniWorkerControl.java | 64 ++++-
.../org/byteveda/taskito/worker/Worker.java | 15 +-
3 files changed, 241 insertions(+), 89 deletions(-)
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 3491fb33..fd53d0c8 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
@@ -1,18 +1,29 @@
package org.byteveda.taskito.internal;
import java.util.Optional;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.function.Supplier;
import org.byteveda.taskito.spi.QueueBackend;
import org.byteveda.taskito.spi.WorkerBridge;
import org.byteveda.taskito.spi.WorkerControl;
-/** JNI-backed {@link QueueBackend} over a native queue handle. */
+/**
+ * JNI-backed {@link QueueBackend} over a native queue handle.
+ *
+ *
The native contract forbids using a handle after {@code close()} or closing
+ * concurrently with a call — violating it is a use-after-free in the native
+ * layer. Every call therefore holds the read lock while touching the handle;
+ * {@code close()} takes the write lock, so it waits out in-flight calls and any
+ * later call fails with {@link IllegalStateException} instead of crashing the JVM.
+ */
public final class JniQueueBackend implements QueueBackend {
private final long handle;
/** Hot byte ops (enqueue/enqueueMany/getResult) route through this; FFM when available, else JNI. */
private final NativeTransport transport;
- private volatile boolean closed;
+ private final ReentrantReadWriteLock stateLock = new ReentrantReadWriteLock();
+ private boolean closed; // guarded by stateLock
private JniQueueBackend(long handle) {
this.handle = handle;
@@ -24,200 +35,228 @@ public static JniQueueBackend open(String optionsJson) {
return new JniQueueBackend(NativeQueue.open(optionsJson));
}
+ private T withOpenHandle(Supplier nativeCall) {
+ stateLock.readLock().lock();
+ try {
+ if (closed) {
+ throw new IllegalStateException("queue backend is closed");
+ }
+ return nativeCall.get();
+ } finally {
+ stateLock.readLock().unlock();
+ }
+ }
+
@Override
public String enqueue(String taskName, byte[] payload, String optionsJson) {
- return transport.enqueue(taskName, payload, optionsJson);
+ return withOpenHandle(() -> transport.enqueue(taskName, payload, optionsJson));
}
@Override
public String[] enqueueMany(String taskName, byte[][] payloads, String optionsJson) {
- return transport.enqueueMany(taskName, payloads, optionsJson);
+ return withOpenHandle(() -> transport.enqueueMany(taskName, payloads, optionsJson));
}
@Override
public Optional getJobJson(String jobId) {
- return Optional.ofNullable(NativeQueue.getJob(handle, jobId));
+ return withOpenHandle(() -> Optional.ofNullable(NativeQueue.getJob(handle, jobId)));
}
@Override
public Optional getResult(String jobId) {
- return Optional.ofNullable(transport.getResult(jobId));
+ return withOpenHandle(() -> Optional.ofNullable(transport.getResult(jobId)));
}
@Override
public boolean cancel(String jobId) {
- return NativeQueue.cancel(handle, jobId);
+ return withOpenHandle(() -> NativeQueue.cancel(handle, jobId));
}
@Override
public boolean requestCancel(String jobId) {
- return NativeQueue.requestCancel(handle, jobId);
+ return withOpenHandle(() -> NativeQueue.requestCancel(handle, jobId));
}
@Override
public boolean isCancelRequested(String jobId) {
- return NativeQueue.isCancelRequested(handle, jobId);
+ return withOpenHandle(() -> NativeQueue.isCancelRequested(handle, jobId));
}
@Override
public void setProgress(String jobId, int progress) {
- NativeQueue.setProgress(handle, jobId, progress);
+ withOpenHandle(() -> {
+ NativeQueue.setProgress(handle, jobId, progress);
+ return null;
+ });
}
@Override
public String statsJson() {
- return NativeQueue.stats(handle);
+ return withOpenHandle(() -> NativeQueue.stats(handle));
}
@Override
public String statsByQueueJson(String queue) {
- return NativeQueue.statsByQueue(handle, queue);
+ return withOpenHandle(() -> NativeQueue.statsByQueue(handle, queue));
}
@Override
public String statsAllQueuesJson() {
- return NativeQueue.statsAllQueues(handle);
+ return withOpenHandle(() -> NativeQueue.statsAllQueues(handle));
}
@Override
public String listJobsJson(String filterJson) {
- return NativeQueue.listJobs(handle, filterJson);
+ return withOpenHandle(() -> NativeQueue.listJobs(handle, filterJson));
}
@Override
public String jobErrorsJson(String jobId) {
- return NativeQueue.jobErrors(handle, jobId);
+ return withOpenHandle(() -> NativeQueue.jobErrors(handle, jobId));
}
@Override
public String metricsJson(String taskNameOrNull, long sinceMs) {
- return NativeQueue.metrics(handle, taskNameOrNull, sinceMs);
+ return withOpenHandle(() -> NativeQueue.metrics(handle, taskNameOrNull, sinceMs));
}
@Override
public String listWorkersJson() {
- return NativeQueue.listWorkers(handle);
+ return withOpenHandle(() -> NativeQueue.listWorkers(handle));
}
@Override
public String listDeadJson(long limit, long offset) {
- return NativeQueue.listDead(handle, limit, offset);
+ return withOpenHandle(() -> NativeQueue.listDead(handle, limit, offset));
}
@Override
public String retryDead(String deadId) {
- return NativeQueue.retryDead(handle, deadId);
+ return withOpenHandle(() -> NativeQueue.retryDead(handle, deadId));
}
@Override
public boolean deleteDead(String deadId) {
- return NativeQueue.deleteDead(handle, deadId);
+ return withOpenHandle(() -> NativeQueue.deleteDead(handle, deadId));
}
@Override
public long purgeDead(long olderThanMs) {
- return NativeQueue.purgeDead(handle, olderThanMs);
+ return withOpenHandle(() -> NativeQueue.purgeDead(handle, olderThanMs));
}
@Override
public String listDeadByTaskJson(String taskName, long limit, long offset) {
- return NativeQueue.listDeadByTask(handle, taskName, limit, offset);
+ return withOpenHandle(() -> NativeQueue.listDeadByTask(handle, taskName, limit, offset));
}
@Override
public long purgeDeadByTask(String taskName) {
- return NativeQueue.purgeDeadByTask(handle, taskName);
+ return withOpenHandle(() -> NativeQueue.purgeDeadByTask(handle, taskName));
}
@Override
public long purgeCompleted(long olderThanMs) {
- return NativeQueue.purgeCompleted(handle, olderThanMs);
+ return withOpenHandle(() -> NativeQueue.purgeCompleted(handle, olderThanMs));
}
@Override
public void pauseQueue(String queue) {
- NativeQueue.pauseQueue(handle, queue);
+ withOpenHandle(() -> {
+ NativeQueue.pauseQueue(handle, queue);
+ return null;
+ });
}
@Override
public void resumeQueue(String queue) {
- NativeQueue.resumeQueue(handle, queue);
+ withOpenHandle(() -> {
+ NativeQueue.resumeQueue(handle, queue);
+ return null;
+ });
}
@Override
public String listPausedQueuesJson() {
- return NativeQueue.listPausedQueues(handle);
+ return withOpenHandle(() -> NativeQueue.listPausedQueues(handle));
}
@Override
public Optional getSetting(String key) {
- return Optional.ofNullable(NativeQueue.getSetting(handle, key));
+ return withOpenHandle(() -> Optional.ofNullable(NativeQueue.getSetting(handle, key)));
}
@Override
public void setSetting(String key, String value) {
- NativeQueue.setSetting(handle, key, value);
+ withOpenHandle(() -> {
+ NativeQueue.setSetting(handle, key, value);
+ return null;
+ });
}
@Override
public boolean deleteSetting(String key) {
- return NativeQueue.deleteSetting(handle, key);
+ return withOpenHandle(() -> NativeQueue.deleteSetting(handle, key));
}
@Override
public String listSettingsJson() {
- return NativeQueue.listSettings(handle);
+ return withOpenHandle(() -> 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);
+ withOpenHandle(() -> {
+ NativeQueue.writeTaskLog(handle, jobId, taskName, level, message, extraOrNull);
+ return null;
+ });
}
@Override
public String getTaskLogsJson(String jobId) {
- return NativeQueue.getTaskLogs(handle, jobId);
+ return withOpenHandle(() -> NativeQueue.getTaskLogs(handle, jobId));
}
@Override
public boolean acquireLock(String name, String ownerId, long ttlMs) {
- return NativeQueue.acquireLock(handle, name, ownerId, ttlMs);
+ return withOpenHandle(() -> NativeQueue.acquireLock(handle, name, ownerId, ttlMs));
}
@Override
public boolean releaseLock(String name, String ownerId) {
- return NativeQueue.releaseLock(handle, name, ownerId);
+ return withOpenHandle(() -> NativeQueue.releaseLock(handle, name, ownerId));
}
@Override
public boolean extendLock(String name, String ownerId, long ttlMs) {
- return NativeQueue.extendLock(handle, name, ownerId, ttlMs);
+ return withOpenHandle(() -> NativeQueue.extendLock(handle, name, ownerId, ttlMs));
}
@Override
public Optional lockInfoJson(String name) {
- return Optional.ofNullable(NativeQueue.getLockInfo(handle, name));
+ return withOpenHandle(() -> Optional.ofNullable(NativeQueue.getLockInfo(handle, name)));
}
@Override
public long registerPeriodic(
String name, String taskName, String cron, byte[] args, String queue, String timezone, boolean enabled) {
- return NativeQueue.registerPeriodic(handle, name, taskName, cron, args, queue, timezone, enabled);
+ return withOpenHandle(
+ () -> NativeQueue.registerPeriodic(handle, name, taskName, cron, args, queue, timezone, enabled));
}
@Override
public String listPeriodicJson() {
- return NativeQueue.listPeriodic(handle);
+ return withOpenHandle(() -> NativeQueue.listPeriodic(handle));
}
@Override
public boolean deletePeriodic(String name) {
- return NativeQueue.deletePeriodic(handle, name);
+ return withOpenHandle(() -> NativeQueue.deletePeriodic(handle, name));
}
@Override
public boolean setPeriodicEnabled(String name, boolean enabled) {
- return NativeQueue.setPeriodicEnabled(handle, name, enabled);
+ return withOpenHandle(() -> NativeQueue.setPeriodicEnabled(handle, name, enabled));
}
@Override
@@ -232,7 +271,7 @@ public String submitWorkflow(
String[] deferredNames,
String parentRunId,
String parentNodeName) {
- return NativeWorkflows.submitWorkflow(
+ return withOpenHandle(() -> NativeWorkflows.submitWorkflow(
handle,
name,
version,
@@ -243,37 +282,41 @@ public String submitWorkflow(
paramsJson,
deferredNames,
parentRunId,
- parentNodeName);
+ parentNodeName));
}
@Override
public String markWorkflowNodeResult(String jobId, boolean succeeded, String error, boolean skipCascade) {
- return NativeWorkflows.markWorkflowNodeResult(handle, jobId, succeeded, error, skipCascade);
+ return withOpenHandle(
+ () -> NativeWorkflows.markWorkflowNodeResult(handle, jobId, succeeded, error, skipCascade));
}
@Override
public Optional getWorkflowStatusJson(String runId) {
- return Optional.ofNullable(NativeWorkflows.getWorkflowStatus(handle, runId));
+ return withOpenHandle(() -> Optional.ofNullable(NativeWorkflows.getWorkflowStatus(handle, runId)));
}
@Override
public void cancelWorkflowRun(String runId) {
- NativeWorkflows.cancelWorkflowRun(handle, runId);
+ withOpenHandle(() -> {
+ NativeWorkflows.cancelWorkflowRun(handle, runId);
+ return null;
+ });
}
@Override
public Optional getWorkflowPlanJson(String runId) {
- return Optional.ofNullable(NativeWorkflows.getWorkflowPlan(handle, runId));
+ return withOpenHandle(() -> Optional.ofNullable(NativeWorkflows.getWorkflowPlan(handle, runId)));
}
@Override
public Optional workflowNodeForJobJson(String jobId) {
- return Optional.ofNullable(NativeWorkflows.workflowNodeForJob(handle, jobId));
+ return withOpenHandle(() -> Optional.ofNullable(NativeWorkflows.workflowNodeForJob(handle, jobId)));
}
@Override
public Optional workflowNameForRun(String runId) {
- return Optional.ofNullable(NativeWorkflows.workflowNameForRun(handle, runId));
+ return withOpenHandle(() -> Optional.ofNullable(NativeWorkflows.workflowNameForRun(handle, runId)));
}
@Override
@@ -287,13 +330,23 @@ public String[] expandFanOut(
int maxRetries,
long timeoutMs,
int priority) {
- return NativeWorkflows.expandFanOut(
- handle, runId, parentNode, childNames, childPayloads, taskName, queue, maxRetries, timeoutMs, priority);
+ return withOpenHandle(() -> NativeWorkflows.expandFanOut(
+ handle,
+ runId,
+ parentNode,
+ childNames,
+ childPayloads,
+ taskName,
+ queue,
+ maxRetries,
+ timeoutMs,
+ priority));
}
@Override
public Optional checkFanOutCompletionJson(String runId, String parentNode) {
- return Optional.ofNullable(NativeWorkflows.checkFanOutCompletion(handle, runId, parentNode));
+ return withOpenHandle(
+ () -> Optional.ofNullable(NativeWorkflows.checkFanOutCompletion(handle, runId, parentNode)));
}
@Override
@@ -306,98 +359,144 @@ public String createDeferredJob(
int maxRetries,
long timeoutMs,
int priority) {
- return NativeWorkflows.createDeferredJob(
- handle, runId, nodeName, payload, taskName, queue, maxRetries, timeoutMs, priority);
+ return withOpenHandle(() -> NativeWorkflows.createDeferredJob(
+ handle, runId, nodeName, payload, taskName, queue, maxRetries, timeoutMs, priority));
}
@Override
public void cascadeSkipPending(String runId) {
- NativeWorkflows.cascadeSkipPending(handle, runId);
+ withOpenHandle(() -> {
+ NativeWorkflows.cascadeSkipPending(handle, runId);
+ return null;
+ });
}
@Override
public Optional finalizeRunIfTerminal(String runId) {
- return Optional.ofNullable(NativeWorkflows.finalizeRunIfTerminal(handle, runId));
+ return withOpenHandle(() -> Optional.ofNullable(NativeWorkflows.finalizeRunIfTerminal(handle, runId)));
}
@Override
public void setWorkflowNodeWaitingApproval(String runId, String nodeName) {
- NativeWorkflows.setWorkflowNodeWaitingApproval(handle, runId, nodeName);
+ withOpenHandle(() -> {
+ NativeWorkflows.setWorkflowNodeWaitingApproval(handle, runId, nodeName);
+ return null;
+ });
}
@Override
public void resolveWorkflowGate(String runId, String nodeName, boolean approved, String error) {
- NativeWorkflows.resolveWorkflowGate(handle, runId, nodeName, approved, error);
+ withOpenHandle(() -> {
+ NativeWorkflows.resolveWorkflowGate(handle, runId, nodeName, approved, error);
+ return null;
+ });
}
@Override
public void setWorkflowNodeRunning(String runId, String nodeName) {
- NativeWorkflows.setWorkflowNodeRunning(handle, runId, nodeName);
+ withOpenHandle(() -> {
+ NativeWorkflows.setWorkflowNodeRunning(handle, runId, nodeName);
+ return null;
+ });
}
@Override
public void failWorkflowNode(String runId, String nodeName, String error) {
- NativeWorkflows.failWorkflowNode(handle, runId, nodeName, error);
+ withOpenHandle(() -> {
+ NativeWorkflows.failWorkflowNode(handle, runId, nodeName, error);
+ return null;
+ });
}
@Override
public void skipWorkflowNode(String runId, String nodeName) {
- NativeWorkflows.skipWorkflowNode(handle, runId, nodeName);
+ withOpenHandle(() -> {
+ NativeWorkflows.skipWorkflowNode(handle, runId, nodeName);
+ return null;
+ });
}
@Override
public void setWorkflowNodeCacheHit(String runId, String nodeName) {
- NativeWorkflows.setWorkflowNodeCacheHit(handle, runId, nodeName);
+ withOpenHandle(() -> {
+ NativeWorkflows.setWorkflowNodeCacheHit(handle, runId, nodeName);
+ return null;
+ });
}
@Override
public void setWorkflowRunCompensating(String runId) {
- NativeWorkflows.setWorkflowRunCompensating(handle, runId);
+ withOpenHandle(() -> {
+ NativeWorkflows.setWorkflowRunCompensating(handle, runId);
+ return null;
+ });
}
@Override
public void setWorkflowRunCompensated(String runId, long completedAt) {
- NativeWorkflows.setWorkflowRunCompensated(handle, runId, completedAt);
+ withOpenHandle(() -> {
+ NativeWorkflows.setWorkflowRunCompensated(handle, runId, completedAt);
+ return null;
+ });
}
@Override
public void setWorkflowRunCompensationFailed(String runId, long completedAt, String error) {
- NativeWorkflows.setWorkflowRunCompensationFailed(handle, runId, completedAt, error);
+ withOpenHandle(() -> {
+ NativeWorkflows.setWorkflowRunCompensationFailed(handle, runId, completedAt, error);
+ return null;
+ });
}
@Override
public void setWorkflowRunCompletedWithFailures(String runId, long completedAt) {
- NativeWorkflows.setWorkflowRunCompletedWithFailures(handle, runId, completedAt);
+ withOpenHandle(() -> {
+ NativeWorkflows.setWorkflowRunCompletedWithFailures(handle, runId, completedAt);
+ return null;
+ });
}
@Override
public void setWorkflowNodeCompensationJob(
String runId, String nodeName, String compensationJobId, long startedAt) {
- NativeWorkflows.setWorkflowNodeCompensationJob(handle, runId, nodeName, compensationJobId, startedAt);
+ withOpenHandle(() -> {
+ NativeWorkflows.setWorkflowNodeCompensationJob(handle, runId, nodeName, compensationJobId, startedAt);
+ return null;
+ });
}
@Override
public void setWorkflowNodeCompensated(String runId, String nodeName, long completedAt) {
- NativeWorkflows.setWorkflowNodeCompensated(handle, runId, nodeName, completedAt);
+ withOpenHandle(() -> {
+ NativeWorkflows.setWorkflowNodeCompensated(handle, runId, nodeName, completedAt);
+ return null;
+ });
}
@Override
public void setWorkflowNodeCompensationFailed(String runId, String nodeName, String error, long completedAt) {
- NativeWorkflows.setWorkflowNodeCompensationFailed(handle, runId, nodeName, error, completedAt);
+ withOpenHandle(() -> {
+ NativeWorkflows.setWorkflowNodeCompensationFailed(handle, runId, nodeName, error, completedAt);
+ return null;
+ });
}
@Override
public WorkerControl startWorker(WorkerBridge bridge, String optionsJson) {
- return new JniWorkerControl(NativeQueue.runWorker(handle, bridge, optionsJson));
+ return withOpenHandle(() -> new JniWorkerControl(NativeQueue.runWorker(handle, bridge, optionsJson)));
}
- /** 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. */
+ /** Idempotent: frees the native queue handle exactly once, after in-flight calls drain. */
@Override
- public synchronized void close() {
- if (!closed) {
- closed = true;
- NativeQueue.close(handle);
+ public void close() {
+ stateLock.writeLock().lock();
+ try {
+ if (!closed) {
+ closed = true;
+ NativeQueue.close(handle);
+ }
+ } finally {
+ stateLock.writeLock().unlock();
}
}
}
diff --git a/sdks/java/src/main/java/org/byteveda/taskito/internal/JniWorkerControl.java b/sdks/java/src/main/java/org/byteveda/taskito/internal/JniWorkerControl.java
index 13f540d0..b9eebe7e 100644
--- a/sdks/java/src/main/java/org/byteveda/taskito/internal/JniWorkerControl.java
+++ b/sdks/java/src/main/java/org/byteveda/taskito/internal/JniWorkerControl.java
@@ -1,48 +1,88 @@
package org.byteveda.taskito.internal;
import java.util.Optional;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.function.Supplier;
import org.byteveda.taskito.spi.WorkerControl;
-/** JNI-backed {@link WorkerControl} over a native worker handle. */
+/**
+ * JNI-backed {@link WorkerControl} over a native worker handle.
+ *
+ * The native contract forbids using a handle after {@code close()} or closing
+ * concurrently with a call — violating it is a use-after-free in the native
+ * layer. Every call therefore holds the read lock while touching the handle;
+ * {@code close()} takes the write lock, so it waits out in-flight calls and any
+ * later call fails with {@link IllegalStateException} instead of crashing the JVM.
+ */
public final class JniWorkerControl implements WorkerControl {
private final long handle;
- private volatile boolean closed;
+ private final ReentrantReadWriteLock stateLock = new ReentrantReadWriteLock();
+ private boolean closed; // guarded by stateLock
JniWorkerControl(long handle) {
this.handle = handle;
}
+ private T withOpenHandle(Supplier nativeCall) {
+ stateLock.readLock().lock();
+ try {
+ if (closed) {
+ throw new IllegalStateException("worker control is closed");
+ }
+ return nativeCall.get();
+ } finally {
+ stateLock.readLock().unlock();
+ }
+ }
+
@Override
public void completeJob(long token, byte[] result) {
- NativeWorker.completeJob(handle, token, result);
+ withOpenHandle(() -> {
+ NativeWorker.completeJob(handle, token, result);
+ return null;
+ });
}
@Override
public void failJob(long token, String error) {
- NativeWorker.failJob(handle, token, error);
+ withOpenHandle(() -> {
+ NativeWorker.failJob(handle, token, error);
+ return null;
+ });
}
@Override
public void cancelJob(long token) {
- NativeWorker.cancelJob(handle, token);
+ withOpenHandle(() -> {
+ NativeWorker.cancelJob(handle, token);
+ return null;
+ });
}
@Override
public void stop() {
- NativeWorker.stop(handle);
+ withOpenHandle(() -> {
+ NativeWorker.stop(handle);
+ return null;
+ });
}
@Override
public Optional meshClusterInfoJson() {
- return Optional.ofNullable(NativeWorker.meshClusterInfo(handle));
+ return withOpenHandle(() -> Optional.ofNullable(NativeWorker.meshClusterInfo(handle)));
}
- /** Idempotent: frees the native worker handle exactly once. */
+ /** Idempotent: frees the native worker handle exactly once, after in-flight calls drain. */
@Override
- public synchronized void close() {
- if (!closed) {
- closed = true;
- NativeWorker.close(handle);
+ public void close() {
+ stateLock.writeLock().lock();
+ try {
+ if (!closed) {
+ closed = true;
+ NativeWorker.close(handle);
+ }
+ } finally {
+ stateLock.writeLock().unlock();
}
}
}
diff --git a/sdks/java/src/main/java/org/byteveda/taskito/worker/Worker.java b/sdks/java/src/main/java/org/byteveda/taskito/worker/Worker.java
index 310fd1a3..729a0780 100644
--- a/sdks/java/src/main/java/org/byteveda/taskito/worker/Worker.java
+++ b/sdks/java/src/main/java/org/byteveda/taskito/worker/Worker.java
@@ -43,6 +43,7 @@
* jobs.
*/
public final class Worker implements AutoCloseable {
+ private static final System.Logger LOG = System.getLogger(Worker.class.getName());
private static final long SHUTDOWN_TIMEOUT_SECONDS = 30;
private static final ObjectMapper MESH_JSON = new ObjectMapper();
@@ -144,13 +145,25 @@ public synchronized void close() {
executor.shutdown(); // stop accepting; let running handlers finish
try {
if (!executor.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
+ // Interrupt stragglers, then give them one more window: shutdownNow()
+ // only *requests* interruption, and a handler stuck in
+ // non-interruptible work can outlive it.
executor.shutdownNow();
+ if (!executor.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
+ LOG.log(
+ System.Logger.Level.WARNING,
+ "handler threads still running after {0}s; closing the native worker anyway —"
+ + " their late completions will be rejected",
+ 2 * SHUTDOWN_TIMEOUT_SECONDS);
+ }
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
executor.shutdownNow();
}
- control.close(); // now safe to free the native handle — no handler can touch it
+ // Safe even if a straggler survives: JniWorkerControl serializes close()
+ // against in-flight native calls and rejects any call made afterwards.
+ control.close();
if (tracker != null) {
tracker.close(); // stop the gate-timeout scheduler
}
From 85ba39d3de5488ffc7e26b990173898ac1eb435d Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sun, 5 Jul 2026 09:29:10 +0530
Subject: [PATCH 2/9] fix(java): fail workflow nodes on promotion errors
A promotion failure previously propagated into the outcome listener,
where it was swallowed silently and left the node PENDING forever.
---
.../org/byteveda/taskito/events/Emitter.java | 12 +++-
.../taskito/workflows/WorkflowTracker.java | 64 ++++++++++++++++++-
2 files changed, 72 insertions(+), 4 deletions(-)
diff --git a/sdks/java/src/main/java/org/byteveda/taskito/events/Emitter.java b/sdks/java/src/main/java/org/byteveda/taskito/events/Emitter.java
index 8e40df08..d3838e95 100644
--- a/sdks/java/src/main/java/org/byteveda/taskito/events/Emitter.java
+++ b/sdks/java/src/main/java/org/byteveda/taskito/events/Emitter.java
@@ -8,6 +8,8 @@
/** Dispatches {@link OutcomeEvent}s to registered listeners. Thread-safe. */
public final class Emitter {
+ private static final System.Logger LOG = System.getLogger(Emitter.class.getName());
+
private final Map>> listeners = new EnumMap<>(EventName.class);
public void on(EventName name, Consumer listener) {
@@ -23,8 +25,14 @@ public void emit(OutcomeEvent event) {
for (Consumer listener : bound) {
try {
listener.accept(event);
- } catch (RuntimeException ignored) {
- // A listener fault must not break event dispatch.
+ } catch (RuntimeException e) {
+ // A listener fault must not break event dispatch — but it must
+ // not vanish either: this is the only place a workflow-tracker
+ // failure would otherwise surface.
+ LOG.log(
+ System.Logger.Level.WARNING,
+ "listener for " + event.name + " (job " + event.jobId + ") threw",
+ e);
}
}
}
diff --git a/sdks/java/src/main/java/org/byteveda/taskito/workflows/WorkflowTracker.java b/sdks/java/src/main/java/org/byteveda/taskito/workflows/WorkflowTracker.java
index e819dbc3..29e20a3a 100644
--- a/sdks/java/src/main/java/org/byteveda/taskito/workflows/WorkflowTracker.java
+++ b/sdks/java/src/main/java/org/byteveda/taskito/workflows/WorkflowTracker.java
@@ -45,6 +45,8 @@
* run reaches a terminal state.
*/
public final class WorkflowTracker {
+ private static final System.Logger LOG = System.getLogger(WorkflowTracker.class.getName());
+
private final QueueBackend backend;
private final Serializer serializer;
private final ObjectMapper json = new ObjectMapper();
@@ -146,7 +148,11 @@ private void onOutcome(String jobId, boolean succeeded, String error) {
return;
}
if (succeeded) {
- expandFanOutIfAny(ref.runId, ref.nodeName, jobId, plan);
+ try {
+ expandFanOutIfAny(ref.runId, ref.nodeName, jobId, plan);
+ } catch (RuntimeException e) {
+ failFanOutsOf(ref.runId, ref.nodeName, plan, e);
+ }
}
// Evaluate every successor's condition — running on-failure recovery nodes
// and skip-cascading the rest. Replaces a blanket fail-fast cascade.
@@ -290,9 +296,63 @@ private void promoteOrSkip(String runId, PlanNode node, Map candidate.fanOut != null)) {
+ try {
+ backend.failWorkflowNode(runId, fanOut.name, describe(cause));
+ promoteSuccessors(runId, fanOut.name, plan);
+ } catch (RuntimeException e) {
+ LOG.log(
+ System.Logger.Level.ERROR,
+ "failed to record failure of fan-out node '" + fanOut.name + "' in run " + runId,
+ e);
+ }
+ }
+ }
+
+ private static String describe(RuntimeException cause) {
+ String message = cause.getMessage();
+ return cause.getClass().getSimpleName() + (message == null ? "" : ": " + message);
}
/** If a prior run cached this step's result within its TTL, mark it a cache hit instead of running it. */
From 388e641e1fc1520a37bd6e03baa245aa08da9673 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sun, 5 Jul 2026 09:29:10 +0530
Subject: [PATCH 3/9] chore(java): publish processor, test-support and spring
---
sdks/java/processor/build.gradle.kts | 29 +++++++++++++++++++++++++
sdks/java/spring/build.gradle.kts | 29 +++++++++++++++++++++++++
sdks/java/test-support/build.gradle.kts | 29 +++++++++++++++++++++++++
3 files changed, 87 insertions(+)
diff --git a/sdks/java/processor/build.gradle.kts b/sdks/java/processor/build.gradle.kts
index 4e53dc47..c1bbb822 100644
--- a/sdks/java/processor/build.gradle.kts
+++ b/sdks/java/processor/build.gradle.kts
@@ -5,11 +5,40 @@ plugins {
`java-library`
checkstyle
id("com.diffplug.spotless") version "7.2.1"
+ id("com.vanniktech.maven.publish") version "0.37.0"
}
group = "org.byteveda"
version = "0.18.0"
+mavenPublishing {
+ publishToMavenCentral()
+ signAllPublications()
+ coordinates(group.toString(), "taskito-processor", version.toString())
+ pom {
+ name.set("Taskito Processor")
+ description.set("Compile-time annotation processor generating task-handler bindings for the Taskito JVM SDK.")
+ url.set("https://github.com/ByteVeda/taskito")
+ licenses {
+ license {
+ name.set("MIT")
+ url.set("https://opensource.org/licenses/MIT")
+ }
+ }
+ developers {
+ developer {
+ id.set("byteveda")
+ name.set("ByteVeda")
+ }
+ }
+ scm {
+ url.set("https://github.com/ByteVeda/taskito")
+ connection.set("scm:git:https://github.com/ByteVeda/taskito.git")
+ developerConnection.set("scm:git:ssh://git@github.com/ByteVeda/taskito.git")
+ }
+ }
+}
+
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
diff --git a/sdks/java/spring/build.gradle.kts b/sdks/java/spring/build.gradle.kts
index 8e15e366..ae028ba0 100644
--- a/sdks/java/spring/build.gradle.kts
+++ b/sdks/java/spring/build.gradle.kts
@@ -4,12 +4,41 @@ plugins {
`java-library`
checkstyle
id("com.diffplug.spotless") version "7.2.1"
+ id("com.vanniktech.maven.publish") version "0.37.0"
}
group = "org.byteveda"
version = "0.18.0"
+mavenPublishing {
+ publishToMavenCentral()
+ signAllPublications()
+ coordinates(group.toString(), "taskito-spring", version.toString())
+ pom {
+ name.set("Taskito Spring")
+ description.set("Spring Boot 3 starter that auto-configures a Taskito bean.")
+ url.set("https://github.com/ByteVeda/taskito")
+ licenses {
+ license {
+ name.set("MIT")
+ url.set("https://opensource.org/licenses/MIT")
+ }
+ }
+ developers {
+ developer {
+ id.set("byteveda")
+ name.set("ByteVeda")
+ }
+ }
+ scm {
+ url.set("https://github.com/ByteVeda/taskito")
+ connection.set("scm:git:https://github.com/ByteVeda/taskito.git")
+ developerConnection.set("scm:git:ssh://git@github.com/ByteVeda/taskito.git")
+ }
+ }
+}
+
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
diff --git a/sdks/java/test-support/build.gradle.kts b/sdks/java/test-support/build.gradle.kts
index 033124d5..3f175f43 100644
--- a/sdks/java/test-support/build.gradle.kts
+++ b/sdks/java/test-support/build.gradle.kts
@@ -5,11 +5,40 @@ plugins {
`java-library`
checkstyle
id("com.diffplug.spotless") version "7.2.1"
+ id("com.vanniktech.maven.publish") version "0.37.0"
}
group = "org.byteveda"
version = "0.18.0"
+mavenPublishing {
+ publishToMavenCentral()
+ signAllPublications()
+ coordinates(group.toString(), "taskito-test", version.toString())
+ pom {
+ name.set("Taskito Test Support")
+ description.set("In-memory queue backend for testing Taskito JVM applications without the native runtime.")
+ url.set("https://github.com/ByteVeda/taskito")
+ licenses {
+ license {
+ name.set("MIT")
+ url.set("https://opensource.org/licenses/MIT")
+ }
+ }
+ developers {
+ developer {
+ id.set("byteveda")
+ name.set("ByteVeda")
+ }
+ }
+ scm {
+ url.set("https://github.com/ByteVeda/taskito")
+ connection.set("scm:git:https://github.com/ByteVeda/taskito.git")
+ developerConnection.set("scm:git:ssh://git@github.com/ByteVeda/taskito.git")
+ }
+ }
+}
+
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
From e6db2875310aaba5fc347b5f70b1875edf4d51dd Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sun, 5 Jul 2026 09:42:14 +0530
Subject: [PATCH 4/9] fix(java): serialize lazy workflow storage init
Concurrent first workflow calls could run the DDL migrations in
parallel and hit Postgres catalog races.
---
crates/taskito-java/src/backend.rs | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/crates/taskito-java/src/backend.rs b/crates/taskito-java/src/backend.rs
index 94e77a99..b40addc2 100644
--- a/crates/taskito-java/src/backend.rs
+++ b/crates/taskito-java/src/backend.rs
@@ -20,6 +20,11 @@ pub struct QueueHandle {
/// runs the workflow-table migrations). Shares this queue's connection pool.
#[cfg(feature = "workflows")]
pub workflow_storage: std::sync::OnceLock,
+ /// Serializes the lazy init above: the constructor runs DDL migrations, and
+ /// concurrent `CREATE TABLE/INDEX IF NOT EXISTS` from separate sessions can
+ /// race in Postgres's catalog, failing one thread's first workflow call.
+ #[cfg(feature = "workflows")]
+ workflow_init: std::sync::Mutex<()>,
}
#[cfg(feature = "workflows")]
@@ -31,8 +36,16 @@ impl QueueHandle {
if let Some(wf) = self.workflow_storage.get() {
return Ok(wf.clone());
}
+ // A poisoned lock only means another thread panicked mid-init; the
+ // OnceLock still tells the truth, so recover and retry the init.
+ let _init = self
+ .workflow_init
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ if let Some(wf) = self.workflow_storage.get() {
+ return Ok(wf.clone()); // another thread won the race while we waited
+ }
let wf = build_workflow_storage(&self.storage)?;
- // A racing thread's handle wraps the same pool, so either is fine.
let _ = self.workflow_storage.set(wf.clone());
Ok(wf)
}
@@ -110,5 +123,7 @@ pub fn open(options: OpenOptions) -> Result {
namespace: options.namespace,
#[cfg(feature = "workflows")]
workflow_storage: std::sync::OnceLock::new(),
+ #[cfg(feature = "workflows")]
+ workflow_init: std::sync::Mutex::new(()),
})
}
From 07517f119f89daaa87034f06a83065bdf69d9de3 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sun, 5 Jul 2026 09:43:35 +0530
Subject: [PATCH 5/9] fix(java): dedup unique keys in batch enqueue
enqueue_batch is a raw insert, so an active duplicate uniqueKey
violated the partial unique index and rolled back the whole batch.
---
crates/taskito-java/src/backend.rs | 82 ++++++++++++++++++-
crates/taskito-java/src/error.rs | 9 ++
crates/taskito-java/src/ffi_c.rs | 6 +-
crates/taskito-java/src/queue/mod.rs | 10 ++-
.../java/org/byteveda/taskito/QueueTest.java | 28 +++++++
5 files changed, 126 insertions(+), 9 deletions(-)
diff --git a/crates/taskito-java/src/backend.rs b/crates/taskito-java/src/backend.rs
index b40addc2..955927e2 100644
--- a/crates/taskito-java/src/backend.rs
+++ b/crates/taskito-java/src/backend.rs
@@ -1,6 +1,6 @@
//! Construct a [`QueueHandle`] from caller options (mirrors the Node shell).
-use taskito_core::{SqliteStorage, StorageBackend};
+use taskito_core::{NewJob, SqliteStorage, Storage, StorageBackend};
use crate::convert::OpenOptions;
use crate::error::BindingError;
@@ -73,6 +73,40 @@ fn build_workflow_storage(
Ok(wf)
}
+/// Enqueue a batch, routing any job with a `unique_key` through the dedup path
+/// (matching single `enqueue`) instead of the raw batch insert. The raw insert
+/// would hit the partial unique index on an active duplicate and roll back the
+/// whole batch; here a duplicate resolves to the existing job's id, and only
+/// keyless jobs share the one-transaction fast path. Ids come back in input order.
+pub fn enqueue_batch_dedup(
+ storage: &StorageBackend,
+ jobs: Vec,
+) -> Result, BindingError> {
+ let mut ids: Vec