diff --git a/AGENTS.md b/AGENTS.md
index 1882bd7..ae42a4c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,7 +2,7 @@
This is the standing brief for any AI agent (or human) working on Threadmill. Read it fully before touching the repository.
-**Project status:** v1.1.0 is the release version and the v1 feature set is complete. Follow-on improvements continue through the public issue tracker. Four storage backends (in-memory, PostgreSQL 18+, Oracle Database 19c+, Redis with standalone/Sentinel/Cluster), the processing engine, scheduling and recurring APIs, the per-queue pause primitive, the bulk-enqueue path, claim-time per-key concurrency with workflow inheritance, queue-family lanes, Spring Boot integration with explicit enqueue transaction modes (`after_commit`, `join_transaction`, `immediate`), Micrometer metrics, optional OpenTelemetry tracing, the data-first dashboard model, Spring dashboard API, static React dashboard UI, docs, examples, and a soak/load module are all in place. Store-backed maintenance leadership, crash-safe Redis claim, producer-side deduplication, long-running job check-ins, idle-worker wake signal, cross-node remote wake hints, and failure-detail truncation are part of the production-readiness baseline. The soak harness verifies its invariants live (bounded streaming checks, fail-fast, `progress.json`), supports node churn and external datastores, and ships a dual-backend `soakEndurance` orchestrator for hours-scale Postgres+Redis sign-off runs.
+**Project status:** v1.1.1 is the release version and the v1 feature set is complete. Follow-on improvements continue through the public issue tracker. Four storage backends (in-memory, PostgreSQL 18+, Oracle Database 19c+, Redis with standalone/Sentinel/Cluster), the processing engine, scheduling and recurring APIs, the per-queue pause primitive, the bulk-enqueue path, claim-time per-key concurrency with workflow inheritance, queue-family lanes, Spring Boot integration with explicit enqueue transaction modes (`after_commit`, `join_transaction`, `immediate`), Micrometer metrics, optional OpenTelemetry tracing, the data-first dashboard model, Spring dashboard API, static React dashboard UI, docs, examples, and a soak/load module are all in place. Store-backed maintenance leadership, crash-safe Redis claim, producer-side deduplication, long-running job check-ins, idle-worker wake signal, cross-node remote wake hints, and failure-detail truncation are part of the production-readiness baseline. The soak harness verifies its invariants live (bounded streaming checks, fail-fast, `progress.json`), supports node churn and external datastores, and ships a dual-backend `soakEndurance` orchestrator for hours-scale Postgres+Redis sign-off runs.
---
@@ -285,10 +285,11 @@ This section is the project's memory: the load-bearing decisions worth knowing b
- **Locking queries cannot carry a row limit (`ORA-02014`).** Locking cursors are index-ordered `FOR UPDATE SKIP LOCKED` statements read with `setFetchSize`/`setMaxRows` = the page budget; Oracle locks `SKIP LOCKED` rows as they are fetched (`unkeyedClaimCursorLocksOnlyTheRowsItFetches` pins this). Keyed candidates are read unlocked and then locked by primary key with `SKIP LOCKED`, so a candidate another claimer holds is dropped rather than waited for.
- **Hot statements carry index hints and their executed plans are asserted.** The optimizer chose full scans and fast-full scans on realistic statistics; hints make the plans deterministic. `OracleQueryPlanTest` runs the store's exact package-private SQL with **typed** binds and reads `DBMS_XPLAN.DISPLAY_CURSOR`: `EXPLAIN PLAN` treats binds as `VARCHAR2`, wraps `RAW` columns in `RAWTOHEX`, and reports full scans that never happen. The test container grants the user `SELECT ANY DICTIONARY`; elsewhere the plan tests skip.
- **Long `CLOB` values go through a temporary `Clob`.** Binding a string longer than ~16K characters directly (`setString` or `setCharacterStream`) made `ojdbc11` 23.26 split a surrogate pair straddling its ~32 KiB chunk boundary, silently corrupting emoji in job bodies. `OracleClobs` binds values up to 8,000 characters directly (below the direct-bind limit even at 3 bytes per UTF-16 unit) and longer ones through `createClob()` + `Clob.setString`, freeing them after the statement so pooled sessions do not accumulate temporary LOBs. `supplementaryCharactersSurviveLongClobBodiesAndPayloads` pins both paths.
-- **Text ordering is explicitly binary.** The driver derives `NLS_LANGUAGE`, and so `NLS_SORT`, from the client JVM locale (a German JVM sorts `a < Ä < B`). Comparisons follow `NLS_COMP` (binary), but `ORDER BY` follows `NLS_SORT`, so linguistic page order made keyset pages (`name > ?`) skip rows and prevented index-ordered reads. Small tables order by `NLSSORT(col, 'NLS_SORT = BINARY')`; the idle concurrency-group page reads a `RAW(2000)` virtual sort key (`idle_sort`). Never `ORDER BY` a text column in a paging query. `keysetPagesFollowBinaryOrderUnderALinguisticClientLocale` runs under a German session.
+- **Text ordering is explicitly binary.** The driver derives `NLS_LANGUAGE`, and so `NLS_SORT`, from the client JVM locale (a German JVM sorts `a < Ä < B`). Comparisons follow `NLS_COMP` (binary), but `ORDER BY` follows `NLS_SORT`, so linguistic page order made keyset pages (`name > ?`) skip rows and prevented index-ordered reads. Small tables order by `NLSSORT(col, 'NLS_SORT = BINARY')`; the idle concurrency-group page reads a `RAW` virtual sort key (`idle_sort`) whose type is derived, never declared: Oracle sizes `NLSSORT` for linguistic sorts whatever the literal says, so a 256-byte key yields `RAW(2000)` under `MAX_STRING_SIZE=STANDARD` but `RAW(2058)` under `EXTENDED`, and v1.1.0's declared `RAW(2000)` failed the baseline with ORA-12899 there. Never `ORDER BY` a text column in a paging query. `keysetPagesFollowBinaryOrderUnderALinguisticClientLocale` runs under a German session.
- **UTC `TIMESTAMP(9)` bound as `LocalDateTime`.** Nanosecond-exact and independent of JVM and session time zones (`java.sql.Timestamp` binding would apply the JVM default zone). Server-side time is `SYS_EXTRACT_UTC(SYSTIMESTAMP)`.
- **Oracle stores `''` as `NULL`.** Recurring payloads and pause reasons map `NULL` back to `""`; mutex holders compare with `DECODE`, which treats two `NULL`s as equal.
- **Statement-level rollback.** A failed statement does not abort an Oracle transaction, so every write unit joined to a caller-owned transaction runs behind a savepoint and rolls back to it on failure (`OracleJobStore.atomically`); otherwise a caller that catches the error and commits would commit the unit's earlier statements. Upserts are update-then-insert with an `ORA-00001` retry; concurrency-group locking inserts and retries on a duplicate; the deduplicated enqueue rolls back to a savepoint and coalesces onto the concurrent winner even inside a joined Spring transaction (PostgreSQL cannot). `requestCronNudge` locks the task row (`FOR UPDATE`) so acceptance is atomic with the existence and enabled checks.
+- **A superseded baseline checksum is accepted, never a silently edited one.** The v1.1.0 `V1__baseline.sql` statement 28 (`idle_sort RAW(2000)`) cannot succeed under `MAX_STRING_SIZE=EXTENDED`, so it was corrected in place: `OracleMigrationRunner.SUPERSEDED_CHECKSUMS` lists the v1.1.0 checksum, a schema that completed it stays valid (identical schema under STANDARD), and a schema stopped at statement 28 resumes with the corrected text and records the current checksum on completion. An entry is only allowed when every earlier statement stays byte-identical; the frozen v1.1.0 copy under `src/test/resources/.../released/v1.1.0/` must never be regenerated. The CI 23ai image runs STANDARD; a STANDARD-only run cannot catch an EXTENDED sizing defect, so run the suite against an EXTENDED database (`-PoracleJdbcUrl=…`) when touching virtual-column types.
- **Migrations are resumable because Oracle commits DDL.** `OracleMigrationRunner` records progress per statement in `threadmill_schema_history` (`statements_applied`, `success`), resumes at the first unrecorded statement, and tolerates an "already exists" error only for that first resumed statement (its DDL may have committed before the crash). Migrators serialize on a `threadmill_schema_lock` row held on a **second** connection, because DDL on the migrating connection would commit and release it. Files use SQL*Plus `/` terminators so emitted SQL runs unchanged; leading comments are stripped from each executed statement. The single `V1__baseline.sql` already contains the full PostgreSQL V1–V11 end state; future Oracle changes ship as additive files.
- **No remote wake channel.** `createRemoteWakeChannel` returns empty: `DBMS_ALERT.SIGNAL` locks the alert name until commit and would serialize enqueuing transactions, and AQ needs grants and infrastructure. Cross-node pickup falls back to `pollInterval`.
- **Spring chooses between the JDBC stores from configuration, not a database round trip.** `JdbcStoreSelection` runs in conditions, before any bean exists: `threadmill.store.jdbc-type`, then the `spring.datasource.url` scheme, then the module present (PostgreSQL on a tie, preserving prior behaviour). The chosen store still verifies the server at startup.
@@ -590,6 +591,7 @@ Every hard-won failure mode that has come up during development, and the test th
| Oracle migration crash between a committed DDL statement and its progress record wedges the schema; concurrent migrators race; edited or unknown migrations accepted | `OracleMigrationRunnerTest.interruptedMigrationResumesAndToleratesTheUnrecordedCommittedStatement` + `concurrentCleanMigrationsAreSerialized` + `migrateAndValidateRefuseAnEditedMigration` + `migrateAndValidateRefuseAnUnknownVersion` + `migrationCommitsWhenConnectionsDefaultToNonAutoCommit` + `emittedCleanInstallSqlAppliesToACleanSchemaAndValidates` |
| Spring wires the wrong JDBC store when both modules are present, or Oracle `join_transaction` does not commit/roll back with the caller | `JdbcStoreSelectionTest` + `SpringOracleAutoConfigurationTest` + `SpringOracleTransactionBoundaryTest` |
| A failed Oracle write joined to a caller transaction leaves its earlier statements behind when the caller catches the error and commits (Oracle rolls back only the failing statement; PR #138 review) | `SpringOracleTransactionBoundaryTest.aFailedJoinedWriteLeavesNothingBehindWhenTheCallerCatchesAndCommits` |
+| Oracle baseline fails with ORA-12899 under `MAX_STRING_SIZE=EXTENDED` (`idle_sort RAW(2000)` vs derived `NLSSORT` length 2058); the fix must not strand a schema stopped at that statement or invalidate a completed v1.1.0 schema | `OracleMigrationRunnerTest.releasedBaselineDiffersOnlyInTheStatementThatFailsUnderExtendedStringSize` + `releasedBaselineStoppedAtTheExtendedStringSizeFailureResumesAndRecordsTheCurrentChecksum` + `schemaCompletedByTheReleasedBaselineStillValidatesAndMigrates`; the full Oracle suite passes against an EXTENDED database via `-PoracleJdbcUrl` |
| A `null` release holder frees a mutex held by an empty holder (Oracle stores `''` as `NULL`, `DECODE` matches two `NULL`s; PR #138 review) | `AbstractJobStoreContractTest.releaseWithANullHolderNeverFreesAnotherHoldersMutex` (all stores) |
### Postgres-layer improvements (engagement notes)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1327b09..93a2a35 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
# Changelog
+## 1.1.1 — 2026-10-08
+
+Threadmill 1.1.1 is a patch release for the Oracle store. See
+[Upgrade from 1.1.0](docs/compatibility.md#upgrade-from-110).
+
+- Fixed the Oracle baseline migration failing with ORA-12899 at statement 28 of
+ `V1__baseline.sql` on databases with `MAX_STRING_SIZE=EXTENDED`. The
+ `threadmill_concurrency_groups.idle_sort` virtual column declared `RAW(2000)`,
+ but Oracle sizes its `NLSSORT` expression at `RAW(2058)` under `EXTENDED`; the
+ column now takes its derived type. A schema stopped at that statement resumes
+ with the corrected statement, and a completed 1.1.0 schema stays valid.
+
## 1.1.0 — 2026-10-07
Threadmill 1.1.0 adds an Oracle Database backend. It is a compatible minor
diff --git a/README.md b/README.md
index 3e68abf..db24ed6 100644
--- a/README.md
+++ b/README.md
@@ -44,16 +44,16 @@ Threadmill is published to Maven Central under the `com.hemju.threadmill`
group. Pick the core plus the store you run against (and the Spring Boot
starter if you use Spring):
-These examples use **1.1.0**. 1.0.x installations upgrade by bumping the
+These examples use **1.1.1**. 1.0.x installations upgrade by bumping the
version (see the [1.0-to-1.1 notes](docs/compatibility.md#upgrade-from-100));
0.3.0 installations follow the
[0.3.0-to-1.0 migration guide](docs/compatibility.md#upgrade-from-v030) first.
```kotlin
// build.gradle.kts
-implementation("com.hemju.threadmill:threadmill-core:1.1.0")
-implementation("com.hemju.threadmill:threadmill-store-postgres:1.1.0") // or -store-oracle / -store-redis / -store-memory
-implementation("com.hemju.threadmill:threadmill-spring-boot:1.1.0") // optional Spring Boot integration
+implementation("com.hemju.threadmill:threadmill-core:1.1.1")
+implementation("com.hemju.threadmill:threadmill-store-postgres:1.1.1") // or -store-oracle / -store-redis / -store-memory
+implementation("com.hemju.threadmill:threadmill-spring-boot:1.1.1") // optional Spring Boot integration
```
```xml
@@ -61,7 +61,7 @@ implementation("com.hemju.threadmill:threadmill-spring-boot:1.1.0") // option
com.hemju.threadmill
threadmill-core
- 1.1.0
+ 1.1.1
```
@@ -236,7 +236,7 @@ stable API.
## Status
-**Threadmill 1.1.0** includes the features below. See the
+**Threadmill 1.1.1** includes the features below. See the
[release checklist](docs/release-checklist.md) for the validation and publication
process.
diff --git a/buildSrc/src/main/kotlin/com/hemju/threadmill/gradle/ThreadmillVersion.kt b/buildSrc/src/main/kotlin/com/hemju/threadmill/gradle/ThreadmillVersion.kt
index 60b3868..01794f7 100644
--- a/buildSrc/src/main/kotlin/com/hemju/threadmill/gradle/ThreadmillVersion.kt
+++ b/buildSrc/src/main/kotlin/com/hemju/threadmill/gradle/ThreadmillVersion.kt
@@ -2,5 +2,5 @@ package com.hemju.threadmill.gradle
/** The single source of truth for the version of every published Threadmill module. */
object ThreadmillVersion {
- const val CURRENT = "1.1.0"
+ const val CURRENT = "1.1.1"
}
diff --git a/docs/compatibility.md b/docs/compatibility.md
index 8b813d4..c9f7ae6 100644
--- a/docs/compatibility.md
+++ b/docs/compatibility.md
@@ -155,6 +155,19 @@ version bump. Keep every Threadmill module on the same version.
- **Jackson.** Threadmill now depends on Jackson 2.21.7. Applications that manage
Jackson through their own BOM should move to the same patched release.
+## Upgrade from 1.1.0
+
+1.1.1 is a patch release that fixes the Oracle baseline migration on databases
+with `MAX_STRING_SIZE=EXTENDED`, where 1.1.0 failed at statement 28 of
+`V1__baseline.sql` with ORA-12899. Upgrading is a version bump:
+
+- A schema that 1.1.0 installed completely stays valid; the runner accepts the
+ 1.1.0 baseline checksum and the schema is identical.
+- A schema whose 1.1.0 migration stopped at statement 28 resumes there with the
+ corrected statement on the next `migrate`. No manual cleanup is needed.
+- Hosts that apply DDL themselves can run `emitPendingSql()` to get the
+ remaining statements.
+
## Changes after 1.0
Compatible minor releases may add optional fields/operations with explicit
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 1e9dbb2..7474c39 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -11,13 +11,13 @@ run the same logical job more than once.
## Dependencies
Use Java 25 and add the Spring module plus one store. These examples use
-the 1.1.0 release.
+the 1.1.1 release.
```kotlin
-implementation("com.hemju.threadmill:threadmill-spring-boot:1.1.0")
-implementation("com.hemju.threadmill:threadmill-store-postgres:1.1.0")
-// or: implementation("com.hemju.threadmill:threadmill-store-oracle:1.1.0") + your Oracle JDBC driver
-// or: implementation("com.hemju.threadmill:threadmill-store-redis:1.1.0")
+implementation("com.hemju.threadmill:threadmill-spring-boot:1.1.1")
+implementation("com.hemju.threadmill:threadmill-store-postgres:1.1.1")
+// or: implementation("com.hemju.threadmill:threadmill-store-oracle:1.1.1") + your Oracle JDBC driver
+// or: implementation("com.hemju.threadmill:threadmill-store-redis:1.1.1")
```
The default Spring enqueue mode is `after_commit`: returned ids are reserved
diff --git a/threadmill-spring-boot/README.md b/threadmill-spring-boot/README.md
index e013349..2e86a71 100644
--- a/threadmill-spring-boot/README.md
+++ b/threadmill-spring-boot/README.md
@@ -22,13 +22,13 @@ single place to confirm "which engine, which store, which lanes" at boot time.
## Quick start
-These dependencies use the 1.1.0 release. Add the store your application uses.
+These dependencies use the 1.1.1 release. Add the store your application uses.
```kotlin
dependencies {
- implementation("com.hemju.threadmill:threadmill-spring-boot:1.1.0")
- implementation("com.hemju.threadmill:threadmill-store-postgres:1.1.0")
- // or: implementation("com.hemju.threadmill:threadmill-store-redis:1.1.0")
+ implementation("com.hemju.threadmill:threadmill-spring-boot:1.1.1")
+ implementation("com.hemju.threadmill:threadmill-store-postgres:1.1.1")
+ // or: implementation("com.hemju.threadmill:threadmill-store-redis:1.1.1")
}
```
diff --git a/threadmill-store-oracle/README.md b/threadmill-store-oracle/README.md
index c913281..4d90588 100644
--- a/threadmill-store-oracle/README.md
+++ b/threadmill-store-oracle/README.md
@@ -5,7 +5,7 @@ Oracle Database 19c+ backend for the `JobStore` SPI. It passes the same shared
and uses standard JDBC only: applications bring their own Oracle driver.
```kotlin
-implementation("com.hemju.threadmill:threadmill-store-oracle:1.1.0")
+implementation("com.hemju.threadmill:threadmill-store-oracle:1.1.1")
runtimeOnly("com.oracle.database.jdbc:ojdbc11:23.26.3.0.0") // or the driver your platform mandates
```
diff --git a/threadmill-store-oracle/src/main/java/com/hemju/threadmill/store/oracle/OracleMigrationRunner.java b/threadmill-store-oracle/src/main/java/com/hemju/threadmill/store/oracle/OracleMigrationRunner.java
index c0484d9..06f77d4 100644
--- a/threadmill-store-oracle/src/main/java/com/hemju/threadmill/store/oracle/OracleMigrationRunner.java
+++ b/threadmill-store-oracle/src/main/java/com/hemju/threadmill/store/oracle/OracleMigrationRunner.java
@@ -52,6 +52,15 @@
*
validates the description and SHA-256 checksum of every applied
* migration before applying anything.
*
+ *
+ * A shipped migration is never edited, with one narrow exception: a
+ * statement that cannot succeed on some supported database may be corrected
+ * when every earlier statement stays byte-identical and the corrected
+ * statement builds an equivalent schema wherever the old one succeeded. The
+ * earlier copy's checksum is then listed in {@link #SUPERSEDED_CHECKSUMS}: a
+ * schema that completed it stays valid, and a schema that stopped at the
+ * failing statement resumes with the corrected text and records the current
+ * checksum on completion.
*/
public final class OracleMigrationRunner {
@@ -62,6 +71,15 @@ public final class OracleMigrationRunner {
private static final Duration LOCK_ACQUIRE_TIMEOUT = Duration.ofMinutes(5);
private static final Duration LOCK_POLL_INTERVAL = Duration.ofMillis(250);
+ /**
+ * Checksums of earlier shipped copies of a migration that the schema history
+ * may still record; see the class documentation for when an entry is allowed.
+ */
+ private static final Map> SUPERSEDED_CHECKSUMS = Map.of(
+ // v1.1.0 declared threadmill_concurrency_groups.idle_sort as RAW(2000),
+ // which fails with ORA-12899 under MAX_STRING_SIZE=EXTENDED (statement 28).
+ 1, Set.of("e95c99fc412a90e037ef8dabcccdbc3005820ccc90aa8d539fdbee8bca95e463"));
+
/** Tables dropped by {@link #dropThreadmillObjects()}, children before parents. */
private static final List THREADMILL_TABLES = List.of(
"threadmill_mutexes",
@@ -274,9 +292,13 @@ private void applyOne(Connection conn, Migration m, AppliedMigration progress)
}
}
OracleTransactions.execute(conn, tx -> {
+ // Resuming a superseded copy finishes with the current statements, so the
+ // completed record carries the current checksum.
try (PreparedStatement ps = tx.prepareStatement("UPDATE threadmill_schema_history "
- + "SET success = 1, applied_at = SYS_EXTRACT_UTC(SYSTIMESTAMP) WHERE version = ?")) {
- ps.setInt(1, m.version());
+ + "SET success = 1, checksum = ?, applied_at = SYS_EXTRACT_UTC(SYSTIMESTAMP) "
+ + "WHERE version = ?")) {
+ ps.setString(1, m.checksum());
+ ps.setInt(2, m.version());
ps.executeUpdate();
}
return null;
@@ -319,7 +341,10 @@ private static Map validateAppliedMigrations(
+ " with description '" + actual.description() + "', expected '"
+ expected.description() + "'");
}
- if (!expected.checksum().equals(actual.checksum())) {
+ if (!expected.checksum().equals(actual.checksum())
+ && !SUPERSEDED_CHECKSUMS
+ .getOrDefault(actual.version(), Set.of())
+ .contains(actual.checksum())) {
throw new MigrationException("Threadmill schema history version " + actual.version()
+ " has checksum " + actual.checksum() + " but the shipped migration hashes to "
+ expected.checksum() + " — the migration file was edited after it was applied");
@@ -480,7 +505,9 @@ private static void appendMigrationSql(StringBuilder sb, Migration m, int fromSt
sb,
"UPDATE threadmill_schema_history SET statements_applied = "
+ m.statements().size()
- + ", success = 1, applied_at = SYS_EXTRACT_UTC(SYSTIMESTAMP) WHERE version = "
+ + ", success = 1, checksum = '"
+ + m.checksum()
+ + "', applied_at = SYS_EXTRACT_UTC(SYSTIMESTAMP) WHERE version = "
+ m.version());
}
appendStatement(sb, "COMMIT");
diff --git a/threadmill-store-oracle/src/main/resources/com/hemju/threadmill/store/oracle/migrations/V1__baseline.sql b/threadmill-store-oracle/src/main/resources/com/hemju/threadmill/store/oracle/migrations/V1__baseline.sql
index 366ba8a..8377e2c 100644
--- a/threadmill-store-oracle/src/main/resources/com/hemju/threadmill/store/oracle/migrations/V1__baseline.sql
+++ b/threadmill-store-oracle/src/main/resources/com/hemju/threadmill/store/oracle/migrations/V1__baseline.sql
@@ -314,7 +314,10 @@ CREATE TABLE threadmill_concurrency_groups (
-- orders identically in every session, whatever its NLS_SORT; ordering the
-- text column would follow a linguistic NLS_SORT (derived from the client
-- JVM locale) and could neither use the index nor match keyset predicates.
- idle_sort RAW(2000) GENERATED ALWAYS AS (
+ -- The type is derived, never declared: Oracle sizes NLSSORT for linguistic
+ -- sorts whatever the literal says, RAW(2000) under MAX_STRING_SIZE=STANDARD
+ -- but RAW(2058) under EXTENDED, where a declared RAW(2000) fails ORA-12899.
+ idle_sort GENERATED ALWAYS AS (
CASE WHEN exclusive_in_flight = 0 AND shared_in_flight = 0
THEN NLSSORT(concurrency_key, 'NLS_SORT = BINARY') END) VIRTUAL,
CONSTRAINT threadmill_concurrency_pk PRIMARY KEY (concurrency_key),
diff --git a/threadmill-store-oracle/src/test/java/com/hemju/threadmill/store/oracle/OracleMigrationRunnerTest.java b/threadmill-store-oracle/src/test/java/com/hemju/threadmill/store/oracle/OracleMigrationRunnerTest.java
index ec351d9..577f60e 100644
--- a/threadmill-store-oracle/src/test/java/com/hemju/threadmill/store/oracle/OracleMigrationRunnerTest.java
+++ b/threadmill-store-oracle/src/test/java/com/hemju/threadmill/store/oracle/OracleMigrationRunnerTest.java
@@ -6,6 +6,7 @@
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
@@ -36,6 +37,13 @@
*/
class OracleMigrationRunnerTest {
+ /** Checksum of {@code V1__baseline.sql} as shipped in v1.1.0 (frozen test resource). */
+ private static final String RELEASED_BASELINE_CHECKSUM =
+ "e95c99fc412a90e037ef8dabcccdbc3005820ccc90aa8d539fdbee8bca95e463";
+
+ /** The 1-based v1.1.0 baseline statement that fails under {@code MAX_STRING_SIZE=EXTENDED}. */
+ private static final int EXTENDED_FAILURE_STATEMENT = 28;
+
private DataSource dataSource;
private OracleMigrationRunner runner;
@@ -129,6 +137,71 @@ void interruptedMigrationResumesAndToleratesTheUnrecordedCommittedStatement() th
}
}
+ /**
+ * v1.1.0 declared {@code idle_sort RAW(2000)}, which fails with ORA-12899
+ * under {@code MAX_STRING_SIZE=EXTENDED} at statement 28. The corrected
+ * baseline must leave every earlier statement byte-identical so a schema
+ * stopped there resumes with the corrected statement.
+ */
+ @Test
+ void releasedBaselineDiffersOnlyInTheStatementThatFailsUnderExtendedStringSize()
+ throws IOException {
+ var released = releasedBaselineStatements();
+ var current = baselineStatements();
+
+ assertThat(sha256(releasedBaselineSql())).isEqualTo(RELEASED_BASELINE_CHECKSUM);
+ assertThat(current).hasSameSizeAs(released);
+ assertThat(current.subList(0, EXTENDED_FAILURE_STATEMENT - 1))
+ .isEqualTo(released.subList(0, EXTENDED_FAILURE_STATEMENT - 1));
+ assertThat(released.get(EXTENDED_FAILURE_STATEMENT - 1)).contains("idle_sort RAW(2000)");
+ assertThat(current.get(EXTENDED_FAILURE_STATEMENT - 1))
+ .contains("idle_sort GENERATED ALWAYS AS");
+ assertThat(current.subList(EXTENDED_FAILURE_STATEMENT, current.size()))
+ .isEqualTo(released.subList(EXTENDED_FAILURE_STATEMENT, released.size()));
+ }
+
+ @Test
+ void releasedBaselineStoppedAtTheExtendedStringSizeFailureResumesAndRecordsTheCurrentChecksum()
+ throws Exception {
+ runner.dropThreadmillObjects();
+ var released = releasedBaselineStatements();
+ // The v1.1.0 runner committed statements 1-27, then statement 28 failed.
+ try (Connection conn = dataSource.getConnection();
+ Statement st = conn.createStatement()) {
+ conn.setAutoCommit(true);
+ for (int i = 0; i < EXTENDED_FAILURE_STATEMENT - 1; i++) st.execute(released.get(i));
+ st.execute(historyDdl());
+ try (PreparedStatement ps = conn.prepareStatement(
+ "INSERT INTO threadmill_schema_history (version, description, checksum, "
+ + "statements_applied, statements_total, success) VALUES (1, 'baseline', ?, ?, ?, 0)")) {
+ ps.setString(1, RELEASED_BASELINE_CHECKSUM);
+ ps.setInt(2, EXTENDED_FAILURE_STATEMENT - 1);
+ ps.setInt(3, released.size());
+ ps.executeUpdate();
+ }
+ }
+
+ runner.migrate();
+
+ runner.validate();
+ assertThat(recordedBaselineChecksum()).isEqualTo(baselineChecksum());
+ var store = new OracleJobStore(dataSource);
+ var job = job();
+ store.insert(job);
+ assertThat(store.findById(job.id())).isPresent();
+ }
+
+ @Test
+ void schemaCompletedByTheReleasedBaselineStillValidatesAndMigrates() throws Exception {
+ execute("UPDATE threadmill_schema_history SET checksum = '" + RELEASED_BASELINE_CHECKSUM
+ + "' WHERE version = 1");
+
+ runner.validate();
+ runner.migrate();
+ assertThat(runner.emitPendingSql()).isEmpty();
+ assertThat(recordedBaselineChecksum()).isEqualTo(RELEASED_BASELINE_CHECKSUM);
+ }
+
@Test
void migrateAndValidateRefuseAnUnknownVersion() throws SQLException {
execute("INSERT INTO threadmill_schema_history (version, description, checksum, "
@@ -229,6 +302,29 @@ private static String baselineSql() throws IOException {
}
}
+ private static String releasedBaselineSql() throws IOException {
+ try (var in = OracleMigrationRunnerTest.class
+ .getClassLoader()
+ .getResourceAsStream(
+ "com/hemju/threadmill/store/oracle/released/v1.1.0/V1__baseline.sql")) {
+ return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+
+ private static List releasedBaselineStatements() throws IOException {
+ return OracleMigrationRunner.splitStatements("V1__baseline.sql", releasedBaselineSql());
+ }
+
+ private String recordedBaselineChecksum() throws SQLException {
+ try (Connection conn = dataSource.getConnection();
+ Statement st = conn.createStatement();
+ ResultSet rs =
+ st.executeQuery("SELECT checksum FROM threadmill_schema_history WHERE version = 1")) {
+ rs.next();
+ return rs.getString(1);
+ }
+ }
+
private static List baselineStatements() throws IOException {
return OracleMigrationRunner.splitStatements("V1__baseline.sql", baselineSql());
}
@@ -239,9 +335,17 @@ private String historyDdl() {
.getFirst();
}
- private static String baselineChecksum() throws Exception {
- var digest =
- MessageDigest.getInstance("SHA-256").digest(baselineSql().getBytes(StandardCharsets.UTF_8));
- return HexFormat.of().formatHex(digest);
+ private static String baselineChecksum() throws IOException {
+ return sha256(baselineSql());
+ }
+
+ private static String sha256(String sql) {
+ try {
+ var digest =
+ MessageDigest.getInstance("SHA-256").digest(sql.getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(digest);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
}
}
diff --git a/threadmill-store-oracle/src/test/resources/com/hemju/threadmill/store/oracle/released/v1.1.0/V1__baseline.sql b/threadmill-store-oracle/src/test/resources/com/hemju/threadmill/store/oracle/released/v1.1.0/V1__baseline.sql
new file mode 100644
index 0000000..366ba8a
--- /dev/null
+++ b/threadmill-store-oracle/src/test/resources/com/hemju/threadmill/store/oracle/released/v1.1.0/V1__baseline.sql
@@ -0,0 +1,451 @@
+-- Threadmill v1 baseline schema (Oracle Database 19c+).
+--
+-- The whole schema installs in one migration. Post-release changes ship as
+-- additive V2__*.sql, V3__*.sql, ... files; this baseline is never edited
+-- again once released. OracleMigrationRunner bootstraps
+-- threadmill_schema_history itself.
+--
+-- Statement format: every statement ends with a line containing only "/"
+-- (the SQL*Plus / SQLcl convention), so the file runs unchanged in those
+-- tools. SQL statements carry no trailing semicolon; PL/SQL blocks do.
+--
+-- Oracle commits DDL implicitly, so a migration is not atomic. The runner
+-- records progress after every statement and resumes a crashed migration at
+-- the first unrecorded statement.
+--
+-- Type mapping and conventions:
+-- * Job and node ids are RAW(16) in big-endian UUID byte order. RAW compares
+-- unsigned byte by byte, which equals JobId's canonical natural order.
+-- * Timestamps are TIMESTAMP(9) holding UTC wall time: nanosecond-exact for
+-- java.time.Instant, time-zone independent, and directly indexable.
+-- * Booleans are NUMBER(1) with a 0/1 check (BOOLEAN is 23ai-only).
+-- * Text uses CHAR length semantics where values are user names, and the
+-- database character set must be AL32UTF8 (checked at startup).
+-- * Oracle has no partial indexes. Each one is emulated with VIRTUAL columns of
+-- the form CASE WHEN THEN END and an index over them:
+-- Oracle B-tree indexes skip rows whose key columns are all NULL, so the
+-- index holds only rows matching . Virtual columns (rather than
+-- expression indexes) give the queries plain column names and let the 19c
+-- optimizer read index order directly, so per-key top-n probes stop after
+-- n index entries instead of sorting the key's whole range.
+--
+-- The body column is the source-of-truth wire form. The other job columns are
+-- denormalised, indexed scalars kept in sync with the body on every write.
+
+CREATE TABLE threadmill_jobs (
+ id RAW(16) NOT NULL,
+ state VARCHAR2(16) NOT NULL,
+ queue VARCHAR2(128 CHAR) NOT NULL,
+ priority NUMBER(10) DEFAULT 0 NOT NULL,
+ handler_signature VARCHAR2(1000 CHAR) NOT NULL,
+ scheduled_at TIMESTAMP(9),
+ owner_node_id RAW(16),
+ owner_heartbeat_at TIMESTAMP(9),
+ last_checkin_at TIMESTAMP(9),
+ current_state_at TIMESTAMP(9) NOT NULL,
+ version NUMBER(19) NOT NULL,
+ execution_revision NUMBER(19) DEFAULT 0 NOT NULL,
+ body CLOB NOT NULL,
+ created_at TIMESTAMP(9) NOT NULL,
+ concurrency_key VARCHAR2(256 BYTE),
+ concurrency_mode VARCHAR2(16),
+ workflow_root_id RAW(16) NOT NULL,
+ parent_job_id RAW(16),
+ -- Claim path, unkeyed lane: ENQUEUED jobs without a concurrency key, per
+ -- queue, in (priority DESC, id) order. The rank is -priority so the index
+ -- is ascending throughout.
+ unkeyed_queue VARCHAR2(128 CHAR) GENERATED ALWAYS AS (
+ CASE WHEN state = 'ENQUEUED' AND concurrency_key IS NULL THEN queue END) VIRTUAL,
+ unkeyed_rank NUMBER(11) GENERATED ALWAYS AS (
+ CASE WHEN state = 'ENQUEUED' AND concurrency_key IS NULL THEN -priority END) VIRTUAL,
+ unkeyed_id RAW(16) GENERATED ALWAYS AS (
+ CASE WHEN state = 'ENQUEUED' AND concurrency_key IS NULL THEN id END) VIRTUAL,
+ -- Claim path, keyed lane: per-queue distinct keys and each key's ENQUEUED
+ -- heads in the engine's (current_state_at, id) in-key order.
+ keyed_queue VARCHAR2(128 CHAR) GENERATED ALWAYS AS (
+ CASE WHEN state = 'ENQUEUED' AND concurrency_key IS NOT NULL THEN queue END) VIRTUAL,
+ keyed_key VARCHAR2(256 BYTE) GENERATED ALWAYS AS (
+ CASE WHEN state = 'ENQUEUED' AND concurrency_key IS NOT NULL THEN concurrency_key END)
+ VIRTUAL,
+ keyed_at TIMESTAMP(9) GENERATED ALWAYS AS (
+ CASE WHEN state = 'ENQUEUED' AND concurrency_key IS NOT NULL THEN current_state_at END)
+ VIRTUAL,
+ keyed_id RAW(16) GENERATED ALWAYS AS (
+ CASE WHEN state = 'ENQUEUED' AND concurrency_key IS NOT NULL THEN id END) VIRTUAL,
+ -- Claim-time admission: pending (ENQUEUED, SCHEDULED, AWAITING) keyed jobs.
+ pending_key VARCHAR2(256 BYTE) GENERATED ALWAYS AS (
+ CASE WHEN state IN ('ENQUEUED', 'SCHEDULED', 'AWAITING')
+ THEN concurrency_key END) VIRTUAL,
+ pending_at TIMESTAMP(9) GENERATED ALWAYS AS (
+ CASE WHEN state IN ('ENQUEUED', 'SCHEDULED', 'AWAITING')
+ AND concurrency_key IS NOT NULL THEN current_state_at END) VIRTUAL,
+ pending_id RAW(16) GENERATED ALWAYS AS (
+ CASE WHEN state IN ('ENQUEUED', 'SCHEDULED', 'AWAITING')
+ AND concurrency_key IS NOT NULL THEN id END) VIRTUAL,
+ -- Claim-time admission: pending EXCLUSIVE jobs (the leapfrog rule).
+ exclusive_key VARCHAR2(256 BYTE) GENERATED ALWAYS AS (
+ CASE WHEN state IN ('ENQUEUED', 'SCHEDULED', 'AWAITING')
+ AND concurrency_mode = 'EXCLUSIVE' THEN concurrency_key END) VIRTUAL,
+ exclusive_at TIMESTAMP(9) GENERATED ALWAYS AS (
+ CASE WHEN state IN ('ENQUEUED', 'SCHEDULED', 'AWAITING')
+ AND concurrency_mode = 'EXCLUSIVE' THEN current_state_at END) VIRTUAL,
+ exclusive_id RAW(16) GENERATED ALWAYS AS (
+ CASE WHEN state IN ('ENQUEUED', 'SCHEDULED', 'AWAITING')
+ AND concurrency_mode = 'EXCLUSIVE' THEN id END) VIRTUAL,
+ -- Workflow holds: non-terminal keyed jobs by (key, workflow root).
+ outstanding_key VARCHAR2(256 BYTE) GENERATED ALWAYS AS (
+ CASE WHEN state NOT IN ('SUCCEEDED', 'FAILED', 'DELETED', 'QUARANTINED')
+ THEN concurrency_key END) VIRTUAL,
+ outstanding_root RAW(16) GENERATED ALWAYS AS (
+ CASE WHEN state NOT IN ('SUCCEEDED', 'FAILED', 'DELETED', 'QUARANTINED')
+ AND concurrency_key IS NOT NULL THEN workflow_root_id END) VIRTUAL,
+ -- Due-for-promotion scan.
+ scheduled_due TIMESTAMP(9) GENERATED ALWAYS AS (
+ CASE WHEN state = 'SCHEDULED' THEN scheduled_at END) VIRTUAL,
+ -- Orphan recovery by the newer of owner heartbeat and check-in. COALESCE
+ -- keeps GREATEST from returning NULL when only one marker exists.
+ processing_liveness TIMESTAMP(9) GENERATED ALWAYS AS (
+ CASE WHEN state = 'PROCESSING' THEN GREATEST(
+ COALESCE(owner_heartbeat_at, last_checkin_at),
+ COALESCE(last_checkin_at, owner_heartbeat_at)) END) VIRTUAL,
+ -- Oldest processing heartbeat (monitoring).
+ processing_heartbeat TIMESTAMP(9) GENERATED ALWAYS AS (
+ CASE WHEN state = 'PROCESSING' THEN owner_heartbeat_at END) VIRTUAL,
+ -- Workflow successor promotion: AWAITING jobs of one parent.
+ awaiting_parent RAW(16) GENERATED ALWAYS AS (
+ CASE WHEN state = 'AWAITING' THEN parent_job_id END) VIRTUAL,
+ awaiting_at TIMESTAMP(9) GENERATED ALWAYS AS (
+ CASE WHEN state = 'AWAITING' AND parent_job_id IS NOT NULL
+ THEN current_state_at END) VIRTUAL,
+ awaiting_id RAW(16) GENERATED ALWAYS AS (
+ CASE WHEN state = 'AWAITING' AND parent_job_id IS NOT NULL THEN id END) VIRTUAL,
+ -- Oldest ENQUEUED job per queue and idle-queue checks.
+ enqueued_queue VARCHAR2(128 CHAR) GENERATED ALWAYS AS (
+ CASE WHEN state = 'ENQUEUED' THEN queue END) VIRTUAL,
+ enqueued_at TIMESTAMP(9) GENERATED ALWAYS AS (
+ CASE WHEN state = 'ENQUEUED' THEN current_state_at END) VIRTUAL,
+ CONSTRAINT threadmill_jobs_pk PRIMARY KEY (id),
+ CONSTRAINT threadmill_jobs_state_ck CHECK (state IN ('AWAITING', 'SCHEDULED', 'ENQUEUED',
+ 'PROCESSING', 'SUCCEEDED', 'FAILED', 'DELETED', 'QUARANTINED', 'PROCESSED')),
+ CONSTRAINT threadmill_jobs_mode_ck
+ CHECK (concurrency_mode IS NULL OR concurrency_mode IN ('SHARED', 'EXCLUSIVE')),
+ CONSTRAINT threadmill_jobs_shape_ck CHECK (
+ (concurrency_key IS NULL AND concurrency_mode IS NULL)
+ OR (concurrency_key IS NOT NULL AND concurrency_mode IS NOT NULL)),
+ CONSTRAINT threadmill_jobs_version_ck CHECK (version >= 0),
+ CONSTRAINT threadmill_jobs_exec_rev_ck CHECK (execution_revision >= 0)
+)
+/
+
+CREATE INDEX threadmill_jobs_unkeyed_idx
+ ON threadmill_jobs (unkeyed_queue, unkeyed_rank, unkeyed_id)
+/
+
+CREATE INDEX threadmill_jobs_keyed_idx
+ ON threadmill_jobs (keyed_queue, keyed_key, keyed_at, keyed_id)
+/
+
+CREATE INDEX threadmill_jobs_pending_idx
+ ON threadmill_jobs (pending_key, pending_at, pending_id)
+/
+
+CREATE INDEX threadmill_jobs_exclusive_idx
+ ON threadmill_jobs (exclusive_key, exclusive_at, exclusive_id)
+/
+
+CREATE INDEX threadmill_jobs_workflow_idx
+ ON threadmill_jobs (outstanding_key, outstanding_root)
+/
+
+CREATE INDEX threadmill_jobs_scheduled_idx ON threadmill_jobs (scheduled_due)
+/
+
+CREATE INDEX threadmill_jobs_liveness_idx ON threadmill_jobs (processing_liveness)
+/
+
+CREATE INDEX threadmill_jobs_heartbeat_idx ON threadmill_jobs (processing_heartbeat)
+/
+
+CREATE INDEX threadmill_jobs_awaiting_idx
+ ON threadmill_jobs (awaiting_parent, awaiting_at, awaiting_id)
+/
+
+CREATE INDEX threadmill_jobs_queue_age_idx ON threadmill_jobs (enqueued_queue, enqueued_at)
+/
+
+-- Retention keyset pages by (current_state_at, id) within one finished state.
+CREATE INDEX threadmill_jobs_retention_idx
+ ON threadmill_jobs (state, current_state_at, id)
+/
+
+-- Stable maintenance keyset pages by id within one state.
+CREATE INDEX threadmill_jobs_state_id_idx ON threadmill_jobs (state, id)
+/
+
+-- Dashboard history pages, newest transition first.
+CREATE INDEX threadmill_jobs_state_page_idx
+ ON threadmill_jobs (state, current_state_at DESC, id)
+/
+
+-- Dashboard search filtered by state, queue, and handler.
+CREATE INDEX threadmill_jobs_search_idx
+ ON threadmill_jobs (state, queue, handler_signature, current_state_at DESC, id)
+/
+
+CREATE INDEX threadmill_jobs_handler_idx ON threadmill_jobs (handler_signature)
+/
+
+CREATE TABLE threadmill_nodes (
+ id RAW(16) NOT NULL,
+ last_heartbeat_at TIMESTAMP(9) NOT NULL,
+ CONSTRAINT threadmill_nodes_pk PRIMARY KEY (id)
+)
+/
+
+-- Recurring task identity. payload_serialized is nullable only because Oracle
+-- stores an empty string as NULL; the store reads NULL back as "".
+CREATE TABLE threadmill_cron_tasks (
+ name VARCHAR2(128 CHAR) NOT NULL,
+ trigger_kind VARCHAR2(16) NOT NULL,
+ trigger_value VARCHAR2(1000 CHAR) NOT NULL,
+ handler_signature VARCHAR2(1000 CHAR) NOT NULL,
+ payload_type_tag VARCHAR2(1000 CHAR) NOT NULL,
+ payload_serialized CLOB,
+ queue VARCHAR2(128 CHAR) DEFAULT 'default' NOT NULL,
+ priority NUMBER(10) DEFAULT 0 NOT NULL,
+ timeout_seconds NUMBER(19),
+ max_attempts NUMBER(10),
+ is_exclusive NUMBER(1) DEFAULT 0 NOT NULL,
+ missed_run_policy VARCHAR2(16) DEFAULT 'DROP' NOT NULL,
+ time_zone VARCHAR2(64 CHAR) DEFAULT 'UTC' NOT NULL,
+ enabled NUMBER(1) DEFAULT 1 NOT NULL,
+ CONSTRAINT threadmill_cron_tasks_pk PRIMARY KEY (name),
+ CONSTRAINT threadmill_cron_kind_ck CHECK (trigger_kind IN ('CRON', 'INTERVAL')),
+ CONSTRAINT threadmill_cron_policy_ck CHECK (missed_run_policy IN ('DROP', 'CATCH_UP')),
+ CONSTRAINT threadmill_cron_timeout_ck CHECK (timeout_seconds IS NULL OR timeout_seconds > 0),
+ CONSTRAINT threadmill_cron_attempts_ck CHECK (max_attempts IS NULL OR max_attempts > 0),
+ CONSTRAINT threadmill_cron_exclusive_ck CHECK (is_exclusive IN (0, 1)),
+ CONSTRAINT threadmill_cron_enabled_ck CHECK (enabled IN (0, 1))
+)
+/
+
+-- Recurring schedule state. The nudge columns are written only by
+-- requestCronNudge / clearCronNudge; the blanket state upsert leaves them
+-- untouched so re-registration cannot clobber a concurrently accepted nudge.
+CREATE TABLE threadmill_cron_task_state (
+ task_name VARCHAR2(128 CHAR) NOT NULL,
+ last_run_at TIMESTAMP(9),
+ last_run_job_id RAW(16),
+ next_run_at TIMESTAMP(9),
+ in_flight_job_id RAW(16),
+ timing_fingerprint VARCHAR2(4000 BYTE),
+ nudge_requested_at TIMESTAMP(9),
+ nudge_revision NUMBER(19),
+ CONSTRAINT threadmill_cron_state_pk PRIMARY KEY (task_name),
+ CONSTRAINT threadmill_cron_state_task_fk FOREIGN KEY (task_name)
+ REFERENCES threadmill_cron_tasks (name) ON DELETE CASCADE
+)
+/
+
+CREATE INDEX threadmill_cron_state_due_idx ON threadmill_cron_task_state (next_run_at)
+/
+
+CREATE TABLE threadmill_cron_task_ownership (
+ namespace VARCHAR2(128 CHAR) NOT NULL,
+ task_name VARCHAR2(128 CHAR) NOT NULL,
+ CONSTRAINT threadmill_cron_owner_pk PRIMARY KEY (namespace, task_name),
+ CONSTRAINT threadmill_cron_owner_task_fk FOREIGN KEY (task_name)
+ REFERENCES threadmill_cron_tasks (name) ON DELETE CASCADE
+)
+/
+
+CREATE INDEX threadmill_cron_owner_task_idx ON threadmill_cron_task_ownership (task_name)
+/
+
+-- Cross-cluster named mutex with a lease. holder is nullable only because
+-- Oracle stores an empty string as NULL; comparisons use DECODE, which treats
+-- two NULLs as equal.
+CREATE TABLE threadmill_mutexes (
+ name VARCHAR2(128 CHAR) NOT NULL,
+ holder VARCHAR2(4000 BYTE),
+ expires_at TIMESTAMP(9) NOT NULL,
+ CONSTRAINT threadmill_mutexes_pk PRIMARY KEY (name)
+)
+/
+
+-- Store-backed leadership lease for the maintenance cycle.
+CREATE TABLE threadmill_leases (
+ name VARCHAR2(64) NOT NULL,
+ holder RAW(16) NOT NULL,
+ expires_at TIMESTAMP(9) NOT NULL,
+ CONSTRAINT threadmill_leases_pk PRIMARY KEY (name)
+)
+/
+
+-- Producer-side deduplication. Cleanup is gated on the referenced job being
+-- terminal so a long-running active job keeps its dedup protection.
+CREATE TABLE threadmill_dedup_keys (
+ queue VARCHAR2(128 CHAR) NOT NULL,
+ dedup_key VARCHAR2(256 BYTE) NOT NULL,
+ job_id RAW(16) NOT NULL,
+ expires_at TIMESTAMP(9) NOT NULL,
+ CONSTRAINT threadmill_dedup_keys_pk PRIMARY KEY (queue, dedup_key),
+ CONSTRAINT threadmill_dedup_keys_job_fk FOREIGN KEY (job_id)
+ REFERENCES threadmill_jobs (id) ON DELETE CASCADE
+)
+/
+
+CREATE INDEX threadmill_dedup_keys_exp_idx ON threadmill_dedup_keys (expires_at)
+/
+
+-- Without this index every retention delete scans the dedup table for the
+-- cascade check.
+CREATE INDEX threadmill_dedup_keys_job_idx ON threadmill_dedup_keys (job_id)
+/
+
+-- Claim-time per-key concurrency bookkeeping, updated in the same transaction
+-- as the job state transition.
+CREATE TABLE threadmill_concurrency_groups (
+ concurrency_key VARCHAR2(256 BYTE) NOT NULL,
+ exclusive_in_flight NUMBER(10) DEFAULT 0 NOT NULL,
+ shared_in_flight NUMBER(10) DEFAULT 0 NOT NULL,
+ last_modified TIMESTAMP(9) NOT NULL,
+ -- Reclaimable groups (no job in flight) in binary key order. A RAW sort key
+ -- orders identically in every session, whatever its NLS_SORT; ordering the
+ -- text column would follow a linguistic NLS_SORT (derived from the client
+ -- JVM locale) and could neither use the index nor match keyset predicates.
+ idle_sort RAW(2000) GENERATED ALWAYS AS (
+ CASE WHEN exclusive_in_flight = 0 AND shared_in_flight = 0
+ THEN NLSSORT(concurrency_key, 'NLS_SORT = BINARY') END) VIRTUAL,
+ CONSTRAINT threadmill_concurrency_pk PRIMARY KEY (concurrency_key),
+ CONSTRAINT threadmill_concurrency_ex_ck
+ CHECK (exclusive_in_flight >= 0 AND exclusive_in_flight <= 1),
+ CONSTRAINT threadmill_concurrency_sh_ck CHECK (shared_in_flight >= 0),
+ CONSTRAINT threadmill_concurrency_mode_ck
+ CHECK (exclusive_in_flight = 0 OR shared_in_flight = 0)
+)
+/
+
+-- Bounded keyset pages over reclaimable groups.
+CREATE INDEX threadmill_concurrency_idle_idx ON threadmill_concurrency_groups (idle_sort)
+/
+
+-- Workflow-root outstanding counts: the key stays held from the root's claim
+-- to the last descendant's terminal save.
+CREATE TABLE threadmill_concurrency_workflow_holds (
+ concurrency_key VARCHAR2(256 BYTE) NOT NULL,
+ workflow_root_id RAW(16) NOT NULL,
+ outstanding NUMBER(10) NOT NULL,
+ CONSTRAINT threadmill_workflow_holds_pk PRIMARY KEY (concurrency_key, workflow_root_id),
+ CONSTRAINT threadmill_workflow_holds_ck CHECK (outstanding >= 0)
+)
+/
+
+-- Per-queue pause primitive.
+CREATE TABLE threadmill_queue_pauses (
+ queue VARCHAR2(128 CHAR) NOT NULL,
+ paused_at TIMESTAMP(9) NOT NULL,
+ paused_by CLOB,
+ CONSTRAINT threadmill_queue_pauses_pk PRIMARY KEY (queue)
+)
+/
+
+-- Incrementally maintained per-state counts, sharded 16 ways by session so
+-- concurrent writers update disjoint rows. Only the SUM per state is
+-- meaningful; individual shards may go negative. Never COUNT(*) the jobs table.
+CREATE TABLE threadmill_job_counts (
+ state VARCHAR2(16) NOT NULL,
+ shard_no NUMBER(2) NOT NULL,
+ job_count NUMBER(19) DEFAULT 0 NOT NULL,
+ CONSTRAINT threadmill_job_counts_pk PRIMARY KEY (state, shard_no)
+)
+/
+
+INSERT INTO threadmill_job_counts (state, shard_no, job_count)
+SELECT s.state, sh.shard_no, 0
+FROM (SELECT 'AWAITING' AS state FROM dual
+ UNION ALL SELECT 'SCHEDULED' FROM dual
+ UNION ALL SELECT 'ENQUEUED' FROM dual
+ UNION ALL SELECT 'PROCESSING' FROM dual
+ UNION ALL SELECT 'PROCESSED' FROM dual
+ UNION ALL SELECT 'SUCCEEDED' FROM dual
+ UNION ALL SELECT 'FAILED' FROM dual
+ UNION ALL SELECT 'DELETED' FROM dual
+ UNION ALL SELECT 'QUARANTINED' FROM dual) s
+CROSS JOIN (SELECT LEVEL - 1 AS shard_no FROM dual CONNECT BY LEVEL <= 16) sh
+/
+
+-- Incrementally maintained ENQUEUED depth per queue, sharded the same way.
+-- Monitoring work scales with queue cardinality, not queued job count.
+CREATE TABLE threadmill_queue_counts (
+ queue VARCHAR2(128 CHAR) NOT NULL,
+ shard_no NUMBER(2) NOT NULL,
+ job_count NUMBER(19) NOT NULL,
+ CONSTRAINT threadmill_queue_counts_pk PRIMARY KEY (queue, shard_no)
+)
+/
+
+-- Maintains both counter tables row by row. The shard is the session id
+-- modulo 16, the Oracle analogue of PostgreSQL's backend pid.
+CREATE OR REPLACE TRIGGER threadmill_jobs_counts_trg
+AFTER INSERT OR DELETE OR UPDATE OF state, queue ON threadmill_jobs
+FOR EACH ROW
+DECLARE
+ sh PLS_INTEGER := MOD(TO_NUMBER(SYS_CONTEXT('USERENV', 'SID')), 16);
+ old_queue threadmill_jobs.queue%TYPE;
+ new_queue threadmill_jobs.queue%TYPE;
+
+ PROCEDURE adjust_state(st VARCHAR2, delta PLS_INTEGER) IS
+ BEGIN
+ UPDATE threadmill_job_counts SET job_count = job_count + delta
+ WHERE state = st AND shard_no = sh;
+ END;
+
+ PROCEDURE adjust_queue(q VARCHAR2, delta PLS_INTEGER) IS
+ BEGIN
+ UPDATE threadmill_queue_counts SET job_count = job_count + delta
+ WHERE queue = q AND shard_no = sh;
+ IF SQL%ROWCOUNT = 0 THEN
+ BEGIN
+ INSERT INTO threadmill_queue_counts (queue, shard_no, job_count)
+ VALUES (q, sh, delta);
+ EXCEPTION
+ WHEN DUP_VAL_ON_INDEX THEN
+ UPDATE threadmill_queue_counts SET job_count = job_count + delta
+ WHERE queue = q AND shard_no = sh;
+ END;
+ END IF;
+ END;
+BEGIN
+ IF INSERTING THEN
+ adjust_state(:NEW.state, 1);
+ ELSIF DELETING THEN
+ adjust_state(:OLD.state, -1);
+ ELSIF :OLD.state <> :NEW.state THEN
+ adjust_state(:OLD.state, -1);
+ adjust_state(:NEW.state, 1);
+ END IF;
+
+ IF NOT INSERTING AND :OLD.state = 'ENQUEUED' THEN
+ old_queue := :OLD.queue;
+ END IF;
+ IF NOT DELETING AND :NEW.state = 'ENQUEUED' THEN
+ new_queue := :NEW.queue;
+ END IF;
+ IF old_queue IS NOT NULL AND new_queue IS NOT NULL AND old_queue = new_queue THEN
+ RETURN;
+ END IF;
+ -- Stable order for queue replacements reduces cross-queue lock inversions.
+ IF old_queue IS NOT NULL AND new_queue IS NOT NULL AND new_queue < old_queue THEN
+ adjust_queue(new_queue, 1);
+ adjust_queue(old_queue, -1);
+ ELSE
+ IF old_queue IS NOT NULL THEN
+ adjust_queue(old_queue, -1);
+ END IF;
+ IF new_queue IS NOT NULL THEN
+ adjust_queue(new_queue, 1);
+ END IF;
+ END IF;
+END;
+/