Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,24 +44,24 @@ 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
<!-- Maven -->
<dependency>
<groupId>com.hemju.threadmill</groupId>
<artifactId>threadmill-core</artifactId>
<version>1.1.0</version>
<version>1.1.1</version>
</dependency>
```

Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
13 changes: 13 additions & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions threadmill-spring-boot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
```

Expand Down
2 changes: 1 addition & 1 deletion threadmill-store-oracle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
Loading
Loading