From 3202886e65bb4c10d3895c7672f4a0c08f672105 Mon Sep 17 00:00:00 2001 From: Helmut Michael Juskewycz Date: Thu, 8 Oct 2026 14:19:13 +0200 Subject: [PATCH 1/2] fix(store-oracle): derive idle_sort type so the baseline installs under MAX_STRING_SIZE=EXTENDED v1.1.0 declared threadmill_concurrency_groups.idle_sort as RAW(2000). Oracle sizes NLSSORT for linguistic sorts regardless of the BINARY literal, so under MAX_STRING_SIZE=EXTENDED the expression is RAW(2058) and V1__baseline.sql statement 28 failed with ORA-12899. The virtual column now takes its derived type (RAW(2000) under STANDARD, unchanged from v1.1.0). The runner accepts the v1.1.0 baseline checksum as superseded: a completed v1.1.0 schema stays valid, and a schema stopped at statement 28 resumes with the corrected statement and records the current checksum. --- AGENTS.md | 4 +- .../store/oracle/OracleMigrationRunner.java | 35 +- .../store/oracle/migrations/V1__baseline.sql | 5 +- .../oracle/OracleMigrationRunnerTest.java | 112 ++++- .../oracle/released/v1.1.0/V1__baseline.sql | 451 ++++++++++++++++++ 5 files changed, 597 insertions(+), 10 deletions(-) create mode 100644 threadmill-store-oracle/src/test/resources/com/hemju/threadmill/store/oracle/released/v1.1.0/V1__baseline.sql diff --git a/AGENTS.md b/AGENTS.md index 1882bd7..2e7c54d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/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; +/ From 8b8feeb0eb4be30df4862080c81ea4bc1eef0588 Mon Sep 17 00:00:00 2001 From: Helmut Michael Juskewycz Date: Thu, 8 Oct 2026 14:19:56 +0200 Subject: [PATCH 2/2] chore(release): prepare 1.1.1 --- AGENTS.md | 2 +- CHANGELOG.md | 12 ++++++++++++ README.md | 12 ++++++------ .../hemju/threadmill/gradle/ThreadmillVersion.kt | 2 +- docs/compatibility.md | 13 +++++++++++++ docs/quickstart.md | 10 +++++----- threadmill-spring-boot/README.md | 8 ++++---- threadmill-store-oracle/README.md | 2 +- 8 files changed, 43 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2e7c54d..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. --- 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 ```