Skip to content
Draft
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ 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.
- The durable job pagination index uses PostgreSQL `CREATE INDEX CONCURRENTLY` with migration-local Flyway `executeInTransaction=false`, preserving production writers while documenting invalid-index recovery and concurrent rollback.
- Scheduled OpenCode maintenance now performs root-cause analysis, tests remediation feasibility against live authority, protection, resource, dependency, path-ownership, and writer-lease constraints, executes and verifies the best safe option available now, and continues exactly one independent bounded mightyETL slice from protected `develop` when only external blockers remain; invalid stacks and separately leased repositories stay untouched.
- Container builds now pin Maven and Eclipse Temurin base-image tags to reviewed SHA-256 digests, with a fail-first contract test preventing mutable registry tags from re-entering the Dockerfile.
- The model-executing hourly OpenCode maintenance job now has read-only issue access; fail-first workflow-contract coverage proves `issues: write` is unnecessary while preserving issue and roadmap inspection.
Expand All @@ -34,6 +36,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`.
- Test-first doctoring for external-wait progress, root-cause analysis, realistic remediation feasibility, exact post-action verification, source-actionable pull-request classification, invalid-stack isolation, and read-only dependency leases in `docs/doctoring/hourly-opencode-nonblocking-progress-evidence.md`.
- Permanent fail-first exact-head workflow contracts and authoritative evidence in `docs/doctoring/exact-head-source-workflow-evidence.md`, including observed synthetic-merge checkout behavior, cross-platform source identity assertions, the rejected ignored Dependency Review ref-override experiment, corrective pull-request event-endpoint semantics, least-privilege boundaries, stack invalidation rules, rollback prohibition, and APA 7th GitHub references.
- A separate fail-closed hourly OpenCode maintenance workflow pinned to OpenCode 1.18.13 and `nvidia/deepseek-ai/deepseek-v4-pro`, using only the existing `NVIDIA_NIM_API_KEY` through OpenCode's `NVIDIA_API_KEY` provider variable while preserving the independent review agent and deterministic merge-disposition workflow.
Expand Down
103 changes: 91 additions & 12 deletions docs/etl/durable-job-intake.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
## Scope

`POST /api/etl/jobs` creates a durable, authenticated-principal-scoped ETL job resource. Durable
execution is now implemented as a separate opt-in worker boundary: mightyETL executes at most one
execution is implemented as a separate opt-in worker boundary: mightyETL executes at most one
eligible durable job per worker poll, while PostgreSQL owns cross-replica claim arbitration through
`FOR UPDATE SKIP LOCKED`, lease fencing, bounded attempts, and exact conditional lifecycle
transitions.
transitions. Authenticated operators can list recent jobs in their own principal namespace through
deterministic keyset pagination.

Both externally reachable intake and background execution remain disabled by default. Durable job
intake is disabled by default and is absent unless an operator explicitly sets the preferred
Expand Down Expand Up @@ -70,6 +71,44 @@ A retry that resolves to the same durable resource returns the same job identifi
transaction-level submission lock returns `409 etl_job_submission_in_progress` rather than waiting
without a client-visible bound.

## List owned jobs

```http
GET /api/etl/jobs?limit=50 HTTP/1.1
Authorization: Basic <credentials>
```

The endpoint returns only jobs owned by the same authenticated principal. Rows are ordered by
`created_at DESC, job_record_id DESC`; the UUID is a deterministic tie-breaker when multiple jobs
share one database timestamp. The default page size is 50 and the canonical accepted range is 1
through 100. Values such as `0`, `101`, `01`, signed values, whitespace-padded values, or 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 never returned;
it only proves that another page exists. When a following page exists, the response body contains an
opaque cursor and RFC 8288 Web Linking advertises the same continuation target:

```http
HTTP/1.1 200 OK
Cache-Control: no-store
Link: </api/etl/jobs?limit=50&cursor=eyJ2IjoxLCJ0IjoiMjAyNi0wOC0wOVQwMzowMDowMFoiLCJpIjoiY2Y0ZjA4M2YtOGM5MC00ZjM0LWE4YjYtYjUzNzYxZGU0NGVmIn0>; rel="next"
Content-Type: application/json
```

The cursor is a canonical unpadded Base64 URL encoding of the final returned creation timestamp and
job identifier. Clients must treat it as opaque. Each next-page query independently binds the
current authenticated principal hash and applies a strict tuple boundary equivalent to “older
creation timestamp, or the same timestamp with a lower UUID.” Cursor contents never grant authority
and contain no payload, raw principal, submission key, or internal 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 the
next cursor and the `Link` header.

Pagination guarantees no duplicate or omitted rows while traversing an unchanged dataset. Concurrent
insertions are visible according to their ordering position; an operational cursor is not a frozen
snapshot. Consumers that require a legally frozen audit set must use an explicit transactional or
warehouse snapshot.

## Read job status

```http
Expand Down Expand Up @@ -143,14 +182,18 @@ bounds used by synchronous ETL admission. The complete body must be a JSON array
fields are rejected, every element must be an object with a safe textual `id`, and normalized field
names must remain unique.

Flyway migration `V2__create_etl_job_records.sql` creates `etl_job_records`; later worker migrations
add lease-fencing columns and a partial eligibility index for claim scans. All schema objects use
descriptive multi-word `snake_case` names. The database stores:
Flyway migrations use descriptive multi-word `snake_case` objects:

- an opaque UUID job identifier;
- SHA-256 hashes of the principal scope, semantic submission key, and exact JSON text;
- the request payload only while the job remains nonterminal;
- status, attempt, failure, lease-owner/token/expiry, and lifecycle timestamp fields.
- `V2__create_etl_job_records.sql` creates `etl_job_records` and the principal-scoped submission
uniqueness contract;
- worker migrations add the lease-fencing fields and concurrent claim-eligibility index; and
- `V5__add_etl_job_owner_pagination_index.sql` creates `etl_job_owner_pagination_index` on
`principal_scope_hash`, `created_at DESC`, and `job_record_id DESC`, matching the owner-scoped
keyset ordering contract.

The database stores an opaque UUID job identifier; SHA-256 hashes of principal scope, semantic
submission key, and exact JSON text; the request payload only while nonterminal; and lifecycle,
attempt, failure, lease-owner/token/expiry, and timestamp fields.

The stable lifecycle vocabulary is `PENDING`, `RUNNING`, `SUCCEEDED`, and `FAILED`. Database checks
require a non-null request payload only for nonterminal states and require the payload to be null for
Expand All @@ -162,6 +205,30 @@ payload is sensitive operational data and inherits the classification of its sou
job is `PENDING` or `RUNNING`, operators must protect it with database access control, encryption,
backup, and retention policy appropriate to the underlying records.

## Pagination migration and rollback

The V5 owner-pagination index uses PostgreSQL `CREATE INDEX CONCURRENTLY` so inserts, updates, and
deletes remain available while PostgreSQL builds the index. Its migration-local companion file
`V5__add_etl_job_owner_pagination_index.sql.conf` contains `executeInTransaction=false` because
PostgreSQL rejects concurrent index creation inside a transaction block.

Concurrent index creation can wait for transactions and can leave an invalid index after a failed
build. Production rollout therefore requires catalog inspection of index validity and readiness,
plus representative monitoring of duration, I/O, replication lag, and transaction age. A matching
object name alone is not proof that the index is usable.

Older application binaries ignore this additive index. After rolling back binaries that depend on
the list access path, remove the database object outside a transaction block only after list traffic
is withdrawn and an execution-plan review confirms the operational boundary:

```sql
DROP INDEX CONCURRENTLY etl_job_owner_pagination_index;
```

Dropping the index does not change query semantics, but it can turn an owner-scoped list operation
into an unacceptable scan, so removal is an explicit performance rollback rather than an emergency
schema shortcut.

## Operational boundary

Enabling intake alone still does not start background processing; enabling the worker alone does not
Expand All @@ -172,22 +239,26 @@ safe by disabling the worker property: existing durable rows remain in PostgreSQ
manually rewrite lease tokens or terminal status to manufacture recovery.

This slice establishes durable execution, lease fencing, bounded retries, terminal payload clearing,
and finite worker telemetry. Higher-level job-list pagination, polling advisories, conditional status
finite worker telemetry, and owner-scoped keyset pagination. Polling advisories, conditional status
reads, cancellation, and replay remain separate later stack items and must not be represented as part
of this boundary until their own exact-head gates pass.

## Standards basis

- RFC 9110 Section 15.3.3 defines `202 Accepted` as noncommittal and recommends that the response
describe current status and point to a status monitor.
- RFC 8288 defines the Web Linking model and HTTP `Link` header used for the optional next-page
relationship.
- RFC 9457 supplies the problem-details representation used by deterministic submission, lookup, and
execution failures.
- RFC 9651 defines the current Structured Fields String syntax accepted for `Idempotency-Key`.
- The expired IETF HTTPAPI `Idempotency-Key` draft-07 is used only as work-in-progress design
evidence for unique client keys, request fingerprints, `422` payload conflicts, and tenant-isolation
security concerns. It expired on April 18, 2026 and is not represented as a published RFC.
- PostgreSQL row locking and `SKIP LOCKED` semantics are the database authority for concurrent claim
behavior; the worker does not attempt to replace that arbitration with process-local locking.
- PostgreSQL 18 documents explicit ordering, row-locking semantics, multicolumn B-tree behavior, and
the availability and recovery trade-offs of concurrent index construction.
- Flyway script configuration supports migration-local transaction overrides required by PostgreSQL
DDL that cannot execute in a transaction block.

### References

Expand All @@ -196,9 +267,17 @@ of this boundary until their own exact-head gates pass.
- Jena, J., & Dalal, S. (2025). *The Idempotency-Key HTTP header field*
(draft-ietf-httpapi-idempotency-key-header-07, expired April 18, 2026). Internet Engineering Task
Force. https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
- 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
- Nottingham, M., & Kamp, P. (2024). *Structured field values for HTTP* (RFC 9651). RFC Editor.
https://www.rfc-editor.org/rfc/rfc9651
- PostgreSQL Global Development Group. (2026). *CREATE INDEX*. PostgreSQL 18 documentation.
https://www.postgresql.org/docs/18/sql-createindex.html
- PostgreSQL Global Development Group. (2026). *Multicolumn indexes*. PostgreSQL 18 documentation.
https://www.postgresql.org/docs/18/indexes-multicolumn.html
- PostgreSQL Global Development Group. (2026). *SELECT*. PostgreSQL 18 documentation.
https://www.postgresql.org/docs/18/sql-select.html
- Redgate Software. (2026). *Flyway script configuration*. Flyway documentation.
https://documentation.red-gate.com/flyway/reference/script-configuration
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -20,24 +23,25 @@
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;
import java.util.Objects;
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.
*
* <p>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.</p>
* status-monitor resource through both the representation and {@code Location} header.</p>
*
* <p>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.</p>
* <p>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.</p>
*
* <p>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
Expand All @@ -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;

/**
Expand Down Expand Up @@ -123,6 +129,51 @@ public ResponseEntity<EtlJobAcceptedResponse> 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<EtlJobPageResponse> 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.
*
Expand Down
32 changes: 32 additions & 0 deletions etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobPage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.xtrmetl.etl.job;

import org.springframework.lang.Nullable;

import java.util.List;
import java.util.Objects;

/**
* Immutable owner-scoped page of operator-safe durable ETL job snapshots.
*
* <p>The list is defensively copied so callers cannot mutate a page after the service has derived
* its next-cursor boundary. The optional cursor is opaque to clients and identifies the last item
* returned by this page; it is absent when the current page is terminal.</p>
*
* @param jobs immutable operator-safe job snapshots in deterministic newest-first order
* @param nextCursor opaque cursor for the following page, or {@code null} when no page follows
*/
public record EtlJobPage(
List<EtlJobSnapshot> jobs,
@Nullable String nextCursor
) {

/**
* Validates and defensively copies the immutable page.
*
* @param jobs non-null snapshots without null elements
* @param nextCursor opaque following-page cursor, or {@code null}
*/
public EtlJobPage {
jobs = List.copyOf(Objects.requireNonNull(jobs, "jobs must not be null"));
}
}
Loading