diff --git a/java/API.md b/java/API.md index bd5a9fe23..92a6ae2ce 100644 --- a/java/API.md +++ b/java/API.md @@ -6,8 +6,8 @@ Java exposes every Go helper. Stored values and protocol behavior still follow G | Go surface | Java surface | Reason for the shape | | --- | --- | --- | -| `NewClient`, `Config`, `Start`, `Stop`, `StopAndCancel` | `Client`, `Workers.Builder`, `start`, `stop`, `stopAndCancel` | A client can insert without starting anything. A running `Workers` owns threads and implements `AutoCloseable`. | -| `JobArgs`, `Job[T]`, worker interfaces and registration | `JobType`, `Job`, `add(type, lambda)` | Records need no River interface; explicit kinds stay stable when Java classes are renamed. | +| `NewClient`, `Config`, `Start`, `Stop`, `StopAndCancel` | `Client.builder`, `Client.start`, `RunHandle.stop/stopAndCancel` | A client can insert without starting anything. A `RunHandle` owns one execution and implements `AutoCloseable`. | +| `JobArgs`, `Job[T]`, worker interfaces and registration | `JobType`, `Job`, `Workers.add(type, lambda)`, `Client.Builder.worker` | Records need no River interface; explicit kinds stay stable when Java classes are renamed. | | `Insert`, `InsertTx`, `InsertMany`, `InsertManyTx` | `insert`, `insertMany`, connection-first overloads | Arguments are typed; results contain persisted `Job` rows, including cross-kind duplicates. Mixed batches use `JobType.submission`. | | `InsertOpts`, `UniqueOpts`, struct tags | `InsertOptions`, `Unique`, `JobType.uniqueBy` | Named builders and fluent options avoid positional flags. Explicit JSON paths replace struct tags. | | `JobGet`, `JobList`, cursor/order/filter params | `get`, `JobQuery`, `JobQuery.Page` | Raw reads use `JsonNode`; `get(id, type)` checks the kind and decodes a known argument type. Pagination remains explicit. | @@ -19,10 +19,10 @@ Java exposes every Go helper. Stored values and protocol behavior still follow G | `ResumableSetStepTx`, `ResumableSetStepCursorTx` | `WorkContext.checkpoint(connection[, cursor])` | Checkpoints commit with application writes; failed transactions leave the last committed progress intact. | | Worker timeout | `Workers.Handler.timeout(job)` | A default method preserves lambda handlers; per-job overrides also govern rescue decisions. | | Retry policy | `RetryPolicy` | A lambda receives the full job and returns `Duration`; the default uses the same error-count backoff and jitter. | -| `Queues`, queue CRUD/control APIs | `Workers.addQueue/removeQueue`, `Client.queues()` | Runtime concurrency and persisted queue state have separate owners. Reads and mutations support caller transactions. | -| `Subscribe`, `EventKind`, `Event` | `Workers.subscribe`, `EventKind`, `Event`, `Subscription` | Enum filters and an `AutoCloseable` subscription replace channels. Callbacks are synchronous; queue events include their queue. | -| Periodic jobs and schedules | `Workers.Builder.periodic`, `Workers.periodicJobs()`, `PeriodicJobBundle`, `PeriodicJob`, `Schedule` | Java time types and a functional scheduling interface; cron implementation details are package-private. | -| Leadership notifications and maintenance config | `requestResign`, worker builder settings | Coordination stays in the database; operational durations use `Duration`. | +| `Queues`, queue CRUD/control APIs | `RunHandle.addQueue/removeQueue`, `Client.queues()` | Runtime concurrency and persisted queue state have separate owners. Reads and mutations support caller transactions. | +| `Subscribe`, `EventKind`, `Event` | `Client.subscribe`, `EventKind`, `Event`, `Subscription` | Enum filters and an `AutoCloseable` subscription replace channels. Callbacks are synchronous; queue events include their queue. | +| Periodic jobs and schedules | `Client.Builder.periodic`, `RunHandle.periodicJobs()`, `PeriodicJobBundle`, `PeriodicJob`, `Schedule` | Java time types and a functional scheduling interface; cron implementation details are package-private. | +| Leadership notifications and maintenance config | `requestResign`, client builder settings | Coordination stays in the database; operational durations use `Duration`. | | `rivermigrate` | `Migrator`, `Direction`, `Options`, executable CLI | Named options replace booleans and sentinel target versions. Down defaults to one migration; explicit target zero removes the line. | | Driver, pool, transaction integration | `Database`, JDBC `DataSource`/`Connection` | Use existing Java database pools. Database drivers remain optional library dependencies. | | Errors and row types | `RiverException.Code`, `Job.State`, nested result records | Exceptions carry failures; enums model closed sets; records keep related values together. Missing rows throw `NOT_FOUND`. | @@ -37,7 +37,7 @@ Java exposes every Go helper. Stored values and protocol behavior still follow G - Added the common `Plugin` marker and `withPlugins` registration to install all of an implementation's hooks and middleware together. Renamed the internal Pro storage interface and registration from `Plugin`/`withPlugin` to `Pilot`/`withPilot`. -- Removed no-op `Client.close()`. Close `Workers` and the application-owned pool. +- Removed no-op `Client.close()`. Close the `RunHandle` returned by `Client.start()` and the application-owned pool. - Added typed retrieval and typed transactional completion, including running-state validation. - Unified bulk insertion around typed homogeneous lists and mixed-kind submissions, with both owned and caller-owned transactions. @@ -79,8 +79,9 @@ configuration code. - `JobType.uniqueBy("account_id", "address")` selects top-level JSON fields without nested lists. The existing component-list overload handles nested paths and retains literal dots in field names. -- Reusing a worker builder generates a separate runtime ID on each `start()`; - an explicit `id` remains the caller's responsibility to keep unique. +- Reusing a client builder generates a separate client ID on each `build()`. + Restarting one client retains its ID; an explicit `id` remains the caller's + responsibility to keep unique. - Observer and subscriber failures are reported without losing a worker slot, changing the attempt outcome, or preventing delivery to the remaining callbacks. Failures in the error handler fall back to the system logger. These callbacks diff --git a/java/CHANGELOG.md b/java/CHANGELOG.md index 8f440d240..4dcd04adc 100644 --- a/java/CHANGELOG.md +++ b/java/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Breaking:** Move runtime configuration to `Client.builder(database)` and startup to `Client.start()`, which returns an `AutoCloseable` `RunHandle` for `stop()`, `stopAndCancel()`, and runtime queue/periodic controls. `Workers` is now a handler registry supplied through `.workers(registry)`; `.worker(type, handler)` supports inline registration. Move subscriptions and event types to `Client`, allowing subscriptions before startup. A client allows one active run and can restart after shutdown completes. [PR #1555](https://github.com/riverqueue/river/pull/1555). + ## [0.1.0] - 2026-10-26 ### Changed diff --git a/java/DIFFERENCES.md b/java/DIFFERENCES.md index da02bb4a9..616285f59 100644 --- a/java/DIFFERENCES.md +++ b/java/DIFFERENCES.md @@ -19,9 +19,14 @@ - Transaction variants use connection-first overloads instead of `Tx` suffixes. Storage failures are unchecked `RiverException` values with a stable code and original cause; JDBC callbacks may throw checked exceptions. +- `Client.start()` returns an `AutoCloseable` `RunHandle`; shutdown is on that + handle, as in JavaScript and Rust, instead of on `Client` as in Go and Ruby. + `Workers` only registers handlers. Each client permits one active run and can + restart after shutdown completes; an old handle cannot stop a later run. - Runtime events use enum kinds and are process-local callbacks. They are not a durable delivery mechanism. A callback should finish quickly, and callback failures are sent to - the runtime error handler. + the runtime error handler. Subscribe through `Client` before startup to avoid + missing early events; subscriptions survive restarts until closed. - Retry policies return relative `Duration` values. Exceptions and null, negative, or overflowing retry delays are reported and fall back to the default backoff. - Explicit retries that update a job send a standard insert notification in the @@ -63,9 +68,9 @@ replace process-wide insert defaults. Insertion is independent of worker registration so a client can enqueue jobs owned by another language. New job kinds always use Go's validated kind syntax; -the legacy bypass is omitted. `Workers.stop()` (also called by `close()`) and -`Workers.stopAndCancel()` wait for attempts to finish instead of exposing a -Go-style stopped channel. `Workers.Builder.stopTimeout` configures their waits. +the legacy bypass is omitted. `RunHandle.stop()` (also called by `close()`) and +`RunHandle.stopAndCancel()` wait for attempts to finish instead of exposing a +Go-style stopped channel. `Client.Builder.stopTimeout` configures their waits. The [feature inventory](conformance/feature-inventory.json) accounts for all 304 entries in the pinned upstream inventory, including internal driver details diff --git a/java/README.md b/java/README.md index 93994441a..be5343b83 100644 --- a/java/README.md +++ b/java/README.md @@ -36,6 +36,10 @@ For SQLite, replace the Postgres JDBC dependency with `org.xerial:sqlite-jdbc:3. ## Quick start +The lifecycle API below is unreleased. For the published `0.1.0` API, see +[its README](https://github.com/riverqueue/river/blob/java/v0.1.0/java/README.md). +Build from source to use these examples. + Define job arguments as a record, give the job a stable kind, and register a worker lambda. This complete `Example.java` starts a client, inserts a job, waits for its completion, and stops the workers. Set `DATABASE_URL` to a @@ -51,18 +55,17 @@ public class Example { public static void main(String[] args) throws Exception { var database = Database.connect(System.getenv("DATABASE_URL")); - var client = new Client(database); new Migrator(database).migrate(); - var completed = new CompletableFuture(); - try (var workers = client.workers() - .queue("default", 10) + var workers = new Workers() .add(JobType.of("send_email", SendEmail.class), context -> { System.out.println("Sending " + context.args().subject() + " to " + context.args().address()); context.output("sent"); - }) - .start(); - var subscription = workers.subscribe(completed::complete, Workers.EventKind.JOB_COMPLETED)) { + }); + var client = Client.builder(database).queue("default", 10).workers(workers).build(); + var completed = new CompletableFuture(); + try (var subscription = client.subscribe(completed::complete, Client.EventKind.JOB_COMPLETED); + var run = client.start()) { client.insert(JobType.of("send_email", SendEmail.class), new SendEmail("hello@example.com", "Welcome")); System.out.println("Completed job " + completed.get(30, TimeUnit.SECONDS).job().id()); @@ -73,7 +76,7 @@ public class Example { Run this demonstration against a development database with no other workers. The worker prints a message; replace its body with your mail service. In a -service, keep `Workers` open until the application stops. Each active job runs +service, keep the `RunHandle` open until the application stops. Each active job runs on a virtual thread; queue limits bound concurrent jobs. In production, run migrations as a deployment step using the CLI below. @@ -263,7 +266,7 @@ selected state, or `SCHEDULED_AT` or `FINALIZED_AT` for an explicit timestamp. A thrown exception triggers [retries](https://riverqueue.com/docs/job-retries) until `maxAttempts` is exhausted, when the job becomes discarded. The default policy uses River's quartic backoff with jitter. Configure a different policy -on the worker builder, or explicitly retry an existing job with `client.retry(id)`. +on the client builder, or explicitly retry an existing job with `client.retry(id)`. An explicit retry that updates a job notifies its queue after commit. Use `client.retry(connection, id)` to make the retry part of an existing transaction. A retry policy receives the full job snapshot before the current failure is @@ -274,15 +277,16 @@ time, River reports the problem and uses its default backoff. ```java var retryingEmail = email.withDefaults(InsertOptions.builder().maxAttempts(10).build()); -var workerConfig = client.workers() +var clientBuilder = Client.builder(database) .queue("default", 10) .retryPolicy(job -> Duration.ofSeconds(Math.min(300, 5L * (job.errors().size() + 1)))) - .add(retryingEmail, context -> + .worker(retryingEmail, context -> mailer.send(context.args().address(), context.args().subject())); client.insert(retryingEmail, new SendEmail("hello@example.com", "Welcome")); ``` -Call `workerConfig.start()` and keep the runtime open as in the first example. +Call `clientBuilder.build()` to create the configured client, then call its +`start()` method and keep the returned handle open as in the first example. Job-type defaults apply when inserting through that `JobType`; another producer must set its own compatible options. @@ -292,14 +296,15 @@ These sections follow the non-Pro entries in the River documentation's [Features sidebar](https://riverqueue.com/docs). Snippets are independent and reuse `client` and `database` from the quick start, with imports from `com.riverqueue`, `java.time`, and `java.util`. Handler examples configure a builder; -start it after registering your handlers. `mailer`, `accounts`, and `deliveries` +call `build()` after registering your handlers, then start that client. In runtime +examples, `run` is the `RunHandle` returned by `client.start()`. `mailer`, `accounts`, and `deliveries` are application services, `applicationDataSource` is your JDBC connection pool, `application.awaitStop()` waits for your service to stop, and `jobId` is an existing job's ID. ```java var email = JobType.of("send_email", SendEmail.class); -var workerConfig = client.workers().queue("default", 10); +var clientBuilder = Client.builder(database).queue("default", 10); ``` ### Cancelling jobs @@ -312,7 +317,7 @@ this ends the attempt without further retries. ```java client.cancel(jobId); -workerConfig.add(email, context -> { +clientBuilder.worker(email, context -> { context.checkCancelled(); if (context.args().address().isBlank()) context.cancel("Missing address"); mailer.send(context.args().address(), context.args().subject()); @@ -326,12 +331,12 @@ requested. Call `context.checkCancelled()` when abandoning unfinished work. ### Worker timeouts -`Workers.Builder.jobTimeout` sets the default attempt timeout. Override +`Client.Builder.jobTimeout` sets the default attempt timeout. Override `Handler.timeout(job)` for a particular worker; the callback receives typed arguments and can choose a duration per job: ```java -workerConfig.add(email, new Workers.Handler() { +clientBuilder.worker(email, new Workers.Handler() { @Override public Duration timeout(Job job) { return Duration.ofMinutes(5); @@ -358,7 +363,7 @@ enqueueing follow-up work that must commit with other writes or job completion. ```java record Welcome(String address) {} var welcome = JobType.of("welcome", Welcome.class); -workerConfig.add(welcome, context -> +clientBuilder.worker(welcome, context -> context.client().insert(email, new SendEmail(context.args().address(), "Welcome"))); ``` @@ -367,7 +372,7 @@ workerConfig.add(welcome, context -> Java exceptions and uncaught `Error`s are handled as failed attempts; River persists failures and retries according to policy. For [application error reporting](https://riverqueue.com/docs/error-handling), attach -a `HookWorkEnd` hook. `Workers.Builder.errorHandler` separately receives +a `HookWorkEnd` hook. `Client.Builder.errorHandler` separately receives runtime failures, such as database or subscriber errors. Failures in observers or event subscribers are reported without changing the job's outcome or preventing other callbacks from running. If the runtime error @@ -381,12 +386,11 @@ HookWorkEnd reportFailure = (context, failure) -> { } return failure; }; -var monitoredClient = client.withPlugins(reportFailure); -var workerConfig = monitoredClient.workers() +var clientBuilder = Client.builder(database).plugins(reportFailure) .queue("default", 10) .errorHandler(failure -> System.getLogger("river").log(System.Logger.Level.ERROR, "Runtime failure", failure)) - .add(email, context -> mailer.send(context.args().address(), context.args().subject())); + .worker(email, context -> mailer.send(context.args().address(), context.args().subject())); ``` Hooks also see the control exceptions used for snoozing and cancellation. Keep @@ -482,7 +486,7 @@ middleware yet. You can explicitly write that shared format; this example appends a fixed message and retains the last ten attempt entries. ```java -workerConfig.add(email, context -> { +clientBuilder.worker(email, context -> { var logs = new ArrayList(); context.job().metadata().path("river:log").forEach(logs::add); try { @@ -505,16 +509,16 @@ different workloads. Set a queue at insertion and configure its worker count. The count is local to each runtime; it is not a cluster-wide concurrency limit. ```java -var workerConfig = client.workers() +var clientBuilder = Client.builder(database) .queue("default", 10) .queue("mail", 4) - .add(email, context -> mailer.send(context.args().address(), context.args().subject())); + .worker(email, context -> mailer.send(context.args().address(), context.args().subject())); client.insert(email, new SendEmail("hello@example.com", "Welcome"), InsertOptions.builder().queue("mail").build()); ``` -A running runtime also supports `workers.addQueue(name, count)` and -`workers.removeQueue(name)`, which drains that queue's active attempts. +A running handle also supports `run.addQueue(name, count)` and +`run.removeQueue(name)`, which drains that queue's active attempts. Use `fetchOnlyKnownKinds(true)` when sharing queues with other language workers that handle different kinds. @@ -550,9 +554,9 @@ Time-zone rules come from the JVM; schedules computed by River clients in other languages match only when they use the same time-zone data. ```java -var workerConfig = client.workers() +var clientBuilder = Client.builder(database) .queue("default", 10) - .add(email, context -> mailer.send(context.args().address(), context.args().subject())) + .worker(email, context -> mailer.send(context.args().address(), context.args().subject())) .periodic("hourly-status", Schedule.every(Duration.ofHours(1)), email, new SendEmail("ops@example.com", "Hourly status"), InsertOptions.defaults(), true) .periodic("daily-report", Schedule.cron("CRON_TZ=America/Chicago 0 9 * * *"), email, @@ -560,16 +564,16 @@ var workerConfig = client.workers() ``` Periodic IDs use the same syntax as job kinds and must fit within 127 UTF-8 bytes. -Use `null` for an anonymous registration. `workers.periodicJobs().add(...)` +Use `null` for an anonymous registration. `run.periodicJobs().add(...)` returns a handle for removing that registration. Builder registration returns no handle, so an anonymous definition added through the builder can only be removed -with `workers.periodicJobs().clear()`. +with `run.periodicJobs().clear()`. OSS schedules are kept in memory and can miss occurrences during downtime or leader changes. Add or remove definitions on a running runtime: ```java -var periodicJobs = workers.periodicJobs(); // PeriodicJobBundle +var periodicJobs = run.periodicJobs(); // PeriodicJobBundle var handle = periodicJobs.add(new PeriodicJob<>( "hourly-status", Schedule.every(Duration.ofHours(1)), email, new SendEmail("ops@example.com", "Hourly status"), InsertOptions.defaults(), true)); @@ -592,7 +596,7 @@ The bundle also provides `addMany`, `removeMany`, `removeManyById`, and `clear`. `addMany` validates the entire batch before registering any jobs and returns handles in input order. Handles identify one registration, so removing an old handle cannot remove a replacement that reused the same ID. The same -`PeriodicJob` definition can be passed to `Workers.Builder.periodic` before start. +`PeriodicJob` definition can be passed to `Client.Builder.periodic` before start. Interval schedules retain their planned cadence when maintenance runs late. Each job's `scheduled_at` defaults to its planned occurrence; an explicit @@ -605,7 +609,7 @@ Each job's `scheduled_at` defaults to its planned occurrence; an explicit River UI can read the result once the attempt finishes. ```java -workerConfig.add(email, context -> { +clientBuilder.worker(email, context -> { mailer.send(context.args().address(), context.args().subject()); context.output(Map.of("delivered", true, "address", context.args().address())); }); @@ -620,7 +624,7 @@ completed before an earlier failure. Keep step names and ordering stable across deployments and languages; anything outside a step runs on every attempt. ```java -workerConfig.add(email, context -> { +clientBuilder.worker(email, context -> { context.step("send", () -> mailer.send(context.args().address(), context.args().subject())); context.step("record-delivery", () -> deliveries.record(context.job().id())); @@ -636,7 +640,7 @@ Use `context.checkpoint(connection)` inside a step to persist its progress in th same transaction as application writes: ```java -workerConfig.add(email, context -> { +clientBuilder.worker(email, context -> { context.step("record-delivery", () -> context.transaction(connection -> { deliveries.record(connection, context.job().id()); context.checkpoint(connection); @@ -676,7 +680,7 @@ consuming a retry. `context.snooze` ends the current attempt and schedules the same job to run again; code after the call does not execute on that attempt. ```java -workerConfig.add(email, context -> { +clientBuilder.worker(email, context -> { if (!mailer.isReady()) context.snooze(Duration.ofMinutes(5)); mailer.send(context.args().address(), context.args().subject()); }); @@ -684,20 +688,22 @@ workerConfig.add(email, context -> { ### Subscriptions -[Subscribe](https://riverqueue.com/docs/subscriptions) to events from a running -`Workers` instance. Callbacks are process-local, run synchronously, and should -finish quickly. They are not a durable stream of all events in the cluster. +[Subscribe](https://riverqueue.com/docs/subscriptions) to events on a `Client`. +Subscribe before `start()` to include events from immediately processed jobs. +Subscriptions survive restarts of that client until closed. Callbacks are +process-local, run synchronously, and should finish quickly. They are not a durable stream of all events in the cluster. Close the subscription to unregister it. ```java -try (var subscription = workers.subscribe( +try (var subscription = client.subscribe( event -> System.out.println("Completed job " + event.job().id()), - Workers.EventKind.JOB_COMPLETED)) { + Client.EventKind.JOB_COMPLETED); + var run = client.start()) { application.awaitStop(); } ``` -Event kinds are `Workers.EventKind` values. Omit the filter to receive every kind. +Event kinds are `Client.EventKind` values. Omit the filter to receive every kind. Job events carry `event.job()`; queue events carry `event.queue()` and a null job. The enum's `value()` retains River's lower-case protocol spelling. @@ -705,7 +711,7 @@ The enum's `value()` retains River's lower-case protocol spelling. Use [isolated database tests](https://riverqueue.com/docs/testing) with JUnit and signals instead of sleeps. This complete test method uses a fresh SQLite file, -subscribes before inserting, and waits for committed completion. Add the SQLite +subscribes before starting, and waits for committed completion. Add the SQLite JDBC driver and JUnit Jupiter to the test classpath. ```java @@ -715,16 +721,14 @@ void completes(@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory) t var echo = JobType.of("echo", Echo.class); var database = Database.connect("jdbc:sqlite:" + directory.resolve("river.db")); new Migrator(database).migrate(); - var client = new Client(database); - var completed = new java.util.concurrent.CompletableFuture(); - - try (var workers = client.workers() + var client = Client.builder(database) .queue("default", 1) - .add(echo, context -> context.output(context.args().message())) - .start(); - var subscription = workers.subscribe(event -> { - if (event.kind() == Workers.EventKind.JOB_COMPLETED) completed.complete(event); - })) { + .worker(echo, context -> context.output(context.args().message())) + .build(); + var completed = new java.util.concurrent.CompletableFuture(); + + try (var subscription = client.subscribe(completed::complete, Client.EventKind.JOB_COMPLETED); + var run = client.start()) { var inserted = client.insert(echo, new Echo("hello")); var event = completed.get(5, java.util.concurrent.TimeUnit.SECONDS); org.junit.jupiter.api.Assertions.assertEquals(inserted.job().id(), event.job().id()); @@ -734,8 +738,8 @@ void completes(@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory) t } ``` -`new Client(database, clock)` accepts an injected `Clock` for deterministic insert -timestamps. The worker runtime uses wall time. Java does not currently provide +`Client.builder(database).clock(clock)` and `new Client(database, clock)` accept +an injected `Clock` for deterministic insert timestamps. The worker runtime uses wall time. Java does not currently provide a dedicated worker-test harness. ### Transactional job completion @@ -746,7 +750,7 @@ rolls back, neither change survives. Return from the handler after committing; external effects such as sending email cannot participate in a JDBC transaction. ```java -workerConfig.add(email, context -> { +clientBuilder.worker(email, context -> { context.transaction(connection -> { deliveries.record(connection, context.job().id()); context.output(Map.of("recorded", true)); @@ -786,7 +790,7 @@ registration, and behavior together without a worker subclass for every kind. ```java Workers.Handler sendEmail = context -> mailer.send(context.args().address(), context.args().subject()); -workerConfig.add(email, sendEmail); +clientBuilder.worker(email, sendEmail); ``` ## SQLite @@ -820,9 +824,22 @@ var jobsClient = new Client(jobsDatabase); ## Stopping workers +`Workers` is a handler registry; it has no lifecycle methods. `Client.start()` +returns the `RunHandle` that owns one execution. Only one run can be active on a +client, including while a timed-out stop is draining. After `stop()` succeeds, +that client can be started again with the same client ID and original settings. +An old handle never stops a later run. Dynamic queue and periodic registrations +belong to their run and are reset from client configuration on restart. + +Registries and builders are mutable configuration objects. `workers(registry)` +copies registrations, and `build()` snapshots settings. `client.toBuilder()` +creates configuration for an independent client; `withHooks`, `withMiddleware`, +and `withPlugins` likewise return new clients. Configure them before starting, +and use the configured client for same-process insert wakeups. + For [graceful stopping](https://riverqueue.com/docs/graceful-shutdown), -`workers.stop()` stops fetching and waits for active attempts. `close()` does the -same, making try-with-resources convenient. `workers.stopAndCancel()` also +`run.stop()` stops fetching and waits for active attempts. `close()` does the +same, making try-with-resources convenient. `run.stopAndCancel()` also requests cancellation of active attempts. Both wait for handlers to return. A graceful-stop timeout leaves attempts running; call `stop()` again to keep waiting or `stopAndCancel()` to request cancellation. Attempt timeouts continue @@ -836,15 +853,16 @@ propagate `InterruptedException`; shutdown interruption requeues the job without consuming an attempt. `awaitCancellation()` only waits for the signal. ```java -var workers = client.workers() +var client = Client.builder(database) .queue("default", 10) .stopTimeout(Duration.ofSeconds(30)) - .add(email, context -> mailer.send(context.args().address(), context.args().subject())) - .start(); + .worker(email, context -> mailer.send(context.args().address(), context.args().subject())) + .build(); +var run = client.start(); try { application.awaitStop(); } finally { - workers.stop(); + run.stop(); } ``` @@ -868,16 +886,16 @@ rescue candidate even when they exceed `rescueAfter`. Worker durations and snooz delays must fit in signed 64-bit nanoseconds, up to about 292 years. ```java -var workerConfig = client.workers() +var clientBuilder = Client.builder(database) .queue("default", 10) .retention(Duration.ofDays(1), Duration.ofDays(1), Duration.ofDays(7)) - .add(email, context -> mailer.send(context.args().address(), context.args().subject())); + .worker(email, context -> mailer.send(context.args().address(), context.args().subject())); ``` Retention arguments are cancelled, completed, and discarded durations, in that order. Negative durations retain that state indefinitely. If a runtime uses `leadership(false)`, another eligible client must run maintenance; periodic -registrations require leadership. `workers.isLeader()` reports the local status. +registrations require leadership. `run.isLeader()` reports the local status. Leadership leases last `serviceInterval` plus ten seconds, keeping the same renewal margin even when the interval is increased. `serviceInterval` also controls maintenance and queue heartbeats. It must be diff --git a/java/conformance/feature-inventory.json b/java/conformance/feature-inventory.json index 701cc1227..4894575d1 100644 --- a/java/conformance/feature-inventory.json +++ b/java/conformance/feature-inventory.json @@ -7,7 +7,7 @@ "id": "client.Driver", "rationale": "Unstable Go accessor for the internal driver seam.", "source": "client.go:river.Client.Driver", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -16,7 +16,7 @@ "id": "client.ID", "rationale": "Accessor for the configured or generated client ID (see config.ID).", "source": "client.go:river.Client.ID", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -29,7 +29,7 @@ "reference_insert_candidate_work" ], "source": "client.go:river.Client.Insert", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -41,7 +41,7 @@ "typed_batch_insertion" ], "source": "client.go:river.Client.InsertMany", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "not_applicable", @@ -50,7 +50,7 @@ "id": "client.InsertManyFast", "rationale": "Ports don't offer fast insertion yet; batches use ordinary typed insertion.", "source": "client.go:river.Client.InsertManyFast", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "not_applicable", @@ -59,7 +59,7 @@ "id": "client.InsertManyFastTx", "rationale": "Ports don't offer fast insertion yet; batches use ordinary typed insertion.", "source": "client.go:river.Client.InsertManyFastTx", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -71,7 +71,7 @@ "transactional_batch_insertion" ], "source": "client.go:river.Client.InsertManyTx", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -84,7 +84,7 @@ "transaction_rollback_visibility" ], "source": "client.go:river.Client.InsertTx", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -98,7 +98,7 @@ "remote_cancel_notification" ], "source": "client.go:river.Client.JobCancel", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -110,7 +110,7 @@ "transactional_cross_language_cancel" ], "source": "client.go:river.Client.JobCancelTx", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -122,7 +122,7 @@ "differential_job_crud" ], "source": "client.go:river.Client.JobDelete", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -134,7 +134,7 @@ "bulk_delete_safety" ], "source": "client.go:river.Client.JobDeleteMany", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -146,7 +146,7 @@ "transactional_crud_commit_rollback" ], "source": "client.go:river.Client.JobDeleteManyTx", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -158,7 +158,7 @@ "transactional_crud_commit_rollback" ], "source": "client.go:river.Client.JobDeleteTx", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -170,7 +170,7 @@ "differential_job_crud" ], "source": "client.go:river.Client.JobGet", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -182,7 +182,7 @@ "transactional_crud_commit_rollback" ], "source": "client.go:river.Client.JobGetTx", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -194,7 +194,7 @@ "differential_job_list_filters_and_cursors" ], "source": "client.go:river.Client.JobList", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -206,7 +206,7 @@ "transactional_crud_commit_rollback" ], "source": "client.go:river.Client.JobListTx", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -219,7 +219,7 @@ "differential_job_crud" ], "source": "client.go:river.Client.JobRetry", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -231,7 +231,7 @@ "transactional_crud_commit_rollback" ], "source": "client.go:river.Client.JobRetryTx", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -243,7 +243,7 @@ "differential_job_crud" ], "source": "client.go:river.Client.JobUpdate", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -255,7 +255,7 @@ "transactional_crud_commit_rollback" ], "source": "client.go:river.Client.JobUpdateTx", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -267,7 +267,7 @@ "mixed_request_resign_terms" ], "source": "client.go:river.Client.Notify", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -276,7 +276,7 @@ "id": "client.PeriodicJobs", "rationale": "Language-native API to add or remove periodic jobs at runtime; the enqueue behavior itself is classified under config.PeriodicJobs.", "source": "client.go:river.Client.PeriodicJobs", - "java_surface": "Workers.periodicJobs, PeriodicJobBundle" + "java_surface": "RunHandle.periodicJobs, PeriodicJobBundle" }, { "applicability": "not_applicable", @@ -285,7 +285,7 @@ "id": "client.Pilot", "rationale": "Unstable Go accessor for the extension seam.", "source": "client.go:river.Client.Pilot", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -297,7 +297,7 @@ "differential_queue_crud" ], "source": "client.go:river.Client.QueueGet", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -309,7 +309,7 @@ "transactional_queue_operations" ], "source": "client.go:river.Client.QueueGetTx", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -321,7 +321,7 @@ "differential_queue_crud" ], "source": "client.go:river.Client.QueueList", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -333,7 +333,7 @@ "transactional_queue_operations" ], "source": "client.go:river.Client.QueueListTx", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -345,7 +345,7 @@ "pause_resume_notification" ], "source": "client.go:river.Client.QueuePause", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -357,7 +357,7 @@ "transactional_queue_operations" ], "source": "client.go:river.Client.QueuePauseTx", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -369,7 +369,7 @@ "pause_resume_notification" ], "source": "client.go:river.Client.QueueResume", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -381,7 +381,7 @@ "transactional_queue_operations" ], "source": "client.go:river.Client.QueueResumeTx", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -393,7 +393,7 @@ "differential_queue_crud" ], "source": "client.go:river.Client.QueueUpdate", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -405,7 +405,7 @@ "transactional_queue_operations" ], "source": "client.go:river.Client.QueueUpdateTx", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -417,7 +417,7 @@ "dynamic_queue_add_reconfigure_remove" ], "source": "client.go:river.Client.Queues", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -426,7 +426,7 @@ "id": "client.Schema", "rationale": "Accessor for the configured schema (see config.Schema).", "source": "client.go:river.Client.Schema", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -438,7 +438,7 @@ "sqlite_runtime_lifecycle_shutdown" ], "source": "client.go:river.Client.Start", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -450,7 +450,7 @@ "sqlite_runtime_lifecycle_shutdown" ], "source": "client.go:river.Client.Stop", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -462,7 +462,7 @@ "ignored_cancellation_hard_abort" ], "source": "client.go:river.Client.StopAndCancel", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "api_equivalent", @@ -471,8 +471,8 @@ "id": "client.Stopped", "rationale": "Go channel closed when the client has fully stopped; other languages signal completion in their own idiom.", "source": "client.go:river.Client.Stopped", - "java_surface": "Client, Workers, Queues, Database", - "java_difference": "Workers.close and stop await shutdown directly; applications can run either through CompletableFuture if they need a completion handle." + "java_surface": "Client, RunHandle, Workers, Queues, Database", + "java_difference": "RunHandle.close and stop await shutdown directly; applications can run either through CompletableFuture if they need a completion handle." }, { "applicability": "api_equivalent", @@ -485,7 +485,7 @@ "sqlite_runtime_extensions_resumable_subscriptions" ], "source": "client.go:river.Client.Subscribe", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "not_applicable", @@ -494,7 +494,7 @@ "id": "client.SubscribeConfig", "rationale": "Go-specific variant of Subscribe that overrides the channel buffer size.", "source": "client.go:river.Client.SubscribeConfig", - "java_surface": "Client, Workers, Queues, Database" + "java_surface": "Client, RunHandle, Workers, Queues, Database" }, { "applicability": "not_applicable", @@ -503,7 +503,7 @@ "id": "config.AdvisoryLockPrefix", "rationale": "Copied into the periodic job enqueuer's configuration but not used to derive any lock key in this version; it has no persisted or cross-process effect to match.", "source": "client.go:river.Config.AdvisoryLockPrefix", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "protocol_visible", @@ -515,7 +515,7 @@ "maintenance_job_cleaner_retention" ], "source": "client.go:river.Config.CancelledJobRetentionPeriod", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "protocol_visible", @@ -527,7 +527,7 @@ "maintenance_job_cleaner_retention" ], "source": "client.go:river.Config.CompletedJobRetentionPeriod", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "protocol_visible", @@ -539,7 +539,7 @@ "maintenance_job_cleaner_retention" ], "source": "client.go:river.Config.DiscardedJobRetentionPeriod", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "api_equivalent", @@ -551,7 +551,7 @@ "error_handler_cancel_override" ], "source": "client.go:river.Config.ErrorHandler", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "api_equivalent", @@ -560,7 +560,7 @@ "id": "config.FetchCooldown", "rationale": "Per-client minimum interval between fetches (a throughput throttle), which also suppresses a client's repeated insert notification for a queue within the interval on every backend. Implementations expose an equivalent client-level knob with the same default and minimum. Rows are unaffected; the reference adapter's 1 ms setting keeps notification scenarios deterministic.", "source": "client.go:river.Config.FetchCooldown", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database", + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database", "java_difference": "Wakeups are coalesced; Java does not expose a separate fetch cooldown knob." }, { @@ -570,7 +570,7 @@ "id": "config.FetchOnlyKnownKinds", "rationale": "Restricts a client's claims to the kinds of its registered workers, including aliases, so jobs of other kinds stay available without using attempts. The filter is local to the claiming client; implementations expose an equivalent option, and the jobs it leaves are ordinary available rows any client may work.", "source": "client.go:river.Config.FetchOnlyKnownKinds", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "api_equivalent", @@ -583,7 +583,7 @@ "notification_only_wakeups" ], "source": "client.go:river.Config.FetchPollInterval", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "api_equivalent", @@ -605,7 +605,7 @@ "sqlite_runtime_attempted_by_ordering" ], "source": "client.go:river.Config.ID", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "internal", @@ -614,7 +614,7 @@ "id": "config.JobCleanerTimeout", "rationale": "Timeout for individual job cleaner queries; bounds local work only and changes no persisted outcome.", "source": "client.go:river.Config.JobCleanerTimeout", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "not_applicable", @@ -623,7 +623,7 @@ "id": "config.JobInsertMiddleware", "rationale": "Deprecated Go field superseded by Plugins. The insert-middleware concept is classified under extension.rivertype.JobInsertMiddleware.", "source": "client.go:river.Config.JobInsertMiddleware", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "api_equivalent", @@ -635,7 +635,7 @@ "stuck_job_detection" ], "source": "client.go:river.Config.JobStuckHandler", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "api_equivalent", @@ -647,7 +647,7 @@ "stuck_job_detection" ], "source": "client.go:river.Config.JobStuckThreshold", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "protocol_visible", @@ -659,7 +659,7 @@ "timeout_cancellation" ], "source": "client.go:river.Config.JobTimeout", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "protocol_visible", @@ -673,7 +673,7 @@ "sqlite_runtime_leader_election_disabled" ], "source": "client.go:river.Config.LeaderElectionDisabled", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "api_equivalent", @@ -682,8 +682,8 @@ "id": "config.Logger", "rationale": "Each implementation uses its own logging facility.", "source": "client.go:river.Config.Logger", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database", - "java_difference": "Uses System.Logger and Workers.Builder.errorHandler instead of a logger object on the client." + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database", + "java_difference": "Uses System.Logger and Client.Builder.errorHandler instead of a logger object on the client." }, { "applicability": "protocol_visible", @@ -696,7 +696,7 @@ "reference_insert_candidate_work" ], "source": "client.go:river.Config.MaxAttempts", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database", + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database", "java_difference": "Configure insert defaults on JobType instead of a process-wide client default." }, { @@ -721,7 +721,7 @@ "sqlite_runtime_periodic_scheduler" ], "source": "client.go:river.Config.PeriodicJobs", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "api_equivalent", @@ -747,7 +747,7 @@ "sqlite_runtime_poll_only_recovery" ], "source": "client.go:river.Config.PollOnly", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "protocol_visible", @@ -760,7 +760,7 @@ "dynamic_queue_add_reconfigure_remove" ], "source": "client.go:river.Config.Queues", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "protocol_visible", @@ -772,7 +772,7 @@ "maintenance_reindexer_skips_artifacts" ], "source": "client.go:river.Config.ReindexerIndexNames", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "protocol_visible", @@ -784,7 +784,7 @@ "maintenance_reindexer_skips_artifacts" ], "source": "client.go:river.Config.ReindexerSchedule", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database", + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database", "java_difference": "Supports a duration override or the default daily UTC schedule; arbitrary reindex cron schedules are not exposed." }, { @@ -794,7 +794,7 @@ "id": "config.ReindexerTimeout", "rationale": "Per-reindex operation timeout; bounds local work only.", "source": "client.go:river.Config.ReindexerTimeout", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "protocol_visible", @@ -808,7 +808,7 @@ "reference_process_kill_candidate_rescue" ], "source": "client.go:river.Config.RescueStuckJobsAfter", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "protocol_visible", @@ -821,7 +821,7 @@ "deterministic_retry_clock_rng" ], "source": "client.go:river.Config.RetryPolicy", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "protocol_visible", @@ -834,7 +834,7 @@ "custom_schema_reference_migrate_candidate_work" ], "source": "client.go:river.Config.Schema", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "api_equivalent", @@ -843,7 +843,7 @@ "id": "config.SkipJobKindValidation", "rationale": "Deprecated escape hatch that skips kind-format validation at insert time; implementations may offer an equivalent legacy-kind option.", "source": "client.go:river.Config.SkipJobKindValidation", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database", + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database", "java_difference": "New JobType values require the Go-compatible kind format; no legacy validation bypass is exposed." }, { @@ -856,7 +856,7 @@ "mixed_unknown_kind_error" ], "source": "client.go:river.Config.SkipUnknownJobCheck", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database", + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database", "java_difference": "Inserts are independent of worker registrations, which permits inserting jobs owned by other languages." }, { @@ -869,7 +869,7 @@ "hard_shutdown_soft_stop_classification" ], "source": "client.go:river.Config.SoftStopTimeout", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "not_applicable", @@ -878,7 +878,7 @@ "id": "config.Test", "rationale": "Go test-environment settings (time generator, unique enforcement toggle). Conformance drives time through the adapter's clock_set instead.", "source": "client.go:river.Config.Test", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "not_applicable", @@ -887,7 +887,7 @@ "id": "config.TestOnly", "rationale": "Go test-suite switch that removes startup jitter; not part of any production behavior.", "source": "client.go:river.Config.TestOnly", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "not_applicable", @@ -896,7 +896,7 @@ "id": "config.WorkerMiddleware", "rationale": "Deprecated Go field superseded by Plugins. The worker-middleware concept is classified under extension.rivertype.WorkerMiddleware.", "source": "client.go:river.Config.WorkerMiddleware", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "api_equivalent", @@ -905,7 +905,7 @@ "id": "config.Workers", "rationale": "Language-native worker registry mapping kinds to handlers.", "source": "client.go:river.Config.Workers", - "java_surface": "Workers.Builder, JobType, Hook, Middleware, Database" + "java_surface": "Client.Builder, JobType, Hook, Middleware, Database" }, { "applicability": "driver_specific", @@ -1773,7 +1773,7 @@ "id": "event_kind.job_cancelled", "rationale": "Local subscription event in each implementation's idiom; not yet asserted by a shared scenario.", "source": "event.go:river.EventKindJobCancelled", - "java_surface": "Workers.Event and Workers.subscribe" + "java_surface": "Client.Event and Client.subscribe" }, { "applicability": "api_equivalent", @@ -1785,7 +1785,7 @@ "sqlite_runtime_extensions_resumable_subscriptions" ], "source": "event.go:river.EventKindJobCompleted", - "java_surface": "Workers.Event and Workers.subscribe" + "java_surface": "Client.Event and Client.subscribe" }, { "applicability": "api_equivalent", @@ -1797,7 +1797,7 @@ "sqlite_runtime_extensions_resumable_subscriptions" ], "source": "event.go:river.EventKindJobFailed", - "java_surface": "Workers.Event and Workers.subscribe" + "java_surface": "Client.Event and Client.subscribe" }, { "applicability": "api_equivalent", @@ -1806,7 +1806,7 @@ "id": "event_kind.job_interrupted", "rationale": "Local subscription event in each implementation's idiom; not yet asserted by a shared scenario.", "source": "event.go:river.EventKindJobInterrupted", - "java_surface": "Workers.Event and Workers.subscribe" + "java_surface": "Client.Event and Client.subscribe" }, { "applicability": "api_equivalent", @@ -1815,7 +1815,7 @@ "id": "event_kind.job_snoozed", "rationale": "Local subscription event in each implementation's idiom; not yet asserted by a shared scenario.", "source": "event.go:river.EventKindJobSnoozed", - "java_surface": "Workers.Event and Workers.subscribe" + "java_surface": "Client.Event and Client.subscribe" }, { "applicability": "api_equivalent", @@ -1828,7 +1828,7 @@ "sqlite_runtime_remote_queue_subscription_events" ], "source": "event.go:river.EventKindQueuePaused", - "java_surface": "Workers.Event and Workers.subscribe" + "java_surface": "Client.Event and Client.subscribe" }, { "applicability": "api_equivalent", @@ -1841,7 +1841,7 @@ "sqlite_runtime_remote_queue_subscription_events" ], "source": "event.go:river.EventKindQueueResumed", - "java_surface": "Workers.Event and Workers.subscribe" + "java_surface": "Client.Event and Client.subscribe" }, { "applicability": "internal", @@ -2621,7 +2621,7 @@ "reserved_metadata_cross_engine" ], "source": "riverdriver/riverpgxv5/internal/dbsqlc/river_job.sql:JobCancel, riverdriver/riversqlite/internal/dbsqlc/river_job.sql:JobCancel", - "java_surface": "WorkContext, Workers, Client" + "java_surface": "WorkContext, RunHandle, Workers, Client" }, { "applicability": "protocol_visible", @@ -2634,7 +2634,7 @@ "reserved_metadata_cross_engine" ], "source": "client.go:river.Client.jobUpdate, recorded_output.go:river.RecordOutput, rivertype/river_type.go:rivertype.MetadataKeyOutput", - "java_surface": "WorkContext, Workers, Client" + "java_surface": "WorkContext, RunHandle, Workers, Client" }, { "applicability": "protocol_visible", @@ -2646,7 +2646,7 @@ "periodic_run_on_start" ], "source": "internal/maintenance/periodic_job_enqueuer.go:maintenance.PeriodicJobEnqueuer.insertParamsFromConstructor", - "java_surface": "WorkContext, Workers, Client" + "java_surface": "WorkContext, RunHandle, Workers, Client" }, { "applicability": "protocol_visible", @@ -2658,7 +2658,7 @@ "reserved_metadata_cross_engine" ], "source": "riverlog/river_log.go:riverlog.Middleware.Work, riverlog/river_log.go:riverlog.appendLogDataWithCap, riverlog/river_log.go:riverlog.metadataKey, riverlog/river_log.go:riverlog.metadataWithLog", - "java_surface": "WorkContext, Workers, Client" + "java_surface": "WorkContext, RunHandle, Workers, Client" }, { "applicability": "protocol_visible", @@ -2671,7 +2671,7 @@ "sqlite_runtime_periodic_scheduler" ], "source": "internal/maintenance/periodic_job_enqueuer.go:maintenance.PeriodicJobEnqueuer.insertParamsFromConstructor, internal/rivercommon/river_common.go:rivercommon.MetadataKeyPeriodicJobID", - "java_surface": "WorkContext, Workers, Client" + "java_surface": "WorkContext, RunHandle, Workers, Client" }, { "applicability": "protocol_visible", @@ -2683,7 +2683,7 @@ "reserved_metadata_cross_engine" ], "source": "internal/rivercommon/river_common.go:rivercommon.MetadataKeyRescueCount, riverdriver/riverpgxv5/internal/dbsqlc/river_job.sql:JobRescueMany, riverdriver/riversqlite/internal/dbsqlc/river_job.sql:JobRescue", - "java_surface": "WorkContext, Workers, Client" + "java_surface": "WorkContext, RunHandle, Workers, Client" }, { "applicability": "protocol_visible", @@ -2696,7 +2696,7 @@ "resumable_validation" ], "source": "internal/rivercommon/river_common.go:rivercommon.MetadataKeyResumableCursor, internal/riverplugin/plugin.go:riverplugin.ResumableMiddleware.Work, resumable_step_tx.go:river.resumableSetStepTx", - "java_surface": "WorkContext, Workers, Client" + "java_surface": "WorkContext, RunHandle, Workers, Client" }, { "applicability": "protocol_visible", @@ -2710,7 +2710,7 @@ "resumable_validation" ], "source": "internal/rivercommon/river_common.go:rivercommon.MetadataKeyResumableStep, internal/riverplugin/plugin.go:riverplugin.ResumableMiddleware.Work, resumable_step_tx.go:river.resumableSetStepTx", - "java_surface": "WorkContext, Workers, Client" + "java_surface": "WorkContext, RunHandle, Workers, Client" }, { "applicability": "protocol_visible", @@ -2723,7 +2723,7 @@ "sqlite_insert_get_unique_cross_language" ], "source": "riverdriver/unique_insert.go:riverdriver.UniqueInsertMetadataKey", - "java_surface": "WorkContext, Workers, Client" + "java_surface": "WorkContext, RunHandle, Workers, Client" }, { "applicability": "protocol_visible", @@ -2736,7 +2736,7 @@ "snooze_once_metadata_transition" ], "source": "internal/jobexecutor/job_executor.go:jobexecutor.JobExecutor.reportResult", - "java_surface": "WorkContext, Workers, Client" + "java_surface": "WorkContext, RunHandle, Workers, Client" }, { "applicability": "internal", @@ -2745,7 +2745,7 @@ "id": "metadata_key.unique_key_conflict", "rationale": "Diagnostic marker the scheduler writes when it discards a scheduled or retryable job whose unique key conflicts with a live job. No reader acts on it and no shared scenario covers scheduler-time unique conflicts yet; promote to protocol_visible when one exists.", "source": "riverdriver/riverpgxv5/internal/dbsqlc/river_job.sql:JobSchedule, riverdriver/riversqlite/internal/dbsqlc/river_job.sql:JobScheduleSetDiscarded", - "java_surface": "WorkContext, Workers, Client" + "java_surface": "WorkContext, RunHandle, Workers, Client" }, { "applicability": "protocol_visible", @@ -2967,7 +2967,7 @@ "remote_queue_subscription_events" ], "source": "producer.go:river.controlEventPayload", - "java_surface": "Client, Queues, Workers" + "java_surface": "Client, Queues, RunHandle" }, { "applicability": "protocol_visible", @@ -2980,7 +2980,7 @@ "transactional_cross_language_cancel" ], "source": "producer.go:river.controlActionCancel", - "java_surface": "Client, Queues, Workers" + "java_surface": "Client, Queues, RunHandle" }, { "applicability": "protocol_visible", @@ -2992,7 +2992,7 @@ "differential_queue_crud" ], "source": "producer.go:river.controlActionMetadataChanged", - "java_surface": "Client, Queues, Workers" + "java_surface": "Client, Queues, RunHandle" }, { "applicability": "protocol_visible", @@ -3005,7 +3005,7 @@ "remote_queue_subscription_events" ], "source": "producer.go:river.controlActionPause", - "java_surface": "Client, Queues, Workers" + "java_surface": "Client, Queues, RunHandle" }, { "applicability": "protocol_visible", @@ -3018,7 +3018,7 @@ "remote_queue_subscription_events" ], "source": "producer.go:river.controlActionResume", - "java_surface": "Client, Queues, Workers" + "java_surface": "Client, Queues, RunHandle" }, { "applicability": "protocol_visible", @@ -3031,7 +3031,7 @@ "transactional_insert_notification_commit_only" ], "source": "producer.go:river.insertPayload", - "java_surface": "Client, Queues, Workers" + "java_surface": "Client, Queues, RunHandle" }, { "applicability": "protocol_visible", @@ -3044,7 +3044,7 @@ "mixed_request_resign_terms" ], "source": "internal/leadership/elector.go:leadership.DBNotification", - "java_surface": "Client, Queues, Workers" + "java_surface": "Client, Queues, RunHandle" }, { "applicability": "protocol_visible", @@ -3056,7 +3056,7 @@ "mixed_request_resign_terms" ], "source": "internal/leadership/elector.go:leadership.DBNotificationKindRequestResign", - "java_surface": "Client, Queues, Workers" + "java_surface": "Client, Queues, RunHandle" }, { "applicability": "protocol_visible", @@ -3069,7 +3069,7 @@ "mixed_request_resign_terms" ], "source": "internal/leadership/elector.go:leadership.DBNotificationKindResigned", - "java_surface": "Client, Queues, Workers" + "java_surface": "Client, Queues, RunHandle" }, { "applicability": "protocol_visible", @@ -3082,7 +3082,7 @@ "transactional_cross_language_cancel" ], "source": "riverdriver/riverpgxv5/internal/dbsqlc/river_job.sql:JobCancel", - "java_surface": "Client, Queues, Workers" + "java_surface": "Client, Queues, RunHandle" }, { "applicability": "protocol_visible", @@ -3094,7 +3094,7 @@ "mixed_leader_failover_both_directions" ], "source": "riverdriver/riverpgxv5/internal/dbsqlc/river_leader.sql:LeaderResign", - "java_surface": "Client, Queues, Workers" + "java_surface": "Client, Queues, RunHandle" }, { "applicability": "protocol_visible", @@ -3107,7 +3107,7 @@ "remote_cancel_notification" ], "source": "internal/notifier/notifier.go:notifier.NotificationTopicControl", - "java_surface": "Client, Queues, Workers" + "java_surface": "Client, Queues, RunHandle" }, { "applicability": "protocol_visible", @@ -3120,7 +3120,7 @@ "transactional_insert_notification_commit_only" ], "source": "internal/notifier/notifier.go:notifier.NotificationTopicInsert", - "java_surface": "Client, Queues, Workers" + "java_surface": "Client, Queues, RunHandle" }, { "applicability": "protocol_visible", @@ -3133,7 +3133,7 @@ "mixed_request_resign_terms" ], "source": "internal/notifier/notifier.go:notifier.NotificationTopicLeadership", - "java_surface": "Client, Queues, Workers" + "java_surface": "Client, Queues, RunHandle" }, { "applicability": "protocol_visible", @@ -3146,7 +3146,7 @@ "sqlite_runtime_periodic_scheduler" ], "source": "periodic_job.go:river.PeriodicJobOpts.ID", - "java_surface": "Workers.Builder.periodic" + "java_surface": "Client.Builder.periodic" }, { "applicability": "protocol_visible", @@ -3158,7 +3158,7 @@ "periodic_run_on_start" ], "source": "periodic_job.go:river.PeriodicJobOpts.RunOnStart", - "java_surface": "Workers.Builder.periodic" + "java_surface": "Client.Builder.periodic" }, { "applicability": "api_equivalent", @@ -3167,7 +3167,7 @@ "id": "queue_config.FetchCooldown", "rationale": "Per-queue override of config.FetchCooldown for fetching only; local throughput throttle. Insert notifications always use the client-level cooldown.", "source": "client.go:river.QueueConfig.FetchCooldown", - "java_surface": "Workers.Builder.queue and Workers.addQueue", + "java_surface": "Client.Builder.queue and RunHandle.addQueue", "java_difference": "Wakeups are coalesced; Java does not expose a separate per-queue cooldown knob." }, { @@ -3177,7 +3177,7 @@ "id": "queue_config.FetchPollInterval", "rationale": "Per-queue override of config.FetchPollInterval; local polling fallback.", "source": "client.go:river.QueueConfig.FetchPollInterval", - "java_surface": "Workers.Builder.queue and Workers.addQueue", + "java_surface": "Client.Builder.queue and RunHandle.addQueue", "java_difference": "Polling is configured per worker runtime; use separate runtimes for different polling policies." }, { @@ -3190,7 +3190,7 @@ "dynamic_queue_add_reconfigure_remove" ], "source": "client.go:river.QueueConfig.MaxWorkers", - "java_surface": "Workers.Builder.queue and Workers.addQueue" + "java_surface": "Client.Builder.queue and RunHandle.addQueue" }, { "applicability": "api_equivalent", diff --git a/java/conformance/src/main/java/com/riverqueue/conformance/RuntimeAdapter.java b/java/conformance/src/main/java/com/riverqueue/conformance/RuntimeAdapter.java index be404345a..2d18408b3 100644 --- a/java/conformance/src/main/java/com/riverqueue/conformance/RuntimeAdapter.java +++ b/java/conformance/src/main/java/com/riverqueue/conformance/RuntimeAdapter.java @@ -11,11 +11,11 @@ import com.riverqueue.JobType; import com.riverqueue.Json; import com.riverqueue.RiverException; +import com.riverqueue.RunHandle; import com.riverqueue.Schedule; import com.riverqueue.Unique; import com.riverqueue.WorkContext; import com.riverqueue.WorkerMiddleware; -import com.riverqueue.Workers; import java.sql.Connection; import java.time.Duration; import java.util.List; @@ -34,7 +34,8 @@ final class RuntimeAdapter { JobType.of("conformance_echo", Adapter.Echo.class); private final Map barriers = new ConcurrentHashMap<>(); private Probe probe = new Probe(); - private Workers workers; + private RunHandle workers; + private Client.Subscription subscription; private volatile CountDownLatch claimBarrier; private final java.util.concurrent.atomic.AtomicBoolean firstClaim = new java.util.concurrent.atomic.AtomicBoolean(); @@ -148,10 +149,12 @@ public Throwable workEnd(WorkContext context, Throwable failure) { void stop(boolean cancel) { if (workers == null) return; if (claimBarrier != null) claimBarrier.countDown(); - Workers previous = workers; - workers = null; + RunHandle previous = workers; if (cancel) previous.stopAndCancel(); else previous.stop(); + workers = null; + subscription.close(); + subscription = null; } private CountDownLatch barrier(String name) { @@ -161,7 +164,7 @@ private CountDownLatch barrier(String name) { return value; } - private Workers requireWorkers() { + private RunHandle requireWorkers() { if (workers == null) throw new IllegalArgumentException("No client running"); return workers; } @@ -180,11 +183,10 @@ private void start(Client river, JsonNode params) { if (unique && !periodic) throw new IllegalArgumentException("periodic_unique requires periodic_run_on_start"); var builder = - river - .workers() + river.toBuilder() .id(params.path("client_id").asString()) .queue(params.path("queue").asString("default"), params.path("max_workers").asInt(1)) - .add(ECHO, this::work) + .worker(ECHO, this::work) .pollOnly(params.path("poll_only").asBoolean(false)) .pollInterval(Duration.ofMillis(params.path("fetch_poll_interval_ms").asLong(10))) .leadership(!params.path("leader_election_disabled").asBoolean(false)) @@ -237,14 +239,22 @@ else if (params.has("job_timeout_ms")) InsertOptions.defaults(), true); } - workers = builder.start(); - workers.subscribe( - event -> { - probe.events.add(event.kind().value()); - if (params.path("error_handler_cancel").asBoolean(false) - && event.kind() == Workers.EventKind.JOB_CANCELLED) - probe.errorHandlerCalls.incrementAndGet(); - }); + var client = builder.build(); + subscription = + client.subscribe( + event -> { + probe.events.add(event.kind().value()); + if (params.path("error_handler_cancel").asBoolean(false) + && event.kind() == Client.EventKind.JOB_CANCELLED) + probe.errorHandlerCalls.incrementAndGet(); + }); + try { + workers = client.start(); + } catch (RuntimeException | Error error) { + subscription.close(); + subscription = null; + throw error; + } } private JsonNode waitFor(Client river, JsonNode params) throws Exception { diff --git a/java/river/src/main/java/com/riverqueue/Client.java b/java/river/src/main/java/com/riverqueue/Client.java index b3a63512f..752f5a9d8 100644 --- a/java/river/src/main/java/com/riverqueue/Client.java +++ b/java/river/src/main/java/com/riverqueue/Client.java @@ -1,24 +1,36 @@ package com.riverqueue; +import java.nio.charset.StandardCharsets; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.HexFormat; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; import java.util.stream.Stream; import tools.jackson.databind.JsonNode; /** * Transactional job storage. Connection overloads use the caller's transaction and never commit or - * close it; other overloads own a transaction. Close the {@link Workers} returned by {@link - * Workers.Builder#start()} to stop execution. The client owns no resources to close. + * close it; other overloads own a transaction. Configure workers with {@link #builder(Database)}, + * then call {@link #start()}. Close the returned {@link RunHandle} to stop execution. The + * application owns its connection pool; inserting jobs does not require a running client. */ public final class Client { + private final Builder config; + private final String id; + private final List> subscribers = new CopyOnWriteArrayList<>(); private final java.util.concurrent.CopyOnWriteArrayList insertListeners = new java.util.concurrent.CopyOnWriteArrayList<>(); private final ThreadLocal ownedCommit = new ThreadLocal<>(); @@ -32,12 +44,24 @@ public final class Client { private final List plugins; private final List workerMiddleware; + private final ReentrantLock lifecycleLock = new ReentrantLock(); + private RunHandle activeRun; + + /** Creates an insert-only client using the system clock. */ public Client(Database database) { this(database, Clock.systemUTC()); } + /** Creates an insert-only client using the supplied clock for storage operations. */ public Client(Database database, Clock clock) { - this(database, clock, List.of(), List.of(), List.of(), new Pilot() {}); + this( + database, + clock, + List.of(), + List.of(), + List.of(), + new Pilot() {}, + new Builder(database, clock)); } private Client( @@ -46,13 +70,20 @@ private Client( List hooks, List middleware, List plugins, - Pilot pilot) { + Pilot pilot, + Builder config) { + this.config = new Builder(config); + this.id = config.id == null ? "java-" + UUID.randomUUID() : config.id; this.pilot = pilot; this.database = Objects.requireNonNull(database); this.clock = Objects.requireNonNull(clock); this.hooks = List.copyOf(hooks); this.middleware = List.copyOf(middleware); this.plugins = List.copyOf(plugins); + this.config.hooks = this.hooks; + this.config.middleware = this.middleware; + this.config.plugins = this.plugins; + this.config.pilot = pilot; this.allHooks = Stream.concat(this.plugins.stream(), this.hooks.stream()) .filter(Hook.class::isInstance) @@ -71,16 +102,88 @@ private Client( .toList(); } + /** Creates a builder for a client using an application-owned database. */ + public static Builder builder(Database database) { + return new Builder(database, Clock.systemUTC()); + } + public Database database() { return database; } + /** Returns this client's ID, shared by its successive runs. */ + public String id() { + return id; + } + public Queues queues() { return new Queues(this); } - public Workers.Builder workers() { - return new Workers.Builder(this); + /** + * Starts this client's configured runtime. Only one run may be active, including while a timed + * out stop is still draining. Once a stop call succeeds, the client can be started again. Each + * run starts with the configured queues and periodic jobs. At least one queue and handler are + * required; insertion does not require starting the client. + */ + public RunHandle start() { + lifecycleLock.lock(); + try { + if (activeRun != null) throw new IllegalStateException("Client already running or stopping"); + var run = new RunHandle(this, config); + activeRun = run; + try { + pilot.starting( + config.leadership, + config.cancelledRetention, + config.completedRetention, + config.discardedRetention); + run.launch(); + return run; + } catch (RuntimeException | Error error) { + try { + run.abortStart(); + } catch (RuntimeException | Error cleanupError) { + error.addSuppressed(cleanupError); + } + throw error; + } + } finally { + lifecycleLock.unlock(); + } + } + + void stopped(RunHandle run) { + lifecycleLock.lock(); + try { + if (activeRun == run) activeRun = null; + } finally { + lifecycleLock.unlock(); + } + } + + /** + * Subscribes before or during execution. Subscriptions belong to this client and survive restarts + * until closed. Callbacks run synchronously and should return promptly. + */ + public Subscription subscribe(Consumer subscriber, EventKind... kinds) { + Objects.requireNonNull(subscriber, "subscriber"); + Set selected = Set.copyOf(List.of(kinds)); + Consumer filtered = + event -> { + if (selected.isEmpty() || selected.contains(event.kind())) subscriber.accept(event); + }; + subscribers.add(filtered); + return () -> subscribers.remove(filtered); + } + + List> subscribers() { + return subscribers; + } + + /** Copies configuration into a builder for an independent, unstarted client. */ + public Builder toBuilder() { + return new Builder(config); } /** @@ -90,7 +193,7 @@ public Workers.Builder workers() { public Client withHooks(Hook... hooks) { var all = new ArrayList<>(this.hooks); all.addAll(List.of(hooks)); - return new Client(database, clock, all, middleware, plugins, pilot); + return new Client(database, clock, all, middleware, plugins, pilot, config); } /** @@ -101,12 +204,13 @@ public Client withHooks(Hook... hooks) { public Client withMiddleware(Middleware... middleware) { var all = new ArrayList<>(this.middleware); all.addAll(List.of(middleware)); - return new Client(database, clock, hooks, all, plugins, pilot); + return new Client(database, clock, hooks, all, plugins, pilot, config); } /** Internal integration point used by River Pro; not a stable application API. */ public Client withPilot(Pilot pilot) { - return new Client(database, clock, hooks, middleware, plugins, Objects.requireNonNull(pilot)); + return new Client( + database, clock, hooks, middleware, plugins, Objects.requireNonNull(pilot), config); } /** @@ -118,7 +222,7 @@ public Client withPilot(Pilot pilot) { public Client withPlugins(Plugin... plugins) { var all = new ArrayList<>(this.plugins); all.addAll(List.of(plugins)); - return new Client(database, clock, hooks, middleware, all, pilot); + return new Client(database, clock, hooks, middleware, all, pilot, config); } Runnable onInsertCommit(Runnable listener) { @@ -126,6 +230,303 @@ Runnable onInsertCommit(Runnable listener) { return () -> insertListeners.remove(listener); } + /** Mutable client configuration. Building a client snapshots its settings and handlers. */ + public static final class Builder { + boolean cancelOnError; + Duration cancelledRetention = Duration.ofDays(1); + Clock clock; + Duration completedRetention = Duration.ofDays(1); + final Database database; + Duration discardedRetention = Duration.ofDays(7); + Consumer errorHandler = + error -> + System.getLogger("com.riverqueue") + .log(System.Logger.Level.WARNING, "River runtime failure", error); + boolean fetchOnlyKnownKinds; + final Map> handlers = new java.util.LinkedHashMap<>(); + List hooks = List.of(); + String id; + Duration jobTimeout = Duration.ofMinutes(1); + boolean jobTimeoutConfigured; + boolean leadership = true; + Duration maintenanceInterval = Duration.ofSeconds(5); + List middleware = List.of(); + final List> observers = new ArrayList<>(); + final List> periodic = new ArrayList<>(); + Pilot pilot = new Pilot() {}; + List plugins = List.of(); + Duration pollInterval = Duration.ofSeconds(1); + boolean pollOnly; + final Map queues = new java.util.LinkedHashMap<>(); + Duration reindexInterval; + List reindexNames = + List.of( + "river_job_args_index", + "river_job_kind", + "river_job_metadata_index", + "river_job_pkey", + "river_job_prioritized_fetching_index", + "river_job_state_and_finalized_at_index", + "river_job_unique_idx"); + Duration rescueAfter; + boolean rescueAfterConfigured; + RetryPolicy retryPolicy = RetryPolicy.defaults(); + Duration serviceInterval = Duration.ofSeconds(1); + Duration stopTimeout = Duration.ofSeconds(30); + Duration stuckThreshold = Duration.ofSeconds(5); + + private Builder(Database database, Clock clock) { + this.database = Objects.requireNonNull(database, "database"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + private Builder(Builder source) { + this(source.database, source.clock); + hooks = source.hooks; + middleware = source.middleware; + plugins = source.plugins; + pilot = source.pilot; + cancelledRetention = source.cancelledRetention; + completedRetention = source.completedRetention; + discardedRetention = source.discardedRetention; + reindexInterval = source.reindexInterval; + reindexNames = source.reindexNames; + cancelOnError = source.cancelOnError; + fetchOnlyKnownKinds = source.fetchOnlyKnownKinds; + errorHandler = source.errorHandler; + handlers.putAll(source.handlers); + id = source.id; + jobTimeout = source.jobTimeout; + jobTimeoutConfigured = source.jobTimeoutConfigured; + leadership = source.leadership; + maintenanceInterval = source.maintenanceInterval; + observers.addAll(source.observers); + periodic.addAll(source.periodic); + pollInterval = source.pollInterval; + pollOnly = source.pollOnly; + queues.putAll(source.queues); + retryPolicy = source.retryPolicy; + rescueAfter = source.rescueAfter; + rescueAfterConfigured = source.rescueAfterConfigured; + stopTimeout = source.stopTimeout; + serviceInterval = source.serviceInterval; + stuckThreshold = source.stuckThreshold; + } + + /** Builds a client without starting background work. */ + public Client build() { + if (!leadership && !periodic.isEmpty()) + throw new IllegalArgumentException("Periodic jobs require leader election"); + var config = new Builder(this); + if (!config.rescueAfterConfigured) + config.rescueAfter = + positive( + !jobTimeoutConfigured || jobTimeout.isNegative() + ? Duration.ofHours(1) + : jobTimeout.plus(Duration.ofHours(1)), + "rescueAfter"); + if (config.rescueAfter.compareTo(jobTimeout) < 0) + throw new IllegalArgumentException("rescueAfter must not be less than jobTimeout"); + return new Client(database, clock, hooks, middleware, plugins, pilot, config); + } + + public Builder cancelOnError(boolean value) { + cancelOnError = value; + return this; + } + + /** Uses the given clock for client operations. */ + public Builder clock(Clock value) { + clock = Objects.requireNonNull(value, "clock"); + return this; + } + + static Duration duration(Duration value, String name) { + Objects.requireNonNull(value, name); + try { + value.toNanos(); + } catch (ArithmeticException error) { + throw new IllegalArgumentException(name + " must fit in signed 64-bit nanoseconds", error); + } + return value; + } + + /** Reports runtime failures. Failures in this callback are logged without stopping workers. */ + public Builder errorHandler(Consumer value) { + errorHandler = Objects.requireNonNull(value, "errorHandler"); + return this; + } + + public Builder fetchOnlyKnownKinds(boolean value) { + fetchOnlyKnownKinds = value; + return this; + } + + /** Replaces operation hooks; only hook interfaces are activated, after plugins. */ + public Builder hooks(Hook... value) { + hooks = List.of(value); + return this; + } + + /** Overrides the client ID (at most 100 UTF-8 bytes). Each client otherwise generates an ID. */ + public Builder id(String value) { + if (value.isEmpty() || value.getBytes(StandardCharsets.UTF_8).length > 100) + throw new IllegalArgumentException("Invalid client ID"); + id = value; + return this; + } + + /** Sets the attempt timeout; a negative duration disables it, including during stop. */ + public Builder jobTimeout(Duration value) { + duration(value, "jobTimeout"); + if (value.isZero()) + throw new IllegalArgumentException( + "Job timeout must be positive or negative to disable it"); + jobTimeout = value; + jobTimeoutConfigured = true; + return this; + } + + public Builder leadership(boolean value) { + leadership = value; + return this; + } + + public Builder maintenanceInterval(Duration value) { + maintenanceInterval = positive(value, "maintenanceInterval"); + return this; + } + + /** Replaces operation middleware; the first middleware is outermost, after plugins. */ + public Builder middleware(Middleware... value) { + middleware = List.of(value); + return this; + } + + /** Observes runtime activity. Callback failures are reported without affecting execution. */ + public Builder observe(Consumer value) { + observers.add(Objects.requireNonNull(value, "observer")); + return this; + } + + /** Registers a periodic definition before starting this runtime. */ + public Builder periodic(PeriodicJob job) { + Objects.requireNonNull(job, "job"); + if (job.id() != null + && periodic.stream().anyMatch(existing -> job.id().equals(existing.id()))) + throw new IllegalArgumentException("Duplicate periodic job ID: " + job.id()); + periodic.add(job); + return this; + } + + public Builder periodic( + String id, + Schedule schedule, + JobType type, + A args, + InsertOptions options, + boolean runOnStart) { + return periodic(new PeriodicJob<>(id, schedule, type, args, options, runOnStart)); + } + + /** Replaces plugins, enabling their hook and middleware interfaces before other callbacks. */ + public Builder plugins(Plugin... value) { + plugins = List.of(value); + return this; + } + + public Builder pollInterval(Duration value) { + pollInterval = positive(value, "pollInterval"); + return this; + } + + public Builder pollOnly(boolean value) { + pollOnly = value; + return this; + } + + private static Duration positive(Duration value, String name) { + duration(value, name); + if (value.isNegative() || value.isZero()) + throw new IllegalArgumentException(name + " must be positive"); + return value; + } + + public Builder queue(String name, int maxWorkers) { + RunHandle.validateQueue(name, maxWorkers); + queues.put(name, maxWorkers); + return this; + } + + public Builder reindex(List names, Duration interval) { + reindexNames = List.copyOf(names); + reindexInterval = interval == null ? null : positive(interval, "reindex interval"); + return this; + } + + /** + * Sets when abandoned jobs may be rescued. Must not be shorter than the attempt timeout. + * Defaults to one hour, plus an explicitly configured positive job timeout. + */ + public Builder rescueAfter(Duration value) { + rescueAfter = positive(value, "rescueAfter"); + rescueAfterConfigured = true; + return this; + } + + public Builder retention(Duration cancelled, Duration completed, Duration discarded) { + cancelledRetention = duration(cancelled, "cancelled retention"); + completedRetention = duration(completed, "completed retention"); + discardedRetention = duration(discarded, "discarded retention"); + return this; + } + + public Builder retryPolicy(RetryPolicy value) { + retryPolicy = Objects.requireNonNull(value, "retryPolicy"); + return this; + } + + /** + * Sets the election and heartbeat interval; leadership leases include ten seconds of margin. + * Must be positive and shorter than one day so active queues do not expire between heartbeats. + */ + public Builder serviceInterval(Duration value) { + positive(value, "serviceInterval"); + if (value.compareTo(RunHandle.QUEUE_RETENTION) >= 0) + throw new IllegalArgumentException( + "serviceInterval must be shorter than the queue retention period of one day"); + serviceInterval = value; + return this; + } + + public Builder stopTimeout(Duration value) { + stopTimeout = positive(value, "stopTimeout"); + return this; + } + + public Builder stuckThreshold(Duration value) { + stuckThreshold = positive(value, "stuckThreshold"); + return this; + } + + /** Adds a worker handler for a job type. Each job kind may be added only once. */ + public Builder worker(JobType type, Workers.Handler handler) { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(handler, "handler"); + if (handlers.putIfAbsent(type.kind(), new Workers.Registration<>(type, handler)) != null) + throw new IllegalArgumentException("Duplicate worker kind"); + return this; + } + + /** Replaces the handler registry with a snapshot of the supplied workers. */ + public Builder workers(Workers value) { + var registrations = Objects.requireNonNull(value, "workers").snapshot(); + handlers.clear(); + handlers.putAll(registrations); + return this; + } + } + private static final class Commit { final Connection connection; boolean inserted; @@ -1005,6 +1406,29 @@ static void validate(InsertOptions options) { throw new IllegalArgumentException("Invalid tag: " + tag); } + /** A job or queue event. The payload corresponding to the event kind is non-null. */ + public record Event(EventKind kind, Job job, Queues.Queue queue) { + public Event(EventKind kind, Job job) { + this(kind, job, null); + } + } + + /** Event names shared with other River clients and the conformance protocol. */ + public enum EventKind { + JOB_CANCELLED, + JOB_COMPLETED, + JOB_FAILED, + JOB_INTERRUPTED, + JOB_SNOOZED, + QUEUE_PAUSED, + QUEUE_RESUMED; + + @com.fasterxml.jackson.annotation.JsonValue + public String value() { + return name().toLowerCase(java.util.Locale.ROOT); + } + } + /** A job and its individual options in an atomic batch. */ public record Submission(JobType type, A args, InsertOptions options) { public Submission { @@ -1014,6 +1438,13 @@ public record Submission(JobType type, A args, InsertOptions options) { } } + /** An idempotent subscription handle whose close method cannot throw a checked exception. */ + @FunctionalInterface + public interface Subscription extends AutoCloseable { + @Override + void close(); + } + /** A callback run in the same JDBC transaction as the application's writes. */ @FunctionalInterface public interface Transaction { diff --git a/java/river/src/main/java/com/riverqueue/CronSchedule.java b/java/river/src/main/java/com/riverqueue/CronSchedule.java index 0522f8706..fe06dbd3b 100644 --- a/java/river/src/main/java/com/riverqueue/CronSchedule.java +++ b/java/river/src/main/java/com/riverqueue/CronSchedule.java @@ -79,7 +79,7 @@ public Optional next(OffsetDateTime after) { @Override public Optional next(ZonedDateTime after) { if (interval != null) return Optional.of(after.truncatedTo(ChronoUnit.SECONDS).plus(interval)); - // Go uses the reference's zone for unqualified cron; Workers supplies the process zone. + // Go uses the reference's zone for unqualified cron; RunHandle supplies the process zone. var current = after .withZoneSameInstant(zone == null ? after.getZone() : zone) diff --git a/java/river/src/main/java/com/riverqueue/PeriodicJobBundle.java b/java/river/src/main/java/com/riverqueue/PeriodicJobBundle.java index f30be7c01..0d9bfe40b 100644 --- a/java/river/src/main/java/com/riverqueue/PeriodicJobBundle.java +++ b/java/river/src/main/java/com/riverqueue/PeriodicJobBundle.java @@ -9,10 +9,10 @@ import java.util.function.BooleanSupplier; /** - * Thread-safe periodic registrations owned by a {@link Workers} instance. Only the leader enqueues - * occurrences, so update every eligible runtime to change schedules cluster-wide. Removal leaves - * previously inserted jobs alone, and an insertion already in progress may finish. Registrations - * can still be added and removed after stop, but no further jobs are enqueued. + * Thread-safe periodic registrations owned by a {@link RunHandle} instance. Only the leader + * enqueues occurrences, so update every eligible runtime to change schedules cluster-wide. Removal + * leaves previously inserted jobs alone, and an insertion already in progress may finish. + * Registrations can still be added and removed after stop, but no further jobs are enqueued. */ public final class PeriodicJobBundle { private final boolean enabled; diff --git a/java/river/src/main/java/com/riverqueue/RunHandle.java b/java/river/src/main/java/com/riverqueue/RunHandle.java new file mode 100644 index 000000000..8346562da --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/RunHandle.java @@ -0,0 +1,1500 @@ +package com.riverqueue; + +import com.riverqueue.Client.Event; +import com.riverqueue.Client.EventKind; +import com.riverqueue.Workers.Handler; +import com.riverqueue.Workers.Registration; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.PriorityBlockingQueue; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; +import org.postgresql.PGConnection; +import tools.jackson.databind.JsonNode; + +/** + * One execution of a client's worker runtime. Close this handle to stop fetching and drain active + * jobs. Stopping this handle never stops a later run of the same client. + */ +public final class RunHandle implements AutoCloseable { + static final Duration QUEUE_RETENTION = Duration.ofDays(1); + + private final Map> active = new ConcurrentHashMap<>(); + private final Map activeThreads = new ConcurrentHashMap<>(); + private final Map pendingCancellation = new ConcurrentHashMap<>(); + private final AtomicBoolean maintenanceBusy = new AtomicBoolean(); + private volatile Instant nextReindex = + Instant.now() + .atOffset(java.time.ZoneOffset.UTC) + .toLocalDate() + .plusDays(1) + .atStartOfDay() + .toInstant(java.time.ZoneOffset.UTC); + private final Client.Builder config; + private final RetryPolicy defaultRetryPolicy = RetryPolicy.defaults(); + private final java.util.concurrent.BlockingQueue completions = + new java.util.concurrent.LinkedBlockingQueue<>(); + private boolean dispatcherStarted; + private final java.util.concurrent.CountDownLatch dispatcherStopped = + new java.util.concurrent.CountDownLatch(1); + // Claims may commit after stop's active-map scan; launch must see the cancellation request. + private volatile boolean cancelOnStop; + private boolean stopFinalized; + private final ReentrantLock stopLock = new ReentrantLock(); + private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + private final PriorityBlockingQueue fetchWakeups = new PriorityBlockingQueue<>(); + private final String id; + private final Protocol.Dispatcher notifications; + // Blocking under monitor locks pins virtual threads on Java 21; use locks that allow yielding. + private final ReentrantLock leadershipLock = new ReentrantLock(); + private volatile LeadershipTerm leaderTerm; + private Instant initializedTerm; + private Instant resigningTerm; + private long leaderEligibleAt = System.nanoTime(); + private volatile Connection listener; + private final Map queues = new ConcurrentHashMap<>(); + private final PeriodicJobBundle periodic; + private final AtomicBoolean periodicChanged = new AtomicBoolean(); + private final java.util.Set pausedQueues = ConcurrentHashMap.newKeySet(); + private final ReentrantLock queueLock = new ReentrantLock(); + private final Condition queueChanged = queueLock.newCondition(); + private final java.util.Set drainingQueues = ConcurrentHashMap.newKeySet(); + private final Semaphore leadershipWake = new Semaphore(0); + private final Client river; + private Runnable unsubscribeInsert = () -> {}; + private final AtomicBoolean running = new AtomicBoolean(true); + private final Semaphore wake = new Semaphore(0); + + RunHandle(Client river, Client.Builder config) { + this.config = config; + this.river = river; + id = river.id(); + notifications = + new Protocol.Dispatcher( + id, active, pendingCancellation, this::queueNotification, this::leadershipNotification); + queues.putAll(config.queues); + periodic = + new PeriodicJobBundle( + config.periodic, + config.leadership, + running::get, + () -> { + if (periodicChanged.compareAndSet(false, true) && isLeader()) + leadershipWake.release(); + }); + if (queues.isEmpty() || config.handlers.isEmpty()) + throw new IllegalArgumentException("Client.start requires queues and handlers"); + if (config.reindexInterval != null) nextReindex = Instant.now().plus(config.reindexInterval); + } + + void abortStart() { + if (!dispatcherStarted) dispatcherStopped.countDown(); + running.set(false); + cancelOnStop = true; + unsubscribeInsert.run(); + wake.release(); + leadershipWake.release(); + executor.shutdownNow(); + try { + if (!executor.awaitTermination(config.stopTimeout.toNanos(), TimeUnit.NANOSECONDS)) + throw new RiverException(RiverException.Code.REJECTED, "Failed startup did not stop"); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new RiverException( + RiverException.Code.REJECTED, "Interrupted stopping failed startup", error); + } + river.stopped(this); + } + + /** Adds or updates a queue in this run without changing client configuration. */ + public void addQueue(String name, int maxWorkers) { + validateQueue(name, maxWorkers); + queueLock.lock(); + try { + if (drainingQueues.contains(name)) + throw new IllegalStateException("Queue is draining: " + name); + queues.put(name, maxWorkers); + heartbeat(name); + } finally { + queueLock.unlock(); + } + wake.release(); + } + + /** Returns the client whose runtime this handle controls. */ + public Client client() { + return river; + } + + /** Stops this run gracefully; equivalent to {@link #stop()}. */ + @Override + public void close() { + stop(); + } + + /** Returns the owning client's ID. */ + public String id() { + return id; + } + + /** Reports whether this runtime still trusts its committed leadership lease. */ + public boolean isLeader() { + return isLeader(System.nanoTime()); + } + + boolean isLeader(long now) { + var term = leaderTerm; + return running.get() && term != null && term.active(now); + } + + void launch() { + for (String queue : queues.keySet()) heartbeat(queue); + unsubscribeInsert = river.onInsertCommit(wake::release); + if (!config.pollOnly) executor.submit(this::listen); + executor.submit(this::completeBatches); + executor.submit(this::supervise); + executor.submit(this::dispatch); + dispatcherStarted = true; + if (config.leadership) executor.submit(this::elect); + } + + /** Returns this runtime's live, thread-safe periodic job bundle. */ + public PeriodicJobBundle periodicJobs() { + return periodic; + } + + /** Drains and removes a queue from this run without changing client configuration. */ + public void removeQueue(String name) { + queueLock.lock(); + try { + if (!queues.containsKey(name) || !drainingQueues.add(name)) + throw new RiverException(RiverException.Code.NOT_FOUND, "Queue not configured: " + name); + try { + // Keep the producer lease alive until every committed claim has settled. + long deadline = System.nanoTime() + config.stopTimeout.toNanos(); + while (active.values().stream().anyMatch(c -> c.job().queue().equals(name))) { + long left = deadline - System.nanoTime(); + if (left <= 0) + throw new RiverException(RiverException.Code.REJECTED, "Queue did not drain: " + name); + queueChanged.awaitNanos(left); + } + queues.remove(name); + heartbeat(name); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new RiverException( + RiverException.Code.REJECTED, "Interrupted draining queue: " + name, error); + } finally { + drainingQueues.remove(name); + } + } finally { + queueLock.unlock(); + } + } + + /** + * Stops fetching jobs and waits for active attempts to finish. A timeout leaves attempts running; + * call again to continue waiting, or use {@link #stopAndCancel()} to request cancellation. + */ + public void stop() { + stop(false); + } + + private void stop(boolean cancel) { + if (running.getAndSet(false)) { + unsubscribeInsert.run(); + leadershipWake.release(); + } + if (cancel) cancelOnStop = true; + long deadline = System.nanoTime() + config.stopTimeout.toNanos(); + if (cancel) + active.values().forEach(c -> c.requestCancellation(WorkContext.Cancellation.SHUTDOWN)); + wake.release(); + try { + if (!dispatcherStopped.await(Math.max(0, deadline - System.nanoTime()), TimeUnit.NANOSECONDS)) + throw new RiverException( + RiverException.Code.REJECTED, "Dispatcher did not stop within the stop deadline"); + executor.shutdown(); + if (!executor.awaitTermination( + Math.max(0, deadline - System.nanoTime()), TimeUnit.NANOSECONDS)) + throw new RiverException( + RiverException.Code.REJECTED, "Workers have not stopped within the stop deadline"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RiverException(RiverException.Code.REJECTED, "Interrupted waiting for workers", e); + } + stopLock.lock(); + try { + if (!stopFinalized) { + RuntimeException failure = null; + try { + queues.keySet().forEach(this::heartbeat); + } catch (RuntimeException error) { + failure = error; + } + try { + resign(); + } catch (RuntimeException error) { + if (failure == null) failure = error; + else failure.addSuppressed(error); + } + if (failure != null) throw failure; + stopFinalized = true; + } + } finally { + stopLock.unlock(); + } + river.stopped(this); + } + + /** Stops fetching, requests cancellation of active attempts, and waits for them to finish. */ + public void stopAndCancel() { + stop(true); + } + + private void dispatch() { + long nextPoll = 0; + long nextMaintenance = 0; + long nextCancelPoll = 0; + while (running.get()) { + try { + long now = System.nanoTime(); + boolean notified = wake.drainPermits() > 0; + if (wakeDueFetches()) notified = true; + if (notified || now >= nextPoll) { + queueLock.lock(); + try { + for (var queue : queues.entrySet()) { + if (drainingQueues.contains(queue.getKey())) continue; + int available = + river.pilot().workerLimit(queue.getKey(), queue.getValue()) + - (int) + active.values().stream() + .filter(c -> c.job().queue().equals(queue.getKey())) + .count(); + if (available > 0) claim(queue.getKey(), available); + } + } finally { + queueLock.unlock(); + } + nextPoll = now + config.pollInterval.toNanos(); + } + if (now >= nextMaintenance) { + queues.keySet().forEach(this::heartbeat); + nextMaintenance = now + config.serviceInterval.toNanos(); + } + if (now >= nextCancelPoll) { + river.transaction( + c -> { + try (var statement = + Sql.prepare(c, Sql.query(river.database(), "cancel_requested")); + var rows = statement.executeQuery()) { + while (rows.next()) { + var context = active.get(rows.getLong(1)); + if (context != null) + context.requestCancellation(WorkContext.Cancellation.REMOTE); + } + } + return null; + }); + pendingCancellation + .entrySet() + .removeIf(entry -> now - entry.getValue() > Duration.ofMinutes(1).toNanos()); + nextCancelPoll = now + TimeUnit.MILLISECONDS.toNanos(50); + } + if (wake.tryAcquire(10, TimeUnit.MILLISECONDS)) wake.release(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + // A failed claim must not consume an insert notification or a timed retry. + nextPoll = 0; + report(e); + try { + TimeUnit.MILLISECONDS.sleep(100); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + break; + } + } + } + dispatcherStopped.countDown(); + } + + private record FetchWakeup(Instant scheduledAt, String notifyQueue) + implements Comparable { + @Override + public int compareTo(FetchWakeup other) { + return scheduledAt.compareTo(other.scheduledAt); + } + } + + private boolean wakeDueFetches() { + Instant now = Instant.now(); + var ready = new ArrayList(); + FetchWakeup due; + while ((due = fetchWakeups.peek()) != null && !due.scheduledAt().isAfter(now)) + ready.add(fetchWakeups.poll()); + var notifyQueues = new java.util.HashSet(); + for (var wakeup : ready) + if (wakeup.notifyQueue() != null) notifyQueues.add(wakeup.notifyQueue()); + if (!notifyQueues.isEmpty()) { + try { + // Rescued jobs may belong to queues consumed only by peers. Notify once due so a peer + // doesn't fetch too early and then wait for its next periodic poll. + river.transaction( + connection -> { + for (String queue : notifyQueues) river.notifyInsert(connection, queue); + return null; + }); + } catch (RuntimeException error) { + fetchWakeups.addAll(ready); + throw error; + } + } + return !ready.isEmpty(); + } + + private void claim(String queue, int count) { + List jobs = + river.transaction( + c -> + river + .pilot() + .claim( + c, + river.driver(), + new Pilot.Claim( + id, + queue, + count, + Instant.now(), + java.util.Set.copyOf(config.handlers.keySet()), + config.fetchOnlyKnownKinds), + ignored -> { + var result = new ArrayList(); + try (var statement = + Sql.prepare( + c, + Sql.query( + river.database(), + config.fetchOnlyKnownKinds ? "claim_known" : "claim"), + config.fetchOnlyKnownKinds + ? new Object[] { + river.database().timestamp(Instant.now()), + id, + queue, + river.database().timestamp(Instant.now()), + Json.encode(config.handlers.keySet()), + count + } + : new Object[] { + river.database().timestamp(Instant.now()), + id, + queue, + river.database().timestamp(Instant.now()), + count + }); + var rows = statement.executeQuery()) { + while (rows.next()) result.add(river.readPartial(rows)); + } + return result; + })); + for (var decoded : jobs) launch(decoded, config.handlers.get(decoded.job().kind())); + } + + private void launch(Client.Decoded decoded, Registration registration) { + var row = decoded.job(); + WorkContext context; + try { + if (decoded.failure() != null) throw decoded.failure(); + if (registration == null) + throw new IllegalArgumentException( + "job kind is not registered in the client's Workers bundle: " + row.kind()); + context = new WorkContext<>(river, river.typed(row, registration.type())); + } catch (Exception e) { + // Invalid jobs own committed claims too: retain their slots and retry acknowledgement. + launch( + row, + new WorkContext<>(river, row, false), + () -> { + throw e; + }); + return; + } + launch( + row, + context, + () -> { + context.timeout = timeout(registration.handler(), context.job()); + workMiddleware( + context, + 0, + () -> { + for (var hook : river.hooks()) + if (hook instanceof HookWorkBegin workHook) workHook.workBegin(context); + Throwable workFailure = null; + try { + registration.handler().work(context); + } catch (Exception | Error e) { + workFailure = e; + } + for (var hook : river.hooks()) + if (hook instanceof HookWorkEnd workHook) + workFailure = workHook.workEnd(context, workFailure); + if (workFailure instanceof Exception exception) throw exception; + if (workFailure instanceof Error error) throw error; + if (workFailure != null) throw new RuntimeException(workFailure); + }); + }); + } + + private void launch(Job row, WorkContext context, WorkContext.Step work) { + context.attach(this, null); + active.put(row.id(), context); + if (row.metadata().has("cancel_attempted_at")) + context.requestCancellation(WorkContext.Cancellation.REMOTE); + if (pendingCancellation.remove(row.id()) != null) + context.requestCancellation(WorkContext.Cancellation.REMOTE); + if (cancelOnStop) context.requestCancellation(WorkContext.Cancellation.SHUTDOWN); + executor.submit( + () -> { + activeThreads.put(row.id(), Thread.currentThread()); + Exception failure = null; + try { + observe("work_begin"); + work.run(); + } catch (Exception e) { + failure = e; + } catch (Throwable e) { + failure = new RuntimeException("Worker threw " + e, e); + } finally { + observe("work_end"); + } + // Interruption belongs to the handler, not to the durable acknowledgement that follows. + // Stop supervising its thread while retaining the slot until completion is committed. + failure = context.finish(failure); + activeThreads.remove(row.id()); + Thread.interrupted(); + // A claimed job keeps its slot until completion is durably acknowledged. + while (true) { + try { + for (var peer : List.copyOf(context.peers.values())) + completePeer( + context, + peer.context(), + failure == null + ? new IllegalStateException("Batch worker did not provide a peer outcome") + : failure); + complete(row, context, failure); + break; + } catch (Exception e) { + report(e); + try { + TimeUnit.MILLISECONDS.sleep(100); + } catch (InterruptedException interrupted) { + // A cancellation already in flight must not abandon this claimed job. + } + } + } + queueLock.lock(); + try { + active.remove(row.id(), context); + queueChanged.signalAll(); + } finally { + queueLock.unlock(); + } + wake.release(); + }); + } + + private void workMiddleware(WorkContext context, int index, WorkContext.Step next) + throws Exception { + if (index == river.workerMiddleware().size()) next.run(); + else + river + .workerMiddleware() + .get(index) + .work(context, () -> workMiddleware(context, index + 1, next)); + } + + List> claimPeers( + WorkContext parent, JobType type, Client.Transaction> operation) { + parent.checkRuntimeCancellation(); + var rows = + river.transaction( + connection -> { + var claimed = operation.run(connection); + river.pilot().afterPeerClaim(connection, river.driver(), claimed); + return claimed; + }); + // Register every committed claim before decoding any arguments. A decoding failure must still + // leave all claimed jobs owned by this attempt so its failure settles the entire batch. + for (var decoded : rows) { + var context = new WorkContext<>(river, decoded.job(), false); + context.timeout = config.jobTimeout; + parent.peers.put(decoded.job().id(), new WorkContext.Peer(decoded.job(), context)); + activeThreads.put(decoded.job().id(), Thread.currentThread()); + active.put(decoded.job().id(), context); + // Publish before checking the parent so a concurrent cancellation cannot miss both. + context.attach(this, parent); + } + var contexts = new ArrayList>(); + for (var decoded : rows) { + if (decoded.failure() != null) throw decoded.failure(); + var provisional = parent.peers.get(decoded.job().id()).context(); + // Decode the plugin's wire representation once, including when evaluating the timeout. + var job = decoded.job().mapArgs(ignored -> river.pilot().decode(decoded.job())); + var context = + provisional.withDecodedJob(job.mapArgs(args -> Json.decode(args, type.argsType()))); + var registration = config.handlers.get(job.kind()); + context.timeout = registration == null ? config.jobTimeout : timeout(registration, job); + parent.peers.put(decoded.job().id(), new WorkContext.Peer(decoded.job(), context)); + active.put(decoded.job().id(), context); + contexts.add(context); + } + return List.copyOf(contexts); + } + + void completePeer(WorkContext parent, WorkContext context, Exception failure) { + var peer = parent.peers.get(context.job().id()); + if (peer == null || peer.context() != context) + throw new IllegalArgumentException("Peer is not owned by this batch attempt"); + if (parent.cancellation() != null) context.requestCancellation(parent.cancellation()); + failure = context.finish(failure); + activeThreads.remove(context.job().id()); + complete(peer.row(), context, failure); + parent.peers.remove(context.job().id(), peer); + queueLock.lock(); + try { + active.remove(context.job().id(), context); + queueChanged.signalAll(); + } finally { + queueLock.unlock(); + } + } + + private void complete(Job original, WorkContext context, Exception failure) { + Instant now = Instant.now(); + var updates = context == null ? Json.object() : context.metadataUpdates(); + Job.State state = Job.State.COMPLETED; + Instant scheduled = original.scheduledAt(); + int attempt = original.attempt(); + EventKind event = EventKind.JOB_COMPLETED; + String error = null; + if (failure != null + && context != null + && context.cancellation() == WorkContext.Cancellation.REMOTE) { + state = Job.State.CANCELLED; + error = "JobCancelError: job cancelled remotely"; + event = EventKind.JOB_CANCELLED; + } else if (failure instanceof WorkContext.Control control) { + if (control.delay != null) { + scheduled = now.plus(control.delay); + state = bypassScheduler(control.delay) ? Job.State.AVAILABLE : Job.State.SCHEDULED; + attempt--; + updates.put("snoozes", nextSnoozeCount(original.metadata())); + event = EventKind.JOB_SNOOZED; + } else { + state = control.state; + error = control.getMessage(); + event = state == Job.State.CANCELLED ? EventKind.JOB_CANCELLED : EventKind.JOB_FAILED; + } + } else if (failure instanceof InterruptedException + && context != null + && context.cancellation() == WorkContext.Cancellation.SHUTDOWN) { + state = Job.State.AVAILABLE; + scheduled = now; + attempt--; + event = EventKind.JOB_INTERRUPTED; + } else if (failure != null) { + error = failure.getMessage() == null ? failure.toString() : failure.getMessage(); + boolean cancelled = config.cancelOnError; + state = + cancelled + ? Job.State.CANCELLED + : original.attempt() >= original.maxAttempts() + ? Job.State.DISCARDED + : Job.State.RETRYABLE; + if (state == Job.State.RETRYABLE) { + Duration delay = retryDelay(original, now); + scheduled = now.plus(delay); + if (bypassScheduler(delay)) state = Job.State.AVAILABLE; + } + event = cancelled ? EventKind.JOB_CANCELLED : EventKind.JOB_FAILED; + } + String trace = ""; + if (failure != null && failure.getCause() instanceof Error) { + var text = new java.io.StringWriter(); + failure.printStackTrace(new java.io.PrintWriter(text)); + trace = text.toString(); + } + String errors = + error == null + ? null + : Json.encode( + new Job.AttemptError(original.attemptedAt(), original.attempt(), error, trace)); + var done = new java.util.concurrent.CompletableFuture>(); + completions.add( + new Completion( + original, + new Object[] { + state.value(), + river.database().timestamp(now), + context == null ? null : context.checkpointStep(), + context == null ? "{}" : Json.encode(context.checkpointFallback()), + Json.encode(updates), + state.value(), + river.database().timestamp(state.isFinalized() ? now : null), + river.database().timestamp(scheduled), + attempt, + errors, + errors, + original.id(), + original.attempt(), + river.database().timestamp(original.attemptedAt()) + }, + done)); + var updated = done.join(); + // An ephemeral job may already have been completed and deleted in the application's + // transaction. + if (updated == null) return; + // Short retries and snoozes bypass the scheduler. The immediate completion wake-up may + // precede scheduled_at, so also wake fetching when the persisted timestamp becomes due. + if (updated.state() == Job.State.AVAILABLE) + fetchWakeups.add(new FetchWakeup(updated.scheduledAt(), null)); + // Another process may have changed the attempt. Match Go's events to the persisted state. + event = + switch (updated.state()) { + case AVAILABLE -> + switch (event) { + case JOB_INTERRUPTED, JOB_SNOOZED -> event; + default -> EventKind.JOB_FAILED; + }; + case CANCELLED -> EventKind.JOB_CANCELLED; + case COMPLETED -> EventKind.JOB_COMPLETED; + case DISCARDED, RETRYABLE -> EventKind.JOB_FAILED; + case SCHEDULED -> EventKind.JOB_SNOOZED; + case PENDING, RUNNING -> null; + }; + if (event != null) publish(new Event(event, updated)); + } + + private boolean bypassScheduler(Duration delay) { + // Maintenance runs from election ticks. Longer service intervals must not strand short + // delays in states that only the scheduler can make available. + return delay.compareTo(config.maintenanceInterval) <= 0 + || delay.compareTo(config.serviceInterval) <= 0; + } + + private void completeBatches() { + while (running.get() + || dispatcherStopped.getCount() != 0 + || !active.isEmpty() + || !completions.isEmpty()) { + try { + var first = completions.poll(20, TimeUnit.MILLISECONDS); + if (first == null) continue; + var batch = new ArrayList(); + batch.add(first); + completions.drainTo(batch, 999); + try { + var results = + river.transaction( + c -> { + var rows = new ArrayList>(); + for (var completion : batch) + try (var statement = + Sql.prepare( + c, Sql.query(river.database(), "complete"), completion.params); + var result = statement.executeQuery()) { + if (result.next()) rows.add(river.readPartial(result).job()); + else { + try { + rows.add(river.get(c, completion.original.id())); + } catch (RiverException error) { + if (error.code() != RiverException.Code.NOT_FOUND) throw error; + rows.add(null); + } + } + } + for (var job : rows) + if (job != null) river.pilot().afterStateChange(c, river.driver(), job); + for (var completion : batch) + river.pilot().afterAttempt(c, river.driver(), completion.original); + return rows; + }); + for (int i = 0; i < batch.size(); i++) batch.get(i).done.complete(results.get(i)); + } catch (Exception e) { + for (var completion : batch) completion.done.completeExceptionally(e); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private void heartbeat(String queue) { + river.transaction( + c -> { + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "queue_heartbeat"), + queue, + river.database().timestamp(Instant.now()), + river.database().timestamp(Instant.now()))) { + statement.executeUpdate(); + } + river + .pilot() + .producer( + c, + river.driver(), + new Pilot.Producer( + id, + queue, + queues.getOrDefault(queue, config.queues.getOrDefault(queue, 1)), + active.values().stream().>map(WorkContext::job).toList(), + !running.get() || !queues.containsKey(queue), + Instant.now())); + return null; + }); + } + + private void listen() { + if (river.database().dialect() == Database.Dialect.SQLITE) { + listenSqlite(); + return; + } + while (running.get()) { + try (var connection = river.database().connection()) { + if (!river.database().supportsNotifications(connection)) return; + listener = connection; + String schema = river.database().schema(); + if (schema.isEmpty()) + try (var statement = connection.createStatement(); + var rows = statement.executeQuery("SELECT current_schema()")) { + rows.next(); + schema = rows.getString(1); + } + for (String topic : + List.of(Protocol.TOPIC_INSERT, Protocol.TOPIC_CONTROL, Protocol.TOPIC_LEADERSHIP)) + try (var statement = connection.createStatement()) { + statement.execute("LISTEN \"" + schema.replace("\"", "\"\"") + "." + topic + "\""); + } + wake.release(); + observe("listen_ready"); + var postgres = connection.unwrap(PGConnection.class); + try { + while (running.get()) { + var notifications = postgres.getNotifications(25); + if (notifications != null) + for (var notification : notifications) + if (notification.getName().startsWith(schema + ".")) + deliver( + notification.getName().substring(schema.length() + 1), + notification.getParameter()); + } + } finally { + try (var statement = connection.createStatement()) { + statement.execute("UNLISTEN *"); + } + } + } catch (Exception e) { + if (running.get()) { + report(e); + try { + TimeUnit.MILLISECONDS.sleep(100); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + } + } finally { + listener = null; + } + } + } + + private void deliver(String topic, String payload) { + // An unrelated publisher must not discard the remaining notifications in this batch. + try { + notifications.dispatch(topic, payload); + } catch (RuntimeException error) { + report(error); + } + } + + private void listenSqlite() { + long after = 0; + boolean initialized = false; + while (running.get()) { + try { + if (!initialized) { + after = + river.transaction( + c -> { + try (var statement = + Sql.prepare(c, Sql.query(river.database(), "notification_cursor")); + var rows = statement.executeQuery()) { + rows.next(); + return rows.getLong(1); + } + }); + initialized = true; + // The initial cursor skips earlier notifications, including inserts since the first + // fetch. Poll again once listening starts, as the Postgres listener does. + wake.release(); + observe("listen_ready"); + } + final long cursor = after; + var messages = + river.transaction( + c -> { + var result = new ArrayList(); + try (var statement = + Sql.prepare(c, Sql.query(river.database(), "notifications"), cursor); + var rows = statement.executeQuery()) { + while (rows.next()) + result.add( + new Notification( + rows.getLong("id"), + rows.getString("topic"), + rows.getString("payload"))); + } + return result; + }); + // Release the read snapshot before a control message starts a write transaction. + for (var message : messages) { + after = message.id(); + deliver(message.topic(), message.payload()); + } + } catch (Exception e) { + report(e); + } + try { + TimeUnit.MILLISECONDS.sleep(25); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private record Notification(long id, String topic, String payload) {} + + private void leadershipNotification(Protocol.LeadershipNotice notice) { + if (notice == Protocol.LeadershipNotice.REQUEST_RESIGN) { + try { + if (isLeader()) resign(); + } finally { + leadershipWake.release(); + } + } else leadershipWake.release(); + } + + private void queueNotification(Protocol.QueueNotice notice) { + String action = notice.action(); + if (action.equals("pause") || action.equals("resume")) { + String queue = notice.queue(); + for (String name : queues.keySet()) + if (queue.equals("*") || queue.equals(name)) { + boolean changed = + action.equals("pause") ? pausedQueues.add(name) : pausedQueues.remove(name); + if (changed) + publish( + new Event( + action.equals("pause") ? EventKind.QUEUE_PAUSED : EventKind.QUEUE_RESUMED, + null, + river.queues().get(name))); + } + } + wake.release(); + } + + private void elect() { + while (running.get()) { + leadershipWake.drainPermits(); + long delay = config.serviceInterval.toNanos(); + try { + maintain(); + observe("leadership_check"); + } catch (Exception error) { + report(error); + delay = Math.min(delay, TimeUnit.SECONDS.toNanos(1)); + } + if (!running.get()) return; + try { + leadershipWake.tryAcquire(delay, TimeUnit.NANOSECONDS); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private void maintain() throws Exception { + leadershipLock.lock(); + try { + if (resigningTerm != null) resign(); + if (System.nanoTime() - leaderEligibleAt < 0) return; + long attemptStarted = System.nanoTime(); + Instant now = Instant.now(); + Instant expiresAt = now.plus(config.serviceInterval).plusSeconds(10); + var previousTerm = leaderTerm; + if (previousTerm != null && !previousTerm.active(attemptStarted)) { + resign(); + return; + } + Instant elected = + river.transaction( + c -> { + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "leader_expire"), + river.database().timestamp(now))) { + statement.executeUpdate(); + } + if (previousTerm != null) { + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "leader_renew"), + river.database().timestamp(expiresAt), + id, + river.database().timestamp(previousTerm.electedAt()), + river.database().timestamp(now))) { + if (statement.executeUpdate() > 0) return previousTerm.electedAt(); + } + } + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "leader_elect"), + id, + river.database().timestamp(now), + river.database().timestamp(expiresAt)); + var rows = statement.executeQuery()) { + return rows.next() ? Database.instant(rows.getString("elected_at")) : null; + } + }); + // A lease is ours only after commit, including when regaining an expired term. + // Like Go, stop trusting it one second before its database expiry, measuring elapsed + // time from before the transaction so pool waits and slow commits consume the lease. + leaderTerm = + elected == null + ? null + : new LeadershipTerm( + elected, attemptStarted + config.serviceInterval.plusSeconds(9).toNanos()); + if (elected == null) return; + long committedAt = System.nanoTime(); + if (!leaderTerm.active(committedAt) + || (previousTerm != null && !previousTerm.active(committedAt))) { + resign(); + return; + } + if (!running.get()) return; + if (!elected.equals(initializedTerm)) { + river.pilot().leadershipStarted(); + for (var hook : river.hooks()) + if (hook instanceof HookPeriodicJobsStart periodicHook) periodicHook.start(); + initializedTerm = elected; + } + Instant term = elected; + if (running.get() && maintenanceBusy.compareAndSet(false, true)) + try { + executor.submit( + () -> { + try { + services(now, term); + } catch (Exception error) { + report(error); + } finally { + maintenanceBusy.set(false); + if (periodicChanged.get()) leadershipWake.release(); + } + }); + } catch (java.util.concurrent.RejectedExecutionException error) { + maintenanceBusy.set(false); + // Stop can close the executor after the running check while election is finishing. + if (running.get()) throw error; + } + } finally { + leadershipLock.unlock(); + } + } + + private void services(Instant now, Instant term) throws SQLException { + periodicChanged.set(false); + for (var state : periodic.snapshot()) { + var job = state.job; + if (!ownsTerm(term)) return; + if (!periodic.contains(state)) continue; + try { + Instant next = term.equals(state.term) ? state.next : null; + if (next == null) + next = + job.runOnStart() + ? now + : job.schedule() + .next(now.atZone(java.time.ZoneId.systemDefault())) + .map(java.time.ZonedDateTime::toInstant) + .orElse(Instant.MAX); + if (!next.isAfter(now)) { + Instant following = + job.schedule() + .next(next.atZone(java.time.ZoneId.systemDefault())) + .map(java.time.ZonedDateTime::toInstant) + .orElse(Instant.MAX); + if (!ownsTerm(term)) return; + if (!periodic.contains(state)) continue; + job.insert(river, next); + next = following; + } + if (!ownsTerm(term)) return; + state.next = next; + state.term = term; + } catch (Exception error) { + // One failing schedule or insertion must not block other jobs or leader maintenance. + report(error); + } + } + if (!ownsTerm(term)) return; + schedule(now); + if (!ownsTerm(term)) return; + river.pilot().maintain(river, now); + if (!ownsTerm(term)) return; + rescue(now, term); + if (!ownsTerm(term)) return; + cleanNotifications(now); + if (!ownsTerm(term)) return; + clean(now); + if (!now.isBefore(nextReindex) && ownsTerm(term)) { + nextReindex = + config.reindexInterval == null + ? now.atOffset(java.time.ZoneOffset.UTC) + .toLocalDate() + .plusDays(1) + .atStartOfDay() + .toInstant(java.time.ZoneOffset.UTC) + : now.plus(config.reindexInterval); + reindex(); + } + } + + private boolean ownsTerm(Instant term) { + var current = leaderTerm; + return running.get() + && current != null + && term.equals(current.electedAt()) + && current.active(System.nanoTime()); + } + + private record LeadershipTerm(Instant electedAt, long trustedUntil) { + boolean active(long now) { + return trustedUntil - now > 0; + } + } + + private void clean(Instant now) { + if (river + .pilot() + .clean( + river, + now, + config.cancelledRetention, + config.completedRetention, + config.discardedRetention)) return; + river.transaction( + c -> { + Object cancelled = + config.cancelledRetention.isNegative() + ? null + : river.database().timestamp(now.minus(config.cancelledRetention)); + Object completed = + config.completedRetention.isNegative() + ? null + : river.database().timestamp(now.minus(config.completedRetention)); + Object discarded = + config.discardedRetention.isNegative() + ? null + : river.database().timestamp(now.minus(config.discardedRetention)); + try (var statement = + Sql.prepare( + c, Sql.query(river.database(), "clean_jobs"), cancelled, completed, discarded)) { + statement.executeUpdate(); + } + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "clean_queues"), + river.database().timestamp(now.minus(QUEUE_RETENTION)))) { + statement.executeUpdate(); + } + return null; + }); + } + + private void cleanNotifications(Instant now) { + if (river.database().dialect() != Database.Dialect.SQLITE) return; + river.transaction( + c -> { + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "clean_notifications"), + river.database().timestamp(now.minus(Duration.ofMinutes(5))))) { + return statement.executeUpdate(); + } + }); + } + + private void reindex() throws SQLException { + if (river.database().dialect() != Database.Dialect.POSTGRES) return; + try (var c = river.database().connection()) { + for (String index : config.reindexNames) { + String qualified; + try (var statement = + Sql.prepare(c, Sql.query(river.database(), "reindex_candidate"), index, index); + var rows = statement.executeQuery()) { + if (!rows.next()) continue; + qualified = rows.getString(1); + } + try (var statement = c.createStatement()) { + statement.setQueryTimeout(300); + statement.execute(Sql.query(river.database(), "reindex").replace("{index}", qualified)); + } + } + } + } + + private void schedule(Instant now) { + river.transaction( + c -> { + if (river.database().dialect() == Database.Dialect.SQLITE) + try (var statement = Sql.prepare(c, Sql.query(river.database(), "writer_lock"))) { + statement.executeUpdate(); + } + var due = new ArrayList(); + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "schedule_select"), + river.database().timestamp(now)); + var rows = statement.executeQuery()) { + while (rows.next()) + due.add(new Scheduled(rows.getLong("id"), rows.getBytes("unique_key"))); + } + for (var job : due) { + boolean collision = false; + if (job.key() != null) + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "schedule_collision"), + job.id(), + job.key()); + var rows = statement.executeQuery()) { + collision = rows.next(); + } + var savepoint = c.setSavepoint(); + try { + if (!collision) + try (var statement = + Sql.prepare( + c, Sql.query(river.database(), "schedule_available"), job.id()); + var rows = statement.executeQuery()) { + if (rows.next()) river.notifyInsert(c, rows.getString(1)); + } + } catch (SQLException error) { + if (!"23505".equals(error.getSQLState())) throw error; + c.rollback(savepoint); + collision = true; + } finally { + c.releaseSavepoint(savepoint); + } + if (collision) + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "schedule_discard"), + river.database().timestamp(now), + job.id())) { + statement.executeUpdate(); + } + } + return null; + }); + } + + private record Scheduled(long id, byte[] key) {} + + private void rescue(Instant now, Instant term) { + if (river + .pilot() + .rescue( + river, + new Pilot.Rescue( + now, + now.minus(config.rescueAfter), + java.util.Set.copyOf(active.keySet()), + java.util.Set.copyOf(config.handlers.keySet()), + this::rescueTimeout, + config.retryPolicy))) return; + long after = 0; + while (ownsTerm(term)) { + final long cursor = after; + var batch = + river.transaction( + c -> { + var stuck = new ArrayList>(); + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "rescue_select"), + river.database().timestamp(now.minus(config.rescueAfter)), + cursor); + var rows = statement.executeQuery()) { + while (rows.next()) stuck.add(river.read(rows)); + } + return stuck; + }); + var rescues = new ArrayList(); + for (var job : batch) { + if (active.containsKey(job.id())) continue; + boolean cancel = false; + if (job.metadata().path("cancel_attempted_at").isString()) + try { + cancel = + !Instant.parse(job.metadata().path("cancel_attempted_at").asString()) + .equals(Instant.parse("0001-01-01T00:00:00Z")); + } catch (RuntimeException ignored) { + } + if (!cancel && config.handlers.containsKey(job.kind())) { + var timeout = rescueTimeout(job); + if (timeout.isNegative() + || Duration.between(job.attemptedAt(), now).compareTo(timeout) < 0) continue; + } + Job.State state = + cancel + ? Job.State.CANCELLED + : !config.handlers.containsKey(job.kind()) || job.attempt() >= job.maxAttempts() + ? Job.State.DISCARDED + : Job.State.RETRYABLE; + Instant scheduled = job.scheduledAt(); + if (!state.isFinalized()) { + Duration delay = retryDelay(job, now); + scheduled = now.plus(delay); + if (bypassScheduler(delay)) state = Job.State.AVAILABLE; + } + String error = + Json.encode( + new Job.AttemptError(now, job.attempt(), "Stuck job rescued by JobRescuer", "")); + String metadata = + Json.encode( + Map.of( + Protocol.METADATA_RESCUE_COUNT, + job.metadata().path(Protocol.METADATA_RESCUE_COUNT).asLong(0) + 1)); + rescues.add(new Rescue(error, job, metadata, scheduled, state)); + } + if (!ownsTerm(term)) return; + var ready = new ArrayList(); + river.transaction( + c -> { + for (var rescue : rescues) { + var job = rescue.job(); + if (active.containsKey(job.id())) continue; + var state = rescue.state(); + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "rescue_update"), + state.value(), + river.database().timestamp(state.isFinalized() ? now : null), + river.database().timestamp(rescue.scheduled()), + job.attempt(), + rescue.metadata(), + rescue.error(), + rescue.error(), + job.id(), + job.attempt(), + river.database().timestamp(job.attemptedAt())); + var rows = statement.executeQuery()) { + if (rows.next() && state == Job.State.AVAILABLE) + ready.add( + new FetchWakeup( + Database.instant(rows.getString("scheduled_at")), job.queue())); + } + } + return null; + }); + // Only committed rescues may wake fetching; use the database's timestamp precision. + fetchWakeups.addAll(ready); + if (!ready.isEmpty()) wake.release(); + if (batch.size() < 1000) break; + after = batch.getLast().id(); + } + } + + private record Rescue( + String error, Job job, String metadata, Instant scheduled, Job.State state) {} + + private Duration rescueTimeout(Job job) { + try { + return timeout(job); + } catch (Throwable error) { + // Malformed arguments or timeout callbacks must not prevent abandoned jobs being rescued. + report(error); + return Duration.ZERO; + } + } + + private void supervise() { + while (running.get() || dispatcherStopped.getCount() != 0 || !active.isEmpty()) { + for (var context : active.values()) { + // Attempts retain their deadlines while fetching is blocked or gracefully stopping. + var timeout = context.timeout; + if (timeout != null + && !timeout.isNegative() + && Duration.between(context.job().attemptedAt(), Instant.now()).compareTo(timeout) >= 0) + context.requestCancellation(WorkContext.Cancellation.TIMEOUT); + if (context.forceIfStuck(config.stuckThreshold)) { + observe("stuck_job"); + var thread = activeThreads.get(context.job().id()); + if (thread != null) thread.interrupt(); + } + } + try { + TimeUnit.MILLISECONDS.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private Duration timeout(Job job) { + var registration = config.handlers.get(job.kind()); + return registration == null + ? config.jobTimeout + : timeout(registration, job.mapArgs(ignored -> river.pilot().decode(job))); + } + + private Duration timeout(Registration registration, Job decoded) { + return timeout( + registration.handler(), + decoded.mapArgs(args -> Json.decode(args, registration.type().argsType()))); + } + + private Duration timeout(Handler handler, Job job) { + var timeout = handler.timeout(job); + if (timeout == null || timeout.isZero()) return config.jobTimeout; + Client.Builder.duration(timeout, "Worker timeout"); + return timeout; + } + + private void observe(String event) { + for (var observer : config.observers) + try { + observer.accept(event); + } catch (Throwable error) { + report(error); + } + } + + private void publish(Event event) { + for (var subscriber : river.subscribers()) + try { + subscriber.accept(event); + } catch (Throwable e) { + report(e); + } + } + + private void report(Throwable error) { + try { + config.errorHandler.accept(error); + } catch (Throwable handlerFailure) { + // Reporting failures must not strand claimed jobs or terminate a background service. + var logger = System.getLogger("com.riverqueue"); + logger.log(System.Logger.Level.WARNING, "River runtime failure", error); + logger.log(System.Logger.Level.WARNING, "River error handler failed", handlerFailure); + } + } + + private Duration retryDelay(Job job, Instant now) { + try { + var delay = Objects.requireNonNull(config.retryPolicy.delay(job), "Retry delay"); + if (delay.isNegative()) + throw new IllegalArgumentException("Retry delay must not be negative"); + delay.toNanos(); + now.plus(delay); + return delay; + } catch (Throwable error) { + report(new IllegalArgumentException("Retry policy failed; using the default delay", error)); + return defaultRetryPolicy.delay(job); + } + } + + private void resign() { + leadershipLock.lock(); + try { + if (leaderTerm != null) { + resigningTerm = leaderTerm.electedAt(); + leaderTerm = null; + initializedTerm = null; + leaderEligibleAt = System.nanoTime() + config.serviceInterval.toNanos(); + } + // Stop local services immediately, but retain the identity until deletion commits so a + // failed resignation can be retried without waiting for the lease to expire. + Instant elected = resigningTerm; + if (elected == null) return; + river.transaction( + c -> { + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "leader_resign"), + id, + river.database().timestamp(elected))) { + if (statement.executeUpdate() > 0) river.notify(c, Protocol.resigned(id)); + } + return null; + }); + resigningTerm = null; + } finally { + leadershipLock.unlock(); + } + } + + static long nextSnoozeCount(JsonNode metadata) { + var value = metadata.path("snoozes"); + // Go's gjson treats true as one; Jackson's numeric coercion does not. + return (value.isBoolean() ? (value.asBoolean() ? 1 : 0) : value.asLong(0)) + 1; + } + + static void validateQueue(String name, int workers) { + if (workers < 1) throw new IllegalArgumentException("maxWorkers must be positive"); + Client.validate(InsertOptions.builder().queue(name).build().resolve(InsertOptions.defaults())); + } + + private record Completion( + Job original, + Object[] params, + java.util.concurrent.CompletableFuture> done) {} +} diff --git a/java/river/src/main/java/com/riverqueue/WorkContext.java b/java/river/src/main/java/com/riverqueue/WorkContext.java index f46c527f2..03c4bc95b 100644 --- a/java/river/src/main/java/com/riverqueue/WorkContext.java +++ b/java/river/src/main/java/com/riverqueue/WorkContext.java @@ -25,7 +25,7 @@ public final class WorkContext { private final Job job; private final ObjectNode metadata = Json.object(); private final Client client; - private Workers runtime; + private RunHandle runtime; final java.util.Map peers; private final SortedSet pendingCursorSteps = new TreeSet<>(); private ObjectNode previousCursors; @@ -80,7 +80,7 @@ public void completePeer(WorkContext peer, Exception failure) { runtime.completePeer(this, peer, failure); } - void attach(Workers runtime, WorkContext parent) { + void attach(RunHandle runtime, WorkContext parent) { this.runtime = runtime; if (parent != null && parent.cancellation() != null) requestCancellation(parent.cancellation()); } diff --git a/java/river/src/main/java/com/riverqueue/Workers.java b/java/river/src/main/java/com/riverqueue/Workers.java index 567d515e1..7610f83b7 100644 --- a/java/river/src/main/java/com/riverqueue/Workers.java +++ b/java/river/src/main/java/com/riverqueue/Workers.java @@ -1,1763 +1,30 @@ package com.riverqueue; -import java.nio.charset.StandardCharsets; -import java.sql.Connection; -import java.sql.SQLException; import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.PriorityBlockingQueue; -import java.util.concurrent.Semaphore; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.locks.Condition; -import java.util.concurrent.locks.ReentrantLock; -import java.util.function.Consumer; -import org.postgresql.PGConnection; -import tools.jackson.databind.JsonNode; -/** A bounded worker runtime using one virtual thread per active job. */ -public final class Workers implements AutoCloseable { - private static final Duration QUEUE_RETENTION = Duration.ofDays(1); +/** Registry of typed job handlers. Clients snapshot registrations when configured. */ +public final class Workers { + private final Map> registrations = new LinkedHashMap<>(); - private final Map> active = new ConcurrentHashMap<>(); - private final Map activeThreads = new ConcurrentHashMap<>(); - private final Map pendingCancellation = new ConcurrentHashMap<>(); - private final AtomicBoolean maintenanceBusy = new AtomicBoolean(); - private volatile Instant nextReindex = - Instant.now() - .atOffset(java.time.ZoneOffset.UTC) - .toLocalDate() - .plusDays(1) - .atStartOfDay() - .toInstant(java.time.ZoneOffset.UTC); - private final Builder config; - private final RetryPolicy defaultRetryPolicy = RetryPolicy.defaults(); - private final java.util.concurrent.BlockingQueue completions = - new java.util.concurrent.LinkedBlockingQueue<>(); - private final java.util.concurrent.CountDownLatch dispatcherStopped = - new java.util.concurrent.CountDownLatch(1); - // Claims may commit after stop's active-map scan; launch must see the cancellation request. - private volatile boolean cancelOnStop; - private boolean stopFinalized; - private final ReentrantLock stopLock = new ReentrantLock(); - private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); - private final PriorityBlockingQueue fetchWakeups = new PriorityBlockingQueue<>(); - private final String id; - private final Protocol.Dispatcher notifications; - // Blocking under monitor locks pins virtual threads on Java 21; use locks that allow yielding. - private final ReentrantLock leadershipLock = new ReentrantLock(); - private volatile LeadershipTerm leaderTerm; - private Instant initializedTerm; - private Instant resigningTerm; - private long leaderEligibleAt = System.nanoTime(); - private volatile Connection listener; - private final Map queues = new ConcurrentHashMap<>(); - private final PeriodicJobBundle periodic; - private final AtomicBoolean periodicChanged = new AtomicBoolean(); - private final java.util.Set pausedQueues = ConcurrentHashMap.newKeySet(); - private final ReentrantLock queueLock = new ReentrantLock(); - private final Condition queueChanged = queueLock.newCondition(); - private final java.util.Set drainingQueues = ConcurrentHashMap.newKeySet(); - private final Semaphore leadershipWake = new Semaphore(0); - private final Client river; - private final Runnable unsubscribeInsert; - private final AtomicBoolean running = new AtomicBoolean(true); - private final List> subscribers = new CopyOnWriteArrayList<>(); - private final Semaphore wake = new Semaphore(0); + /** Creates an empty handler registry. */ + public Workers() {} - private Workers(Builder config) { - this.config = config; - river = config.river; - id = config.id == null ? "java-" + UUID.randomUUID() : config.id; - notifications = - new Protocol.Dispatcher( - id, active, pendingCancellation, this::queueNotification, this::leadershipNotification); - queues.putAll(config.queues); - periodic = - new PeriodicJobBundle( - config.periodic, - config.leadership, - running::get, - () -> { - if (periodicChanged.compareAndSet(false, true) && isLeader()) - leadershipWake.release(); - }); - if (queues.isEmpty() || config.handlers.isEmpty()) - throw new IllegalArgumentException("Workers require queues and handlers"); - if (config.reindexInterval != null) nextReindex = Instant.now().plus(config.reindexInterval); - for (String queue : queues.keySet()) heartbeat(queue); - unsubscribeInsert = river.onInsertCommit(wake::release); - if (!config.pollOnly) executor.submit(this::listen); - executor.submit(this::completeBatches); - executor.submit(this::supervise); - executor.submit(this::dispatch); - if (config.leadership) executor.submit(this::elect); + /** Registers a handler for a job kind, rejecting duplicate kinds. */ + public Workers add(JobType type, Handler handler) { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(handler, "handler"); + if (registrations.putIfAbsent(type.kind(), new Registration<>(type, handler)) != null) + throw new IllegalArgumentException("Duplicate worker kind"); + return this; } - public void addQueue(String name, int maxWorkers) { - validateQueue(name, maxWorkers); - queueLock.lock(); - try { - if (drainingQueues.contains(name)) - throw new IllegalStateException("Queue is draining: " + name); - queues.put(name, maxWorkers); - heartbeat(name); - } finally { - queueLock.unlock(); - } - wake.release(); - } - - @Override - public void close() { - stop(); - } - - public String id() { - return id; - } - - /** Reports whether this runtime still trusts its committed leadership lease. */ - public boolean isLeader() { - return isLeader(System.nanoTime()); - } - - boolean isLeader(long now) { - var term = leaderTerm; - return running.get() && term != null && term.active(now); - } - - /** Returns this runtime's live, thread-safe periodic job bundle. */ - public PeriodicJobBundle periodicJobs() { - return periodic; - } - - public void removeQueue(String name) { - queueLock.lock(); - try { - if (!queues.containsKey(name) || !drainingQueues.add(name)) - throw new RiverException(RiverException.Code.NOT_FOUND, "Queue not configured: " + name); - try { - // Keep the producer lease alive until every committed claim has settled. - long deadline = System.nanoTime() + config.stopTimeout.toNanos(); - while (active.values().stream().anyMatch(c -> c.job().queue().equals(name))) { - long left = deadline - System.nanoTime(); - if (left <= 0) - throw new RiverException(RiverException.Code.REJECTED, "Queue did not drain: " + name); - queueChanged.awaitNanos(left); - } - queues.remove(name); - heartbeat(name); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new RiverException( - RiverException.Code.REJECTED, "Interrupted draining queue: " + name, error); - } finally { - drainingQueues.remove(name); - } - } finally { - queueLock.unlock(); - } - } - - /** - * Stops fetching jobs and waits for active attempts to finish. A timeout leaves attempts running; - * call again to continue waiting, or use {@link #stopAndCancel()} to request cancellation. - */ - public void stop() { - stop(false); - } - - private void stop(boolean cancel) { - if (running.getAndSet(false)) { - unsubscribeInsert.run(); - leadershipWake.release(); - } - if (cancel) cancelOnStop = true; - long deadline = System.nanoTime() + config.stopTimeout.toNanos(); - if (cancel) - active.values().forEach(c -> c.requestCancellation(WorkContext.Cancellation.SHUTDOWN)); - wake.release(); - try { - if (!dispatcherStopped.await(Math.max(0, deadline - System.nanoTime()), TimeUnit.NANOSECONDS)) - throw new RiverException( - RiverException.Code.REJECTED, "Dispatcher did not stop within the stop deadline"); - executor.shutdown(); - if (!executor.awaitTermination( - Math.max(0, deadline - System.nanoTime()), TimeUnit.NANOSECONDS)) - throw new RiverException( - RiverException.Code.REJECTED, "Workers have not stopped within the stop deadline"); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RiverException(RiverException.Code.REJECTED, "Interrupted waiting for workers", e); - } - stopLock.lock(); - try { - if (!stopFinalized) { - RuntimeException failure = null; - try { - queues.keySet().forEach(this::heartbeat); - } catch (RuntimeException error) { - failure = error; - } - try { - resign(); - } catch (RuntimeException error) { - if (failure == null) failure = error; - else failure.addSuppressed(error); - } - if (failure != null) throw failure; - stopFinalized = true; - } - } finally { - stopLock.unlock(); - } - } - - /** Stops fetching, requests cancellation of active attempts, and waits for them to finish. */ - public void stopAndCancel() { - stop(true); - } - - /** Subscribes to the requested event kinds, or all kinds when none are supplied. */ - public Subscription subscribe(Consumer subscriber, EventKind... kinds) { - Objects.requireNonNull(subscriber, "subscriber"); - Set selected = Set.copyOf(List.of(kinds)); - Consumer filtered = - event -> { - if (selected.isEmpty() || selected.contains(event.kind())) subscriber.accept(event); - }; - subscribers.add(filtered); - return () -> subscribers.remove(filtered); - } - - private void dispatch() { - long nextPoll = 0; - long nextMaintenance = 0; - long nextCancelPoll = 0; - while (running.get()) { - try { - long now = System.nanoTime(); - boolean notified = wake.drainPermits() > 0; - if (wakeDueFetches()) notified = true; - if (notified || now >= nextPoll) { - queueLock.lock(); - try { - for (var queue : queues.entrySet()) { - if (drainingQueues.contains(queue.getKey())) continue; - int available = - river.pilot().workerLimit(queue.getKey(), queue.getValue()) - - (int) - active.values().stream() - .filter(c -> c.job().queue().equals(queue.getKey())) - .count(); - if (available > 0) claim(queue.getKey(), available); - } - } finally { - queueLock.unlock(); - } - nextPoll = now + config.pollInterval.toNanos(); - } - if (now >= nextMaintenance) { - queues.keySet().forEach(this::heartbeat); - nextMaintenance = now + config.serviceInterval.toNanos(); - } - if (now >= nextCancelPoll) { - river.transaction( - c -> { - try (var statement = - Sql.prepare(c, Sql.query(river.database(), "cancel_requested")); - var rows = statement.executeQuery()) { - while (rows.next()) { - var context = active.get(rows.getLong(1)); - if (context != null) - context.requestCancellation(WorkContext.Cancellation.REMOTE); - } - } - return null; - }); - pendingCancellation - .entrySet() - .removeIf(entry -> now - entry.getValue() > Duration.ofMinutes(1).toNanos()); - nextCancelPoll = now + TimeUnit.MILLISECONDS.toNanos(50); - } - if (wake.tryAcquire(10, TimeUnit.MILLISECONDS)) wake.release(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } catch (Exception e) { - // A failed claim must not consume an insert notification or a timed retry. - nextPoll = 0; - report(e); - try { - TimeUnit.MILLISECONDS.sleep(100); - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - break; - } - } - } - dispatcherStopped.countDown(); - } - - private record FetchWakeup(Instant scheduledAt, String notifyQueue) - implements Comparable { - @Override - public int compareTo(FetchWakeup other) { - return scheduledAt.compareTo(other.scheduledAt); - } - } - - private boolean wakeDueFetches() { - Instant now = Instant.now(); - var ready = new ArrayList(); - FetchWakeup due; - while ((due = fetchWakeups.peek()) != null && !due.scheduledAt().isAfter(now)) - ready.add(fetchWakeups.poll()); - var notifyQueues = new java.util.HashSet(); - for (var wakeup : ready) - if (wakeup.notifyQueue() != null) notifyQueues.add(wakeup.notifyQueue()); - if (!notifyQueues.isEmpty()) { - try { - // Rescued jobs may belong to queues consumed only by peers. Notify once due so a peer - // doesn't fetch too early and then wait for its next periodic poll. - river.transaction( - connection -> { - for (String queue : notifyQueues) river.notifyInsert(connection, queue); - return null; - }); - } catch (RuntimeException error) { - fetchWakeups.addAll(ready); - throw error; - } - } - return !ready.isEmpty(); - } - - private void claim(String queue, int count) { - List jobs = - river.transaction( - c -> - river - .pilot() - .claim( - c, - river.driver(), - new Pilot.Claim( - id, - queue, - count, - Instant.now(), - java.util.Set.copyOf(config.handlers.keySet()), - config.fetchOnlyKnownKinds), - ignored -> { - var result = new ArrayList(); - try (var statement = - Sql.prepare( - c, - Sql.query( - river.database(), - config.fetchOnlyKnownKinds ? "claim_known" : "claim"), - config.fetchOnlyKnownKinds - ? new Object[] { - river.database().timestamp(Instant.now()), - id, - queue, - river.database().timestamp(Instant.now()), - Json.encode(config.handlers.keySet()), - count - } - : new Object[] { - river.database().timestamp(Instant.now()), - id, - queue, - river.database().timestamp(Instant.now()), - count - }); - var rows = statement.executeQuery()) { - while (rows.next()) result.add(river.readPartial(rows)); - } - return result; - })); - for (var decoded : jobs) launch(decoded, config.handlers.get(decoded.job().kind())); - } - - private void launch(Client.Decoded decoded, Registration registration) { - var row = decoded.job(); - WorkContext context; - try { - if (decoded.failure() != null) throw decoded.failure(); - if (registration == null) - throw new IllegalArgumentException( - "job kind is not registered in the client's Workers bundle: " + row.kind()); - context = new WorkContext<>(river, river.typed(row, registration.type)); - } catch (Exception e) { - // Invalid jobs own committed claims too: retain their slots and retry acknowledgement. - launch( - row, - new WorkContext<>(river, row, false), - () -> { - throw e; - }); - return; - } - launch( - row, - context, - () -> { - context.timeout = timeout(registration.handler, context.job()); - workMiddleware( - context, - 0, - () -> { - for (var hook : river.hooks()) - if (hook instanceof HookWorkBegin workHook) workHook.workBegin(context); - Throwable workFailure = null; - try { - registration.handler.work(context); - } catch (Exception | Error e) { - workFailure = e; - } - for (var hook : river.hooks()) - if (hook instanceof HookWorkEnd workHook) - workFailure = workHook.workEnd(context, workFailure); - if (workFailure instanceof Exception exception) throw exception; - if (workFailure instanceof Error error) throw error; - if (workFailure != null) throw new RuntimeException(workFailure); - }); - }); - } - - private void launch(Job row, WorkContext context, WorkContext.Step work) { - context.attach(this, null); - active.put(row.id(), context); - if (row.metadata().has("cancel_attempted_at")) - context.requestCancellation(WorkContext.Cancellation.REMOTE); - if (pendingCancellation.remove(row.id()) != null) - context.requestCancellation(WorkContext.Cancellation.REMOTE); - if (cancelOnStop) context.requestCancellation(WorkContext.Cancellation.SHUTDOWN); - executor.submit( - () -> { - activeThreads.put(row.id(), Thread.currentThread()); - Exception failure = null; - try { - observe("work_begin"); - work.run(); - } catch (Exception e) { - failure = e; - } catch (Throwable e) { - failure = new RuntimeException("Worker threw " + e, e); - } finally { - observe("work_end"); - } - // Interruption belongs to the handler, not to the durable acknowledgement that follows. - // Stop supervising its thread while retaining the slot until completion is committed. - failure = context.finish(failure); - activeThreads.remove(row.id()); - Thread.interrupted(); - // A claimed job keeps its slot until completion is durably acknowledged. - while (true) { - try { - for (var peer : List.copyOf(context.peers.values())) - completePeer( - context, - peer.context(), - failure == null - ? new IllegalStateException("Batch worker did not provide a peer outcome") - : failure); - complete(row, context, failure); - break; - } catch (Exception e) { - report(e); - try { - TimeUnit.MILLISECONDS.sleep(100); - } catch (InterruptedException interrupted) { - // A cancellation already in flight must not abandon this claimed job. - } - } - } - queueLock.lock(); - try { - active.remove(row.id(), context); - queueChanged.signalAll(); - } finally { - queueLock.unlock(); - } - wake.release(); - }); - } - - private void workMiddleware(WorkContext context, int index, WorkContext.Step next) - throws Exception { - if (index == river.workerMiddleware().size()) next.run(); - else - river - .workerMiddleware() - .get(index) - .work(context, () -> workMiddleware(context, index + 1, next)); - } - - List> claimPeers( - WorkContext parent, JobType type, Client.Transaction> operation) { - parent.checkRuntimeCancellation(); - var rows = - river.transaction( - connection -> { - var claimed = operation.run(connection); - river.pilot().afterPeerClaim(connection, river.driver(), claimed); - return claimed; - }); - // Register every committed claim before decoding any arguments. A decoding failure must still - // leave all claimed jobs owned by this attempt so its failure settles the entire batch. - for (var decoded : rows) { - var context = new WorkContext<>(river, decoded.job(), false); - context.timeout = config.jobTimeout; - parent.peers.put(decoded.job().id(), new WorkContext.Peer(decoded.job(), context)); - activeThreads.put(decoded.job().id(), Thread.currentThread()); - active.put(decoded.job().id(), context); - // Publish before checking the parent so a concurrent cancellation cannot miss both. - context.attach(this, parent); - } - var contexts = new ArrayList>(); - for (var decoded : rows) { - if (decoded.failure() != null) throw decoded.failure(); - var provisional = parent.peers.get(decoded.job().id()).context(); - // Decode the plugin's wire representation once, including when evaluating the timeout. - var job = decoded.job().mapArgs(ignored -> river.pilot().decode(decoded.job())); - var context = - provisional.withDecodedJob(job.mapArgs(args -> Json.decode(args, type.argsType()))); - var registration = config.handlers.get(job.kind()); - context.timeout = registration == null ? config.jobTimeout : timeout(registration, job); - parent.peers.put(decoded.job().id(), new WorkContext.Peer(decoded.job(), context)); - active.put(decoded.job().id(), context); - contexts.add(context); - } - return List.copyOf(contexts); - } - - void completePeer(WorkContext parent, WorkContext context, Exception failure) { - var peer = parent.peers.get(context.job().id()); - if (peer == null || peer.context() != context) - throw new IllegalArgumentException("Peer is not owned by this batch attempt"); - if (parent.cancellation() != null) context.requestCancellation(parent.cancellation()); - failure = context.finish(failure); - activeThreads.remove(context.job().id()); - complete(peer.row(), context, failure); - parent.peers.remove(context.job().id(), peer); - queueLock.lock(); - try { - active.remove(context.job().id(), context); - queueChanged.signalAll(); - } finally { - queueLock.unlock(); - } - } - - private void complete(Job original, WorkContext context, Exception failure) { - Instant now = Instant.now(); - var updates = context == null ? Json.object() : context.metadataUpdates(); - Job.State state = Job.State.COMPLETED; - Instant scheduled = original.scheduledAt(); - int attempt = original.attempt(); - EventKind event = EventKind.JOB_COMPLETED; - String error = null; - if (failure != null - && context != null - && context.cancellation() == WorkContext.Cancellation.REMOTE) { - state = Job.State.CANCELLED; - error = "JobCancelError: job cancelled remotely"; - event = EventKind.JOB_CANCELLED; - } else if (failure instanceof WorkContext.Control control) { - if (control.delay != null) { - scheduled = now.plus(control.delay); - state = bypassScheduler(control.delay) ? Job.State.AVAILABLE : Job.State.SCHEDULED; - attempt--; - updates.put("snoozes", nextSnoozeCount(original.metadata())); - event = EventKind.JOB_SNOOZED; - } else { - state = control.state; - error = control.getMessage(); - event = state == Job.State.CANCELLED ? EventKind.JOB_CANCELLED : EventKind.JOB_FAILED; - } - } else if (failure instanceof InterruptedException - && context != null - && context.cancellation() == WorkContext.Cancellation.SHUTDOWN) { - state = Job.State.AVAILABLE; - scheduled = now; - attempt--; - event = EventKind.JOB_INTERRUPTED; - } else if (failure != null) { - error = failure.getMessage() == null ? failure.toString() : failure.getMessage(); - boolean cancelled = config.cancelOnError; - state = - cancelled - ? Job.State.CANCELLED - : original.attempt() >= original.maxAttempts() - ? Job.State.DISCARDED - : Job.State.RETRYABLE; - if (state == Job.State.RETRYABLE) { - Duration delay = retryDelay(original, now); - scheduled = now.plus(delay); - if (bypassScheduler(delay)) state = Job.State.AVAILABLE; - } - event = cancelled ? EventKind.JOB_CANCELLED : EventKind.JOB_FAILED; - } - String trace = ""; - if (failure != null && failure.getCause() instanceof Error) { - var text = new java.io.StringWriter(); - failure.printStackTrace(new java.io.PrintWriter(text)); - trace = text.toString(); - } - String errors = - error == null - ? null - : Json.encode( - new Job.AttemptError(original.attemptedAt(), original.attempt(), error, trace)); - var done = new java.util.concurrent.CompletableFuture>(); - completions.add( - new Completion( - original, - new Object[] { - state.value(), - river.database().timestamp(now), - context == null ? null : context.checkpointStep(), - context == null ? "{}" : Json.encode(context.checkpointFallback()), - Json.encode(updates), - state.value(), - river.database().timestamp(state.isFinalized() ? now : null), - river.database().timestamp(scheduled), - attempt, - errors, - errors, - original.id(), - original.attempt(), - river.database().timestamp(original.attemptedAt()) - }, - done)); - var updated = done.join(); - // An ephemeral job may already have been completed and deleted in the application's - // transaction. - if (updated == null) return; - // Short retries and snoozes bypass the scheduler. The immediate completion wake-up may - // precede scheduled_at, so also wake fetching when the persisted timestamp becomes due. - if (updated.state() == Job.State.AVAILABLE) - fetchWakeups.add(new FetchWakeup(updated.scheduledAt(), null)); - // Another process may have changed the attempt. Match Go's events to the persisted state. - event = - switch (updated.state()) { - case AVAILABLE -> - switch (event) { - case JOB_INTERRUPTED, JOB_SNOOZED -> event; - default -> EventKind.JOB_FAILED; - }; - case CANCELLED -> EventKind.JOB_CANCELLED; - case COMPLETED -> EventKind.JOB_COMPLETED; - case DISCARDED, RETRYABLE -> EventKind.JOB_FAILED; - case SCHEDULED -> EventKind.JOB_SNOOZED; - case PENDING, RUNNING -> null; - }; - if (event != null) publish(new Event(event, updated)); - } - - private boolean bypassScheduler(Duration delay) { - // Maintenance runs from election ticks. Longer service intervals must not strand short - // delays in states that only the scheduler can make available. - return delay.compareTo(config.maintenanceInterval) <= 0 - || delay.compareTo(config.serviceInterval) <= 0; - } - - private void completeBatches() { - while (running.get() - || dispatcherStopped.getCount() != 0 - || !active.isEmpty() - || !completions.isEmpty()) { - try { - var first = completions.poll(20, TimeUnit.MILLISECONDS); - if (first == null) continue; - var batch = new ArrayList(); - batch.add(first); - completions.drainTo(batch, 999); - try { - var results = - river.transaction( - c -> { - var rows = new ArrayList>(); - for (var completion : batch) - try (var statement = - Sql.prepare( - c, Sql.query(river.database(), "complete"), completion.params); - var result = statement.executeQuery()) { - if (result.next()) rows.add(river.readPartial(result).job()); - else { - try { - rows.add(river.get(c, completion.original.id())); - } catch (RiverException error) { - if (error.code() != RiverException.Code.NOT_FOUND) throw error; - rows.add(null); - } - } - } - for (var job : rows) - if (job != null) river.pilot().afterStateChange(c, river.driver(), job); - for (var completion : batch) - river.pilot().afterAttempt(c, river.driver(), completion.original); - return rows; - }); - for (int i = 0; i < batch.size(); i++) batch.get(i).done.complete(results.get(i)); - } catch (Exception e) { - for (var completion : batch) completion.done.completeExceptionally(e); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - } - } - - private void heartbeat(String queue) { - river.transaction( - c -> { - try (var statement = - Sql.prepare( - c, - Sql.query(river.database(), "queue_heartbeat"), - queue, - river.database().timestamp(Instant.now()), - river.database().timestamp(Instant.now()))) { - statement.executeUpdate(); - } - river - .pilot() - .producer( - c, - river.driver(), - new Pilot.Producer( - id, - queue, - queues.getOrDefault(queue, config.queues.getOrDefault(queue, 1)), - active.values().stream().>map(WorkContext::job).toList(), - !running.get() || !queues.containsKey(queue), - Instant.now())); - return null; - }); - } - - private void listen() { - if (river.database().dialect() == Database.Dialect.SQLITE) { - listenSqlite(); - return; - } - while (running.get()) { - try (var connection = river.database().connection()) { - if (!river.database().supportsNotifications(connection)) return; - listener = connection; - String schema = river.database().schema(); - if (schema.isEmpty()) - try (var statement = connection.createStatement(); - var rows = statement.executeQuery("SELECT current_schema()")) { - rows.next(); - schema = rows.getString(1); - } - for (String topic : - List.of(Protocol.TOPIC_INSERT, Protocol.TOPIC_CONTROL, Protocol.TOPIC_LEADERSHIP)) - try (var statement = connection.createStatement()) { - statement.execute("LISTEN \"" + schema.replace("\"", "\"\"") + "." + topic + "\""); - } - wake.release(); - observe("listen_ready"); - var postgres = connection.unwrap(PGConnection.class); - try { - while (running.get()) { - var notifications = postgres.getNotifications(25); - if (notifications != null) - for (var notification : notifications) - if (notification.getName().startsWith(schema + ".")) - deliver( - notification.getName().substring(schema.length() + 1), - notification.getParameter()); - } - } finally { - try (var statement = connection.createStatement()) { - statement.execute("UNLISTEN *"); - } - } - } catch (Exception e) { - if (running.get()) { - report(e); - try { - TimeUnit.MILLISECONDS.sleep(100); - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - return; - } - } - } finally { - listener = null; - } - } - } - - private void deliver(String topic, String payload) { - // An unrelated publisher must not discard the remaining notifications in this batch. - try { - notifications.dispatch(topic, payload); - } catch (RuntimeException error) { - report(error); - } - } - - private void listenSqlite() { - long after = 0; - boolean initialized = false; - while (running.get()) { - try { - if (!initialized) { - after = - river.transaction( - c -> { - try (var statement = - Sql.prepare(c, Sql.query(river.database(), "notification_cursor")); - var rows = statement.executeQuery()) { - rows.next(); - return rows.getLong(1); - } - }); - initialized = true; - // The initial cursor skips earlier notifications, including inserts since the first - // fetch. Poll again once listening starts, as the Postgres listener does. - wake.release(); - observe("listen_ready"); - } - final long cursor = after; - var messages = - river.transaction( - c -> { - var result = new ArrayList(); - try (var statement = - Sql.prepare(c, Sql.query(river.database(), "notifications"), cursor); - var rows = statement.executeQuery()) { - while (rows.next()) - result.add( - new Notification( - rows.getLong("id"), - rows.getString("topic"), - rows.getString("payload"))); - } - return result; - }); - // Release the read snapshot before a control message starts a write transaction. - for (var message : messages) { - after = message.id(); - deliver(message.topic(), message.payload()); - } - } catch (Exception e) { - report(e); - } - try { - TimeUnit.MILLISECONDS.sleep(25); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - } - } - - private record Notification(long id, String topic, String payload) {} - - private void leadershipNotification(Protocol.LeadershipNotice notice) { - if (notice == Protocol.LeadershipNotice.REQUEST_RESIGN) { - try { - if (isLeader()) resign(); - } finally { - leadershipWake.release(); - } - } else leadershipWake.release(); - } - - private void queueNotification(Protocol.QueueNotice notice) { - String action = notice.action(); - if (action.equals("pause") || action.equals("resume")) { - String queue = notice.queue(); - for (String name : queues.keySet()) - if (queue.equals("*") || queue.equals(name)) { - boolean changed = - action.equals("pause") ? pausedQueues.add(name) : pausedQueues.remove(name); - if (changed) - publish( - new Event( - action.equals("pause") ? EventKind.QUEUE_PAUSED : EventKind.QUEUE_RESUMED, - null, - river.queues().get(name))); - } - } - wake.release(); - } - - private void elect() { - while (running.get()) { - leadershipWake.drainPermits(); - long delay = config.serviceInterval.toNanos(); - try { - maintain(); - observe("leadership_check"); - } catch (Exception error) { - report(error); - delay = Math.min(delay, TimeUnit.SECONDS.toNanos(1)); - } - if (!running.get()) return; - try { - leadershipWake.tryAcquire(delay, TimeUnit.NANOSECONDS); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - return; - } - } - } - - private void maintain() throws Exception { - leadershipLock.lock(); - try { - if (resigningTerm != null) resign(); - if (System.nanoTime() - leaderEligibleAt < 0) return; - long attemptStarted = System.nanoTime(); - Instant now = Instant.now(); - Instant expiresAt = now.plus(config.serviceInterval).plusSeconds(10); - var previousTerm = leaderTerm; - if (previousTerm != null && !previousTerm.active(attemptStarted)) { - resign(); - return; - } - Instant elected = - river.transaction( - c -> { - try (var statement = - Sql.prepare( - c, - Sql.query(river.database(), "leader_expire"), - river.database().timestamp(now))) { - statement.executeUpdate(); - } - if (previousTerm != null) { - try (var statement = - Sql.prepare( - c, - Sql.query(river.database(), "leader_renew"), - river.database().timestamp(expiresAt), - id, - river.database().timestamp(previousTerm.electedAt()), - river.database().timestamp(now))) { - if (statement.executeUpdate() > 0) return previousTerm.electedAt(); - } - } - try (var statement = - Sql.prepare( - c, - Sql.query(river.database(), "leader_elect"), - id, - river.database().timestamp(now), - river.database().timestamp(expiresAt)); - var rows = statement.executeQuery()) { - return rows.next() ? Database.instant(rows.getString("elected_at")) : null; - } - }); - // A lease is ours only after commit, including when regaining an expired term. - // Like Go, stop trusting it one second before its database expiry, measuring elapsed - // time from before the transaction so pool waits and slow commits consume the lease. - leaderTerm = - elected == null - ? null - : new LeadershipTerm( - elected, attemptStarted + config.serviceInterval.plusSeconds(9).toNanos()); - if (elected == null) return; - long committedAt = System.nanoTime(); - if (!leaderTerm.active(committedAt) - || (previousTerm != null && !previousTerm.active(committedAt))) { - resign(); - return; - } - if (!running.get()) return; - if (!elected.equals(initializedTerm)) { - river.pilot().leadershipStarted(); - for (var hook : river.hooks()) - if (hook instanceof HookPeriodicJobsStart periodicHook) periodicHook.start(); - initializedTerm = elected; - } - Instant term = elected; - if (running.get() && maintenanceBusy.compareAndSet(false, true)) - try { - executor.submit( - () -> { - try { - services(now, term); - } catch (Exception error) { - report(error); - } finally { - maintenanceBusy.set(false); - if (periodicChanged.get()) leadershipWake.release(); - } - }); - } catch (java.util.concurrent.RejectedExecutionException error) { - maintenanceBusy.set(false); - // Stop can close the executor after the running check while election is finishing. - if (running.get()) throw error; - } - } finally { - leadershipLock.unlock(); - } - } - - private void services(Instant now, Instant term) throws SQLException { - periodicChanged.set(false); - for (var state : periodic.snapshot()) { - var job = state.job; - if (!ownsTerm(term)) return; - if (!periodic.contains(state)) continue; - try { - Instant next = term.equals(state.term) ? state.next : null; - if (next == null) - next = - job.runOnStart() - ? now - : job.schedule() - .next(now.atZone(java.time.ZoneId.systemDefault())) - .map(java.time.ZonedDateTime::toInstant) - .orElse(Instant.MAX); - if (!next.isAfter(now)) { - Instant following = - job.schedule() - .next(next.atZone(java.time.ZoneId.systemDefault())) - .map(java.time.ZonedDateTime::toInstant) - .orElse(Instant.MAX); - if (!ownsTerm(term)) return; - if (!periodic.contains(state)) continue; - job.insert(river, next); - next = following; - } - if (!ownsTerm(term)) return; - state.next = next; - state.term = term; - } catch (Exception error) { - // One failing schedule or insertion must not block other jobs or leader maintenance. - report(error); - } - } - if (!ownsTerm(term)) return; - schedule(now); - if (!ownsTerm(term)) return; - river.pilot().maintain(river, now); - if (!ownsTerm(term)) return; - rescue(now, term); - if (!ownsTerm(term)) return; - cleanNotifications(now); - if (!ownsTerm(term)) return; - clean(now); - if (!now.isBefore(nextReindex) && ownsTerm(term)) { - nextReindex = - config.reindexInterval == null - ? now.atOffset(java.time.ZoneOffset.UTC) - .toLocalDate() - .plusDays(1) - .atStartOfDay() - .toInstant(java.time.ZoneOffset.UTC) - : now.plus(config.reindexInterval); - reindex(); - } - } - - private boolean ownsTerm(Instant term) { - var current = leaderTerm; - return running.get() - && current != null - && term.equals(current.electedAt()) - && current.active(System.nanoTime()); - } - - private record LeadershipTerm(Instant electedAt, long trustedUntil) { - boolean active(long now) { - return trustedUntil - now > 0; - } - } - - private void clean(Instant now) { - if (river - .pilot() - .clean( - river, - now, - config.cancelledRetention, - config.completedRetention, - config.discardedRetention)) return; - river.transaction( - c -> { - Object cancelled = - config.cancelledRetention.isNegative() - ? null - : river.database().timestamp(now.minus(config.cancelledRetention)); - Object completed = - config.completedRetention.isNegative() - ? null - : river.database().timestamp(now.minus(config.completedRetention)); - Object discarded = - config.discardedRetention.isNegative() - ? null - : river.database().timestamp(now.minus(config.discardedRetention)); - try (var statement = - Sql.prepare( - c, Sql.query(river.database(), "clean_jobs"), cancelled, completed, discarded)) { - statement.executeUpdate(); - } - try (var statement = - Sql.prepare( - c, - Sql.query(river.database(), "clean_queues"), - river.database().timestamp(now.minus(QUEUE_RETENTION)))) { - statement.executeUpdate(); - } - return null; - }); - } - - private void cleanNotifications(Instant now) { - if (river.database().dialect() != Database.Dialect.SQLITE) return; - river.transaction( - c -> { - try (var statement = - Sql.prepare( - c, - Sql.query(river.database(), "clean_notifications"), - river.database().timestamp(now.minus(Duration.ofMinutes(5))))) { - return statement.executeUpdate(); - } - }); - } - - private void reindex() throws SQLException { - if (river.database().dialect() != Database.Dialect.POSTGRES) return; - try (var c = river.database().connection()) { - for (String index : config.reindexNames) { - String qualified; - try (var statement = - Sql.prepare(c, Sql.query(river.database(), "reindex_candidate"), index, index); - var rows = statement.executeQuery()) { - if (!rows.next()) continue; - qualified = rows.getString(1); - } - try (var statement = c.createStatement()) { - statement.setQueryTimeout(300); - statement.execute(Sql.query(river.database(), "reindex").replace("{index}", qualified)); - } - } - } - } - - private void schedule(Instant now) { - river.transaction( - c -> { - if (river.database().dialect() == Database.Dialect.SQLITE) - try (var statement = Sql.prepare(c, Sql.query(river.database(), "writer_lock"))) { - statement.executeUpdate(); - } - var due = new ArrayList(); - try (var statement = - Sql.prepare( - c, - Sql.query(river.database(), "schedule_select"), - river.database().timestamp(now)); - var rows = statement.executeQuery()) { - while (rows.next()) - due.add(new Scheduled(rows.getLong("id"), rows.getBytes("unique_key"))); - } - for (var job : due) { - boolean collision = false; - if (job.key() != null) - try (var statement = - Sql.prepare( - c, - Sql.query(river.database(), "schedule_collision"), - job.id(), - job.key()); - var rows = statement.executeQuery()) { - collision = rows.next(); - } - var savepoint = c.setSavepoint(); - try { - if (!collision) - try (var statement = - Sql.prepare( - c, Sql.query(river.database(), "schedule_available"), job.id()); - var rows = statement.executeQuery()) { - if (rows.next()) river.notifyInsert(c, rows.getString(1)); - } - } catch (SQLException error) { - if (!"23505".equals(error.getSQLState())) throw error; - c.rollback(savepoint); - collision = true; - } finally { - c.releaseSavepoint(savepoint); - } - if (collision) - try (var statement = - Sql.prepare( - c, - Sql.query(river.database(), "schedule_discard"), - river.database().timestamp(now), - job.id())) { - statement.executeUpdate(); - } - } - return null; - }); - } - - private record Scheduled(long id, byte[] key) {} - - private void rescue(Instant now, Instant term) { - if (river - .pilot() - .rescue( - river, - new Pilot.Rescue( - now, - now.minus(config.rescueAfter), - java.util.Set.copyOf(active.keySet()), - java.util.Set.copyOf(config.handlers.keySet()), - this::rescueTimeout, - config.retryPolicy))) return; - long after = 0; - while (ownsTerm(term)) { - final long cursor = after; - var batch = - river.transaction( - c -> { - var stuck = new ArrayList>(); - try (var statement = - Sql.prepare( - c, - Sql.query(river.database(), "rescue_select"), - river.database().timestamp(now.minus(config.rescueAfter)), - cursor); - var rows = statement.executeQuery()) { - while (rows.next()) stuck.add(river.read(rows)); - } - return stuck; - }); - var rescues = new ArrayList(); - for (var job : batch) { - if (active.containsKey(job.id())) continue; - boolean cancel = false; - if (job.metadata().path("cancel_attempted_at").isString()) - try { - cancel = - !Instant.parse(job.metadata().path("cancel_attempted_at").asString()) - .equals(Instant.parse("0001-01-01T00:00:00Z")); - } catch (RuntimeException ignored) { - } - if (!cancel && config.handlers.containsKey(job.kind())) { - var timeout = rescueTimeout(job); - if (timeout.isNegative() - || Duration.between(job.attemptedAt(), now).compareTo(timeout) < 0) continue; - } - Job.State state = - cancel - ? Job.State.CANCELLED - : !config.handlers.containsKey(job.kind()) || job.attempt() >= job.maxAttempts() - ? Job.State.DISCARDED - : Job.State.RETRYABLE; - Instant scheduled = job.scheduledAt(); - if (!state.isFinalized()) { - Duration delay = retryDelay(job, now); - scheduled = now.plus(delay); - if (bypassScheduler(delay)) state = Job.State.AVAILABLE; - } - String error = - Json.encode( - new Job.AttemptError(now, job.attempt(), "Stuck job rescued by JobRescuer", "")); - String metadata = - Json.encode( - Map.of( - Protocol.METADATA_RESCUE_COUNT, - job.metadata().path(Protocol.METADATA_RESCUE_COUNT).asLong(0) + 1)); - rescues.add(new Rescue(error, job, metadata, scheduled, state)); - } - if (!ownsTerm(term)) return; - var ready = new ArrayList(); - river.transaction( - c -> { - for (var rescue : rescues) { - var job = rescue.job(); - if (active.containsKey(job.id())) continue; - var state = rescue.state(); - try (var statement = - Sql.prepare( - c, - Sql.query(river.database(), "rescue_update"), - state.value(), - river.database().timestamp(state.isFinalized() ? now : null), - river.database().timestamp(rescue.scheduled()), - job.attempt(), - rescue.metadata(), - rescue.error(), - rescue.error(), - job.id(), - job.attempt(), - river.database().timestamp(job.attemptedAt())); - var rows = statement.executeQuery()) { - if (rows.next() && state == Job.State.AVAILABLE) - ready.add( - new FetchWakeup( - Database.instant(rows.getString("scheduled_at")), job.queue())); - } - } - return null; - }); - // Only committed rescues may wake fetching; use the database's timestamp precision. - fetchWakeups.addAll(ready); - if (!ready.isEmpty()) wake.release(); - if (batch.size() < 1000) break; - after = batch.getLast().id(); - } - } - - private record Rescue( - String error, Job job, String metadata, Instant scheduled, Job.State state) {} - - private Duration rescueTimeout(Job job) { - try { - return timeout(job); - } catch (Throwable error) { - // Malformed arguments or timeout callbacks must not prevent abandoned jobs being rescued. - report(error); - return Duration.ZERO; - } - } - - private void supervise() { - while (running.get() || dispatcherStopped.getCount() != 0 || !active.isEmpty()) { - for (var context : active.values()) { - // Attempts retain their deadlines while fetching is blocked or gracefully stopping. - var timeout = context.timeout; - if (timeout != null - && !timeout.isNegative() - && Duration.between(context.job().attemptedAt(), Instant.now()).compareTo(timeout) >= 0) - context.requestCancellation(WorkContext.Cancellation.TIMEOUT); - if (context.forceIfStuck(config.stuckThreshold)) { - observe("stuck_job"); - var thread = activeThreads.get(context.job().id()); - if (thread != null) thread.interrupt(); - } - } - try { - TimeUnit.MILLISECONDS.sleep(10); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - } - } - - private Duration timeout(Job job) { - var registration = config.handlers.get(job.kind()); - return registration == null - ? config.jobTimeout - : timeout(registration, job.mapArgs(ignored -> river.pilot().decode(job))); - } - - private Duration timeout(Registration registration, Job decoded) { - return timeout( - registration.handler, - decoded.mapArgs(args -> Json.decode(args, registration.type.argsType()))); - } - - private Duration timeout(Handler handler, Job job) { - var timeout = handler.timeout(job); - if (timeout == null || timeout.isZero()) return config.jobTimeout; - Builder.duration(timeout, "Worker timeout"); - return timeout; - } - - private void observe(String event) { - for (var observer : config.observers) - try { - observer.accept(event); - } catch (Throwable error) { - report(error); - } - } - - private void publish(Event event) { - for (var subscriber : subscribers) - try { - subscriber.accept(event); - } catch (Throwable e) { - report(e); - } - } - - private void report(Throwable error) { - try { - config.errorHandler.accept(error); - } catch (Throwable handlerFailure) { - // Reporting failures must not strand claimed jobs or terminate a background service. - var logger = System.getLogger("com.riverqueue"); - logger.log(System.Logger.Level.WARNING, "River runtime failure", error); - logger.log(System.Logger.Level.WARNING, "River error handler failed", handlerFailure); - } - } - - private Duration retryDelay(Job job, Instant now) { - try { - var delay = Objects.requireNonNull(config.retryPolicy.delay(job), "Retry delay"); - if (delay.isNegative()) - throw new IllegalArgumentException("Retry delay must not be negative"); - delay.toNanos(); - now.plus(delay); - return delay; - } catch (Throwable error) { - report(new IllegalArgumentException("Retry policy failed; using the default delay", error)); - return defaultRetryPolicy.delay(job); - } - } - - private void resign() { - leadershipLock.lock(); - try { - if (leaderTerm != null) { - resigningTerm = leaderTerm.electedAt(); - leaderTerm = null; - initializedTerm = null; - leaderEligibleAt = System.nanoTime() + config.serviceInterval.toNanos(); - } - // Stop local services immediately, but retain the identity until deletion commits so a - // failed resignation can be retried without waiting for the lease to expire. - Instant elected = resigningTerm; - if (elected == null) return; - river.transaction( - c -> { - try (var statement = - Sql.prepare( - c, - Sql.query(river.database(), "leader_resign"), - id, - river.database().timestamp(elected))) { - if (statement.executeUpdate() > 0) river.notify(c, Protocol.resigned(id)); - } - return null; - }); - resigningTerm = null; - } finally { - leadershipLock.unlock(); - } - } - - static long nextSnoozeCount(JsonNode metadata) { - var value = metadata.path("snoozes"); - // Go's gjson treats true as one; Jackson's numeric coercion does not. - return (value.isBoolean() ? (value.asBoolean() ? 1 : 0) : value.asLong(0)) + 1; - } - - private static void validateQueue(String name, int workers) { - if (workers < 1) throw new IllegalArgumentException("maxWorkers must be positive"); - Client.validate(InsertOptions.builder().queue(name).build().resolve(InsertOptions.defaults())); - } - - /** Runtime configuration; no worker-specific implementation classes are needed. */ - public static final class Builder { - private Duration cancelledRetention = Duration.ofDays(1); - private Duration completedRetention = Duration.ofDays(1); - private Duration discardedRetention = Duration.ofDays(7); - private Duration reindexInterval; - private List reindexNames = - List.of( - "river_job_args_index", - "river_job_kind", - "river_job_metadata_index", - "river_job_pkey", - "river_job_prioritized_fetching_index", - "river_job_state_and_finalized_at_index", - "river_job_unique_idx"); - private boolean cancelOnError; - private boolean fetchOnlyKnownKinds; - private Consumer errorHandler = - error -> - System.getLogger("com.riverqueue") - .log(System.Logger.Level.WARNING, "River runtime failure", error); - private final Map> handlers = new java.util.LinkedHashMap<>(); - private String id; - private Duration jobTimeout = Duration.ofMinutes(1); - private boolean jobTimeoutConfigured; - private boolean leadership = true; - private Duration maintenanceInterval = Duration.ofSeconds(5); - private final List> observers = new ArrayList<>(); - private final List> periodic = new ArrayList<>(); - private Duration pollInterval = Duration.ofSeconds(1); - private boolean pollOnly; - private final Map queues = new java.util.LinkedHashMap<>(); - private RetryPolicy retryPolicy = RetryPolicy.defaults(); - private Duration rescueAfter; - private final Client river; - private Duration stopTimeout = Duration.ofSeconds(30); - private Duration serviceInterval = Duration.ofSeconds(1); - private Duration stuckThreshold = Duration.ofSeconds(5); - - Builder(Client river) { - this.river = river; - } - - private Builder(Builder source) { - this(source.river); - cancelledRetention = source.cancelledRetention; - completedRetention = source.completedRetention; - discardedRetention = source.discardedRetention; - reindexInterval = source.reindexInterval; - reindexNames = source.reindexNames; - cancelOnError = source.cancelOnError; - fetchOnlyKnownKinds = source.fetchOnlyKnownKinds; - errorHandler = source.errorHandler; - handlers.putAll(source.handlers); - id = source.id; - jobTimeout = source.jobTimeout; - jobTimeoutConfigured = source.jobTimeoutConfigured; - leadership = source.leadership; - maintenanceInterval = source.maintenanceInterval; - observers.addAll(source.observers); - periodic.addAll(source.periodic); - pollInterval = source.pollInterval; - pollOnly = source.pollOnly; - queues.putAll(source.queues); - retryPolicy = source.retryPolicy; - rescueAfter = source.rescueAfter; - stopTimeout = source.stopTimeout; - serviceInterval = source.serviceInterval; - stuckThreshold = source.stuckThreshold; - } - - public Builder retention(Duration cancelled, Duration completed, Duration discarded) { - cancelledRetention = duration(cancelled, "cancelled retention"); - completedRetention = duration(completed, "completed retention"); - discardedRetention = duration(discarded, "discarded retention"); - return this; - } - - public Builder reindex(List names, Duration interval) { - reindexNames = List.copyOf(names); - reindexInterval = interval == null ? null : positive(interval, "reindex interval"); - return this; - } - - public Builder cancelOnError(boolean value) { - cancelOnError = value; - return this; - } - - public Builder fetchOnlyKnownKinds(boolean value) { - fetchOnlyKnownKinds = value; - return this; - } - - /** Reports runtime failures. Failures in this callback are logged without stopping workers. */ - public Builder errorHandler(Consumer value) { - errorHandler = Objects.requireNonNull(value, "errorHandler"); - return this; - } - - /** Adds a worker handler for a job type. Each job kind may be added only once. */ - public Builder add(JobType type, Handler handler) { - Objects.requireNonNull(handler, "handler"); - if (handlers.putIfAbsent(type.kind(), new Registration<>(type, handler)) != null) - throw new IllegalArgumentException("Duplicate worker kind"); - return this; - } - - /** Overrides the runtime ID (at most 100 UTF-8 bytes). Each start otherwise generates an ID. */ - public Builder id(String value) { - if (value.isEmpty() || value.getBytes(StandardCharsets.UTF_8).length > 100) - throw new IllegalArgumentException("Invalid client ID"); - id = value; - return this; - } - - /** Sets the attempt timeout; a negative duration disables it, including during stop. */ - public Builder jobTimeout(Duration value) { - duration(value, "jobTimeout"); - if (value.isZero()) - throw new IllegalArgumentException( - "Job timeout must be positive or negative to disable it"); - jobTimeout = value; - jobTimeoutConfigured = true; - return this; - } - - public Builder leadership(boolean value) { - leadership = value; - return this; - } - - public Builder maintenanceInterval(Duration value) { - maintenanceInterval = positive(value, "maintenanceInterval"); - return this; - } - - /** Observes runtime activity. Callback failures are reported without affecting execution. */ - public Builder observe(Consumer value) { - observers.add(Objects.requireNonNull(value, "observer")); - return this; - } - - /** Registers a periodic definition before starting this runtime. */ - public Builder periodic(PeriodicJob job) { - Objects.requireNonNull(job, "job"); - if (job.id() != null - && periodic.stream().anyMatch(existing -> job.id().equals(existing.id()))) - throw new IllegalArgumentException("Duplicate periodic job ID: " + job.id()); - periodic.add(job); - return this; - } - - public Builder periodic( - String id, - Schedule schedule, - JobType type, - A args, - InsertOptions options, - boolean runOnStart) { - return periodic(new PeriodicJob<>(id, schedule, type, args, options, runOnStart)); - } - - public Builder pollInterval(Duration value) { - pollInterval = positive(value, "pollInterval"); - return this; - } - - public Builder pollOnly(boolean value) { - pollOnly = value; - return this; - } - - public Builder queue(String name, int maxWorkers) { - validateQueue(name, maxWorkers); - queues.put(name, maxWorkers); - return this; - } - - public Builder retryPolicy(RetryPolicy value) { - retryPolicy = Objects.requireNonNull(value, "retryPolicy"); - return this; - } - - /** - * Sets when abandoned jobs may be rescued. Must not be shorter than the attempt timeout. - * Defaults to one hour, plus an explicitly configured positive job timeout. - */ - public Builder rescueAfter(Duration value) { - rescueAfter = positive(value, "rescueAfter"); - return this; - } - - /** - * Sets the election and heartbeat interval; leadership leases include ten seconds of margin. - * Must be positive and shorter than one day so active queues do not expire between heartbeats. - */ - public Builder serviceInterval(Duration value) { - positive(value, "serviceInterval"); - if (value.compareTo(QUEUE_RETENTION) >= 0) - throw new IllegalArgumentException( - "serviceInterval must be shorter than the queue retention period of one day"); - serviceInterval = value; - return this; - } - - public Builder stopTimeout(Duration value) { - stopTimeout = positive(value, "stopTimeout"); - return this; - } - - public Builder stuckThreshold(Duration value) { - stuckThreshold = positive(value, "stuckThreshold"); - return this; - } - - private static Duration positive(Duration value, String name) { - duration(value, name); - if (value.isNegative() || value.isZero()) - throw new IllegalArgumentException(name + " must be positive"); - return value; - } - - private static Duration duration(Duration value, String name) { - Objects.requireNonNull(value, name); - try { - value.toNanos(); - } catch (ArithmeticException error) { - throw new IllegalArgumentException(name + " must fit in signed 64-bit nanoseconds", error); - } - return value; - } - - public Workers start() { - if (!leadership && !periodic.isEmpty()) - throw new IllegalArgumentException("Periodic jobs require leader election"); - var config = new Builder(this); - if (config.rescueAfter == null) - config.rescueAfter = - positive( - !jobTimeoutConfigured || jobTimeout.isNegative() - ? Duration.ofHours(1) - : jobTimeout.plus(Duration.ofHours(1)), - "rescueAfter"); - if (config.rescueAfter.compareTo(jobTimeout) < 0) - throw new IllegalArgumentException("rescueAfter must not be less than jobTimeout"); - river - .pilot() - .starting(leadership, cancelledRetention, completedRetention, discardedRetention); - return new Workers(config); - } - } - - /** A job or queue event. The payload corresponding to the event kind is non-null. */ - public record Event(EventKind kind, Job job, Queues.Queue queue) { - public Event(EventKind kind, Job job) { - this(kind, job, null); - } - } - - /** Event names shared with other River clients and the conformance protocol. */ - public enum EventKind { - JOB_CANCELLED, - JOB_COMPLETED, - JOB_FAILED, - JOB_INTERRUPTED, - JOB_SNOOZED, - QUEUE_PAUSED, - QUEUE_RESUMED; - - @com.fasterxml.jackson.annotation.JsonValue - public String value() { - return name().toLowerCase(java.util.Locale.ROOT); - } - } - - /** An idempotent subscription handle whose close method cannot throw a checked exception. */ - @FunctionalInterface - public interface Subscription extends AutoCloseable { - @Override - void close(); + Map> snapshot() { + return Map.copyOf(registrations); } - private record Completion( - Job original, - Object[] params, - java.util.concurrent.CompletableFuture> done) {} - /** A worker handler. Lambdas inherit the runtime's attempt timeout. */ @FunctionalInterface public interface Handler { @@ -1771,8 +38,9 @@ default Duration timeout(Job job) { return null; } + /** Performs one job attempt; a normal return marks it successful. */ void work(WorkContext context) throws Exception; } - private record Registration(JobType type, Handler handler) {} + record Registration(JobType type, Handler handler) {} } diff --git a/java/river/src/main/java/com/riverqueue/package-info.java b/java/river/src/main/java/com/riverqueue/package-info.java index bfc4db07b..43930b474 100644 --- a/java/river/src/main/java/com/riverqueue/package-info.java +++ b/java/river/src/main/java/com/riverqueue/package-info.java @@ -1,8 +1,8 @@ /** * Transactional River jobs on Postgres and SQLite. Define a {@link com.riverqueue.JobType}, insert * through a {@link com.riverqueue.Client}, and register worker lambdas through {@link - * com.riverqueue.Workers.Builder}. Only a running {@code Workers} instance owns resources; close it - * or call {@code stop()} when the application stops. + * com.riverqueue.Workers}. Start the configured {@code Client} to obtain a {@code RunHandle}; close + * the handle or call {@code stop()} when the application stops. * *

Connection-first overloads require an existing JDBC transaction with auto-commit disabled. * They use savepoints and leave commit, rollback, and close to the caller. Other overloads open and diff --git a/java/river/src/test/java/com/riverqueue/ClientLifecycleTest.java b/java/river/src/test/java/com/riverqueue/ClientLifecycleTest.java new file mode 100644 index 000000000..6fda7cf5e --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/ClientLifecycleTest.java @@ -0,0 +1,261 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.Proxy; +import java.nio.file.Path; +import java.sql.SQLException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@SuppressWarnings("try") // Resources intentionally scope subscriptions and background activity. +class ClientLifecycleTest { + private static final JobType TYPE = JobType.of("lifecycle_test", String.class); + @RegisterExtension final TestDatabase databases = new TestDatabase(); + @TempDir Path directory; + + private Client.Builder builder() { + return Client.builder(databases.open(directory.resolve("river.db"))) + .leadership(false) + .pollInterval(Duration.ofMillis(10)) + .pollOnly(true) + .queue("default", 1) + .stopTimeout(Duration.ofSeconds(5)); + } + + @Test + void clientCanInsertWithoutStarting() { + var database = databases.open(directory.resolve("river.db")); + var client = Client.builder(database).build(); + var job = client.insert(TYPE, "insert only").job(); + assertEquals(Job.State.AVAILABLE, client.get(job.id()).state()); + assertThrows(IllegalArgumentException.class, client::start); + assertEquals(Job.State.AVAILABLE, new Client(database).get(job.id()).state()); + } + + @Test + void concurrentStartsAllowOnlyOneRun() throws Exception { + var client = builder().worker(TYPE, context -> {}).build(); + var ready = new CountDownLatch(4); + var start = new CountDownLatch(1); + var runs = new LinkedBlockingQueue(); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var attempts = new ArrayList>(); + for (int i = 0; i < 4; i++) { + attempts.add( + executor.submit( + () -> { + ready.countDown(); + start.await(); + try { + runs.add(client.start()); + return true; + } catch (IllegalStateException expected) { + return false; + } + })); + } + try { + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + int started = 0; + for (var attempt : attempts) if (attempt.get(5, TimeUnit.SECONDS)) started++; + assertEquals(1, started); + assertEquals(1, runs.size()); + } finally { + start.countDown(); + // Wait for every contender before closing the winner: otherwise another could start. + for (var attempt : attempts) attempt.get(5, TimeUnit.SECONDS); + for (var run : runs) run.close(); + } + } + } + + @Test + void copiedConfigurationRecomputesDefaultRescueDelay() { + var builder = Client.builder(Database.connect("jdbc:sqlite::memory:")); + var defaultClient = builder.build(); + var explicitClient = builder.rescueAfter(Duration.ofHours(1)).build(); + assertDoesNotThrow(() -> defaultClient.toBuilder().jobTimeout(Duration.ofHours(2)).build()); + assertEquals( + "rescueAfter must not be less than jobTimeout", + assertThrows( + IllegalArgumentException.class, + () -> explicitClient.toBuilder().jobTimeout(Duration.ofHours(2)).build()) + .getMessage()); + } + + @Test + void failedDatabaseStartupCanBeRetried() throws Exception { + var database = databases.open(directory.resolve("river.db")); + var fail = new AtomicBoolean(true); + var source = + (DataSource) + Proxy.newProxyInstance( + DataSource.class.getClassLoader(), + new Class[] {DataSource.class}, + (proxy, method, args) -> { + if (!method.getName().equals("getConnection")) + throw new UnsupportedOperationException(); + if (fail.compareAndSet(true, false)) + throw new SQLException("temporary startup failure"); + return database.connection(); + }); + var client = + Client.builder(new Database(source, database.dialect()).withSchema(database.schema())) + .leadership(false) + .pollOnly(true) + .queue("default", 1) + .workers(new Workers().add(TYPE, context -> {})) + .build(); + assertEquals( + RiverException.Code.DATABASE, assertThrows(RiverException.class, client::start).code()); + var events = new LinkedBlockingQueue(); + try (var subscription = client.subscribe(events::add, Client.EventKind.JOB_COMPLETED); + var run = client.start()) { + var job = client.insert(TYPE, "retry").job(); + var event = events.poll(5, TimeUnit.SECONDS); + assertNotNull(event); + assertEquals(job.id(), event.job().id()); + } + } + + @Test + void failedPilotStartupCanBeRetried() { + var fail = new AtomicBoolean(true); + var client = + builder() + .worker(TYPE, context -> {}) + .build() + .withPilot( + new Pilot() { + @Override + public void starting(boolean leadership) { + if (fail.compareAndSet(true, false)) + throw new IllegalStateException("temporary pilot failure"); + } + }); + assertEquals( + "temporary pilot failure", + assertThrows(IllegalStateException.class, client::start).getMessage()); + try (var run = client.start()) { + assertSame(client, run.client()); + } + } + + @Test + void sameClientInsertWakesRuntimeWithoutPolling() throws Exception { + var fetched = new CompletableFuture(); + var worked = new CompletableFuture(); + var client = + builder() + .pollInterval(Duration.ofHours(1)) + .worker(TYPE, context -> worked.complete(context.client())) + .build() + .withPilot( + new Pilot() { + @Override + public java.util.List claim( + java.sql.Connection connection, + Client.Driver driver, + Claim claim, + Client.Transaction> next) + throws Exception { + var rows = next.run(connection); + if (rows.isEmpty()) fetched.complete(null); + return rows; + } + }); + try (var run = client.start()) { + fetched.get(5, TimeUnit.SECONDS); + client.insert(TYPE, "wake"); + assertSame(client, worked.get(5, TimeUnit.SECONDS)); + } + } + + @Test + void subscriptionsSurviveRestartAndOldHandlesCannotStopNewRuns() throws Exception { + var client = builder().workers(new Workers().add(TYPE, context -> {})).build(); + var events = new LinkedBlockingQueue(); + var removed = new LinkedBlockingQueue(); + try (var subscription = client.subscribe(events::add, Client.EventKind.JOB_COMPLETED)) { + // Already queued work may finish immediately after start; subscribe beforehand. + var firstJob = client.insert(TYPE, "first").job(); + var first = client.start(); + try (first) { + assertEquals(client.id(), first.id()); + assertThrows(IllegalStateException.class, client::start); + var event = events.poll(5, TimeUnit.SECONDS); + assertNotNull(event); + assertEquals(firstJob.id(), event.job().id()); + } + var closed = client.subscribe(removed::add); + closed.close(); + closed.close(); + try (var second = client.start()) { + assertNotSame(first, second); + assertEquals(first.id(), second.id()); + first.stopAndCancel(); + first.close(); + assertThrows(IllegalStateException.class, client::start); + var secondJob = client.insert(TYPE, "second").job(); + var event = events.poll(5, TimeUnit.SECONDS); + assertNotNull(event); + assertEquals(secondJob.id(), event.job().id()); + assertTrue(removed.isEmpty()); + } + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void timedOutStopPreventsOverlappingRuns(boolean cancel) throws Exception { + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var events = new LinkedBlockingQueue(); + var client = + builder() + .stopTimeout(Duration.ofMillis(100)) + .worker( + TYPE, + context -> { + entered.countDown(); + release.await(); + }) + .build(); + try (var subscription = client.subscribe(events::add)) { + var run = client.start(); + try { + client.insert(TYPE, "blocked"); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertThrows( + RiverException.class, + () -> { + if (cancel) run.stopAndCancel(); + else run.stop(); + }); + assertThrows(IllegalStateException.class, client::start); + } finally { + release.countDown(); + assertNotNull(events.poll(5, TimeUnit.SECONDS)); + run.stop(); + } + try (var restarted = client.start()) { + assertEquals(run.id(), restarted.id()); + } + } + } +} diff --git a/java/river/src/test/java/com/riverqueue/ClientTest.java b/java/river/src/test/java/com/riverqueue/ClientTest.java index dfb0d6e90..2bb20316f 100644 --- a/java/river/src/test/java/com/riverqueue/ClientTest.java +++ b/java/river/src/test/java/com/riverqueue/ClientTest.java @@ -295,13 +295,13 @@ void fetchOnlyKnownKindsLeavesPeerJobsAvailable() throws Exception { var unknown = river.insert(peer, new Email("peer@example.com")).job(); var worked = new CountDownLatch(1); try (var workers = - river - .workers() + river.toBuilder() .queue("default", 4) .leadership(false) .pollOnly(true) .fetchOnlyKnownKinds(true) - .add(EMAIL, context -> worked.countDown()) + .worker(EMAIL, context -> worked.countDown()) + .build() .start()) { river.insert(EMAIL, new Email("java@example.com")); assertTrue(worked.await(10, TimeUnit.SECONDS)); @@ -320,18 +320,18 @@ void removingQueueDrainsItsCommittedClaims() throws Exception { var first = river.insert(EMAIL, new Email("first@example.com")).job(); var second = river.insert(EMAIL, new Email("second@example.com")).job(); try (var workers = - river - .workers() + river.toBuilder() .queue("default", 1) .leadership(false) .pollOnly(true) - .add( + .worker( EMAIL, context -> { calls.incrementAndGet(); entered.countDown(); release.await(); }) + .build() .start()) { assertTrue(entered.await(10, TimeUnit.SECONDS)); var removal = @@ -364,16 +364,15 @@ void workerCompletionCommitsApplicationChangesWithJob() throws Exception { return null; }); var release = new CountDownLatch(1); - var done = new CompletableFuture(); + var done = new CompletableFuture(); var failed = new CompletableFuture(); try (var workers = - river - .workers() + river.toBuilder() .queue("default", 1) .leadership(false) .pollInterval(Duration.ofMillis(10)) .errorHandler(failed::complete) - .add( + .worker( EMAIL, context -> { release.await(); @@ -391,11 +390,14 @@ void workerCompletionCommitsApplicationChangesWithJob() throws Exception { return context.complete(c); }); }) + .build() .start()) { - workers.subscribe( - event -> { - if (event.kind() == Workers.EventKind.JOB_COMPLETED) done.complete(event); - }); + workers + .client() + .subscribe( + event -> { + if (event.kind() == Client.EventKind.JOB_COMPLETED) done.complete(event); + }); var inserted = river.insert(EMAIL, new Email("one@example.com")); release.countDown(); var completed = done.get(10, TimeUnit.SECONDS); @@ -530,12 +532,12 @@ void sqliteQueueNotificationFailureRollsBackQueueMutation() throws Exception { new Client( TestDatabase.sqlite(directory.resolve("river.db")), Clock.fixed(NOW, ZoneOffset.UTC)); try (var workers = - river - .workers() + river.toBuilder() .queue("default", 1) .leadership(false) .pollOnly(true) - .add(EMAIL, ignored -> {}) + .worker(EMAIL, ignored -> {}) + .build() .start(); var connection = river.database().connection()) { connection.setAutoCommit(false); @@ -560,15 +562,14 @@ void sqliteQueueNotificationFailureRollsBackQueueMutation() throws Exception { @SuppressWarnings("try") // Resources intentionally scope background activity. void committedCompletionWinsOverSubsequentHandlerFailure() throws Exception { var river = database(); - var event = new CompletableFuture(); + var event = new CompletableFuture(); var failedEvents = new java.util.concurrent.atomic.AtomicInteger(); try (var workers = - river - .workers() + river.toBuilder() .queue("default", 1) .leadership(false) .pollOnly(true) - .add( + .worker( EMAIL, context -> { Job completed = context.transaction(context::complete); @@ -576,11 +577,15 @@ void committedCompletionWinsOverSubsequentHandlerFailure() throws Exception { assertEquals(Job.State.COMPLETED, completed.state()); throw new IllegalStateException("failure after committed completion"); }) + .build() .start(); - var subscription = workers.subscribe(event::complete, Workers.EventKind.JOB_COMPLETED); + var subscription = + workers.client().subscribe(event::complete, Client.EventKind.JOB_COMPLETED); var failures = - workers.subscribe( - ignored -> failedEvents.incrementAndGet(), Workers.EventKind.JOB_FAILED)) { + workers + .client() + .subscribe( + ignored -> failedEvents.incrementAndGet(), Client.EventKind.JOB_FAILED)) { var inserted = river.insert(EMAIL, new Email("one@example.com")); assertEquals(inserted.job().id(), event.get(10, TimeUnit.SECONDS).job().id()); } @@ -592,10 +597,9 @@ void committedCompletionWinsOverSubsequentHandlerFailure() throws Exception { void retryPolicyReceivesFailedAttemptSnapshot() throws Exception { var river = database(); var observed = new CompletableFuture>(); - var failed = new CompletableFuture(); + var failed = new CompletableFuture(); try (var workers = - river - .workers() + river.toBuilder() .queue("default", 1) .leadership(false) .pollOnly(true) @@ -604,13 +608,15 @@ void retryPolicyReceivesFailedAttemptSnapshot() throws Exception { observed.complete(job); return Duration.ofDays(1); }) - .add( + .worker( EMAIL, ignored -> { throw new IllegalStateException("retry me"); }) + .build() .start(); - var subscription = workers.subscribe(failed::complete, Workers.EventKind.JOB_FAILED)) { + var subscription = + workers.client().subscribe(failed::complete, Client.EventKind.JOB_FAILED)) { var inserted = river.insert( EMAIL, @@ -633,18 +639,18 @@ void jobKindLengthMatchesDatabaseConstraint() throws Exception { var type = JobType.of("k".repeat(127), Email.class); String oversized = "k".repeat(128); assertThrows(IllegalArgumentException.class, () -> JobType.of(oversized, Email.class)); - var completed = new CompletableFuture(); + var completed = new CompletableFuture(); var worked = new CompletableFuture>(); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .leadership(false) .pollOnly(true) - .add(type, context -> worked.complete(context.job())) + .worker(type, context -> worked.complete(context.job())) + .build() .start(); var subscription = - workers.subscribe(completed::complete, Workers.EventKind.JOB_COMPLETED)) { + workers.client().subscribe(completed::complete, Client.EventKind.JOB_COMPLETED)) { var args = new Email("long-kind@example.com"); var inserted = client.insert(type, args).job(); assertEquals(type.kind(), inserted.kind()); @@ -696,19 +702,19 @@ public void afterAttempt( var entered = new CompletableFuture>(); var release = new CountDownLatch(1); var workers = - river - .workers() + river.toBuilder() .queue("default", 1) .leadership(false) .pollOnly(true) .stopTimeout(Duration.ofMillis(100)) .stuckThreshold(Duration.ofMinutes(1)) - .add( + .worker( EMAIL, context -> { entered.complete(context); release.await(); }) + .build() .start(); try { river.insert(EMAIL, new Email("one@example.com")); diff --git a/java/river/src/test/java/com/riverqueue/HookMiddlewareTest.java b/java/river/src/test/java/com/riverqueue/HookMiddlewareTest.java index cb47eda4b..cd6abd7a0 100644 --- a/java/river/src/test/java/com/riverqueue/HookMiddlewareTest.java +++ b/java/river/src/test/java/com/riverqueue/HookMiddlewareTest.java @@ -202,9 +202,10 @@ void workBeginFailureSkipsHandlerAndEndHooks() throws Exception { trace.add("after"); } }); - var completed = new CompletableFuture(); - try (var workers = workers(river).add(TYPE, context -> trace.add("handler")).start(); - var subscription = workers.subscribe(completed::complete)) { + var completed = new CompletableFuture(); + try (var workers = + workers(river).worker(TYPE, context -> trace.add("handler")).build().start(); + var subscription = workers.client().subscribe(completed::complete)) { river.insert(TYPE, "one", InsertOptions.builder().maxAttempts(1).build()); assertEquals(Job.State.DISCARDED, completed.get(5, TimeUnit.SECONDS).job().state()); } @@ -270,10 +271,10 @@ void workEndHooksChainOutcomesInsideMiddleware(String outcome, String action) th } }); } - var completed = new CompletableFuture(); + var completed = new CompletableFuture(); try (var workers = workers(river) - .add( + .worker( TYPE, context -> { trace.add("handler"); @@ -284,8 +285,9 @@ void workEndHooksChainOutcomesInsideMiddleware(String outcome, String action) th default -> {} } }) + .build() .start(); - var subscription = workers.subscribe(completed::complete)) { + var subscription = workers.client().subscribe(completed::complete)) { river.insert(TYPE, "one", InsertOptions.builder().maxAttempts(1).build()); var expected = action.equals("clear") || (outcome.equals("success") && action.equals("preserve")) @@ -302,9 +304,8 @@ void workEndHooksChainOutcomesInsideMiddleware(String outcome, String action) th if (action.equals("clear")) assertNull(observed.get()); } - private Workers.Builder workers(Client river) { - return river - .workers() + private Client.Builder workers(Client river) { + return river.toBuilder() .queue("default", 1) .leadership(false) .pollOnly(true) diff --git a/java/river/src/test/java/com/riverqueue/IdentifierTest.java b/java/river/src/test/java/com/riverqueue/IdentifierTest.java index 1f6bbad26..8f6b4743a 100644 --- a/java/river/src/test/java/com/riverqueue/IdentifierTest.java +++ b/java/river/src/test/java/com/riverqueue/IdentifierTest.java @@ -10,8 +10,8 @@ class IdentifierTest { private static final JobType TYPE = JobType.of("identifier_test", String.class); - private final Workers.Builder workers = - new Client(Database.connect("jdbc:sqlite::memory:")).workers(); + private final Client.Builder workers = + new Client(Database.connect("jdbc:sqlite::memory:")).toBuilder(); @Test void acceptsAnonymousPeriodicJobs() { diff --git a/java/river/src/test/java/com/riverqueue/LeadershipTest.java b/java/river/src/test/java/com/riverqueue/LeadershipTest.java index b66b7209e..95cc57bf3 100644 --- a/java/river/src/test/java/com/riverqueue/LeadershipTest.java +++ b/java/river/src/test/java/com/riverqueue/LeadershipTest.java @@ -75,8 +75,7 @@ public void start() { } }); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .serviceInterval(Duration.ofHours(1)) @@ -86,7 +85,8 @@ public void start() { errors.add(error); reported.join(); }) - .add(TYPE, context -> {}) + .worker(TYPE, context -> {}) + .build() .start()) { try { assertNotNull(errors.poll(5, TimeUnit.SECONDS)); @@ -139,8 +139,7 @@ void leaseCoversTheRenewalInterval(long seconds) throws Exception { var ready = new CountDownLatch(1); var interval = Duration.ofSeconds(seconds); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .serviceInterval(interval) .stopTimeout(Duration.ofSeconds(5)) @@ -150,7 +149,8 @@ void leaseCoversTheRenewalInterval(long seconds) throws Exception { if (event.equals("listen_ready")) ready.countDown(); if (event.equals("leadership_check")) checked.add(event); }) - .add(TYPE, context -> {}) + .worker(TYPE, context -> {}) + .build() .start()) { assertTrue(ready.await(5, TimeUnit.SECONDS)); assertNotNull(checked.poll(5, TimeUnit.SECONDS)); @@ -198,14 +198,14 @@ public void start() { } }); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .serviceInterval(Duration.ofMillis(20)) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add(TYPE, context -> {}) + .worker(TYPE, context -> {}) + .build() .start()) { assertNotNull(starts.poll(5, TimeUnit.SECONDS)); var original = lease(client); @@ -235,8 +235,7 @@ void resignationWakesPeersOnBothDatabases() throws Exception { var secondCheck = new CountDownLatch(1); var errors = new LinkedBlockingQueue(); var first = - client - .workers() + client.toBuilder() .queue("default", 1) .id("first") .serviceInterval(Duration.ofHours(1)) @@ -247,7 +246,8 @@ void resignationWakesPeersOnBothDatabases() throws Exception { if (event.equals("listen_ready")) ready.countDown(); if (event.equals("leadership_check")) firstCheck.countDown(); }) - .add(TYPE, context -> {}) + .worker(TYPE, context -> {}) + .build() .start(); try (first) { assertTrue(firstCheck.await(5, TimeUnit.SECONDS)); @@ -260,8 +260,7 @@ public void start() { } }); try (var second = - follower - .workers() + follower.toBuilder() .queue("default", 1) .id("second") .serviceInterval(Duration.ofHours(1)) @@ -272,7 +271,8 @@ public void start() { if (event.equals("listen_ready")) ready.countDown(); if (event.equals("leadership_check")) secondCheck.countDown(); }) - .add(TYPE, context -> {}) + .worker(TYPE, context -> {}) + .build() .start()) { assertTrue(ready.await(5, TimeUnit.SECONDS)); assertTrue(secondCheck.await(5, TimeUnit.SECONDS)); @@ -312,8 +312,7 @@ public boolean rescue(Client client, Rescue request) { } }); var first = - blocked - .workers() + blocked.toBuilder() .queue("default", 1) .id("old-leader") .serviceInterval(Duration.ofHours(1)) @@ -323,7 +322,8 @@ public boolean rescue(Client client, Rescue request) { event -> { if (event.equals("listen_ready")) ready.countDown(); }) - .add(TYPE, context -> {}) + .worker(TYPE, context -> {}) + .build() .start(); try { assertTrue(ready.await(5, TimeUnit.SECONDS)); @@ -338,14 +338,15 @@ public void start() { } }); try (var second = - peer.workers() + peer.toBuilder() .queue("default", 1) .id("new-leader") .pollOnly(true) .serviceInterval(Duration.ofMillis(20)) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add(TYPE, context -> {}) + .worker(TYPE, context -> {}) + .build() .start()) { base.requestResign(); assertTrue(elected.await(5, TimeUnit.SECONDS)); @@ -379,14 +380,14 @@ public void start() { } }); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .serviceInterval(Duration.ofMillis(20)) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add(TYPE, context -> {}) + .worker(TYPE, context -> {}) + .build() .start()) { assertTrue(started.await(5, TimeUnit.SECONDS)); assertTrue(workers.isLeader()); @@ -419,8 +420,7 @@ public void producer( } }); var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .serviceInterval(Duration.ofHours(1)) @@ -429,7 +429,8 @@ public void producer( event -> { if (event.equals("leadership_check")) checked.countDown(); }) - .add(TYPE, context -> {}) + .worker(TYPE, context -> {}) + .build() .start(); try { assertTrue(checked.await(5, TimeUnit.SECONDS)); diff --git a/java/river/src/test/java/com/riverqueue/NotificationCleanupTest.java b/java/river/src/test/java/com/riverqueue/NotificationCleanupTest.java index d7bc09a6a..4a7814d72 100644 --- a/java/river/src/test/java/com/riverqueue/NotificationCleanupTest.java +++ b/java/river/src/test/java/com/riverqueue/NotificationCleanupTest.java @@ -87,13 +87,13 @@ public boolean clean(Client river, Instant now) { } }); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) - .add(JobType.of("unused", String.class), context -> {}) + .worker(JobType.of("unused", String.class), context -> {}) .pollOnly(true) .serviceInterval(Duration.ofHours(1)) .errorHandler(errors::add) + .build() .start()) { assertEquals(List.of(2L), cleaned.get(5, TimeUnit.SECONDS)); } diff --git a/java/river/src/test/java/com/riverqueue/NotificationTest.java b/java/river/src/test/java/com/riverqueue/NotificationTest.java index a4718fc9f..22866628e 100644 --- a/java/river/src/test/java/com/riverqueue/NotificationTest.java +++ b/java/river/src/test/java/com/riverqueue/NotificationTest.java @@ -70,11 +70,10 @@ public List claim( var worked = new CompletableFuture>(); var retry = new CompletableFuture(); var ready = new CompletableFuture(); - var paused = new CompletableFuture(); + var paused = new CompletableFuture(); var errors = new LinkedBlockingQueue(); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .leadership(false) .pollInterval(Duration.ofHours(1)) @@ -89,9 +88,11 @@ public List claim( event -> { if (event.equals("listen_ready")) ready.complete(null); }) - .add(type, context -> worked.complete(context.job())) + .worker(type, context -> worked.complete(context.job())) + .build() .start(); - var subscription = workers.subscribe(paused::complete, Workers.EventKind.QUEUE_PAUSED)) { + var subscription = + workers.client().subscribe(paused::complete, Client.EventKind.QUEUE_PAUSED)) { try { var error = errors.poll(5, TimeUnit.SECONDS); assertNotNull(error); diff --git a/java/river/src/test/java/com/riverqueue/PeerCancellationTest.java b/java/river/src/test/java/com/riverqueue/PeerCancellationTest.java index b0ef87333..6a2a0c363 100644 --- a/java/river/src/test/java/com/riverqueue/PeerCancellationTest.java +++ b/java/river/src/test/java/com/riverqueue/PeerCancellationTest.java @@ -61,14 +61,13 @@ public JsonNode decode(Job job) { var completed = new CompletableFuture(); var errors = new LinkedBlockingQueue(); var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .leadership(false) .pollOnly(true) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add( + .worker( TYPE, context -> { parent.complete(context); @@ -98,6 +97,7 @@ public JsonNode decode(Job job) { context.completePeer(peer, new InterruptedException("peer interrupted")); completed.complete(null); }) + .build() .start(); try { client.insert(TYPE, "parent"); @@ -143,7 +143,7 @@ void peerTimeoutInterruptsTheOwningWorker(String phase) throws Exception { var entered = new CompletableFuture(); var interrupted = new CompletableFuture(); var release = new CountDownLatch(1); - var events = new LinkedBlockingQueue(); + var events = new LinkedBlockingQueue(); var errors = new LinkedBlockingQueue(); var database = databases.open(directory.resolve("river.db")); var client = @@ -169,8 +169,7 @@ public JsonNode decode(Job job) { .insert(TYPE, "peer", InsertOptions.builder().queue("peers").maxAttempts(1).build()) .job(); var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .leadership(false) .pollOnly(true) @@ -178,7 +177,7 @@ public JsonNode decode(Job job) { .stuckThreshold(Duration.ofMillis(20)) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add( + .worker( TYPE, new Workers.Handler<>() { @Override @@ -221,15 +220,16 @@ public void work(WorkContext context) throws Exception { } } }) + .build() .start(); - try (var subscription = workers.subscribe(events::add)) { + try (var subscription = workers.client().subscribe(events::add)) { client.insert(TYPE, "parent", InsertOptions.builder().maxAttempts(1).build()); entered.get(5, TimeUnit.SECONDS); interrupted.get(5, TimeUnit.SECONDS); for (int i = 0; i < 2; i++) { var event = events.poll(5, TimeUnit.SECONDS); assertNotNull(event); - assertEquals(Workers.EventKind.JOB_FAILED, event.kind()); + assertEquals(Client.EventKind.JOB_FAILED, event.kind()); } assertEquals(Job.State.DISCARDED, client.get(peerJob.id()).state()); } finally { diff --git a/java/river/src/test/java/com/riverqueue/PluginTest.java b/java/river/src/test/java/com/riverqueue/PluginTest.java index e5d4dce97..f83b02a7d 100644 --- a/java/river/src/test/java/com/riverqueue/PluginTest.java +++ b/java/river/src/test/java/com/riverqueue/PluginTest.java @@ -110,18 +110,18 @@ void pluginsActivateAllJobInterfacesBeforeExplicitRegistrations(boolean pluginsF .withPlugins(first) .withPlugins(second)) .withPilot(new Pilot() {}); - var completed = new CompletableFuture(); + var completed = new CompletableFuture(); try (var workers = - river - .workers() + river.toBuilder() .queue("default", 1) .leadership(false) .pollOnly(true) .pollInterval(Duration.ofMillis(10)) .stopTimeout(Duration.ofSeconds(5)) - .add(TYPE, context -> trace.add("handler")) + .worker(TYPE, context -> trace.add("handler")) + .build() .start(); - var subscription = workers.subscribe(completed::complete)) { + var subscription = workers.client().subscribe(completed::complete)) { river.insert(TYPE, "one"); assertEquals(Job.State.COMPLETED, completed.get(5, TimeUnit.SECONDS).job().state()); } @@ -162,13 +162,13 @@ void pluginsIncludePeriodicStartHooks() throws Exception { .withHooks((HookPeriodicJobsStart) () -> started.add("explicit")) .withPlugins(first, second); try (var workers = - river - .workers() + river.toBuilder() .queue("default", 1) .pollOnly(true) .serviceInterval(Duration.ofHours(1)) .stopTimeout(Duration.ofSeconds(5)) - .add(TYPE, context -> {}) + .worker(TYPE, context -> {}) + .build() .start()) { assertEquals("first", started.poll(5, TimeUnit.SECONDS)); assertEquals("second", started.poll(5, TimeUnit.SECONDS)); diff --git a/java/river/src/test/java/com/riverqueue/ProtocolStorageTest.java b/java/river/src/test/java/com/riverqueue/ProtocolStorageTest.java index 47fdbc6fe..9cafbdd19 100644 --- a/java/river/src/test/java/com/riverqueue/ProtocolStorageTest.java +++ b/java/river/src/test/java/com/riverqueue/ProtocolStorageTest.java @@ -34,12 +34,11 @@ void periodicMetadata() throws Exception { var claimed = new CompletableFuture>(); var errors = new LinkedBlockingQueue(); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .errorHandler(errors::add) - .add(TYPE, context -> claimed.complete(context.job())) + .worker(TYPE, context -> claimed.complete(context.job())) .periodic( "conformance_periodic", Schedule.every(Duration.ofDays(1)), @@ -47,6 +46,7 @@ void periodicMetadata() throws Exception { "periodic", InsertOptions.builder().metadata(Map.of("source", "conformance")).build(), true) + .build() .start()) { var job = claimed.get(5, TimeUnit.SECONDS); var metadata = client.get(job.id()).metadata(); @@ -110,12 +110,11 @@ public boolean clean(Client client, Instant now) { fixture.required("attempt_error"), Json.tree(client.get(inserted.id()).errors().getFirst())); - var completed = new CompletableFuture(); + var completed = new CompletableFuture(); var writeFailure = new CompletableFuture(); var errors = new LinkedBlockingQueue(); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .errorHandler(errors::add) @@ -136,10 +135,11 @@ public boolean clean(Client client, Instant now) { } return Duration.ofDays(1); }) - .add(TYPE, context -> context.output(Map.of("ok", true))) + .worker(TYPE, context -> context.output(Map.of("ok", true))) + .build() .start(); var subscription = - workers.subscribe(completed::complete, Workers.EventKind.JOB_COMPLETED)) { + workers.client().subscribe(completed::complete, Client.EventKind.JOB_COMPLETED)) { if (!postgres) assertNull( writeFailure.get(10, TimeUnit.SECONDS), "Rescue callbacks must not hold the writer"); diff --git a/java/river/src/test/java/com/riverqueue/ProtocolTest.java b/java/river/src/test/java/com/riverqueue/ProtocolTest.java index 6d6f015d4..3496f78a0 100644 --- a/java/river/src/test/java/com/riverqueue/ProtocolTest.java +++ b/java/river/src/test/java/com/riverqueue/ProtocolTest.java @@ -494,7 +494,7 @@ Stream snoozeCounters() throws Exception { () -> assertEquals( fixture.required("expected_snoozes").asLong(), - Workers.nextSnoozeCount(fixture.required("metadata"))))); + RunHandle.nextSnoozeCount(fixture.required("metadata"))))); return tests.stream(); } diff --git a/java/river/src/test/java/com/riverqueue/RescueTest.java b/java/river/src/test/java/com/riverqueue/RescueTest.java index 549d5448f..a167925c9 100644 --- a/java/river/src/test/java/com/riverqueue/RescueTest.java +++ b/java/river/src/test/java/com/riverqueue/RescueTest.java @@ -44,14 +44,13 @@ public boolean rescue(Client client, Rescue rescue) { }); var job = client.insert(TYPE, mode).job(); try (var workers = - client - .workers() + client.toBuilder() .queue("other", 1) .pollOnly(true) .jobTimeout(Duration.ofMinutes(5)) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add( + .worker( TYPE, new Workers.Handler<>() { @Override @@ -72,6 +71,7 @@ public void work(WorkContext context) { fail("Wrong queue"); } }) + .build() .start()) { var rescue = request.get(5, TimeUnit.SECONDS); var expected = @@ -131,8 +131,7 @@ public boolean clean(Client client, Instant now) { return null; }); var workers = - client - .workers() + client.toBuilder() .queue("other", 1) .pollOnly(true) .serviceInterval(Duration.ofHours(1)) @@ -146,7 +145,7 @@ public boolean clean(Client client, Instant now) { } return Duration.ofDays(1); }) - .add( + .worker( TYPE, new Workers.Handler<>() { @Override @@ -163,6 +162,7 @@ public void work(WorkContext context) { fail("Wrong queue"); } }) + .build() .start(); try { entered.get(5, TimeUnit.SECONDS); @@ -246,15 +246,14 @@ public boolean clean(Client client, Instant now) { }); } try (var workers = - client - .workers() + client.toBuilder() .queue("other", 1) .pollOnly(true) .jobTimeout(Duration.ofNanos(defaultDisabled ? -1 : 1)) .rescueAfter(Duration.ofHours(1)) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add( + .worker( TYPE, new Workers.Handler<>() { @Override @@ -275,6 +274,7 @@ public void work(WorkContext context) { fail("Wrong queue"); } }) + .build() .start()) { maintained.get(5, TimeUnit.SECONDS); for (var entry : jobs.entrySet()) { @@ -352,8 +352,7 @@ public List claim( }); try (var follower = peer - ? peerClient - .workers() + ? peerClient.toBuilder() .queue("default", 1) .leadership(false) .pollInterval(Duration.ofHours(1)) @@ -364,13 +363,13 @@ public List claim( event -> { if (event.equals("listen_ready")) listening.complete(null); }) - .add(TYPE, context -> worked.complete(context.job())) + .worker(TYPE, context -> worked.complete(context.job())) + .build() .start() : null) { if (peer) fetched.get(5, TimeUnit.SECONDS); try (var workers = - client - .workers() + client.toBuilder() .queue(peer ? "other" : "default", 1) .pollOnly(true) .pollInterval(Duration.ofHours(1)) @@ -378,7 +377,8 @@ public List claim( .stopTimeout(Duration.ofSeconds(5)) .retryPolicy(job -> Duration.ofSeconds(1)) .errorHandler(errors::add) - .add(TYPE, context -> worked.complete(context.job())) + .worker(TYPE, context -> worked.complete(context.job())) + .build() .start()) { maintained.get(5, TimeUnit.SECONDS); var rescued = client.get(inserted.id()); diff --git a/java/river/src/test/java/com/riverqueue/ResumableTest.java b/java/river/src/test/java/com/riverqueue/ResumableTest.java index 255b7953e..b20beae92 100644 --- a/java/river/src/test/java/com/riverqueue/ResumableTest.java +++ b/java/river/src/test/java/com/riverqueue/ResumableTest.java @@ -25,17 +25,16 @@ class ResumableTest { @SuppressWarnings("try") // Resources intentionally scope background activity. void cursorOnlyStepRemainsRecoverableAfterItIsOmitted() throws Exception { var client = new Client(databases.open(directory.resolve("cursor-only.db"))); - var events = new LinkedBlockingQueue(); + var events = new LinkedBlockingQueue(); var received = new LinkedBlockingQueue(); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .leadership(false) .retryPolicy(job -> Duration.ofDays(1)) .stopTimeout(Duration.ofSeconds(5)) - .add( + .worker( TYPE, context -> { if (context.job().attempt() > 1) @@ -47,14 +46,15 @@ void cursorOnlyStepRemainsRecoverableAfterItIsOmitted() throws Exception { }); context.step("later", () -> {}); }) + .build() .start(); - var subscription = workers.subscribe(events::add)) { + var subscription = workers.client().subscribe(events::add)) { var metadata = Map.of(Protocol.METADATA_RESUMABLE_CURSOR, Map.of("loop", 0)); var inserted = client.insert(TYPE, "work", InsertOptions.builder().metadata(metadata).build()).job(); var failed = events.poll(5, TimeUnit.SECONDS); assertNotNull(failed); - assertEquals(Workers.EventKind.JOB_FAILED, failed.kind()); + assertEquals(Client.EventKind.JOB_FAILED, failed.kind()); var savedMetadata = client.get(inserted.id()).metadata(); assertEquals( Json.tree(metadata).path(Protocol.METADATA_RESUMABLE_CURSOR), @@ -63,7 +63,7 @@ void cursorOnlyStepRemainsRecoverableAfterItIsOmitted() throws Exception { client.retry(inserted.id()); var completed = events.poll(5, TimeUnit.SECONDS); assertNotNull(completed); - assertEquals(Workers.EventKind.JOB_COMPLETED, completed.kind()); + assertEquals(Client.EventKind.JOB_COMPLETED, completed.kind()); assertEquals(java.util.List.of(0), java.util.List.copyOf(received)); } } @@ -83,20 +83,19 @@ void cursorOnlyStepRemainsRecoverableAfterItIsOmitted() throws Exception { void checkpointCommitsWithApplicationWrites( boolean priorCheckpoint, boolean rollback, boolean withCursor) throws Exception { var client = new Client(databases.open(directory.resolve("river.db"))); - var events = new LinkedBlockingQueue(); + var events = new LinkedBlockingQueue(); var errors = new LinkedBlockingQueue(); var preparations = new AtomicInteger(); var steps = new LinkedBlockingQueue(); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .leadership(false) .retryPolicy(job -> Duration.ofDays(1)) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add( + .worker( TYPE, context -> { if (priorCheckpoint) @@ -146,8 +145,9 @@ void checkpointCommitsWithApplicationWrites( if (withCursor) context.stepWithCursor("process", action); else context.step("process", () -> action.run(Json.object().path("missing"))); }) + .build() .start(); - var subscription = workers.subscribe(events::add)) { + var subscription = workers.client().subscribe(events::add)) { var inserted = client .insert( @@ -159,7 +159,7 @@ void checkpointCommitsWithApplicationWrites( .job(); var failed = events.poll(5, TimeUnit.SECONDS); assertNotNull(failed); - assertEquals(Workers.EventKind.JOB_FAILED, failed.kind()); + assertEquals(Client.EventKind.JOB_FAILED, failed.kind()); var saved = client.get(inserted.id()); assertEquals( rollback ? "prepare" : "process", @@ -174,7 +174,7 @@ void checkpointCommitsWithApplicationWrites( client.retry(inserted.id()); var completed = events.poll(5, TimeUnit.SECONDS); assertNotNull(completed); - assertEquals(Workers.EventKind.JOB_COMPLETED, completed.kind()); + assertEquals(Client.EventKind.JOB_COMPLETED, completed.kind()); assertEquals(1, preparations.get()); assertEquals( rollback || withCursor ? java.util.List.of(1, 2) : java.util.List.of(1), @@ -289,22 +289,21 @@ void checkpointRetainsOnlyCommittedProgress(boolean rollback, boolean finishStep @SuppressWarnings("try") // Resources intentionally scope background activity. void cursorDoesNotCompleteFailedSteps(String mode) throws Exception { var client = new Client(databases.open(directory.resolve("river.db"))); - var events = new LinkedBlockingQueue(); + var events = new LinkedBlockingQueue(); var errors = new LinkedBlockingQueue(); var prepares = new AtomicInteger(); var calls = new AtomicInteger(); boolean first = mode.startsWith("first"); boolean nullCursor = mode.contains("null"); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .leadership(false) .retryPolicy(job -> Duration.ofDays(1)) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add( + .worker( TYPE, context -> { if (!first) @@ -325,12 +324,13 @@ void cursorDoesNotCompleteFailedSteps(String mode) throws Exception { context.step("process", () -> action.run(Json.tree(null))); else context.stepWithCursor("process", action); }) + .build() .start(); - var subscription = workers.subscribe(events::add)) { + var subscription = workers.client().subscribe(events::add)) { var job = client.insert(TYPE, "args").job(); var failed = events.poll(5, TimeUnit.SECONDS); assertNotNull(failed); - assertEquals(Workers.EventKind.JOB_FAILED, failed.kind()); + assertEquals(Client.EventKind.JOB_FAILED, failed.kind()); var metadata = client.get(job.id()).metadata(); assertEquals( first ? "" : "prepare", metadata.path(Protocol.METADATA_RESUMABLE_STEP).asString("")); @@ -338,7 +338,7 @@ void cursorDoesNotCompleteFailedSteps(String mode) throws Exception { client.retry(job.id()); var completed = events.poll(5, TimeUnit.SECONDS); assertNotNull(completed); - assertEquals(Workers.EventKind.JOB_COMPLETED, completed.kind()); + assertEquals(Client.EventKind.JOB_COMPLETED, completed.kind()); assertEquals(2, calls.get()); assertEquals(first ? 0 : 1, prepares.get()); } @@ -379,19 +379,18 @@ void cursorInFirstStepIsBufferedAndNestedStepsAreRejected() throws Exception { @SuppressWarnings("try") // Resources intentionally scope background activity. void cursorMapsReplaceCompletedStepsAndPreserveNullValues() throws Exception { var client = new Client(databases.open(directory.resolve("river.db"))); - var events = new LinkedBlockingQueue(); + var events = new LinkedBlockingQueue(); var errors = new LinkedBlockingQueue(); var firstCalls = new AtomicInteger(); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .leadership(false) .retryPolicy(job -> Duration.ofDays(1)) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add( + .worker( TYPE, context -> { context.stepWithCursor( @@ -417,14 +416,15 @@ void cursorMapsReplaceCompletedStepsAndPreserveNullValues() throws Exception { assertTrue(cursor.path("nested").path("value").isNull()); }); }) + .build() .start(); - var subscription = workers.subscribe(events::add)) { + var subscription = workers.client().subscribe(events::add)) { var job = client.insert(TYPE, "args").job(); for (int attempt = 1; attempt <= 3; attempt++) { var event = events.poll(5, TimeUnit.SECONDS); assertNotNull(event); assertEquals( - attempt == 3 ? Workers.EventKind.JOB_COMPLETED : Workers.EventKind.JOB_FAILED, + attempt == 3 ? Client.EventKind.JOB_COMPLETED : Client.EventKind.JOB_FAILED, event.kind()); if (attempt == 2) { var cursors = client.get(job.id()).metadata().path(Protocol.METADATA_RESUMABLE_CURSOR); diff --git a/java/river/src/test/java/com/riverqueue/RetryTest.java b/java/river/src/test/java/com/riverqueue/RetryTest.java index 30f9bdd73..683b2a0eb 100644 --- a/java/river/src/test/java/com/riverqueue/RetryTest.java +++ b/java/river/src/test/java/com/riverqueue/RetryTest.java @@ -112,8 +112,7 @@ public List claim( var worked = new CompletableFuture>(); var errors = new LinkedBlockingQueue(); try (var workers = - client - .workers() + client.toBuilder() .queue("urgent", 1) .leadership(false) .pollOnly(mode.equals("local")) @@ -125,16 +124,17 @@ public List claim( if (event.equals("listen_ready")) listening.complete(null); }) .errorHandler(errors::add) - .add( + .worker( TYPE, context -> { listening.get(5, TimeUnit.SECONDS); if (context.args().equals("retry")) worked.complete(context.job()); }) + .build() .start()) { // Completing the warmup drains startup wake-ups before testing retry's notification. idle.get(5, TimeUnit.SECONDS); - var actor = mode.equals("local") ? client : new Client(database); + var actor = mode.equals("local") ? workers.client() : new Client(database); if (mode.equals("transaction")) { try (var connection = database.connection()) { connection.setAutoCommit(false); diff --git a/java/river/src/test/java/com/riverqueue/RunHandleTest.java b/java/river/src/test/java/com/riverqueue/RunHandleTest.java new file mode 100644 index 000000000..46efd1c4f --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/RunHandleTest.java @@ -0,0 +1,995 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +class RunHandleTest { + private static final JobType TYPE = JobType.of("runtime_test", String.class); + @TempDir Path directory; + @RegisterExtension final TestDatabase databases = new TestDatabase(); + + private Client client() { + var database = databases.open(directory.resolve("river.db")); + return new Client(database); + } + + private Client.Builder workers(Client client) { + return client.toBuilder() + .queue("default", 1) + .leadership(false) + .pollOnly(true) + .stopTimeout(Duration.ofSeconds(1)); + } + + @Test + void builderGeneratesAnIdForEachClient() { + var builder = workers(client()).worker(TYPE, context -> {}); + try (var first = builder.build().start(); + var second = builder.build().start()) { + assertNotEquals(first.id(), second.id()); + } + try (var explicit = builder.id("chosen-id").build().start()) { + assertEquals("chosen-id", explicit.id()); + } + } + + @ParameterizedTest + @CsvSource({ + "PT0.000000001S,true", + "PT1H,true", + "PT23H59M59.999999999S,true", + "PT24H,false", + "PT24H0.000000001S,false", + "PT48H,false" + }) + void builderRequiresServiceIntervalsBelowQueueRetention(String value, boolean valid) { + var builder = new Client(Database.connect("jdbc:postgresql://127.0.0.1:1/unused")).toBuilder(); + var interval = Duration.parse(value); + if (valid) assertSame(builder, builder.serviceInterval(interval)); + else + assertEquals( + "serviceInterval must be shorter than the queue retention period of one day", + assertThrows(IllegalArgumentException.class, () -> builder.serviceInterval(interval)) + .getMessage()); + } + + @ParameterizedTest + @CsvSource({"REMOTE,false", "REMOTE,true", "TIMEOUT,false", "TIMEOUT,true"}) + @SuppressWarnings("try") // Resources intentionally scope background activity. + void cancellationRespectsTheHandlersOutcome(WorkContext.Cancellation cause, boolean interrupted) + throws Exception { + var client = client(); + var entered = new CompletableFuture>(); + var outcome = new CompletableFuture(); + var reported = new LinkedBlockingQueue(); + try (var workers = + workers(client) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(reported::add) + .worker( + TYPE, + context -> { + entered.complete(context); + context.awaitCancellation(); + if (interrupted) context.checkCancelled(); + }) + .build() + .start(); + var subscription = workers.client().subscribe(outcome::complete)) { + var inserted = + client.insert(TYPE, "cancel", InsertOptions.builder().maxAttempts(1).build()).job(); + var context = entered.get(5, TimeUnit.SECONDS); + if (cause == WorkContext.Cancellation.REMOTE) client.cancel(inserted.id()); + else context.requestCancellation(cause); + var event = outcome.get(5, TimeUnit.SECONDS); + var expected = + !interrupted + ? Job.State.COMPLETED + : cause == WorkContext.Cancellation.REMOTE + ? Job.State.CANCELLED + : Job.State.DISCARDED; + assertEquals(cause, context.cancellation()); + assertEquals(expected, event.job().state()); + var job = client.get(inserted.id()); + assertEquals(expected, job.state()); + assertEquals(interrupted ? 1 : 0, job.errors().size()); + assertEquals(1, job.attempt()); + assertNotNull(job.finalizedAt()); + } + assertTrue(reported.isEmpty(), () -> "Unexpected worker errors: " + reported); + } + + @ParameterizedTest + @EnumSource( + value = Job.State.class, + names = {"AVAILABLE", "PENDING", "RETRYABLE", "RUNNING", "SCHEDULED"}) + @SuppressWarnings("try") // Resources intentionally scope background activity. + void completionEventsReflectPersistedState(Job.State state) throws Exception { + var client = client(); + var database = client.database(); + var changed = new CompletableFuture(); + var events = new LinkedBlockingQueue(); + var reported = new LinkedBlockingQueue(); + try (var workers = + workers(client) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(reported::add) + .worker( + TYPE, + context -> { + context.transaction( + connection -> { + // A rescuer or another attempt changed this row before the old handler + // returned. + try (var statement = + Sql.prepare( + connection, + "UPDATE " + + database.prefix() + + "river_job SET state='" + + state.value() + + "', scheduled_at=?, attempt=? WHERE id=?", + database.timestamp(Instant.now().plusSeconds(3600)), + state == Job.State.RUNNING ? 2 : 1, + context.job().id())) { + assertEquals(1, statement.executeUpdate()); + } + return null; + }); + changed.complete(null); + }) + .build() + .start(); + var subscription = workers.client().subscribe(events::add)) { + var job = client.insert(TYPE, "changed").job(); + changed.get(5, TimeUnit.SECONDS); + workers.stop(); + assertEquals(state, client.get(job.id()).state()); + var expected = + switch (state) { + case AVAILABLE, RETRYABLE -> Client.EventKind.JOB_FAILED; + case SCHEDULED -> Client.EventKind.JOB_SNOOZED; + default -> null; + }; + if (expected == null) assertTrue(events.isEmpty()); + else { + assertEquals(1, events.size()); + assertEquals(expected, events.element().kind()); + assertEquals(state, events.element().job().state()); + } + } + assertTrue(reported.isEmpty(), () -> "Unexpected worker errors: " + reported); + } + + @Test + @SuppressWarnings("try") // Resources intentionally scope background activity. + void completionRetriesSurviveAnInterruptedHandler() throws Exception { + var attempts = new AtomicInteger(); + var client = + client() + .withPilot( + new Pilot() { + @Override + public void afterAttempt( + Connection connection, + Client.Driver driver, + Job job) + throws SQLException { + if (attempts.getAndIncrement() == 0) + throw new SQLException("transient completion failure"); + } + }); + var completed = new LinkedBlockingQueue(); + var reported = new LinkedBlockingQueue(); + try (var workers = + workers(client) + .errorHandler(reported::add) + .worker(TYPE, context -> Thread.currentThread().interrupt()) + .build() + .start(); + var subscription = + workers.client().subscribe(completed::add, Client.EventKind.JOB_COMPLETED)) { + for (int i = 0; i < 2; i++) { + var job = client.insert(TYPE, "job " + i).job(); + var event = completed.poll(5, TimeUnit.SECONDS); + assertNotNull( + event, "An interrupted handler must not strand completion or its worker slot"); + assertEquals(job.id(), event.job().id()); + assertEquals(Job.State.COMPLETED, client.get(job.id()).state()); + } + assertEquals(3, attempts.get()); + assertEquals(1, reported.size()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"args", "kind", "row"}) + @SuppressWarnings("try") // Resources intentionally scope background activity. + void completionRetriesSurviveInvalidJobs(String invalid) throws Exception { + var attempts = new AtomicInteger(); + var client = + client() + .withPilot( + new Pilot() { + @Override + public void afterAttempt( + Connection connection, + Client.Driver driver, + Job job) + throws SQLException { + if (job.args().isObject() && attempts.getAndIncrement() == 0) + throw new SQLException("transient completion failure"); + } + + @Override + public java.util.List claim( + Connection connection, + Client.Driver driver, + Pilot.Claim claim, + Client.Transaction> next) + throws Exception { + var rows = next.run(connection); + return rows.stream() + .map( + row -> + invalid.equals("row") && row.job().args().isObject() + ? new Client.Decoded( + row.job(), new IllegalArgumentException("invalid row")) + : row) + .toList(); + } + }); + var type = + JobType.of( + invalid.equals("kind") ? "unknown_kind" : TYPE.kind(), + tools.jackson.databind.JsonNode.class); + var handled = new LinkedBlockingQueue(); + var reported = new LinkedBlockingQueue(); + var finalized = new LinkedBlockingQueue(); + try (var workers = + workers(client) + .queue("default", 2) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(reported::add) + .worker(TYPE, context -> handled.add(context.job().id())) + .build() + .start(); + var subscription = workers.client().subscribe(finalized::add)) { + var jobs = + client.insertMany( + java.util.List.of( + type.submission(Json.object(), InsertOptions.builder().maxAttempts(1).build()), + TYPE.submission("valid"))); + var bad = jobs.getFirst().job(); + var good = jobs.getLast().job(); + assertNotNull(finalized.poll(5, TimeUnit.SECONDS), "The first claimed job must settle"); + assertNotNull(finalized.poll(5, TimeUnit.SECONDS), "Every committed claim must settle"); + workers.stop(); + assertEquals(java.util.List.of(good.id()), java.util.List.copyOf(handled)); + assertEquals(Job.State.DISCARDED, client.get(bad.id()).state()); + assertEquals(Job.State.COMPLETED, client.get(good.id()).state()); + assertEquals(1, client.get(bad.id()).errors().size()); + assertEquals(2, attempts.get()); + // A rolled-back completion batch may report the same failure for both claims. + assertFalse(reported.isEmpty()); + assertTrue( + reported.stream() + .allMatch(error -> error.toString().contains("transient completion failure"))); + } + } + + @Test + @SuppressWarnings("try") // Resources intentionally scope background activity. + void completedHandlerIsNotCancelledWhileAcknowledging() throws Exception { + var acknowledging = new CountDownLatch(1); + var release = new CountDownLatch(1); + var context = new CompletableFuture>(); + var client = + client() + .withPilot( + new Pilot() { + @Override + public void afterAttempt( + Connection connection, + Client.Driver driver, + Job job) + throws InterruptedException { + acknowledging.countDown(); + release.await(); + } + }); + var completed = new CompletableFuture(); + var workers = workers(client).worker(TYPE, context::complete).build().start(); + try (var subscription = workers.client().subscribe(completed::complete)) { + var job = client.insert(TYPE, "acknowledge").job(); + assertTrue(acknowledging.await(5, TimeUnit.SECONDS)); + // Stop requests cancellation while the handler's successful outcome is awaiting commit. + assertThrows(RiverException.class, workers::stopAndCancel); + assertNull(context.get(5, TimeUnit.SECONDS).cancellation()); + release.countDown(); + assertEquals(Client.EventKind.JOB_COMPLETED, completed.get(5, TimeUnit.SECONDS).kind()); + assertEquals(Job.State.COMPLETED, client.get(job.id()).state()); + } finally { + release.countDown(); + workers.stopAndCancel(); + } + } + + @Test + @SuppressWarnings("try") // Resources intentionally scope background activity. + void jobTimeoutDoesNotDependOnTheDispatcher() throws Exception { + var blocked = new CountDownLatch(1); + var release = new CountDownLatch(1); + var client = + client() + .withPilot( + new Pilot() { + @Override + public void producer( + Connection connection, Client.Driver driver, Producer producer) + throws InterruptedException { + if (!producer.active().isEmpty()) { + blocked.countDown(); + release.await(); + } + } + }); + var cancelled = new CompletableFuture(); + var failed = new CompletableFuture(); + var workers = + workers(client) + .jobTimeout(Duration.ofNanos(1)) + .serviceInterval(Duration.ofNanos(1)) + .stuckThreshold(Duration.ofMinutes(1)) + .worker( + TYPE, + context -> { + context.awaitCancellation(); + cancelled.complete(context.cancellation()); + release.await(); + context.checkCancelled(); + }) + .build() + .start(); + try (var subscription = workers.client().subscribe(failed::complete)) { + client.insert(TYPE, "timeout", InsertOptions.builder().maxAttempts(1).build()); + assertTrue(blocked.await(5, TimeUnit.SECONDS)); + assertEquals(WorkContext.Cancellation.TIMEOUT, cancelled.get(5, TimeUnit.SECONDS)); + release.countDown(); + assertEquals(Job.State.DISCARDED, failed.get(5, TimeUnit.SECONDS).job().state()); + } finally { + release.countDown(); + workers.stopAndCancel(); + } + } + + @ParameterizedTest + @ValueSource( + strings = { + "job", + "poll", + "service", + "stop", + "stuck", + "rescue", + "maintenance", + "reindex", + "retention" + }) + void oversizedDurationsFailDuringConfiguration(String option) { + var builder = new Client(Database.connect("jdbc:postgresql://127.0.0.1:1/unused")).toBuilder(); + var oversized = Duration.ofSeconds(Long.MAX_VALUE); + assertThrows( + IllegalArgumentException.class, + () -> { + switch (option) { + case "job" -> builder.jobTimeout(oversized); + case "poll" -> builder.pollInterval(oversized); + case "service" -> builder.serviceInterval(oversized); + case "stop" -> builder.stopTimeout(oversized); + case "stuck" -> builder.stuckThreshold(oversized); + case "rescue" -> builder.rescueAfter(oversized); + case "maintenance" -> builder.maintenanceInterval(oversized); + case "reindex" -> builder.reindex(java.util.List.of(), oversized); + case "retention" -> builder.retention(oversized, Duration.ZERO, Duration.ZERO); + default -> throw new AssertionError(option); + } + }); + } + + @ParameterizedTest + @ValueSource(strings = {"error", "shutdown", "snooze"}) + @SuppressWarnings("try") // Resources intentionally scope background activity. + void firstStepCursorSurvivesIncompleteAttempt(String outcome) throws Exception { + var client = client(); + var entered = new CountDownLatch(1); + var finished = new CompletableFuture(); + try (var workers = + workers(client) + .worker( + TYPE, + context -> { + context.stepWithCursor( + "loop", + cursor -> { + context.cursor(7); + entered.countDown(); + if (outcome.equals("snooze")) context.snooze(Duration.ofSeconds(60)); + if (outcome.equals("shutdown")) { + context.awaitCancellation(); + context.checkCancelled(); + } + throw new Exception("retry"); + }); + }) + .build() + .start(); + var subscription = workers.client().subscribe(finished::complete)) { + var job = client.insert(TYPE, "cursor", InsertOptions.builder().maxAttempts(1).build()).job(); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + if (outcome.equals("shutdown")) workers.stopAndCancel(); + var event = finished.get(5, TimeUnit.SECONDS); + assertEquals(job.id(), event.job().id()); + assertFalse(event.job().metadata().has(Protocol.METADATA_RESUMABLE_STEP)); + assertEquals( + Json.tree(java.util.Map.of("loop", 7)), + event.job().metadata().path(Protocol.METADATA_RESUMABLE_CURSOR)); + } + } + + @Test + @SuppressWarnings("try") // Resources intentionally scope background activity. + void oversizedSnoozeFailsTheAttemptInsteadOfStrandingIt() throws Exception { + var client = client(); + var failed = new CompletableFuture(); + try (var workers = + workers(client) + .worker(TYPE, context -> context.snooze(Duration.ofSeconds(Long.MAX_VALUE))) + .build() + .start(); + var subscription = workers.client().subscribe(failed::complete)) { + var job = client.insert(TYPE, "snooze", InsertOptions.builder().maxAttempts(1).build()).job(); + var event = failed.get(5, TimeUnit.SECONDS); + assertEquals(job.id(), event.job().id()); + assertEquals(Job.State.DISCARDED, event.job().state()); + assertTrue(event.job().errors().getFirst().error().contains("Snooze duration")); + } + } + + // The subscription is intentionally held open until the worker completes. + @SuppressWarnings("try") + @Test + void resumableCursorUpdatesReplaceStoredCursors() throws Exception { + var client = client(); + var steps = new LinkedBlockingQueue(); + var completed = new CompletableFuture(); + try (var workers = + workers(client) + .retryPolicy(job -> Duration.ZERO) + .worker( + TYPE, + context -> { + int attempt = context.job().attempt(); + context.step("a", () -> steps.add(attempt + ":a")); + context.stepWithCursor( + "c", + cursor -> { + steps.add(attempt + ":c"); + if (attempt > 1) return; + context.cursor(1); + throw new IllegalStateException("retry c"); + }); + context.stepWithCursor( + "d", + cursor -> { + steps.add(attempt + ":d:" + cursor.asInt(0)); + if (attempt > 2) return; + context.cursor(7); + throw new IllegalStateException("retry d"); + }); + }) + .build() + .start(); + var subscription = + workers.client().subscribe(completed::complete, Client.EventKind.JOB_COMPLETED)) { + client.insert(TYPE, "resumable"); + assertEquals(3, completed.get(10, TimeUnit.SECONDS).job().attempt()); + } + // The third attempt resumes after "c"; a cursor left over from attempt one must not rerun it. + assertEquals( + java.util.List.of("1:a", "1:c", "2:c", "2:d:0", "3:d:7"), java.util.List.copyOf(steps)); + } + + @Test + void rescueCannotPreemptAConfiguredTimeout() { + var builder = + new Client(Database.connect("jdbc:postgresql://127.0.0.1:1/unused")) + .toBuilder() + .queue("default", 1) + .worker(TYPE, context -> {}) + .jobTimeout(Duration.ofHours(2)) + .rescueAfter(Duration.ofHours(1)); + assertEquals( + "rescueAfter must not be less than jobTimeout", + assertThrows(IllegalArgumentException.class, () -> builder.build().start()).getMessage()); + } + + @ParameterizedTest + @ValueSource(ints = {-1, 0, 120}) + @SuppressWarnings("try") // Resources intentionally scope background activity. + void rescueDefaultAccountsForTheJobTimeout(int timeoutMinutes) throws Exception { + var rescue = new CompletableFuture(); + var client = + client() + .withPilot( + new Pilot() { + @Override + public boolean rescue(Client client, Rescue request) { + rescue.complete(request); + return true; + } + }); + var builder = workers(client).leadership(true).worker(TYPE, context -> {}); + if (timeoutMinutes != 0) builder.jobTimeout(Duration.ofMinutes(timeoutMinutes)); + try (var workers = builder.build().start()) { + var request = rescue.get(5, TimeUnit.SECONDS); + assertEquals( + Duration.ofMinutes(60 + Math.max(0, timeoutMinutes)), + Duration.between(request.horizon(), request.now())); + } + } + + @Test + @SuppressWarnings("try") // Resources intentionally scope background activity. + void goQueueNotificationsRespectTopicsAndSurviveMalformedPayloads() throws Exception { + var client = client(); + var ready = new CountDownLatch(1); + var errors = new LinkedBlockingQueue(); + var events = new LinkedBlockingQueue(); + var pause = notification("pause"); + var resume = notification("resume"); + String insertTopic = notification("insert").required("topic").asString(); + try (var workers = + client.toBuilder() + .queue("priority", 1) + .leadership(false) + .errorHandler(errors::add) + .observe( + event -> { + if (event.equals("listen_ready")) ready.countDown(); + }) + .worker(TYPE, context -> {}) + .build() + .start(); + var subscription = + workers + .client() + .subscribe( + events::add, Client.EventKind.QUEUE_PAUSED, Client.EventKind.QUEUE_RESUMED)) { + assertTrue(ready.await(5, TimeUnit.SECONDS)); + client.transaction( + connection -> { + client.driver().notify(connection, insertTopic, Json.encode(pause.required("payload"))); + // The later malformed notification acts as a barrier: the preceding one was dispatched. + client.driver().notify(connection, pause.required("topic").asString(), "{"); + return null; + }); + assertNotNull(errors.poll(5, TimeUnit.SECONDS)); + assertTrue(events.isEmpty(), "A control payload on the insert topic must not pause a queue"); + + for (var fixture : java.util.List.of(pause, resume)) { + client.transaction( + connection -> { + client + .driver() + .notify( + connection, + fixture.required("topic").asString(), + Json.encode(fixture.required("payload"))); + return null; + }); + var event = events.poll(5, TimeUnit.SECONDS); + assertNotNull(event, "Malformed JSON must not stop later notification delivery"); + assertEquals( + fixture == pause ? Client.EventKind.QUEUE_PAUSED : Client.EventKind.QUEUE_RESUMED, + event.kind()); + assertEquals("priority", event.queue().name()); + } + assertTrue(events.isEmpty()); + assertTrue(errors.isEmpty()); + } + } + + @Test + void goResignationWakesLeadershipBeforeNextPoll() throws Exception { + var client = client(); + var ready = new CountDownLatch(1); + var checked = new CountDownLatch(1); + var elected = new CountDownLatch(1); + var errors = new LinkedBlockingQueue(); + var fixture = notification("resigned"); + var leaderId = fixture.required("payload").required("leader_id").asString(); + Instant previousTerm = + client.transaction( + connection -> { + var now = Instant.now(); + try (var statement = + Sql.prepare( + connection, + Sql.query(client.database(), "leader_elect"), + leaderId, + client.database().timestamp(now), + client.database().timestamp(now.plusSeconds(3600))); + var rows = statement.executeQuery()) { + assertTrue(rows.next()); + return Database.instant(rows.getString("elected_at")); + } + }); + var observer = + client.withHooks( + new HookPeriodicJobsStart() { + @Override + public void start() { + elected.countDown(); + } + }); + try (var workers = + observer.toBuilder() + .id("observer") + .queue("priority", 1) + .serviceInterval(Duration.ofHours(1)) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(errors::add) + .observe( + event -> { + if (event.equals("listen_ready")) ready.countDown(); + if (event.equals("leadership_check")) checked.countDown(); + }) + .worker(TYPE, context -> {}) + .build() + .start()) { + assertTrue(ready.await(5, TimeUnit.SECONDS)); + assertTrue(checked.await(5, TimeUnit.SECONDS)); + assertFalse(workers.isLeader()); + client.transaction( + connection -> { + try (var statement = + Sql.prepare( + connection, + Sql.query(client.database(), "leader_resign"), + leaderId, + client.database().timestamp(previousTerm))) { + assertEquals(1, statement.executeUpdate()); + } + client + .driver() + .notify( + connection, + fixture.required("topic").asString(), + Json.encode(fixture.required("payload"))); + return null; + }); + assertTrue( + elected.await(5, TimeUnit.SECONDS), + "Peer resignation must wake election without waiting for its poll interval"); + assertTrue(workers.isLeader()); + assertTrue(errors.isEmpty(), () -> errors.toString()); + } + } + + private static tools.jackson.databind.JsonNode notification(String name) throws Exception { + for (var fixture : Conformance.fixture("protocol_values.json").required("notifications")) + if (fixture.required("name").asString().equals(name)) return fixture; + throw new AssertionError("Missing Go notification fixture: " + name); + } + + @ParameterizedTest + @ValueSource(strings = {"observer", "subscriber", "errorHandler"}) + @SuppressWarnings("try") // Resources intentionally scope background activity. + void callbackFailuresDoNotLoseWorkerSlots(String callback) throws Exception { + var client = client(); + var completed = new LinkedBlockingQueue(); + var reported = new LinkedBlockingQueue(); + var handled = new AtomicInteger(); + var observedEnds = new AtomicInteger(); + try (var workers = + workers(client) + .errorHandler( + error -> { + reported.add(error); + if (callback.equals("errorHandler")) + throw new IllegalStateException("error handler"); + }) + .observe( + event -> { + if (callback.equals("observer") && event.equals("work_end")) + throw new AssertionError("observer"); + }) + .observe( + event -> { + if (event.equals("work_end")) observedEnds.incrementAndGet(); + }) + .worker(TYPE, context -> handled.incrementAndGet()) + .build() + .start(); + var broken = + workers + .client() + .subscribe( + event -> { + if (callback.equals("subscriber")) throw new AssertionError("subscriber"); + if (callback.equals("errorHandler")) + throw new IllegalArgumentException("subscriber"); + }); + var subscription = + workers.client().subscribe(completed::add, Client.EventKind.JOB_COMPLETED)) { + for (int i = 0; i < 2; i++) { + var inserted = client.insert(TYPE, "job " + i); + var event = completed.poll(5, TimeUnit.SECONDS); + assertNotNull(event, "Callback failure must not strand the attempt or its worker slot"); + assertEquals(inserted.job().id(), event.job().id()); + assertEquals(Job.State.COMPLETED, client.get(event.job().id()).state()); + } + } + assertEquals(2, handled.get()); + assertEquals(2, observedEnds.get()); + assertEquals(2, reported.size()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "throw", + "null", + "negative", + "overflow", + "nanosecond_overflow", + "timestamp_overflow" + }) + @SuppressWarnings("try") // Resources intentionally scope background activity. + void invalidRetryPoliciesFallBack(String policy) throws Exception { + var client = client(); + var failed = new LinkedBlockingQueue(); + var reported = new LinkedBlockingQueue(); + try (var workers = + workers(client) + .errorHandler(reported::add) + .maintenanceInterval(Duration.ofMillis(1)) + .serviceInterval(Duration.ofMillis(1)) + .retryPolicy( + job -> + switch (policy) { + case "throw" -> throw new IllegalStateException("retry policy"); + case "null" -> null; + case "negative" -> Duration.ofSeconds(-1); + case "nanosecond_overflow" -> + Duration.ofNanos(Long.MAX_VALUE).plusNanos(1); + case "timestamp_overflow" -> Duration.ofDays(365_000_000); + default -> Duration.ofSeconds(Long.MAX_VALUE); + }) + .worker( + TYPE, + context -> { + throw new IllegalStateException("worker failed"); + }) + .build() + .start(); + var subscription = workers.client().subscribe(failed::add, Client.EventKind.JOB_FAILED)) { + client.insert(TYPE, "retry"); + var event = failed.poll(5, TimeUnit.SECONDS); + assertNotNull(event, "A broken retry policy must not strand the attempt"); + assertEquals(Job.State.RETRYABLE, event.job().state()); + assertTrue(event.job().scheduledAt().isAfter(event.job().attemptedAt())); + assertEquals("worker failed", event.job().errors().getFirst().error()); + assertEquals(1, reported.size()); + } + } + + @ParameterizedTest + @CsvSource({"false,1,5000", "true,1,5000", "false,3600,1", "true,3600,1"}) + @SuppressWarnings("try") // Resources intentionally scope background activity. + void shortDelaysWakeFetchingBeforeThePollInterval( + boolean snooze, long serviceSeconds, long maintenanceMillis) throws Exception { + var client = client(); + var attempts = new AtomicInteger(); + var deferred = new CompletableFuture(); + var completed = new CompletableFuture(); + var reported = new LinkedBlockingQueue(); + var delay = Duration.ofSeconds(1); + var runningClient = + workers(client) + .pollInterval(Duration.ofMinutes(1)) + .maintenanceInterval(Duration.ofMillis(maintenanceMillis)) + .serviceInterval(Duration.ofSeconds(serviceSeconds)) + .retryPolicy(job -> delay) + .errorHandler(reported::add) + .worker( + TYPE, + context -> { + if (attempts.incrementAndGet() == 1) { + if (snooze) context.snooze(delay); + throw new IllegalStateException("retry"); + } + }) + .build(); + try (var subscription = + runningClient.subscribe( + event -> { + if (event.kind() == Client.EventKind.JOB_COMPLETED) completed.complete(event); + else deferred.complete(event); + }); + var run = runningClient.start()) { + var inserted = runningClient.insert(TYPE, "short delay").job(); + var pending = deferred.get(5, TimeUnit.SECONDS); + assertEquals( + snooze ? Client.EventKind.JOB_SNOOZED : Client.EventKind.JOB_FAILED, pending.kind()); + assertEquals(Job.State.AVAILABLE, pending.job().state()); + var done = completed.get(10, TimeUnit.SECONDS).job(); + assertEquals(inserted.id(), done.id()); + assertFalse(done.attemptedAt().isBefore(pending.job().scheduledAt())); + assertEquals(snooze ? 1 : 2, done.attempt()); + assertEquals(2, attempts.get()); + } + assertTrue(reported.isEmpty(), () -> "Unexpected worker errors: " + reported); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + @SuppressWarnings("try") // Resources intentionally scope background activity. + void shutdownRespectsTheHandlersOutcome(boolean interrupted) throws Exception { + var client = client(); + var entered = new CompletableFuture>(); + var outcome = new CompletableFuture(); + var reported = new LinkedBlockingQueue(); + var workers = + workers(client) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(reported::add) + .worker( + TYPE, + context -> { + entered.complete(context); + context.awaitCancellation(); + if (interrupted) context.checkCancelled(); + }) + .build() + .start(); + try (var subscription = workers.client().subscribe(outcome::complete)) { + var inserted = + client.insert(TYPE, "stop", InsertOptions.builder().maxAttempts(1).build()).job(); + var context = entered.get(5, TimeUnit.SECONDS); + workers.stopAndCancel(); + assertEquals(WorkContext.Cancellation.SHUTDOWN, context.cancellation()); + var event = outcome.get(5, TimeUnit.SECONDS); + assertEquals( + interrupted ? Client.EventKind.JOB_INTERRUPTED : Client.EventKind.JOB_COMPLETED, + event.kind()); + var job = client.get(inserted.id()); + assertEquals(interrupted ? Job.State.AVAILABLE : Job.State.COMPLETED, job.state()); + assertEquals(interrupted ? 0 : 1, job.attempt()); + assertTrue(job.errors().isEmpty()); + if (interrupted) assertNull(job.finalizedAt()); + else assertNotNull(job.finalizedAt()); + } finally { + workers.stopAndCancel(); + } + assertTrue(reported.isEmpty(), () -> "Unexpected worker errors: " + reported); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + @SuppressWarnings("try") // Resources intentionally scope background activity. + void stoppingDuringAClaimPreservesCancellation(boolean cancel) throws Exception { + var claimed = new CountDownLatch(1); + var commit = new CountDownLatch(1); + var client = + client() + .withPilot( + new Pilot() { + @Override + public java.util.List claim( + Connection connection, + Client.Driver driver, + Claim claim, + Client.Transaction> next) + throws Exception { + var rows = next.run(connection); + if (!rows.isEmpty()) { + claimed.countDown(); + commit.await(); + } + return rows; + } + }); + var entered = new CompletableFuture>(); + var outcome = new CompletableFuture(); + var reported = new LinkedBlockingQueue(); + var workers = + workers(client) + .errorHandler(reported::add) + .worker( + TYPE, + context -> { + entered.complete(context); + context.checkCancelled(); + }) + .build() + .start(); + try (var subscription = workers.client().subscribe(outcome::complete)) { + var inserted = client.insert(TYPE, "late claim").job(); + assertTrue(claimed.await(5, TimeUnit.SECONDS)); + // Let the active-map scan finish while the claim is still uncommitted and unregistered. + var error = + assertThrows( + RiverException.class, + () -> { + if (cancel) workers.stopAndCancel(); + else workers.stop(); + }); + assertTrue(error.getMessage().contains("Dispatcher did not stop")); + commit.countDown(); + var context = entered.get(5, TimeUnit.SECONDS); + assertEquals(cancel ? WorkContext.Cancellation.SHUTDOWN : null, context.cancellation()); + var event = outcome.get(5, TimeUnit.SECONDS); + assertEquals( + cancel ? Client.EventKind.JOB_INTERRUPTED : Client.EventKind.JOB_COMPLETED, event.kind()); + workers.stop(); + var job = client.get(inserted.id()); + assertEquals(cancel ? Job.State.AVAILABLE : Job.State.COMPLETED, job.state()); + assertEquals(cancel ? 0 : 1, job.attempt()); + assertTrue(job.errors().isEmpty()); + } finally { + commit.countDown(); + workers.stopAndCancel(); + } + assertTrue(reported.isEmpty(), () -> "Unexpected worker errors: " + reported); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + @SuppressWarnings("try") // Resources intentionally scope background activity. + void terminalFailuresDoNotConsultRetryPolicy(boolean cancel) throws Exception { + var client = client(); + var failed = new LinkedBlockingQueue(); + var policyCalls = new AtomicInteger(); + try (var workers = + workers(client) + .cancelOnError(cancel) + .retryPolicy( + job -> { + policyCalls.incrementAndGet(); + return Duration.ofDays(1); + }) + .worker( + TYPE, + context -> { + throw new IllegalStateException("worker failed"); + }) + .build() + .start(); + var subscription = workers.client().subscribe(failed::add)) { + client.insert(TYPE, "terminal", InsertOptions.builder().maxAttempts(1).build()); + var event = failed.poll(5, TimeUnit.SECONDS); + assertNotNull(event); + assertEquals(cancel ? Job.State.CANCELLED : Job.State.DISCARDED, event.job().state()); + assertEquals(0, policyCalls.get()); + } + } +} diff --git a/java/river/src/test/java/com/riverqueue/SchedulingTest.java b/java/river/src/test/java/com/riverqueue/SchedulingTest.java index d2e752ef6..969c0eadd 100644 --- a/java/river/src/test/java/com/riverqueue/SchedulingTest.java +++ b/java/river/src/test/java/com/riverqueue/SchedulingTest.java @@ -37,13 +37,12 @@ void anonymousPeriodicJobsOmitTheIdMetadata() throws Exception { var worked = new CompletableFuture>(); var errors = new LinkedBlockingQueue(); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add(TYPE, context -> worked.complete(context.job())) + .worker(TYPE, context -> worked.complete(context.job())) .periodic( new PeriodicJob<>( null, @@ -52,6 +51,7 @@ void anonymousPeriodicJobsOmitTheIdMetadata() throws Exception { "args", InsertOptions.defaults(), true)) + .build() .start()) { var job = worked.get(5, TimeUnit.SECONDS); assertTrue(job.metadata().path("periodic").asBoolean()); @@ -64,12 +64,12 @@ void anonymousPeriodicJobsOmitTheIdMetadata() throws Exception { void dynamicPeriodicJobsRequireLeadershipAndValidIds() throws Exception { var client = new Client(databases.open(directory.resolve("river.db"))); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .leadership(false) - .add(TYPE, context -> {}) + .worker(TYPE, context -> {}) + .build() .start()) { assertThrows( IllegalStateException.class, @@ -85,7 +85,12 @@ void dynamicPeriodicJobsRequireLeadershipAndValidIds() throws Exception { true)); } try (var workers = - client.workers().queue("default", 1).pollOnly(true).add(TYPE, context -> {}).start()) { + client.toBuilder() + .queue("default", 1) + .pollOnly(true) + .worker(TYPE, context -> {}) + .build() + .start()) { for (var id : java.util.List.of("", "invalid id", "x".repeat(128))) assertThrows( IllegalArgumentException.class, @@ -109,19 +114,18 @@ void dynamicPeriodicJobsWaitForLeadership() throws Exception { var worked = new LinkedBlockingQueue(); var client = new Client(databases.open(directory.resolve("river.db"))); var builder = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .serviceInterval(Duration.ofMillis(20)) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add(TYPE, context -> worked.add(context.args())); - var first = builder.observe(checks::add).start(); + .worker(TYPE, context -> worked.add(context.args())); + var first = builder.observe(checks::add).build().start(); try { assertEquals("leadership_check", checks.poll(5, TimeUnit.SECONDS)); assertTrue(first.isLeader()); - try (var second = builder.start()) { + try (var second = builder.build().start()) { second .periodicJobs() .add( @@ -160,14 +164,14 @@ public void maintain(Client client, Instant now) { } }); var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .serviceInterval(Duration.ofHours(1)) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add(TYPE, context -> worked.add(context.args())) + .worker(TYPE, context -> worked.add(context.args())) + .build() .start(); try { entered.get(5, TimeUnit.SECONDS); @@ -249,14 +253,13 @@ public void afterInsert( "scheduled", InsertOptions.builder().scheduledAt(Instant.now().minusSeconds(1)).build()); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .serviceInterval(Duration.ofMillis(20)) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add(TYPE, context -> worked.add(context.args())) + .worker(TYPE, context -> worked.add(context.args())) .periodic( "broken", after -> { @@ -275,6 +278,7 @@ public void afterInsert( "periodic", InsertOptions.defaults(), true) + .build() .start()) { var first = worked.poll(5, TimeUnit.SECONDS); var second = worked.poll(5, TimeUnit.SECONDS); @@ -309,14 +313,13 @@ public void maintain(Client client, Instant now) { default -> Optional.of(after.plusDays(1)); }; try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .serviceInterval(Duration.ofMillis(20)) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add(TYPE, context -> {}) + .worker(TYPE, context -> {}) .periodic( "retry", schedule, @@ -324,6 +327,7 @@ public void maintain(Client client, Instant now) { "args", InsertOptions.builder().queue("periodic").build(), false) + .build() .start()) { maintained.get(5, TimeUnit.SECONDS); assertEquals(1, client.list(JobQuery.all()).jobs().size()); @@ -357,15 +361,15 @@ public void afterInsert( return Optional.of(after.plusDays(1)); }; try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .serviceInterval(Duration.ofMillis(20)) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add(TYPE, context -> worked.complete(context.job())) + .worker(TYPE, context -> worked.complete(context.job())) .periodic("cadence", schedule, TYPE, "args", InsertOptions.defaults(), false) + .build() .start()) { var actual = nextFrom.get(5, TimeUnit.SECONDS); assertEquals(planned.get(), actual); @@ -408,15 +412,15 @@ public Optional next(ZonedDateTime after) { TimeZone.setDefault(TimeZone.getTimeZone(zone)); var client = new Client(databases.open(directory.resolve("river.db"))); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .serviceInterval(Duration.ofMillis(20)) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add(TYPE, context -> {}) + .worker(TYPE, context -> {}) .periodic("local-zone", schedule, TYPE, "args", InsertOptions.defaults(), runOnStart) + .build() .start()) { for (int i = 0; i < (runOnStart ? 1 : 2); i++) { var reference = calls.poll(5, TimeUnit.SECONDS); @@ -447,13 +451,13 @@ public void maintain(Client client, Instant now) { } }); var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add(TYPE, context -> {}) + .worker(TYPE, context -> {}) + .build() .start(); try { var handle = diff --git a/java/river/src/test/java/com/riverqueue/TimeoutTest.java b/java/river/src/test/java/com/riverqueue/TimeoutTest.java index 0cd4ac3c3..dbc5d14db 100644 --- a/java/river/src/test/java/com/riverqueue/TimeoutTest.java +++ b/java/river/src/test/java/com/riverqueue/TimeoutTest.java @@ -25,20 +25,19 @@ void handlerTimeoutControlsTheAttempt(String mode) throws Exception { var client = new Client(databases.open(directory.resolve("river.db"))); var calls = new AtomicInteger(); var worked = new AtomicInteger(); - var result = new CompletableFuture(); + var result = new CompletableFuture(); var errors = new LinkedBlockingQueue(); boolean invalid = java.util.Set.of("overflow", "throw").contains(mode); boolean completes = mode.equals("disabled") || mode.equals("longer"); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .leadership(false) .jobTimeout(mode.equals("override") ? Duration.ofHours(1) : Duration.ofNanos(1)) .stopTimeout(Duration.ofSeconds(5)) .errorHandler(errors::add) - .add( + .worker( TYPE, new Workers.Handler<>() { @Override @@ -68,12 +67,13 @@ public void work(WorkContext context) throws Exception { } } }) + .build() .start(); - var subscription = workers.subscribe(result::complete)) { + var subscription = workers.client().subscribe(result::complete)) { client.insert(TYPE, mode, InsertOptions.builder().maxAttempts(1).build()); var event = result.get(5, TimeUnit.SECONDS); assertEquals( - completes ? Workers.EventKind.JOB_COMPLETED : Workers.EventKind.JOB_FAILED, event.kind()); + completes ? Client.EventKind.JOB_COMPLETED : Client.EventKind.JOB_FAILED, event.kind()); assertEquals(completes ? Job.State.COMPLETED : Job.State.DISCARDED, event.job().state()); assertEquals(1, calls.get()); assertEquals(invalid ? 0 : 1, worked.get()); diff --git a/java/river/src/test/java/com/riverqueue/VirtualThreadTest.java b/java/river/src/test/java/com/riverqueue/VirtualThreadTest.java index dfe2d8aed..201a2b657 100644 --- a/java/river/src/test/java/com/riverqueue/VirtualThreadTest.java +++ b/java/river/src/test/java/com/riverqueue/VirtualThreadTest.java @@ -100,13 +100,13 @@ public void start() { } }); try (var workers = - client - .workers() + client.toBuilder() .queue("default", 1) .pollOnly(true) .leadership(args[0].equals("leadership")) .stopTimeout(Duration.ofSeconds(5)) - .add(JobType.of("noop", String.class), context -> {}) + .worker(JobType.of("noop", String.class), context -> {}) + .build() .start()) { try { if (!entered.await(10, TimeUnit.SECONDS)) diff --git a/java/river/src/test/java/com/riverqueue/WorkersTest.java b/java/river/src/test/java/com/riverqueue/WorkersTest.java index 2d54db34b..a213e3cb3 100644 --- a/java/river/src/test/java/com/riverqueue/WorkersTest.java +++ b/java/river/src/test/java/com/riverqueue/WorkersTest.java @@ -3,975 +3,59 @@ import static org.junit.jupiter.api.Assertions.*; import java.nio.file.Path; -import java.sql.Connection; -import java.sql.SQLException; import java.time.Duration; -import java.time.Instant; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.EnumSource; -import org.junit.jupiter.params.provider.ValueSource; class WorkersTest { - private static final JobType TYPE = JobType.of("runtime_test", String.class); - @TempDir Path directory; @RegisterExtension final TestDatabase databases = new TestDatabase(); - - private Client client() { - var database = databases.open(directory.resolve("river.db")); - return new Client(database); - } - - private Workers.Builder workers(Client client) { - return client - .workers() - .queue("default", 1) - .leadership(false) - .pollOnly(true) - .stopTimeout(Duration.ofSeconds(1)); - } - - @Test - void builderGeneratesAnIdForEachRuntime() { - var builder = workers(client()).add(TYPE, context -> {}); - try (var first = builder.start(); - var second = builder.start()) { - assertNotEquals(first.id(), second.id()); - } - try (var explicit = builder.id("chosen-id").start()) { - assertEquals("chosen-id", explicit.id()); - } - } - - @ParameterizedTest - @CsvSource({ - "PT0.000000001S,true", - "PT1H,true", - "PT23H59M59.999999999S,true", - "PT24H,false", - "PT24H0.000000001S,false", - "PT48H,false" - }) - void builderRequiresServiceIntervalsBelowQueueRetention(String value, boolean valid) { - var builder = new Client(Database.connect("jdbc:postgresql://127.0.0.1:1/unused")).workers(); - var interval = Duration.parse(value); - if (valid) assertSame(builder, builder.serviceInterval(interval)); - else - assertEquals( - "serviceInterval must be shorter than the queue retention period of one day", - assertThrows(IllegalArgumentException.class, () -> builder.serviceInterval(interval)) - .getMessage()); - } - - @ParameterizedTest - @CsvSource({"REMOTE,false", "REMOTE,true", "TIMEOUT,false", "TIMEOUT,true"}) - @SuppressWarnings("try") // Resources intentionally scope background activity. - void cancellationRespectsTheHandlersOutcome(WorkContext.Cancellation cause, boolean interrupted) - throws Exception { - var client = client(); - var entered = new CompletableFuture>(); - var outcome = new CompletableFuture(); - var reported = new LinkedBlockingQueue(); - try (var workers = - workers(client) - .stopTimeout(Duration.ofSeconds(5)) - .errorHandler(reported::add) - .add( - TYPE, - context -> { - entered.complete(context); - context.awaitCancellation(); - if (interrupted) context.checkCancelled(); - }) - .start(); - var subscription = workers.subscribe(outcome::complete)) { - var inserted = - client.insert(TYPE, "cancel", InsertOptions.builder().maxAttempts(1).build()).job(); - var context = entered.get(5, TimeUnit.SECONDS); - if (cause == WorkContext.Cancellation.REMOTE) client.cancel(inserted.id()); - else context.requestCancellation(cause); - var event = outcome.get(5, TimeUnit.SECONDS); - var expected = - !interrupted - ? Job.State.COMPLETED - : cause == WorkContext.Cancellation.REMOTE - ? Job.State.CANCELLED - : Job.State.DISCARDED; - assertEquals(cause, context.cancellation()); - assertEquals(expected, event.job().state()); - var job = client.get(inserted.id()); - assertEquals(expected, job.state()); - assertEquals(interrupted ? 1 : 0, job.errors().size()); - assertEquals(1, job.attempt()); - assertNotNull(job.finalizedAt()); - } - assertTrue(reported.isEmpty(), () -> "Unexpected worker errors: " + reported); - } - - @ParameterizedTest - @EnumSource( - value = Job.State.class, - names = {"AVAILABLE", "PENDING", "RETRYABLE", "RUNNING", "SCHEDULED"}) - @SuppressWarnings("try") // Resources intentionally scope background activity. - void completionEventsReflectPersistedState(Job.State state) throws Exception { - var client = client(); - var database = client.database(); - var changed = new CompletableFuture(); - var events = new LinkedBlockingQueue(); - var reported = new LinkedBlockingQueue(); - try (var workers = - workers(client) - .stopTimeout(Duration.ofSeconds(5)) - .errorHandler(reported::add) - .add( - TYPE, - context -> { - context.transaction( - connection -> { - // A rescuer or another attempt changed this row before the old handler - // returned. - try (var statement = - Sql.prepare( - connection, - "UPDATE " - + database.prefix() - + "river_job SET state='" - + state.value() - + "', scheduled_at=?, attempt=? WHERE id=?", - database.timestamp(Instant.now().plusSeconds(3600)), - state == Job.State.RUNNING ? 2 : 1, - context.job().id())) { - assertEquals(1, statement.executeUpdate()); - } - return null; - }); - changed.complete(null); - }) - .start(); - var subscription = workers.subscribe(events::add)) { - var job = client.insert(TYPE, "changed").job(); - changed.get(5, TimeUnit.SECONDS); - workers.stop(); - assertEquals(state, client.get(job.id()).state()); - var expected = - switch (state) { - case AVAILABLE, RETRYABLE -> Workers.EventKind.JOB_FAILED; - case SCHEDULED -> Workers.EventKind.JOB_SNOOZED; - default -> null; - }; - if (expected == null) assertTrue(events.isEmpty()); - else { - assertEquals(1, events.size()); - assertEquals(expected, events.element().kind()); - assertEquals(state, events.element().job().state()); - } - } - assertTrue(reported.isEmpty(), () -> "Unexpected worker errors: " + reported); - } - - @Test - @SuppressWarnings("try") // Resources intentionally scope background activity. - void completionRetriesSurviveAnInterruptedHandler() throws Exception { - var attempts = new AtomicInteger(); - var client = - client() - .withPilot( - new Pilot() { - @Override - public void afterAttempt( - Connection connection, - Client.Driver driver, - Job job) - throws SQLException { - if (attempts.getAndIncrement() == 0) - throw new SQLException("transient completion failure"); - } - }); - var completed = new LinkedBlockingQueue(); - var reported = new LinkedBlockingQueue(); - try (var workers = - workers(client) - .errorHandler(reported::add) - .add(TYPE, context -> Thread.currentThread().interrupt()) - .start(); - var subscription = workers.subscribe(completed::add, Workers.EventKind.JOB_COMPLETED)) { - for (int i = 0; i < 2; i++) { - var job = client.insert(TYPE, "job " + i).job(); - var event = completed.poll(5, TimeUnit.SECONDS); - assertNotNull( - event, "An interrupted handler must not strand completion or its worker slot"); - assertEquals(job.id(), event.job().id()); - assertEquals(Job.State.COMPLETED, client.get(job.id()).state()); - } - assertEquals(3, attempts.get()); - assertEquals(1, reported.size()); - } - } - - @ParameterizedTest - @ValueSource(strings = {"args", "kind", "row"}) - @SuppressWarnings("try") // Resources intentionally scope background activity. - void completionRetriesSurviveInvalidJobs(String invalid) throws Exception { - var attempts = new AtomicInteger(); - var client = - client() - .withPilot( - new Pilot() { - @Override - public void afterAttempt( - Connection connection, - Client.Driver driver, - Job job) - throws SQLException { - if (job.args().isObject() && attempts.getAndIncrement() == 0) - throw new SQLException("transient completion failure"); - } - - @Override - public java.util.List claim( - Connection connection, - Client.Driver driver, - Pilot.Claim claim, - Client.Transaction> next) - throws Exception { - var rows = next.run(connection); - return rows.stream() - .map( - row -> - invalid.equals("row") && row.job().args().isObject() - ? new Client.Decoded( - row.job(), new IllegalArgumentException("invalid row")) - : row) - .toList(); - } - }); - var type = - JobType.of( - invalid.equals("kind") ? "unknown_kind" : TYPE.kind(), - tools.jackson.databind.JsonNode.class); - var handled = new LinkedBlockingQueue(); - var reported = new LinkedBlockingQueue(); - var finalized = new LinkedBlockingQueue(); - try (var workers = - workers(client) - .queue("default", 2) - .stopTimeout(Duration.ofSeconds(5)) - .errorHandler(reported::add) - .add(TYPE, context -> handled.add(context.job().id())) - .start(); - var subscription = workers.subscribe(finalized::add)) { - var jobs = - client.insertMany( - java.util.List.of( - type.submission(Json.object(), InsertOptions.builder().maxAttempts(1).build()), - TYPE.submission("valid"))); - var bad = jobs.getFirst().job(); - var good = jobs.getLast().job(); - assertNotNull(finalized.poll(5, TimeUnit.SECONDS), "The first claimed job must settle"); - assertNotNull(finalized.poll(5, TimeUnit.SECONDS), "Every committed claim must settle"); - workers.stop(); - assertEquals(java.util.List.of(good.id()), java.util.List.copyOf(handled)); - assertEquals(Job.State.DISCARDED, client.get(bad.id()).state()); - assertEquals(Job.State.COMPLETED, client.get(good.id()).state()); - assertEquals(1, client.get(bad.id()).errors().size()); - assertEquals(2, attempts.get()); - // A rolled-back completion batch may report the same failure for both claims. - assertFalse(reported.isEmpty()); - assertTrue( - reported.stream() - .allMatch(error -> error.toString().contains("transient completion failure"))); - } - } - - @Test - @SuppressWarnings("try") // Resources intentionally scope background activity. - void completedHandlerIsNotCancelledWhileAcknowledging() throws Exception { - var acknowledging = new CountDownLatch(1); - var release = new CountDownLatch(1); - var context = new CompletableFuture>(); - var client = - client() - .withPilot( - new Pilot() { - @Override - public void afterAttempt( - Connection connection, - Client.Driver driver, - Job job) - throws InterruptedException { - acknowledging.countDown(); - release.await(); - } - }); - var completed = new CompletableFuture(); - var workers = workers(client).add(TYPE, context::complete).start(); - try (var subscription = workers.subscribe(completed::complete)) { - var job = client.insert(TYPE, "acknowledge").job(); - assertTrue(acknowledging.await(5, TimeUnit.SECONDS)); - // Stop requests cancellation while the handler's successful outcome is awaiting commit. - assertThrows(RiverException.class, workers::stopAndCancel); - assertNull(context.get(5, TimeUnit.SECONDS).cancellation()); - release.countDown(); - assertEquals(Workers.EventKind.JOB_COMPLETED, completed.get(5, TimeUnit.SECONDS).kind()); - assertEquals(Job.State.COMPLETED, client.get(job.id()).state()); - } finally { - release.countDown(); - workers.stopAndCancel(); - } - } + @TempDir Path directory; @Test - @SuppressWarnings("try") // Resources intentionally scope background activity. - void jobTimeoutDoesNotDependOnTheDispatcher() throws Exception { - var blocked = new CountDownLatch(1); - var release = new CountDownLatch(1); - var client = - client() - .withPilot( - new Pilot() { - @Override - public void producer( - Connection connection, Client.Driver driver, Producer producer) - throws InterruptedException { - if (!producer.active().isEmpty()) { - blocked.countDown(); - release.await(); - } - } - }); - var cancelled = new CompletableFuture(); - var failed = new CompletableFuture(); - var workers = - workers(client) - .jobTimeout(Duration.ofNanos(1)) - .serviceInterval(Duration.ofNanos(1)) - .stuckThreshold(Duration.ofMinutes(1)) - .add( - TYPE, - context -> { - context.awaitCancellation(); - cancelled.complete(context.cancellation()); - release.await(); - context.checkCancelled(); - }) - .start(); - try (var subscription = workers.subscribe(failed::complete)) { - client.insert(TYPE, "timeout", InsertOptions.builder().maxAttempts(1).build()); - assertTrue(blocked.await(5, TimeUnit.SECONDS)); - assertEquals(WorkContext.Cancellation.TIMEOUT, cancelled.get(5, TimeUnit.SECONDS)); - release.countDown(); - assertEquals(Job.State.DISCARDED, failed.get(5, TimeUnit.SECONDS).job().state()); - } finally { - release.countDown(); - workers.stopAndCancel(); - } - } - - @ParameterizedTest - @ValueSource( - strings = { - "job", - "poll", - "service", - "stop", - "stuck", - "rescue", - "maintenance", - "reindex", - "retention" - }) - void oversizedDurationsFailDuringConfiguration(String option) { - var builder = new Client(Database.connect("jdbc:postgresql://127.0.0.1:1/unused")).workers(); - var oversized = Duration.ofSeconds(Long.MAX_VALUE); + void duplicateKindsAndNullHandlersAreRejected() { + var type = JobType.of("registry_test", String.class); + var workers = new Workers(); + assertThrows(NullPointerException.class, () -> workers.add(null, context -> {})); + assertThrows(NullPointerException.class, () -> workers.add(type, null)); + assertSame(workers, workers.add(type, context -> {})); assertThrows( IllegalArgumentException.class, - () -> { - switch (option) { - case "job" -> builder.jobTimeout(oversized); - case "poll" -> builder.pollInterval(oversized); - case "service" -> builder.serviceInterval(oversized); - case "stop" -> builder.stopTimeout(oversized); - case "stuck" -> builder.stuckThreshold(oversized); - case "rescue" -> builder.rescueAfter(oversized); - case "maintenance" -> builder.maintenanceInterval(oversized); - case "reindex" -> builder.reindex(java.util.List.of(), oversized); - case "retention" -> builder.retention(oversized, Duration.ZERO, Duration.ZERO); - default -> throw new AssertionError(option); - } - }); - } - - @ParameterizedTest - @ValueSource(strings = {"error", "shutdown", "snooze"}) - @SuppressWarnings("try") // Resources intentionally scope background activity. - void firstStepCursorSurvivesIncompleteAttempt(String outcome) throws Exception { - var client = client(); - var entered = new CountDownLatch(1); - var finished = new CompletableFuture(); - try (var workers = - workers(client) - .add( - TYPE, - context -> { - context.stepWithCursor( - "loop", - cursor -> { - context.cursor(7); - entered.countDown(); - if (outcome.equals("snooze")) context.snooze(Duration.ofSeconds(60)); - if (outcome.equals("shutdown")) { - context.awaitCancellation(); - context.checkCancelled(); - } - throw new Exception("retry"); - }); - }) - .start(); - var subscription = workers.subscribe(finished::complete)) { - var job = client.insert(TYPE, "cursor", InsertOptions.builder().maxAttempts(1).build()).job(); - assertTrue(entered.await(5, TimeUnit.SECONDS)); - if (outcome.equals("shutdown")) workers.stopAndCancel(); - var event = finished.get(5, TimeUnit.SECONDS); - assertEquals(job.id(), event.job().id()); - assertFalse(event.job().metadata().has(Protocol.METADATA_RESUMABLE_STEP)); - assertEquals( - Json.tree(java.util.Map.of("loop", 7)), - event.job().metadata().path(Protocol.METADATA_RESUMABLE_CURSOR)); - } + () -> workers.add(JobType.of(type.kind(), Integer.class), context -> {})); } @Test @SuppressWarnings("try") // Resources intentionally scope background activity. - void oversizedSnoozeFailsTheAttemptInsteadOfStrandingIt() throws Exception { - var client = client(); - var failed = new CompletableFuture(); - try (var workers = - workers(client) - .add(TYPE, context -> context.snooze(Duration.ofSeconds(Long.MAX_VALUE))) - .start(); - var subscription = workers.subscribe(failed::complete)) { - var job = client.insert(TYPE, "snooze", InsertOptions.builder().maxAttempts(1).build()).job(); - var event = failed.get(5, TimeUnit.SECONDS); - assertEquals(job.id(), event.job().id()); - assertEquals(Job.State.DISCARDED, event.job().state()); - assertTrue(event.job().errors().getFirst().error().contains("Snooze duration")); - } - } - - // The subscription is intentionally held open until the worker completes. - @SuppressWarnings("try") - @Test - void resumableCursorUpdatesReplaceStoredCursors() throws Exception { - var client = client(); - var steps = new LinkedBlockingQueue(); - var completed = new CompletableFuture(); - try (var workers = - workers(client) - .retryPolicy(job -> Duration.ZERO) - .add( - TYPE, - context -> { - int attempt = context.job().attempt(); - context.step("a", () -> steps.add(attempt + ":a")); - context.stepWithCursor( - "c", - cursor -> { - steps.add(attempt + ":c"); - if (attempt > 1) return; - context.cursor(1); - throw new IllegalStateException("retry c"); - }); - context.stepWithCursor( - "d", - cursor -> { - steps.add(attempt + ":d:" + cursor.asInt(0)); - if (attempt > 2) return; - context.cursor(7); - throw new IllegalStateException("retry d"); - }); - }) - .start(); - var subscription = - workers.subscribe(completed::complete, Workers.EventKind.JOB_COMPLETED)) { - client.insert(TYPE, "resumable"); - assertEquals(3, completed.get(10, TimeUnit.SECONDS).job().attempt()); - } - // The third attempt resumes after "c"; a cursor left over from attempt one must not rerun it. - assertEquals( - java.util.List.of("1:a", "1:c", "2:c", "2:d:0", "3:d:7"), java.util.List.copyOf(steps)); - } - - @Test - void rescueCannotPreemptAConfiguredTimeout() { + void registryAndBuilderAreSnapshotted() throws Exception { + var first = JobType.of("first", String.class); + var second = JobType.of("second", String.class); + var third = JobType.of("third", String.class); + var workers = new Workers().add(first, context -> {}); var builder = - new Client(Database.connect("jdbc:postgresql://127.0.0.1:1/unused")) - .workers() + Client.builder(databases.open(directory.resolve("river.db"))) + .leadership(false) + .pollOnly(true) + .pollInterval(Duration.ofMillis(10)) .queue("default", 1) - .add(TYPE, context -> {}) - .jobTimeout(Duration.ofHours(2)) - .rescueAfter(Duration.ofHours(1)); - assertEquals( - "rescueAfter must not be less than jobTimeout", - assertThrows(IllegalArgumentException.class, builder::start).getMessage()); - } - - @ParameterizedTest - @ValueSource(ints = {-1, 0, 120}) - @SuppressWarnings("try") // Resources intentionally scope background activity. - void rescueDefaultAccountsForTheJobTimeout(int timeoutMinutes) throws Exception { - var rescue = new CompletableFuture(); - var client = - client() - .withPilot( - new Pilot() { - @Override - public boolean rescue(Client client, Rescue request) { - rescue.complete(request); - return true; - } - }); - var builder = workers(client).leadership(true).add(TYPE, context -> {}); - if (timeoutMinutes != 0) builder.jobTimeout(Duration.ofMinutes(timeoutMinutes)); - try (var workers = builder.start()) { - var request = rescue.get(5, TimeUnit.SECONDS); - assertEquals( - Duration.ofMinutes(60 + Math.max(0, timeoutMinutes)), - Duration.between(request.horizon(), request.now())); - } - } - - @Test - @SuppressWarnings("try") // Resources intentionally scope background activity. - void goQueueNotificationsRespectTopicsAndSurviveMalformedPayloads() throws Exception { - var client = client(); - var ready = new CountDownLatch(1); - var errors = new LinkedBlockingQueue(); - var events = new LinkedBlockingQueue(); - var pause = notification("pause"); - var resume = notification("resume"); - String insertTopic = notification("insert").required("topic").asString(); - try (var workers = - client - .workers() - .queue("priority", 1) - .leadership(false) - .errorHandler(errors::add) - .observe( - event -> { - if (event.equals("listen_ready")) ready.countDown(); - }) - .add(TYPE, context -> {}) - .start(); - var subscription = - workers.subscribe( - events::add, Workers.EventKind.QUEUE_PAUSED, Workers.EventKind.QUEUE_RESUMED)) { - assertTrue(ready.await(5, TimeUnit.SECONDS)); - client.transaction( - connection -> { - client.driver().notify(connection, insertTopic, Json.encode(pause.required("payload"))); - // The later malformed notification acts as a barrier: the preceding one was dispatched. - client.driver().notify(connection, pause.required("topic").asString(), "{"); - return null; - }); - assertNotNull(errors.poll(5, TimeUnit.SECONDS)); - assertTrue(events.isEmpty(), "A control payload on the insert topic must not pause a queue"); - - for (var fixture : java.util.List.of(pause, resume)) { - client.transaction( - connection -> { - client - .driver() - .notify( - connection, - fixture.required("topic").asString(), - Json.encode(fixture.required("payload"))); - return null; - }); - var event = events.poll(5, TimeUnit.SECONDS); - assertNotNull(event, "Malformed JSON must not stop later notification delivery"); - assertEquals( - fixture == pause ? Workers.EventKind.QUEUE_PAUSED : Workers.EventKind.QUEUE_RESUMED, - event.kind()); - assertEquals("priority", event.queue().name()); - } - assertTrue(events.isEmpty()); - assertTrue(errors.isEmpty()); - } - } - - @Test - void goResignationWakesLeadershipBeforeNextPoll() throws Exception { - var client = client(); - var ready = new CountDownLatch(1); - var checked = new CountDownLatch(1); - var elected = new CountDownLatch(1); - var errors = new LinkedBlockingQueue(); - var fixture = notification("resigned"); - var leaderId = fixture.required("payload").required("leader_id").asString(); - Instant previousTerm = - client.transaction( - connection -> { - var now = Instant.now(); - try (var statement = - Sql.prepare( - connection, - Sql.query(client.database(), "leader_elect"), - leaderId, - client.database().timestamp(now), - client.database().timestamp(now.plusSeconds(3600))); - var rows = statement.executeQuery()) { - assertTrue(rows.next()); - return Database.instant(rows.getString("elected_at")); - } - }); - var observer = - client.withHooks( - new HookPeriodicJobsStart() { - @Override - public void start() { - elected.countDown(); - } - }); - try (var workers = - observer - .workers() - .id("observer") - .queue("priority", 1) - .serviceInterval(Duration.ofHours(1)) - .stopTimeout(Duration.ofSeconds(5)) - .errorHandler(errors::add) - .observe( - event -> { - if (event.equals("listen_ready")) ready.countDown(); - if (event.equals("leadership_check")) checked.countDown(); - }) - .add(TYPE, context -> {}) - .start()) { - assertTrue(ready.await(5, TimeUnit.SECONDS)); - assertTrue(checked.await(5, TimeUnit.SECONDS)); - assertFalse(workers.isLeader()); - client.transaction( - connection -> { - try (var statement = - Sql.prepare( - connection, - Sql.query(client.database(), "leader_resign"), - leaderId, - client.database().timestamp(previousTerm))) { - assertEquals(1, statement.executeUpdate()); - } - client - .driver() - .notify( - connection, - fixture.required("topic").asString(), - Json.encode(fixture.required("payload"))); - return null; - }); - assertTrue( - elected.await(5, TimeUnit.SECONDS), - "Peer resignation must wake election without waiting for its poll interval"); - assertTrue(workers.isLeader()); - assertTrue(errors.isEmpty(), () -> errors.toString()); - } - } - - private static tools.jackson.databind.JsonNode notification(String name) throws Exception { - for (var fixture : Conformance.fixture("protocol_values.json").required("notifications")) - if (fixture.required("name").asString().equals(name)) return fixture; - throw new AssertionError("Missing Go notification fixture: " + name); - } - - @ParameterizedTest - @ValueSource(strings = {"observer", "subscriber", "errorHandler"}) - @SuppressWarnings("try") // Resources intentionally scope background activity. - void callbackFailuresDoNotLoseWorkerSlots(String callback) throws Exception { - var client = client(); - var completed = new LinkedBlockingQueue(); - var reported = new LinkedBlockingQueue(); - var handled = new AtomicInteger(); - var observedEnds = new AtomicInteger(); - try (var workers = - workers(client) - .errorHandler( - error -> { - reported.add(error); - if (callback.equals("errorHandler")) - throw new IllegalStateException("error handler"); - }) - .observe( - event -> { - if (callback.equals("observer") && event.equals("work_end")) - throw new AssertionError("observer"); - }) - .observe( - event -> { - if (event.equals("work_end")) observedEnds.incrementAndGet(); - }) - .add(TYPE, context -> handled.incrementAndGet()) - .start(); - var broken = - workers.subscribe( - event -> { - if (callback.equals("subscriber")) throw new AssertionError("subscriber"); - if (callback.equals("errorHandler")) - throw new IllegalArgumentException("subscriber"); - }); - var subscription = workers.subscribe(completed::add, Workers.EventKind.JOB_COMPLETED)) { - for (int i = 0; i < 2; i++) { - var inserted = client.insert(TYPE, "job " + i); - var event = completed.poll(5, TimeUnit.SECONDS); - assertNotNull(event, "Callback failure must not strand the attempt or its worker slot"); - assertEquals(inserted.job().id(), event.job().id()); - assertEquals(Job.State.COMPLETED, client.get(event.job().id()).state()); - } - } - assertEquals(2, handled.get()); - assertEquals(2, observedEnds.get()); - assertEquals(2, reported.size()); - } - - @ParameterizedTest - @ValueSource( - strings = { - "throw", - "null", - "negative", - "overflow", - "nanosecond_overflow", - "timestamp_overflow" - }) - @SuppressWarnings("try") // Resources intentionally scope background activity. - void invalidRetryPoliciesFallBack(String policy) throws Exception { - var client = client(); - var failed = new LinkedBlockingQueue(); - var reported = new LinkedBlockingQueue(); - try (var workers = - workers(client) - .errorHandler(reported::add) - .maintenanceInterval(Duration.ofMillis(1)) - .serviceInterval(Duration.ofMillis(1)) - .retryPolicy( - job -> - switch (policy) { - case "throw" -> throw new IllegalStateException("retry policy"); - case "null" -> null; - case "negative" -> Duration.ofSeconds(-1); - case "nanosecond_overflow" -> - Duration.ofNanos(Long.MAX_VALUE).plusNanos(1); - case "timestamp_overflow" -> Duration.ofDays(365_000_000); - default -> Duration.ofSeconds(Long.MAX_VALUE); - }) - .add( - TYPE, - context -> { - throw new IllegalStateException("worker failed"); - }) - .start(); - var subscription = workers.subscribe(failed::add, Workers.EventKind.JOB_FAILED)) { - client.insert(TYPE, "retry"); - var event = failed.poll(5, TimeUnit.SECONDS); - assertNotNull(event, "A broken retry policy must not strand the attempt"); - assertEquals(Job.State.RETRYABLE, event.job().state()); - assertTrue(event.job().scheduledAt().isAfter(event.job().attemptedAt())); - assertEquals("worker failed", event.job().errors().getFirst().error()); - assertEquals(1, reported.size()); - } - } - - @ParameterizedTest - @CsvSource({"false,1,5000", "true,1,5000", "false,3600,1", "true,3600,1"}) - @SuppressWarnings("try") // Resources intentionally scope background activity. - void shortDelaysWakeFetchingBeforeThePollInterval( - boolean snooze, long serviceSeconds, long maintenanceMillis) throws Exception { - var client = client(); - var attempts = new AtomicInteger(); - var deferred = new CompletableFuture(); - var completed = new CompletableFuture(); - var reported = new LinkedBlockingQueue(); - var delay = Duration.ofSeconds(1); - try (var workers = - workers(client) - .pollInterval(Duration.ofMinutes(1)) - .maintenanceInterval(Duration.ofMillis(maintenanceMillis)) - .serviceInterval(Duration.ofSeconds(serviceSeconds)) - .retryPolicy(job -> delay) - .errorHandler(reported::add) - .add( - TYPE, - context -> { - if (attempts.incrementAndGet() == 1) { - if (snooze) context.snooze(delay); - throw new IllegalStateException("retry"); - } - }) - .start(); - var subscription = - workers.subscribe( - event -> { - if (event.kind() == Workers.EventKind.JOB_COMPLETED) completed.complete(event); - else deferred.complete(event); - })) { - var inserted = client.insert(TYPE, "short delay").job(); - var pending = deferred.get(5, TimeUnit.SECONDS); - assertEquals( - snooze ? Workers.EventKind.JOB_SNOOZED : Workers.EventKind.JOB_FAILED, pending.kind()); - assertEquals(Job.State.AVAILABLE, pending.job().state()); - var done = completed.get(10, TimeUnit.SECONDS).job(); - assertEquals(inserted.id(), done.id()); - assertFalse(done.attemptedAt().isBefore(pending.job().scheduledAt())); - assertEquals(snooze ? 1 : 2, done.attempt()); - assertEquals(2, attempts.get()); - } - assertTrue(reported.isEmpty(), () -> "Unexpected worker errors: " + reported); - } - - @ParameterizedTest - @ValueSource(booleans = {false, true}) - @SuppressWarnings("try") // Resources intentionally scope background activity. - void shutdownRespectsTheHandlersOutcome(boolean interrupted) throws Exception { - var client = client(); - var entered = new CompletableFuture>(); - var outcome = new CompletableFuture(); - var reported = new LinkedBlockingQueue(); - var workers = - workers(client) - .stopTimeout(Duration.ofSeconds(5)) - .errorHandler(reported::add) - .add( - TYPE, - context -> { - entered.complete(context); - context.awaitCancellation(); - if (interrupted) context.checkCancelled(); - }) - .start(); - try (var subscription = workers.subscribe(outcome::complete)) { - var inserted = - client.insert(TYPE, "stop", InsertOptions.builder().maxAttempts(1).build()).job(); - var context = entered.get(5, TimeUnit.SECONDS); - workers.stopAndCancel(); - assertEquals(WorkContext.Cancellation.SHUTDOWN, context.cancellation()); - var event = outcome.get(5, TimeUnit.SECONDS); - assertEquals( - interrupted ? Workers.EventKind.JOB_INTERRUPTED : Workers.EventKind.JOB_COMPLETED, - event.kind()); - var job = client.get(inserted.id()); - assertEquals(interrupted ? Job.State.AVAILABLE : Job.State.COMPLETED, job.state()); - assertEquals(interrupted ? 0 : 1, job.attempt()); - assertTrue(job.errors().isEmpty()); - if (interrupted) assertNull(job.finalizedAt()); - else assertNotNull(job.finalizedAt()); - } finally { - workers.stopAndCancel(); - } - assertTrue(reported.isEmpty(), () -> "Unexpected worker errors: " + reported); - } - - @ParameterizedTest - @ValueSource(booleans = {false, true}) - @SuppressWarnings("try") // Resources intentionally scope background activity. - void stoppingDuringAClaimPreservesCancellation(boolean cancel) throws Exception { - var claimed = new CountDownLatch(1); - var commit = new CountDownLatch(1); - var client = - client() - .withPilot( - new Pilot() { - @Override - public java.util.List claim( - Connection connection, - Client.Driver driver, - Claim claim, - Client.Transaction> next) - throws Exception { - var rows = next.run(connection); - if (!rows.isEmpty()) { - claimed.countDown(); - commit.await(); - } - return rows; - } - }); - var entered = new CompletableFuture>(); - var outcome = new CompletableFuture(); - var reported = new LinkedBlockingQueue(); - var workers = - workers(client) - .errorHandler(reported::add) - .add( - TYPE, - context -> { - entered.complete(context); - context.checkCancelled(); - }) - .start(); - try (var subscription = workers.subscribe(outcome::complete)) { - var inserted = client.insert(TYPE, "late claim").job(); - assertTrue(claimed.await(5, TimeUnit.SECONDS)); - // Let the active-map scan finish while the claim is still uncommitted and unregistered. - var error = - assertThrows( - RiverException.class, - () -> { - if (cancel) workers.stopAndCancel(); - else workers.stop(); - }); - assertTrue(error.getMessage().contains("Dispatcher did not stop")); - commit.countDown(); - var context = entered.get(5, TimeUnit.SECONDS); - assertEquals(cancel ? WorkContext.Cancellation.SHUTDOWN : null, context.cancellation()); - var event = outcome.get(5, TimeUnit.SECONDS); - assertEquals( - cancel ? Workers.EventKind.JOB_INTERRUPTED : Workers.EventKind.JOB_COMPLETED, - event.kind()); - workers.stop(); - var job = client.get(inserted.id()); - assertEquals(cancel ? Job.State.AVAILABLE : Job.State.COMPLETED, job.state()); - assertEquals(cancel ? 0 : 1, job.attempt()); - assertTrue(job.errors().isEmpty()); - } finally { - commit.countDown(); - workers.stopAndCancel(); - } - assertTrue(reported.isEmpty(), () -> "Unexpected worker errors: " + reported); - } - - @ParameterizedTest - @ValueSource(booleans = {false, true}) - @SuppressWarnings("try") // Resources intentionally scope background activity. - void terminalFailuresDoNotConsultRetryPolicy(boolean cancel) throws Exception { - var client = client(); - var failed = new LinkedBlockingQueue(); - var policyCalls = new AtomicInteger(); - try (var workers = - workers(client) - .cancelOnError(cancel) - .retryPolicy( - job -> { - policyCalls.incrementAndGet(); - return Duration.ofDays(1); - }) - .add( - TYPE, - context -> { - throw new IllegalStateException("worker failed"); - }) - .start(); - var subscription = workers.subscribe(failed::add)) { - client.insert(TYPE, "terminal", InsertOptions.builder().maxAttempts(1).build()); - var event = failed.poll(5, TimeUnit.SECONDS); + .fetchOnlyKnownKinds(true) + .workers(workers); + // Changing the registry after supplying it does not change the builder. + workers.add(second, context -> fail("Registry mutation leaked into client")); + var client = builder.build(); + builder.worker(third, context -> fail("Builder mutation leaked into client")); + var events = new LinkedBlockingQueue(); + var secondJob = client.insert(second, "unregistered").job(); + var thirdJob = client.insert(third, "unregistered").job(); + var firstJob = client.insert(first, "registered").job(); + try (var subscription = client.subscribe(events::add, Client.EventKind.JOB_COMPLETED); + var run = client.start()) { + var event = events.poll(5, TimeUnit.SECONDS); assertNotNull(event); - assertEquals(cancel ? Job.State.CANCELLED : Job.State.DISCARDED, event.job().state()); - assertEquals(0, policyCalls.get()); + assertEquals(firstJob.id(), event.job().id()); } + assertEquals(Job.State.AVAILABLE, client.get(secondJob.id()).state()); + assertEquals(Job.State.AVAILABLE, client.get(thirdJob.id()).state()); } }