Date: Wed, 5 Aug 2026 17:11:40 +0900
Subject: [PATCH 08/29] feat: expose owner-scoped durable job pagination
---
.../etl/controller/EtlJobController.java | 63 +++++++++++++++++--
1 file changed, 57 insertions(+), 6 deletions(-)
diff --git a/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java b/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java
index 1d467840..336e67c4 100644
--- a/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java
+++ b/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java
@@ -1,6 +1,8 @@
package com.xtrmetl.etl.controller;
import com.xtrmetl.etl.job.EtlJobAcceptedResponse;
+import com.xtrmetl.etl.job.EtlJobPage;
+import com.xtrmetl.etl.job.EtlJobPageResponse;
import com.xtrmetl.etl.job.EtlJobService;
import com.xtrmetl.etl.job.EtlJobSnapshot;
import com.xtrmetl.etl.job.EtlJobStatusResponse;
@@ -11,6 +13,7 @@
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.dao.DataAccessException;
import org.springframework.http.CacheControl;
+import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
@@ -20,7 +23,9 @@
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.security.Principal;
@@ -28,16 +33,15 @@
import java.util.UUID;
/**
- * Exposes durable asynchronous ETL job submission and owner-scoped status resources.
+ * Exposes durable asynchronous ETL job submission, discovery, and status resources.
*
* Submission requires authentication and an {@code Idempotency-Key}. The accepted response is
* intentionally noncommittal under RFC 9110: it reports the durable pending state and supplies a
- * status-monitor resource through both the representation and {@code Location} header. This intake
- * slice does not claim that worker execution has started.
+ * status-monitor resource through both the representation and {@code Location} header.
*
- * Because this bounded slice retains payloads but does not yet execute jobs or clear terminal
- * payloads, the controller is disabled by default. Operators must explicitly set
- * {@code xtrmetl.etl.jobs.intake-enabled=true} after accepting that temporary lifecycle boundary.
+ * Job discovery is owner-scoped and uses an opaque keyset cursor. A next-page link is emitted
+ * under RFC 8288 only when another page exists. The service independently binds every list query to
+ * the authenticated principal hash, so cursor contents never grant authority.
*
* Success and covered failure responses use {@code Cache-Control: no-store}. Malformed, absent,
* and foreign-owned job identifiers use the same owner-safe not-found classification so the status
@@ -56,6 +60,8 @@ public class EtlJobController {
/** Response header indicating whether a prior durable submission was replayed. */
public static final String IDEMPOTENCY_REPLAYED_HEADER = "Idempotency-Replayed";
+ private static final String DEFAULT_JOB_PAGE_LIMIT_TEXT = "50";
+
private final EtlJobService etlJobService;
/**
@@ -123,6 +129,51 @@ public ResponseEntity submit(
.body(responseBody);
}
+ /**
+ * Lists one deterministic page of jobs in the authenticated principal namespace.
+ *
+ * @param cursor opaque next-page cursor, or {@code null} for the newest page
+ * @param limit canonical decimal page size from 1 through 100, or {@code null} for 50
+ * @param principal authenticated principal namespace
+ * @return owner-scoped page with an RFC 8288 next link only when another page exists
+ */
+ @GetMapping
+ @Observed(name = "etl.jobs.list", contextualName = "etl-job-list")
+ public ResponseEntity list(
+ @RequestParam(value = "cursor", required = false) @Nullable String cursor,
+ @RequestParam(value = "limit", required = false) @Nullable String limit,
+ @Nullable Principal principal
+ ) {
+ if (principal == null) {
+ throw new EtlRequestException(EtlRequestError.IDEMPOTENCY_PRINCIPAL_REQUIRED);
+ }
+
+ final EtlJobPage page;
+ try {
+ page = etlJobService.listOwned(principal.getName(), cursor, limit);
+ } catch (EtlRequestException | DataAccessException exception) {
+ throw exception;
+ } catch (RuntimeException exception) {
+ throw new EtlUnexpectedException(exception);
+ }
+
+ ResponseEntity.BodyBuilder responseBuilder = ResponseEntity.ok()
+ .cacheControl(CacheControl.noStore());
+ if (page.nextCursor() != null) {
+ String effectiveLimit = limit == null ? DEFAULT_JOB_PAGE_LIMIT_TEXT : limit;
+ String nextTarget = UriComponentsBuilder.fromPath("/api/etl/jobs")
+ .queryParam("limit", effectiveLimit)
+ .queryParam("cursor", page.nextCursor())
+ .build()
+ .toUriString();
+ responseBuilder.header(
+ HttpHeaders.LINK,
+ "<" + nextTarget + ">; rel=\"next\""
+ );
+ }
+ return responseBuilder.body(EtlJobPageResponse.from(page));
+ }
+
/**
* Returns one status resource only within the authenticated principal namespace.
*
From 0da3c39bd993c96f1f0de6e815135e4935df0af9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 17:11:49 +0900
Subject: [PATCH 09/29] feat: index owner-scoped durable job pagination
---
.../migration/V4__add_etl_job_owner_pagination_index.sql | 8 ++++++++
1 file changed, 8 insertions(+)
create mode 100644 etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql
diff --git a/etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql b/etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql
new file mode 100644
index 00000000..926a6c00
--- /dev/null
+++ b/etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql
@@ -0,0 +1,8 @@
+-- Support deterministic newest-first keyset pagination inside one hashed principal namespace.
+-- PostgreSQL can scan this B-tree in either direction; explicit DESC documents the API order.
+CREATE INDEX etl_job_owner_pagination_index
+ ON etl_job_records (
+ principal_scope_hash,
+ created_at DESC,
+ job_record_id DESC
+ );
From 95578c2dd2b9677f183d295f0914049e3f3231db Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 17:13:09 +0900
Subject: [PATCH 10/29] test: require pagination index and operator
documentation
---
.../job/EtlJobMigrationDocumentationTest.java | 31 +++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
index 698acc9c..d5d34316 100644
--- a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
+++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
@@ -50,6 +50,21 @@ void migrationReservesStableWorkerStatesAndRequiresTerminalPayloadClearing() thr
assertTrue(migration.contains("request_payload IS NULL"));
}
+ @Test
+ void paginationMigrationUsesTheOwnerAndCompleteStableOrderingKey() throws IOException {
+ String migration = read(
+ "etl-service/src/main/resources/db/migration/"
+ + "V4__add_etl_job_owner_pagination_index.sql"
+ ).replaceAll("\\s+", " ");
+
+ assertTrue(migration.contains("CREATE INDEX etl_job_owner_pagination_index"));
+ assertTrue(migration.contains(
+ "ON etl_job_records ( principal_scope_hash, created_at DESC, job_record_id DESC )"
+ ));
+ assertFalse(migration.contains(" OFFSET "));
+ assertFalse(migration.contains("principal_name"));
+ }
+
@Test
void runbookDocumentsAcceptedSemanticsOwnershipAndLeaseFencedExecution() throws IOException {
String runbook = read("docs/etl/durable-job-intake.md").replaceAll("\\s+", " ");
@@ -70,6 +85,22 @@ void runbookDocumentsAcceptedSemanticsOwnershipAndLeaseFencedExecution() throws
assertTrue(runbook.contains("xtrmetl.*"));
}
+ @Test
+ void runbookDocumentsOwnerScopedKeysetPaginationAndRollback() throws IOException {
+ String runbook = read("docs/etl/durable-job-intake.md").replaceAll("\\s+", " ");
+
+ assertTrue(runbook.contains("GET /api/etl/jobs?limit=50"));
+ assertTrue(runbook.contains("created_at DESC, job_record_id DESC"));
+ assertTrue(runbook.contains("strict tuple boundary"));
+ assertTrue(runbook.contains("Link: <"));
+ assertTrue(runbook.contains("rel=\"next\""));
+ assertTrue(runbook.contains("etl_invalid_job_page_limit"));
+ assertTrue(runbook.contains("etl_invalid_job_page_cursor"));
+ assertTrue(runbook.contains("V4__add_etl_job_owner_pagination_index.sql"));
+ assertTrue(runbook.contains("DROP INDEX etl_job_owner_pagination_index"));
+ assertTrue(runbook.contains("RFC 8288"));
+ }
+
private static String read(String relativePath) throws IOException {
return Files.readString(projectRoot().resolve(relativePath), StandardCharsets.UTF_8);
}
From 8f9b430a81fe570036757c6decd539b339dc9e55 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 17:14:21 +0900
Subject: [PATCH 11/29] docs: document owner-scoped durable job pagination
---
docs/etl/durable-job-intake.md | 103 ++++++++++++++++++++++++++++++---
1 file changed, 95 insertions(+), 8 deletions(-)
diff --git a/docs/etl/durable-job-intake.md b/docs/etl/durable-job-intake.md
index dc54f365..658c71b8 100644
--- a/docs/etl/durable-job-intake.md
+++ b/docs/etl/durable-job-intake.md
@@ -4,9 +4,10 @@
`POST /api/etl/jobs` creates a durable, authenticated-principal-scoped ETL job resource. A separate
lease-fenced worker claims accepted jobs across replicas, replays or writes the durable response
-ledger, writes target rows, and commits terminal state atomically.
+ledger, writes target rows, and commits terminal state atomically. Authenticated operators can also
+list recent jobs in their own principal namespace through deterministic keyset pagination.
-Both capabilities are fail-closed:
+Both intake and execution capabilities are fail-closed:
```text
mightyetl.etl.jobs.intake-enabled=false
@@ -57,6 +58,56 @@ same job identifier and `Idempotency-Replayed: true`. Reusing one principal-scop
JSON returns `422 etl_job_submission_key_reused`. A concurrent creation attempt that cannot acquire
the transaction-level submission lock returns `409 etl_job_submission_in_progress`.
+## List owned jobs
+
+```http
+GET /api/etl/jobs?limit=50 HTTP/1.1
+Authorization: Basic
+```
+
+The endpoint returns only jobs owned by the same authenticated principal. It orders rows by
+`created_at DESC, job_record_id DESC`; the UUID is a deterministic tie-breaker when jobs share one
+database timestamp. The default page size is 50 and the accepted canonical range is 1 through 100.
+Values such as `0`, `101`, `01`, signed values, whitespace-padded values, and non-decimal text fail
+with `400 etl_invalid_job_page_limit` before table access.
+
+The service fetches one additional row beyond the requested page size. That row is not returned; it
+only proves that another page exists. When a following page is available, the body includes an opaque
+URL-safe cursor and the response advertises the same target through RFC 8288 Web Linking:
+
+```http
+HTTP/1.1 200 OK
+Cache-Control: no-store
+Link: ; rel="next"
+Content-Type: application/json
+
+{
+ "jobs": [
+ {
+ "jobRecordId": "cf4f083f-8c90-4f34-a8b6-b53761de44ef",
+ "jobStatus": "SUCCEEDED",
+ "attemptCount": 1,
+ "createdAt": "2026-08-05T01:00:00Z",
+ "updatedAt": "2026-08-05T01:00:05Z"
+ }
+ ],
+ "nextCursor": "eyJvcGFxdWUiOiJleGFtcGxlIn0"
+}
+```
+
+The actual cursor is a canonical unpadded Base64 URL encoding of the last returned creation timestamp
+and job identifier. Clients must treat it as opaque. Each following query still binds the current
+principal hash and applies a strict tuple boundary equivalent to “older timestamp, or the same
+timestamp with a lower UUID.” Cursor contents never grant authority and reveal no payload, principal,
+submission key, or hash. Malformed, oversized, incomplete, non-canonical, or stale-format cursors fail
+closed with `400 etl_invalid_job_page_cursor` before database access. A terminal or empty page omits
+both `nextCursor` and the `Link` header.
+
+Pagination guarantees no duplicates or omissions while traversing an unchanged dataset. Concurrent
+insertions are visible according to their ordering position and do not convert a cursor into a
+snapshot transaction. Consumers needing a legally frozen audit set must export from an explicit
+transactional or warehouse snapshot rather than treating this operational list as one.
+
## Read job status
```http
@@ -68,8 +119,8 @@ The query binds the current principal hash and job identifier. A malformed, miss
identifier returns the same `404 etl_job_not_found`, preventing tenant-existence probing.
The representation exposes only the opaque job identifier, stable lifecycle state, bounded attempt
-count, stable failure code where applicable, status URL, and timestamps. It excludes request payload,
-raw principal, raw submission key, internal hashes, lease identifiers, SQL, and response-ledger data.
+count, stable failure code where applicable, and timestamps. It excludes request payload, raw
+principal, raw submission key, internal hashes, lease identifiers, SQL, and response-ledger data.
## Lifecycle and distribution
@@ -77,7 +128,10 @@ Flyway migrations create descriptive multi-word `snake_case` objects:
- `V2__create_etl_job_records.sql` creates `etl_job_records` and the submission uniqueness contract;
- `V3__add_etl_job_lease_fencing.sql` adds `lease_claim_id`, `lease_owner_id`,
- `lease_expires_at`, lifecycle constraints, and `etl_job_claim_eligibility_index`.
+ `lease_expires_at`, lifecycle constraints, and `etl_job_claim_eligibility_index`;
+- `V4__add_etl_job_owner_pagination_index.sql` adds `etl_job_owner_pagination_index` on
+ `principal_scope_hash`, `created_at DESC`, and `job_record_id DESC` for the exact owner-scoped
+ ordering contract.
The stable lifecycle is `PENDING`, `RUNNING`, `SUCCEEDED`, and `FAILED`.
@@ -125,16 +179,40 @@ failure, attempts exhaustion, and non-retryable failure clear the payload in the
transition. Apply least privilege, encryption, backup, restore, and retention controls while data is
retained.
-Metrics and ordinary logs must not include payloads, principals, client keys, hashes, job or lease
-identifiers, SQL, exception messages, or unbounded error classes. Operational procedures and metric
-contracts are authoritative in `docs/operations/durable-job-worker.md`.
+List and status representations exclude payloads, raw principals, raw keys, hashes, lease identifiers,
+SQL, and exception messages. Metrics and ordinary logs must not include those values or unbounded
+error classes. Operational procedures and metric contracts are authoritative in
+`docs/operations/durable-job-worker.md`.
+
+## Migration and rollback
+
+Apply Flyway migrations in version order. `V4__add_etl_job_owner_pagination_index.sql` is additive and
+does not change row contents or the API state machine. On a large production table, measure index
+creation lock and I/O impact in a representative staging environment before rollout.
+
+Application rollback is compatible with the additional index because older binaries ignore it. After
+rolling back all binaries that depend on the list endpoint, the database-only rollback is:
+
+```sql
+DROP INDEX etl_job_owner_pagination_index;
+```
+
+Dropping the index while the pagination endpoint is still active preserves query correctness but can
+cause an unacceptable owner-list scan cost. Do not remove it until traffic is withdrawn and an
+execution-plan review confirms the rollback boundary.
## Standards basis
- RFC 9110 Section 15.3.3 defines `202 Accepted` as noncommittal and recommends a current-status
representation and status monitor.
+- RFC 8288 defines the Web Linking model and the HTTP `Link` header used for the optional next-page
+ relationship.
- RFC 9457 supplies deterministic problem-details representations.
- RFC 9651 defines the accepted Structured Fields String syntax.
+- PostgreSQL 18 requires explicit `ORDER BY` for guaranteed result ordering and recommends a unique
+ ordering when `LIMIT` is used.
+- PostgreSQL 18 documents that equality constraints on leading multicolumn B-tree keys plus a range
+ constraint on the next key efficiently limit the scanned index portion.
- PostgreSQL 18 documents `SKIP LOCKED` as unsuitable for a general consistent view but useful for
avoiding contention among multiple consumers of a queue-like table.
- Spring fixed-delay scheduling measures each delay from completion of the preceding invocation.
@@ -146,6 +224,9 @@ contracts are authoritative in `docs/operations/durable-job-worker.md`.
Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor.
https://www.rfc-editor.org/rfc/rfc9110
+Nottingham, M. (2017). *Web linking* (RFC 8288). RFC Editor.
+https://doi.org/10.17487/RFC8288
+
Nottingham, M., & Wilde, E. (2023). *Problem details for HTTP APIs* (RFC 9457). RFC Editor.
https://www.rfc-editor.org/rfc/rfc9457
@@ -156,8 +237,14 @@ OpenTelemetry Authors. (2026). *OpenTelemetry semantic conventions 1.43.0: Seman
SQL databases client operations*. Cloud Native Computing Foundation.
https://opentelemetry.io/docs/specs/semconv/db/sql/
+PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Multicolumn indexes*.
+https://www.postgresql.org/docs/18/indexes-multicolumn.html
+
PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: SELECT*.
https://www.postgresql.org/docs/18/sql-select.html
+PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Sorting rows (ORDER BY)*.
+https://www.postgresql.org/docs/18/queries-order.html
+
Spring Authors. (2026). *Task execution and scheduling*. Broadcom.
https://docs.spring.io/spring-framework/reference/integration/scheduling.html
From 5ba0d835a66ebe05b8976adaa7406779c0b7309f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 17:15:40 +0900
Subject: [PATCH 12/29] docs: record durable job pagination slice
---
CHANGELOG.md | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f206858f..082fd959 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- Durable job operators can now list only their own jobs through bounded newest-first keyset pagination with canonical opaque cursors, deterministic timestamp-plus-UUID ordering, `Cache-Control: no-store`, and RFC 8288 next-page links without offset drift or cross-tenant existence leakage.
- Durable asynchronous ETL jobs now progress from `PENDING` through lease-fenced execution to `SUCCEEDED` or `FAILED`; PostgreSQL owns cross-replica claiming, stale workers cannot commit target or lifecycle effects, and intake and execution remain independently fail-closed.
- Durable-worker observability now records one terminal outcome counter and one matching duration sample for every completed poll, including idle polls and database failures while persisting retry or terminal transitions.
- The hourly pull-request disposition loop now requires at least one non-author approval anchored to the exact current head SHA; stale approvals, comment-only reviews, and the mere absence of requested changes cannot authorize unattended merge.
@@ -28,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Owner-scoped durable job list models and HTTP contract, strict cursor and page-limit validation, one-extra-row next-page detection, the descriptive `etl_job_owner_pagination_index`, deterministic tenant-isolation and equal-timestamp tests, migration rollback guidance, and APA 7th standards evidence in `docs/etl/durable-job-intake.md`.
- PostgreSQL `FOR UPDATE SKIP LOCKED` durable-job claiming, per-process and per-claim lease fencing, expiry reclaim, bounded attempts, exact-live-lease transitions, terminal payload clearing, stable failure codes, and finite-cardinality worker metrics.
- Hashed durable execution identity and domain-separated reuse of `etl_idempotency_records`, coupling response replay or creation, target writes, and terminal `SUCCEEDED` in one transaction without retaining or reconstructing raw principals or raw client idempotency keys.
- Deterministic migration, concurrency, expiry, exhaustion, response-replay, integrity, stale-lease rollback, privacy, configuration-boundary, and operator-recovery tests plus `docs/operations/durable-job-worker.md`.
@@ -62,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security
+- Job-list cursors contain only ordering keys, never authority or sensitive values; every page query independently binds the hashed authenticated principal, malformed and non-canonical cursors fail before database access, and list responses exclude payloads, principals, keys, hashes, lease identifiers, SQL, and exception text.
- Durable-worker metrics and ordinary logs exclude payloads, raw principals, raw idempotency keys, hashes, job and lease identifiers, SQL, exception messages, and unbounded exception labels.
- Retained payload or response-ledger identity conflicts fail closed with `etl_job_integrity_failure`; an expired or superseded lease rolls back target, ledger, and terminal-state effects.
@@ -87,12 +90,11 @@ existing xtrmETL platform.
- Project overview and value proposition
- Quick start guide with prerequisites
- Service descriptions for all microservices
- - Authentication flow and API examples
- - Database setup scripts
- - Testing instructions
- - Monitoring setup with Zipkin
- - Technology stack reference
- - Development guidelines
+ - Authentication flow
+ - Data flow diagrams
+ - Configuration reference
+ - Deployment guide
+ - Troubleshooting section
2. **PRD.md** (608 lines)
- Executive summary and product vision
From b8b64f07aa85bb33989355b38ed03423a9b22d61 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 17:19:17 +0900
Subject: [PATCH 13/29] test: complete durable job list controller coverage
---
.../job/EtlJobPaginationControllerTest.java | 48 +++++++++++++++++++
1 file changed, 48 insertions(+)
diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobPaginationControllerTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobPaginationControllerTest.java
index 3b53d123..2efb43cb 100644
--- a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobPaginationControllerTest.java
+++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobPaginationControllerTest.java
@@ -2,6 +2,8 @@
import com.xtrmetl.etl.controller.EtlApiProblemHandler;
import com.xtrmetl.etl.controller.EtlJobController;
+import com.xtrmetl.etl.service.EtlRequestError;
+import com.xtrmetl.etl.service.EtlRequestException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
@@ -73,6 +75,21 @@ void listsOwnedJobsAndAdvertisesOnlyTheExistingNextPage() throws Exception {
verify(etlJobService).listOwned("tenant_alpha", "current_cursor", "2");
}
+ @Test
+ void usesTheDocumentedDefaultLimitInTheNextPageLink() throws Exception {
+ when(etlJobService.listOwned("tenant_alpha", null, null))
+ .thenReturn(new EtlJobPage(List.of(snapshot()), "next_cursor"));
+
+ mockMvc.perform(get(JOBS_PATH).principal(PRINCIPAL))
+ .andExpect(status().isOk())
+ .andExpect(header().string(
+ "Link",
+ "; rel=\"next\""
+ ));
+
+ verify(etlJobService).listOwned("tenant_alpha", null, null);
+ }
+
@Test
void omitsTheNextLinkAndCursorForTheTerminalPage() throws Exception {
when(etlJobService.listOwned("tenant_alpha", null, null))
@@ -88,6 +105,37 @@ void omitsTheNextLinkAndCursorForTheTerminalPage() throws Exception {
verify(etlJobService).listOwned("tenant_alpha", null, null);
}
+ @Test
+ void preservesTypedListValidationFailures() throws Exception {
+ when(etlJobService.listOwned("tenant_alpha", null, "0"))
+ .thenThrow(new EtlRequestException(EtlRequestError.INVALID_JOB_PAGE_LIMIT));
+
+ mockMvc.perform(get(JOBS_PATH)
+ .principal(PRINCIPAL)
+ .queryParam("limit", "0"))
+ .andExpect(status().isBadRequest())
+ .andExpect(header().string("Cache-Control", "no-store"))
+ .andExpect(jsonPath("$.errorCode").value("etl_invalid_job_page_limit"));
+
+ verify(etlJobService).listOwned("tenant_alpha", null, "0");
+ }
+
+ @Test
+ void mapsUnexpectedListFailuresWithoutLeakingMessages() throws Exception {
+ when(etlJobService.listOwned("tenant_alpha", null, null))
+ .thenThrow(new IllegalStateException("secret runtime detail"));
+
+ mockMvc.perform(get(JOBS_PATH).principal(PRINCIPAL))
+ .andExpect(status().isInternalServerError())
+ .andExpect(header().string("Cache-Control", "no-store"))
+ .andExpect(jsonPath("$.errorCode").value("etl_internal_error"))
+ .andExpect(jsonPath("$.detail").value(
+ "The ETL request could not be processed."
+ ));
+
+ verify(etlJobService).listOwned("tenant_alpha", null, null);
+ }
+
@Test
void requiresAuthenticationBeforeListingJobs() throws Exception {
mockMvc.perform(get(JOBS_PATH))
From 35d53c8188c55f3e2fbc7bdf5c47ba40907c29c3 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 17:20:13 +0900
Subject: [PATCH 14/29] test: cover noncanonical durable job cursor rejection
---
.../xtrmetl/etl/job/EtlJobPaginationServiceIntegrationTest.java | 1 +
1 file changed, 1 insertion(+)
diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobPaginationServiceIntegrationTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobPaginationServiceIntegrationTest.java
index a7428d5a..57fb6646 100644
--- a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobPaginationServiceIntegrationTest.java
+++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobPaginationServiceIntegrationTest.java
@@ -146,6 +146,7 @@ void rejectsMalformedLimitsAndCursorsBeforeDatabaseAccess() {
encode("not-an-instant|00000000-0000-0000-0000-000000000000"),
encode("2026-08-05T01:00:00Z|not-a-uuid"),
encode("2026-08-05T01:00:00Z|00000000-0000-0000-0000-000000000000") + "=",
+ encode("2026-08-05T01:00:00.000Z|00000000-0000-0000-0000-000000000000"),
"a".repeat(193)
);
for (String invalidCursor : invalidCursors) {
From ce02c0133b0664475c752f32d851f11b6d429d68 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 17:23:37 +0900
Subject: [PATCH 15/29] docs: preserve historical changelog evidence
---
CHANGELOG.md | 32 +++++++++++++++++++-------------
1 file changed, 19 insertions(+), 13 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 082fd959..05689308 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -90,11 +90,12 @@ existing xtrmETL platform.
- Project overview and value proposition
- Quick start guide with prerequisites
- Service descriptions for all microservices
- - Authentication flow
- - Data flow diagrams
- - Configuration reference
- - Deployment guide
- - Troubleshooting section
+ - Authentication flow and API examples
+ - Database setup scripts
+ - Testing instructions
+ - Monitoring setup with Zipkin
+ - Technology stack reference
+ - Development guidelines
2. **PRD.md** (608 lines)
- Executive summary and product vision
@@ -194,16 +195,21 @@ Through code analysis, identified the platform as:
- PostgreSQL 12+
- Apache Kafka
- Netflix Zuul
-- Netflix Eureka
-- Maven
+- Eureka Server
+- Zipkin
+- Maven 3.6+
+- Docker & Docker Compose
#### Identified Technical Debt
-- Common module referenced but not implemented
-- MyBatis dependencies present but unused
-- Redis integration configured but not utilized
-- Config Server implemented but not actively used
-- Missing Spring Boot Actuator health checks
+- Limited test coverage
+- Hard-coded database configurations
+- Missing common module implementation
+- No centralized configuration management
+- Limited error handling
+- No circuit breaker patterns
+- Basic security implementation
+- No automated CI/CD pipeline
#### Future Enhancements Documented
@@ -217,11 +223,11 @@ Through code analysis, identified the platform as:
### Files Changed
-- `CHANGELOG.md` (new)
- `README.md` (new)
- `PRD.md` (new)
- `ARCHITECTURE.md` (new)
- `SUMMARY_KR.md` (new)
+- `CHANGELOG.md` (new)
### Issue Resolved
From bc4fd8a529d67bf9d05dc86b96107f51b9b04195 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 17:25:18 +0900
Subject: [PATCH 16/29] docs: limit changelog diff to pagination evidence
---
CHANGELOG.md | 21 ++++++++-------------
1 file changed, 8 insertions(+), 13 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05689308..f94904e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -195,21 +195,16 @@ Through code analysis, identified the platform as:
- PostgreSQL 12+
- Apache Kafka
- Netflix Zuul
-- Eureka Server
-- Zipkin
-- Maven 3.6+
-- Docker & Docker Compose
+- Netflix Eureka
+- Maven
#### Identified Technical Debt
-- Limited test coverage
-- Hard-coded database configurations
-- Missing common module implementation
-- No centralized configuration management
-- Limited error handling
-- No circuit breaker patterns
-- Basic security implementation
-- No automated CI/CD pipeline
+- Common module referenced but not implemented
+- MyBatis dependencies present but unused
+- Redis integration configured but not utilized
+- Config Server implemented but not actively used
+- Missing Spring Boot Actuator health checks
#### Future Enhancements Documented
@@ -223,11 +218,11 @@ Through code analysis, identified the platform as:
### Files Changed
+- `CHANGELOG.md` (new)
- `README.md` (new)
- `PRD.md` (new)
- `ARCHITECTURE.md` (new)
- `SUMMARY_KR.md` (new)
-- `CHANGELOG.md` (new)
### Issue Resolved
From ba0e35325464f9a26be9bd26636b6a0063485adf Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 18:04:35 +0900
Subject: [PATCH 17/29] test(etl): require nonblocking pagination index
migration
---
.../job/EtlJobMigrationDocumentationTest.java | 25 +++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
index d5d34316..4c7c98ba 100644
--- a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
+++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
@@ -65,6 +65,31 @@ void paginationMigrationUsesTheOwnerAndCompleteStableOrderingKey() throws IOExce
assertFalse(migration.contains("principal_name"));
}
+ @Test
+ void paginationIndexMigrationDoesNotBlockProductionWriters() throws IOException {
+ String migrationPath = "etl-service/src/main/resources/db/migration/"
+ + "V4__add_etl_job_owner_pagination_index.sql";
+ String configurationPath = migrationPath + ".conf";
+ String migration = read(migrationPath).replaceAll("\\s+", " ");
+ Path configuration = projectRoot().resolve(configurationPath);
+ String configurationText = Files.exists(configuration)
+ ? Files.readString(configuration, StandardCharsets.UTF_8).trim()
+ : "";
+
+ assertTrue(
+ migration.contains("CREATE INDEX CONCURRENTLY etl_job_owner_pagination_index"),
+ "the production pagination index must not block concurrent inserts or updates"
+ );
+ assertTrue(
+ Files.exists(configuration),
+ "Flyway requires a per-script configuration for non-transactional PostgreSQL DDL"
+ );
+ assertTrue(
+ configurationText.contains("executeInTransaction=false"),
+ "CREATE INDEX CONCURRENTLY cannot run inside Flyway's default transaction"
+ );
+ }
+
@Test
void runbookDocumentsAcceptedSemanticsOwnershipAndLeaseFencedExecution() throws IOException {
String runbook = read("docs/etl/durable-job-intake.md").replaceAll("\\s+", " ");
From 9fb1015a266edd5a967ed0d662519786597755a5 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 18:06:30 +0900
Subject: [PATCH 18/29] fix(etl): build pagination index without blocking
writers
---
.../db/migration/V4__add_etl_job_owner_pagination_index.sql | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql b/etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql
index 926a6c00..66802e4c 100644
--- a/etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql
+++ b/etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql
@@ -1,6 +1,8 @@
-- Support deterministic newest-first keyset pagination inside one hashed principal namespace.
--- PostgreSQL can scan this B-tree in either direction; explicit DESC documents the API order.
-CREATE INDEX etl_job_owner_pagination_index
+-- CONCURRENTLY preserves inserts, updates, and deletes while PostgreSQL builds the index.
+-- The companion .sql.conf disables Flyway's per-migration transaction because PostgreSQL
+-- rejects CREATE INDEX CONCURRENTLY inside a transaction block.
+CREATE INDEX CONCURRENTLY etl_job_owner_pagination_index
ON etl_job_records (
principal_scope_hash,
created_at DESC,
From 2f2fb0d73558c07f7da3f33d27eda24b36ca73da Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 18:06:36 +0900
Subject: [PATCH 19/29] fix(etl): run concurrent index migration outside
transaction
---
.../db/migration/V4__add_etl_job_owner_pagination_index.sql.conf | 1 +
1 file changed, 1 insertion(+)
create mode 100644 etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql.conf
diff --git a/etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql.conf b/etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql.conf
new file mode 100644
index 00000000..73bd53a1
--- /dev/null
+++ b/etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql.conf
@@ -0,0 +1 @@
+executeInTransaction=false
From ba4c3aa2ddc93d5cc93230ad40630101f420d4d7 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 18:07:18 +0900
Subject: [PATCH 20/29] test(etl): align migration and rollback contracts
---
.../etl/job/EtlJobMigrationDocumentationTest.java | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
index 4c7c98ba..2c6ec16c 100644
--- a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
+++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
@@ -57,7 +57,9 @@ void paginationMigrationUsesTheOwnerAndCompleteStableOrderingKey() throws IOExce
+ "V4__add_etl_job_owner_pagination_index.sql"
).replaceAll("\\s+", " ");
- assertTrue(migration.contains("CREATE INDEX etl_job_owner_pagination_index"));
+ assertTrue(migration.contains(
+ "CREATE INDEX CONCURRENTLY etl_job_owner_pagination_index"
+ ));
assertTrue(migration.contains(
"ON etl_job_records ( principal_scope_hash, created_at DESC, job_record_id DESC )"
));
@@ -122,7 +124,10 @@ void runbookDocumentsOwnerScopedKeysetPaginationAndRollback() throws IOException
assertTrue(runbook.contains("etl_invalid_job_page_limit"));
assertTrue(runbook.contains("etl_invalid_job_page_cursor"));
assertTrue(runbook.contains("V4__add_etl_job_owner_pagination_index.sql"));
- assertTrue(runbook.contains("DROP INDEX etl_job_owner_pagination_index"));
+ assertTrue(runbook.contains("executeInTransaction=false"));
+ assertTrue(runbook.contains(
+ "DROP INDEX CONCURRENTLY etl_job_owner_pagination_index"
+ ));
assertTrue(runbook.contains("RFC 8288"));
}
From afdddcb6ba0d913683ea0d8fa7d8f0af01cbe713 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 18:08:26 +0900
Subject: [PATCH 21/29] docs(etl): document nonblocking concurrent index
rollout
---
docs/etl/durable-job-intake.md | 35 ++++++++++++++++++++++++++++++----
1 file changed, 31 insertions(+), 4 deletions(-)
diff --git a/docs/etl/durable-job-intake.md b/docs/etl/durable-job-intake.md
index 658c71b8..3440366b 100644
--- a/docs/etl/durable-job-intake.md
+++ b/docs/etl/durable-job-intake.md
@@ -187,14 +187,25 @@ error classes. Operational procedures and metric contracts are authoritative in
## Migration and rollback
Apply Flyway migrations in version order. `V4__add_etl_job_owner_pagination_index.sql` is additive and
-does not change row contents or the API state machine. On a large production table, measure index
-creation lock and I/O impact in a representative staging environment before rollout.
+does not change row contents or the API state machine. It uses PostgreSQL `CREATE INDEX CONCURRENTLY`
+so inserts, updates, and deletes remain available while the index is built. Its companion
+`V4__add_etl_job_owner_pagination_index.sql.conf` contains `executeInTransaction=false` because
+PostgreSQL rejects concurrent index creation inside a transaction block and Flyway otherwise executes
+SQL migrations transactionally by default.
+
+Concurrent index creation performs more work and can wait for transactions that could affect the
+index. Measure duration, I/O, replication lag, and transaction age in a representative staging
+environment, then schedule production rollout with explicit monitoring. If the build fails,
+PostgreSQL can leave an invalid `etl_job_owner_pagination_index`; inspect catalog validity, remove the
+invalid object concurrently, correct the root cause, and rerun the migration under the repository's
+repair procedure rather than treating schema-history evidence as a usable index.
Application rollback is compatible with the additional index because older binaries ignore it. After
-rolling back all binaries that depend on the list endpoint, the database-only rollback is:
+rolling back all binaries that depend on the list endpoint, run the database-only rollback outside a
+transaction block:
```sql
-DROP INDEX etl_job_owner_pagination_index;
+DROP INDEX CONCURRENTLY etl_job_owner_pagination_index;
```
Dropping the index while the pagination endpoint is still active preserves query correctness but can
@@ -213,8 +224,12 @@ execution-plan review confirms the rollback boundary.
ordering when `LIMIT` is used.
- PostgreSQL 18 documents that equality constraints on leading multicolumn B-tree keys plus a range
constraint on the next key efficiently limit the scanned index portion.
+- PostgreSQL 18 documents that ordinary index construction blocks writes, while concurrent index
+ construction preserves writes with additional scans, waits, and invalid-index recovery caveats.
- PostgreSQL 18 documents `SKIP LOCKED` as unsuitable for a general consistent view but useful for
avoiding contention among multiple consumers of a queue-like table.
+- Flyway script configuration supports a migration-matched `.sql.conf` file and the
+ `executeInTransaction=false` override required for non-transactional PostgreSQL DDL.
- Spring fixed-delay scheduling measures each delay from completion of the preceding invocation.
- OpenTelemetry SQL/PostgreSQL semantic conventions define stable database telemetry fields; raw
query text and parameters remain privacy-sensitive opt-in data.
@@ -237,6 +252,12 @@ OpenTelemetry Authors. (2026). *OpenTelemetry semantic conventions 1.43.0: Seman
SQL databases client operations*. Cloud Native Computing Foundation.
https://opentelemetry.io/docs/specs/semconv/db/sql/
+PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: CREATE INDEX*.
+https://www.postgresql.org/docs/18/sql-createindex.html
+
+PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Introduction to indexes*.
+https://www.postgresql.org/docs/18/indexes-intro.html
+
PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Multicolumn indexes*.
https://www.postgresql.org/docs/18/indexes-multicolumn.html
@@ -246,5 +267,11 @@ https://www.postgresql.org/docs/18/sql-select.html
PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Sorting rows (ORDER BY)*.
https://www.postgresql.org/docs/18/queries-order.html
+Redgate Software. (2026, July 20). *Flyway execute in transaction setting*.
+https://documentation.red-gate.com/fd/flyway-execute-in-transaction-setting-277578997.html
+
+Redgate Software. (2026, July 20). *Script configuration*.
+https://documentation.red-gate.com/flyway/reference/script-configuration
+
Spring Authors. (2026). *Task execution and scheduling*. Broadcom.
https://docs.spring.io/spring-framework/reference/integration/scheduling.html
From ca6403fcd83f9818d479f71fef0a1153808f8521 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 18:10:25 +0900
Subject: [PATCH 22/29] docs(changelog): record nonblocking pagination index
rollout
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f94904e2..6077220a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- The durable job pagination index now uses PostgreSQL `CREATE INDEX CONCURRENTLY` with a migration-local Flyway `executeInTransaction=false` companion configuration, preserving production writers and documenting invalid-index recovery and concurrent rollback.
- Durable job operators can now list only their own jobs through bounded newest-first keyset pagination with canonical opaque cursors, deterministic timestamp-plus-UUID ordering, `Cache-Control: no-store`, and RFC 8288 next-page links without offset drift or cross-tenant existence leakage.
- Durable asynchronous ETL jobs now progress from `PENDING` through lease-fenced execution to `SUCCEEDED` or `FAILED`; PostgreSQL owns cross-replica claiming, stale workers cannot commit target or lifecycle effects, and intake and execution remain independently fail-closed.
- Durable-worker observability now records one terminal outcome counter and one matching duration sample for every completed poll, including idle polls and database failures while persisting retry or terminal transitions.
From fa23fdfc7fa71e68f859dcd3eb24a2aef6d1df82 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 20:18:26 +0900
Subject: [PATCH 23/29] fix(etl): sequence pagination index after claim index
---
.../V5__add_etl_job_owner_pagination_index.sql | 10 ++++++++++
1 file changed, 10 insertions(+)
create mode 100644 etl-service/src/main/resources/db/migration/V5__add_etl_job_owner_pagination_index.sql
diff --git a/etl-service/src/main/resources/db/migration/V5__add_etl_job_owner_pagination_index.sql b/etl-service/src/main/resources/db/migration/V5__add_etl_job_owner_pagination_index.sql
new file mode 100644
index 00000000..66802e4c
--- /dev/null
+++ b/etl-service/src/main/resources/db/migration/V5__add_etl_job_owner_pagination_index.sql
@@ -0,0 +1,10 @@
+-- Support deterministic newest-first keyset pagination inside one hashed principal namespace.
+-- CONCURRENTLY preserves inserts, updates, and deletes while PostgreSQL builds the index.
+-- The companion .sql.conf disables Flyway's per-migration transaction because PostgreSQL
+-- rejects CREATE INDEX CONCURRENTLY inside a transaction block.
+CREATE INDEX CONCURRENTLY etl_job_owner_pagination_index
+ ON etl_job_records (
+ principal_scope_hash,
+ created_at DESC,
+ job_record_id DESC
+ );
From 8b400660d5540d9246b295ceb2e2925d29362144 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 20:18:38 +0900
Subject: [PATCH 24/29] build(etl): sequence pagination migration configuration
---
.../db/migration/V5__add_etl_job_owner_pagination_index.sql.conf | 1 +
1 file changed, 1 insertion(+)
create mode 100644 etl-service/src/main/resources/db/migration/V5__add_etl_job_owner_pagination_index.sql.conf
diff --git a/etl-service/src/main/resources/db/migration/V5__add_etl_job_owner_pagination_index.sql.conf b/etl-service/src/main/resources/db/migration/V5__add_etl_job_owner_pagination_index.sql.conf
new file mode 100644
index 00000000..73bd53a1
--- /dev/null
+++ b/etl-service/src/main/resources/db/migration/V5__add_etl_job_owner_pagination_index.sql.conf
@@ -0,0 +1 @@
+executeInTransaction=false
From c92e12427cba3407a58f122042f6ccdb24767dd8 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 20:18:50 +0900
Subject: [PATCH 25/29] fix(etl): free V4 for claim eligibility index
---
.../V4__add_etl_job_owner_pagination_index.sql | 10 ----------
1 file changed, 10 deletions(-)
delete mode 100644 etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql
diff --git a/etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql b/etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql
deleted file mode 100644
index 66802e4c..00000000
--- a/etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql
+++ /dev/null
@@ -1,10 +0,0 @@
--- Support deterministic newest-first keyset pagination inside one hashed principal namespace.
--- CONCURRENTLY preserves inserts, updates, and deletes while PostgreSQL builds the index.
--- The companion .sql.conf disables Flyway's per-migration transaction because PostgreSQL
--- rejects CREATE INDEX CONCURRENTLY inside a transaction block.
-CREATE INDEX CONCURRENTLY etl_job_owner_pagination_index
- ON etl_job_records (
- principal_scope_hash,
- created_at DESC,
- job_record_id DESC
- );
From 5e84e9329200d29999696e9d2081c97f0904ee52 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 20:19:01 +0900
Subject: [PATCH 26/29] fix(etl): free V4 migration configuration slot
---
.../db/migration/V4__add_etl_job_owner_pagination_index.sql.conf | 1 -
1 file changed, 1 deletion(-)
delete mode 100644 etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql.conf
diff --git a/etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql.conf b/etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql.conf
deleted file mode 100644
index 73bd53a1..00000000
--- a/etl-service/src/main/resources/db/migration/V4__add_etl_job_owner_pagination_index.sql.conf
+++ /dev/null
@@ -1 +0,0 @@
-executeInTransaction=false
From c71b8c73f5ea5ca82085db99fb4e4fc42da0cfae Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 20:20:02 +0900
Subject: [PATCH 27/29] test(etl): sequence pagination migration after claim
index
---
.../etl/job/EtlJobMigrationDocumentationTest.java | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
index 2c6ec16c..1f1152e3 100644
--- a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
+++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
@@ -54,7 +54,7 @@ void migrationReservesStableWorkerStatesAndRequiresTerminalPayloadClearing() thr
void paginationMigrationUsesTheOwnerAndCompleteStableOrderingKey() throws IOException {
String migration = read(
"etl-service/src/main/resources/db/migration/"
- + "V4__add_etl_job_owner_pagination_index.sql"
+ + "V5__add_etl_job_owner_pagination_index.sql"
).replaceAll("\\s+", " ");
assertTrue(migration.contains(
@@ -70,7 +70,7 @@ void paginationMigrationUsesTheOwnerAndCompleteStableOrderingKey() throws IOExce
@Test
void paginationIndexMigrationDoesNotBlockProductionWriters() throws IOException {
String migrationPath = "etl-service/src/main/resources/db/migration/"
- + "V4__add_etl_job_owner_pagination_index.sql";
+ + "V5__add_etl_job_owner_pagination_index.sql";
String configurationPath = migrationPath + ".conf";
String migration = read(migrationPath).replaceAll("\\s+", " ");
Path configuration = projectRoot().resolve(configurationPath);
@@ -123,7 +123,7 @@ void runbookDocumentsOwnerScopedKeysetPaginationAndRollback() throws IOException
assertTrue(runbook.contains("rel=\"next\""));
assertTrue(runbook.contains("etl_invalid_job_page_limit"));
assertTrue(runbook.contains("etl_invalid_job_page_cursor"));
- assertTrue(runbook.contains("V4__add_etl_job_owner_pagination_index.sql"));
+ assertTrue(runbook.contains("V5__add_etl_job_owner_pagination_index.sql"));
assertTrue(runbook.contains("executeInTransaction=false"));
assertTrue(runbook.contains(
"DROP INDEX CONCURRENTLY etl_job_owner_pagination_index"
@@ -135,6 +135,9 @@ private static String read(String relativePath) throws IOException {
return Files.readString(projectRoot().resolve(relativePath), StandardCharsets.UTF_8);
}
+ /**
+ * Finds the reactor root from either repository-root or module-local Maven execution.
+ */
private static Path projectRoot() {
Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath();
Path lastPomParent = null;
From 89a81443334d5b2d416d2c10c003c5386c93b106 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 20:21:35 +0900
Subject: [PATCH 28/29] docs(etl): sequence nonblocking durable-job indexes
---
docs/etl/durable-job-intake.md | 73 ++++++++++++++++------------------
1 file changed, 35 insertions(+), 38 deletions(-)
diff --git a/docs/etl/durable-job-intake.md b/docs/etl/durable-job-intake.md
index 3440366b..c00121d7 100644
--- a/docs/etl/durable-job-intake.md
+++ b/docs/etl/durable-job-intake.md
@@ -127,11 +127,13 @@ principal, raw submission key, internal hashes, lease identifiers, SQL, and resp
Flyway migrations create descriptive multi-word `snake_case` objects:
- `V2__create_etl_job_records.sql` creates `etl_job_records` and the submission uniqueness contract;
-- `V3__add_etl_job_lease_fencing.sql` adds `lease_claim_id`, `lease_owner_id`,
- `lease_expires_at`, lifecycle constraints, and `etl_job_claim_eligibility_index`;
-- `V4__add_etl_job_owner_pagination_index.sql` adds `etl_job_owner_pagination_index` on
- `principal_scope_hash`, `created_at DESC`, and `job_record_id DESC` for the exact owner-scoped
- ordering contract.
+- `V3__add_etl_job_lease_fencing.sql` transactionally adds `lease_claim_id`, `lease_owner_id`,
+ `lease_expires_at`, legacy-data repair, and lifecycle constraints;
+- `V4__add_etl_job_claim_eligibility_index.sql` concurrently adds
+ `etl_job_claim_eligibility_index` for oldest eligible queue-like claims without blocking writers;
+- `V5__add_etl_job_owner_pagination_index.sql` concurrently adds
+ `etl_job_owner_pagination_index` on `principal_scope_hash`, `created_at DESC`, and
+ `job_record_id DESC` for the exact owner-scoped ordering contract.
The stable lifecycle is `PENDING`, `RUNNING`, `SUCCEEDED`, and `FAILED`.
@@ -182,35 +184,40 @@ retained.
List and status representations exclude payloads, raw principals, raw keys, hashes, lease identifiers,
SQL, and exception messages. Metrics and ordinary logs must not include those values or unbounded
error classes. Operational procedures and metric contracts are authoritative in
-`docs/operations/durable-job-worker.md`.
+`docs/operations/durable-job-worker.md`. Claim-index deployment and invalid-index recovery are
+specified in `docs/operations/durable-job-claim-index-rollout.md`.
## Migration and rollback
-Apply Flyway migrations in version order. `V4__add_etl_job_owner_pagination_index.sql` is additive and
-does not change row contents or the API state machine. It uses PostgreSQL `CREATE INDEX CONCURRENTLY`
-so inserts, updates, and deletes remain available while the index is built. Its companion
-`V4__add_etl_job_owner_pagination_index.sql.conf` contains `executeInTransaction=false` because
-PostgreSQL rejects concurrent index creation inside a transaction block and Flyway otherwise executes
-SQL migrations transactionally by default.
+Apply Flyway migrations in version order. V3 remains transactional so lease columns, legacy-data
+repair, and lifecycle constraints commit together. V4 and V5 are isolated additive index migrations.
+Both use PostgreSQL `CREATE INDEX CONCURRENTLY` so inserts, updates, and deletes remain available while
+the indexes are built. Their matching `.sql.conf` files contain `executeInTransaction=false` because
+PostgreSQL rejects concurrent index creation inside a transaction block. The application also sets
+`spring.flyway.postgresql.transactional-lock=false`, selecting Flyway's PostgreSQL session-lock mode
+required for concurrent index DDL.
-Concurrent index creation performs more work and can wait for transactions that could affect the
+Concurrent index creation performs more work and can wait for transactions that could affect an
index. Measure duration, I/O, replication lag, and transaction age in a representative staging
-environment, then schedule production rollout with explicit monitoring. If the build fails,
-PostgreSQL can leave an invalid `etl_job_owner_pagination_index`; inspect catalog validity, remove the
-invalid object concurrently, correct the root cause, and rerun the migration under the repository's
-repair procedure rather than treating schema-history evidence as a usable index.
+environment, then schedule production rollout with explicit monitoring. If a build fails, PostgreSQL
+can leave an invalid index. Inspect catalog validity rather than treating a matching object name or
+schema-history row as usable evidence. The claim-index recovery procedure is documented separately;
+the same fail-closed catalog inspection, concurrent removal, root-cause correction, approved Flyway
+repair, and unchanged migration replay applies to the pagination index.
-Application rollback is compatible with the additional index because older binaries ignore it. After
-rolling back all binaries that depend on the list endpoint, run the database-only rollback outside a
-transaction block:
+Application rollback is compatible with either additional index because older binaries ignore them.
+After rolling back every binary that depends on the relevant access path, run the database-only
+rollback outside a transaction block:
```sql
DROP INDEX CONCURRENTLY etl_job_owner_pagination_index;
+DROP INDEX CONCURRENTLY etl_job_claim_eligibility_index;
```
-Dropping the index while the pagination endpoint is still active preserves query correctness but can
-cause an unacceptable owner-list scan cost. Do not remove it until traffic is withdrawn and an
-execution-plan review confirms the rollback boundary.
+Dropping the pagination index while the list endpoint is active preserves query correctness but can
+cause an unacceptable owner-list scan cost. Dropping the claim index while workers are active can
+cause expensive claim scans and contention. Do not remove either index until its traffic is withdrawn
+and an execution-plan review confirms the rollback boundary.
## Standards basis
@@ -230,6 +237,8 @@ execution-plan review confirms the rollback boundary.
avoiding contention among multiple consumers of a queue-like table.
- Flyway script configuration supports a migration-matched `.sql.conf` file and the
`executeInTransaction=false` override required for non-transactional PostgreSQL DDL.
+- Flyway's PostgreSQL integration documents session-level migration locking for statements such as
+ `CREATE INDEX CONCURRENTLY`.
- Spring fixed-delay scheduling measures each delay from completion of the preceding invocation.
- OpenTelemetry SQL/PostgreSQL semantic conventions define stable database telemetry fields; raw
query text and parameters remain privacy-sensitive opt-in data.
@@ -258,20 +267,8 @@ https://www.postgresql.org/docs/18/sql-createindex.html
PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Introduction to indexes*.
https://www.postgresql.org/docs/18/indexes-intro.html
-PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Multicolumn indexes*.
-https://www.postgresql.org/docs/18/indexes-multicolumn.html
+Redgate Software. (2026). *Flyway PostgreSQL transactional lock setting*.
+https://documentation.red-gate.com/fd/flyway-postgresql-transactional-lock-setting-277579114.html
-PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: SELECT*.
-https://www.postgresql.org/docs/18/sql-select.html
-
-PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Sorting rows (ORDER BY)*.
-https://www.postgresql.org/docs/18/queries-order.html
-
-Redgate Software. (2026, July 20). *Flyway execute in transaction setting*.
-https://documentation.red-gate.com/fd/flyway-execute-in-transaction-setting-277578997.html
-
-Redgate Software. (2026, July 20). *Script configuration*.
+Redgate Software. (2026). *Flyway script configuration*.
https://documentation.red-gate.com/flyway/reference/script-configuration
-
-Spring Authors. (2026). *Task execution and scheduling*. Broadcom.
-https://docs.spring.io/spring-framework/reference/integration/scheduling.html
From 5e882cf2302bdbfed321d0c83028cd2e1ed405ac Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 20:33:33 +0900
Subject: [PATCH 29/29] docs(changelog): reconcile worker and pagination stack
---
CHANGELOG.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6077220a..1ceb8aaf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- The durable job pagination index now uses PostgreSQL `CREATE INDEX CONCURRENTLY` with a migration-local Flyway `executeInTransaction=false` companion configuration, preserving production writers and documenting invalid-index recovery and concurrent rollback.
+- The durable-job claim eligibility index now builds in a separate PostgreSQL `CREATE INDEX CONCURRENTLY` migration with Flyway non-transactional script configuration and session-level PostgreSQL migration locking, preserving normal job writes during rollout while keeping lease columns and constraints transactional.
- Durable job operators can now list only their own jobs through bounded newest-first keyset pagination with canonical opaque cursors, deterministic timestamp-plus-UUID ordering, `Cache-Control: no-store`, and RFC 8288 next-page links without offset drift or cross-tenant existence leakage.
- Durable asynchronous ETL jobs now progress from `PENDING` through lease-fenced execution to `SUCCEEDED` or `FAILED`; PostgreSQL owns cross-replica claiming, stale workers cannot commit target or lifecycle effects, and intake and execution remain independently fail-closed.
- Durable-worker observability now records one terminal outcome counter and one matching duration sample for every completed poll, including idle polls and database failures while persisting retry or terminal transitions.
@@ -31,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Owner-scoped durable job list models and HTTP contract, strict cursor and page-limit validation, one-extra-row next-page detection, the descriptive `etl_job_owner_pagination_index`, deterministic tenant-isolation and equal-timestamp tests, migration rollback guidance, and APA 7th standards evidence in `docs/etl/durable-job-intake.md`.
+- A production rollout and invalid-index recovery runbook for the nonblocking durable-job claim index: `docs/operations/durable-job-claim-index-rollout.md`.
- PostgreSQL `FOR UPDATE SKIP LOCKED` durable-job claiming, per-process and per-claim lease fencing, expiry reclaim, bounded attempts, exact-live-lease transitions, terminal payload clearing, stable failure codes, and finite-cardinality worker metrics.
- Hashed durable execution identity and domain-separated reuse of `etl_idempotency_records`, coupling response replay or creation, target writes, and terminal `SUCCEEDED` in one transaction without retaining or reconstructing raw principals or raw client idempotency keys.
- Deterministic migration, concurrency, expiry, exhaustion, response-replay, integrity, stale-lease rollback, privacy, configuration-boundary, and operator-recovery tests plus `docs/operations/durable-job-worker.md`.
@@ -271,4 +273,4 @@ This changelog will be updated:
**Changelog Version**: 1.0
**Last Updated**: 2026-08-05
-**Maintained By**: Development Team
\ No newline at end of file
+**Maintained By**: Development Team