From 62093451a73876bf92c42069100bb3dee0421212 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 14:10:34 +0900 Subject: [PATCH 1/7] test(etl): require conditional durable-job status validation --- .../etl/job/EtlJobConditionalStatusTest.java | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobConditionalStatusTest.java diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobConditionalStatusTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobConditionalStatusTest.java new file mode 100644 index 00000000..1525d3ac --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobConditionalStatusTest.java @@ -0,0 +1,215 @@ +package com.xtrmetl.etl.job; + +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.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.security.Principal; +import java.time.Instant; +import java.util.UUID; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Defines conditional-request behavior for owner-scoped durable-job status polling. + */ +class EtlJobConditionalStatusTest { + + private static final String JOBS_PATH = "/api/etl/jobs"; + private static final UUID JOB_RECORD_ID = UUID.fromString( + "cf4f083f-8c90-4f34-a8b6-b53761de44ef" + ); + private static final Principal PRINCIPAL = () -> "tenant_alpha"; + private static final Instant CREATED_AT = Instant.parse("2026-08-05T01:00:00Z"); + private static final Instant UPDATED_AT = Instant.parse("2026-08-05T01:00:05Z"); + + private EtlJobService etlJobService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + etlJobService = mock(EtlJobService.class); + mockMvc = MockMvcBuilders + .standaloneSetup(new EtlJobController(etlJobService)) + .setControllerAdvice(new EtlApiProblemHandler()) + .build(); + } + + @Test + void unchangedStatusUsesAWeakEntityTagForAnEmptyNotModifiedResponse() throws Exception { + when(etlJobService.findOwned(JOB_RECORD_ID, "tenant_alpha")) + .thenReturn(snapshot(EtlJobStatus.PENDING, 0, null, UPDATED_AT)); + + MvcResult initialResult = mockMvc.perform(statusRequest()) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(header().string(HttpHeaders.ETAG, startsWith("W/\""))) + .andReturn(); + String entityTag = initialResult.getResponse().getHeader(HttpHeaders.ETAG); + assertNotNull(entityTag); + assertTrue(entityTag.endsWith("\"")); + + mockMvc.perform(statusRequest().header(HttpHeaders.IF_NONE_MATCH, entityTag)) + .andExpect(status().isNotModified()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(header().string(HttpHeaders.ETAG, entityTag)) + .andExpect(content().string("")); + + verify(etlJobService, times(2)).findOwned(JOB_RECORD_ID, "tenant_alpha"); + } + + @Test + void changedStatusInvalidatesThePriorEntityTag() throws Exception { + when(etlJobService.findOwned(JOB_RECORD_ID, "tenant_alpha")) + .thenReturn( + snapshot(EtlJobStatus.PENDING, 0, null, UPDATED_AT), + snapshot(EtlJobStatus.RUNNING, 1, null, UPDATED_AT.plusSeconds(5)) + ); + + String priorEntityTag = mockMvc.perform(statusRequest()) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getHeader(HttpHeaders.ETAG); + assertNotNull(priorEntityTag); + + mockMvc.perform(statusRequest().header(HttpHeaders.IF_NONE_MATCH, priorEntityTag)) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.ETAG, not(equalTo(priorEntityTag)))) + .andExpect(jsonPath("$.jobStatus").value("RUNNING")) + .andExpect(jsonPath("$.attemptCount").value(1)); + } + + @Test + void changedFailureCodeInvalidatesTheTagEvenAtTheSameTimestamp() throws Exception { + when(etlJobService.findOwned(JOB_RECORD_ID, "tenant_alpha")) + .thenReturn( + snapshot(EtlJobStatus.FAILED, 3, "etl_source_failure", UPDATED_AT), + snapshot(EtlJobStatus.FAILED, 3, "etl_target_failure", UPDATED_AT) + ); + + String priorEntityTag = mockMvc.perform(statusRequest()) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getHeader(HttpHeaders.ETAG); + assertNotNull(priorEntityTag); + + mockMvc.perform(statusRequest().header(HttpHeaders.IF_NONE_MATCH, priorEntityTag)) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.ETAG, not(equalTo(priorEntityTag)))) + .andExpect(jsonPath("$.failureCode").value("etl_target_failure")); + } + + @Test + void nullAndEmptyFailureCodesHaveDifferentRepresentationValidators() throws Exception { + when(etlJobService.findOwned(JOB_RECORD_ID, "tenant_alpha")) + .thenReturn( + snapshot(EtlJobStatus.FAILED, 3, null, UPDATED_AT), + snapshot(EtlJobStatus.FAILED, 3, "", UPDATED_AT) + ); + + String priorEntityTag = mockMvc.perform(statusRequest()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.failureCode").doesNotExist()) + .andReturn() + .getResponse() + .getHeader(HttpHeaders.ETAG); + assertNotNull(priorEntityTag); + + mockMvc.perform(statusRequest().header(HttpHeaders.IF_NONE_MATCH, priorEntityTag)) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.ETAG, not(equalTo(priorEntityTag)))) + .andExpect(jsonPath("$.failureCode").value("")); + } + + @Test + void wildcardIfNoneMatchRecognizesTheExistingOwnerScopedRepresentation() throws Exception { + when(etlJobService.findOwned(JOB_RECORD_ID, "tenant_alpha")) + .thenReturn(snapshot(EtlJobStatus.SUCCEEDED, 1, null, UPDATED_AT)); + + mockMvc.perform(statusRequest().header(HttpHeaders.IF_NONE_MATCH, "*")) + .andExpect(status().isNotModified()) + .andExpect(header().string(HttpHeaders.ETAG, startsWith("W/\""))) + .andExpect(content().string("")); + + verify(etlJobService).findOwned(JOB_RECORD_ID, "tenant_alpha"); + } + + @Test + void wildcardDoesNotBypassTheOwnerSafeNotFoundBoundary() throws Exception { + when(etlJobService.findOwned(JOB_RECORD_ID, "tenant_alpha")) + .thenThrow(new EtlRequestException(EtlRequestError.JOB_NOT_FOUND)); + + mockMvc.perform(statusRequest().header(HttpHeaders.IF_NONE_MATCH, "*")) + .andExpect(status().isNotFound()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(header().doesNotExist(HttpHeaders.ETAG)) + .andExpect(jsonPath("$.errorCode").value("etl_job_not_found")); + + verify(etlJobService).findOwned(JOB_RECORD_ID, "tenant_alpha"); + } + + @Test + void submissionResponsesDoNotReceiveAStatusEntityTag() throws Exception { + String requestPayload = "[{\"id\":\"record_alpha\"}]"; + String idempotencyKey = "\"550e8400-e29b-41d4-a716-446655440000\""; + when(etlJobService.submit(requestPayload, idempotencyKey, "tenant_alpha")) + .thenReturn(new EtlJobSubmission( + JOB_RECORD_ID, + EtlJobStatus.PENDING, + false + )); + + mockMvc.perform(post(JOBS_PATH) + .principal(PRINCIPAL) + .header("Idempotency-Key", idempotencyKey) + .contentType(MediaType.APPLICATION_JSON) + .content(requestPayload)) + .andExpect(status().isAccepted()) + .andExpect(header().doesNotExist(HttpHeaders.ETAG)); + } + + private static org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder + statusRequest() { + return get(JOBS_PATH + "/" + JOB_RECORD_ID).principal(PRINCIPAL); + } + + private static EtlJobSnapshot snapshot( + EtlJobStatus jobStatus, + int attemptCount, + String failureCode, + Instant updatedAt + ) { + return new EtlJobSnapshot( + JOB_RECORD_ID, + jobStatus, + attemptCount, + failureCode, + CREATED_AT, + updatedAt + ); + } +} From f7301e6faff1da31e42b8ca1b0eecc6dbe0da7ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 14:14:56 +0900 Subject: [PATCH 2/7] feat(etl): add owner-scoped conditional status validation --- .../etl/controller/EtlJobController.java | 71 ++++++++++++++++++- 1 file changed, 68 insertions(+), 3 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 336e67c4..bb2c1e0e 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 @@ -9,11 +9,13 @@ import com.xtrmetl.etl.job.EtlJobSubmission; import com.xtrmetl.etl.service.EtlRequestError; import com.xtrmetl.etl.service.EtlRequestException; +import com.xtrmetl.etl.service.Sha256Digest; import io.micrometer.observation.annotation.Observed; 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.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.lang.Nullable; @@ -45,7 +47,9 @@ * *

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 - * endpoint does not become a cross-principal existence oracle.

+ * endpoint does not become a cross-principal existence oracle. Successful status responses also + * carry a weak entity tag so an authenticated client can explicitly validate an unchanged + * representation without authorizing shared-cache persistence.

*/ @ConditionalOnBooleanProperty( prefix = "xtrmetl.etl.jobs", @@ -177,14 +181,25 @@ public ResponseEntity list( /** * Returns one status resource only within the authenticated principal namespace. * + *

The response includes a weak {@code ETag} derived from every operator-visible status + * field. Spring MVC applies RFC 9110 weak comparison to ordinary {@code If-None-Match} entity + * tags. The controller handles the RFC wildcard after the existing owner-safe lookup because + * Spring Framework 6.2.19 does not treat {@code If-None-Match: *} as a match for safe methods. + * Either matching form returns {@code 304 Not Modified} with no representation body. + * Authentication and owner-safe lookup still occur before validation, and + * {@code Cache-Control: no-store} remains in force.

+ * * @param jobRecordIdText opaque durable job identifier text + * @param ifNoneMatch optional conditional request field * @param principal authenticated principal namespace - * @return operator-safe status representation + * @return operator-safe status representation or an empty not-modified response */ @GetMapping("/{jobRecordId}") @Observed(name = "etl.jobs.status", contextualName = "etl-job-status") public ResponseEntity status( @PathVariable("jobRecordId") String jobRecordIdText, + @RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) + @Nullable String ifNoneMatch, @Nullable Principal principal ) { if (principal == null) { @@ -200,9 +215,59 @@ public ResponseEntity status( } catch (RuntimeException exception) { throw new EtlUnexpectedException(exception); } + EtlJobStatusResponse responseBody = EtlJobStatusResponse.from(snapshot); + String entityTag = statusEntityTag(responseBody); + if ("*".equals(Objects.toString(ifNoneMatch, "").trim())) { + return ResponseEntity.status(HttpStatus.NOT_MODIFIED) + .cacheControl(CacheControl.noStore()) + .eTag(entityTag) + .build(); + } return ResponseEntity.ok() .cacheControl(CacheControl.noStore()) - .body(EtlJobStatusResponse.from(snapshot)); + .eTag(entityTag) + .body(responseBody); + } + + /** + * Builds an opaque weak validator from the complete status representation. + * + *

Each non-null field is marked and length-prefixed before SHA-256 hashing, while a + * dedicated marker represents {@code null}. This prevents adjacent-value ambiguity and keeps + * an omitted nullable JSON field distinct from an explicitly empty string. The digest prevents + * the response header from exposing even the operator-safe values themselves. Payloads, + * principals, idempotency keys, lease identifiers, SQL text, and exception text are not part of + * the response model and therefore cannot enter this tag.

+ * + * @param responseBody complete owner-authorized status representation + * @return syntactically valid weak HTTP entity tag + */ + private static String statusEntityTag(EtlJobStatusResponse responseBody) { + EtlJobStatusResponse requiredResponse = Objects.requireNonNull( + responseBody, + "responseBody must not be null" + ); + String canonicalRepresentation = canonicalField(requiredResponse.jobRecordId()) + + canonicalField(requiredResponse.jobStatus()) + + canonicalField(requiredResponse.attemptCount()) + + canonicalField(requiredResponse.failureCode()) + + canonicalField(requiredResponse.createdAt()) + + canonicalField(requiredResponse.updatedAt()); + return "W/\"" + Sha256Digest.digest(canonicalRepresentation) + "\""; + } + + /** + * Encodes one possibly-null value without delimiter or null/empty ambiguity. + * + * @param value representation field value + * @return a null marker or a value marker followed by length-prefixed canonical text + */ + private static String canonicalField(@Nullable Object value) { + if (value == null) { + return "N;"; + } + String canonicalValue = value.toString(); + return "V" + canonicalValue.length() + ":" + canonicalValue; } private static UUID parseJobRecordId(String jobRecordIdText) { From 3e5edf5bf6ef224d20b0bf32ba6b27064d887792 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 14:16:56 +0900 Subject: [PATCH 3/7] docs(etl): document conditional polling validators --- docs/etl/durable-job-polling.md | 81 +++++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/docs/etl/durable-job-polling.md b/docs/etl/durable-job-polling.md index d2e72c6b..9e4ac83f 100644 --- a/docs/etl/durable-job-polling.md +++ b/docs/etl/durable-job-polling.md @@ -1,23 +1,24 @@ -# Durable job polling advisory +# Durable job polling contract ## Purpose -Authenticated clients poll `GET /api/etl/jobs/{job_record_id}` while a durable ETL job is active. Without a machine-readable cadence, independently implemented clients tend to use arbitrary tight loops that waste control-plane capacity and produce inconsistent operator behavior. +Authenticated clients poll `GET /api/etl/jobs/{job_record_id}` while a durable ETL job is active. Without a machine-readable cadence, independently implemented clients tend to use arbitrary tight loops that waste control-plane capacity and produce inconsistent operator behavior. Without a representation validator, every unchanged poll also retransmits the complete JSON status body. -mightyETL emits the RFC 9110 `Retry-After` response field only when both conditions hold: +mightyETL therefore provides two complementary RFC 9110 mechanisms: -1. the owner-scoped status representation is `PENDING` or `RUNNING`; and -2. durable-job execution is explicitly enabled. +- `Retry-After` is emitted only when the owner-scoped status is `PENDING` or `RUNNING` and durable-job execution is explicitly enabled, giving clients a bounded minimum polling delay. +- A weak `ETag` is emitted on every successful owner-scoped status response, allowing an authenticated client to send `If-None-Match` and receive an empty `304 Not Modified` response when the complete operator-visible representation is unchanged. -The field is advisory: it does not grant authority, alter the job state machine, extend a worker lease, or replace client-side exponential backoff and jitter. Intake-only maintenance mode does not emit a processing cadence because no local worker is available to advance accepted jobs. +Neither mechanism grants authority, alters the job state machine, extends a worker lease, authorizes shared-cache storage, or replaces client-side exponential backoff and jitter. Intake-only maintenance mode omits the processing cadence because no local worker is available to advance accepted jobs. ## Wire contract -An active job with the worker enabled returns the existing operator-safe status representation, `Cache-Control: no-store`, and a whole-second polling delay: +An active job with the worker enabled returns the existing operator-safe status representation, `Cache-Control: no-store`, a weak entity tag, and a whole-second polling delay: ```http HTTP/1.1 200 OK Cache-Control: no-store +ETag: W/"86b79e..." Retry-After: 5 Content-Type: application/json @@ -30,7 +31,25 @@ Content-Type: application/json } ``` -`SUCCEEDED` and `FAILED` are terminal and omit `Retry-After`. An active job also omits the field when `mightyetl.etl.jobs.worker.enabled=false`, preventing a maintenance-mode intake deployment from advertising a cadence that cannot advance work. Submission responses, list responses, problem responses, and unrelated controllers are unchanged. +The client can explicitly validate that representation on its next owner-authorized request: + +```http +GET /api/etl/jobs/cf4f083f-8c90-4f34-a8b6-b53761de44ef HTTP/1.1 +Authorization: Bearer +If-None-Match: W/"86b79e..." +``` + +When the complete representation is unchanged, ordinary entity tags use Spring MVC's RFC 9110 weak comparison and return no representation body: + +```http +HTTP/1.1 304 Not Modified +Cache-Control: no-store +ETag: W/"86b79e..." +``` + +The RFC wildcard form `If-None-Match: *` is evaluated explicitly after the same authenticated owner-safe lookup. This narrow compatibility handling is required because Spring Framework 6.2.19 does not recognize the wildcard as a matching safe-method validator, even though RFC 9110 requires it to match any selected current representation. + +When any represented value changes, including lifecycle state, attempt count, failure code, creation timestamp, or update timestamp, the request returns `200 OK`, the current JSON representation, and a new weak tag. `SUCCEEDED` and `FAILED` retain their validator but omit `Retry-After`. An active job also omits `Retry-After` when `mightyetl.etl.jobs.worker.enabled=false`, preventing intake-only maintenance mode from advertising a cadence that cannot advance work. Submission responses, list responses, problem responses, and unrelated controllers receive neither a status entity tag nor a polling cadence. ## Cadence derivation @@ -46,33 +65,55 @@ Fractional seconds are rounded upward, so one millisecond through one second adv The worker schedule remains a local execution setting rather than a completion promise. Queue depth, target latency, retries, process restarts, and lease recovery can make a job remain active for multiple polling intervals. When execution is disabled, the absent field signals that the service has no active local cadence to advertise; clients must use their maintenance-window or operator-escalation policy instead of tight polling. +## Entity-tag derivation + +The controller first authenticates the caller, parses the opaque UUID, and performs the existing owner-scoped lookup. Only the resulting operator-safe `EtlJobStatusResponse` is eligible for a validator. Every response field is converted to canonical text and then SHA-256 hashed. The hexadecimal digest becomes a weak HTTP entity tag. + +Every non-null value receives a value marker and decimal length prefix before its canonical text. A dedicated null marker is distinct from the value marker and a zero-length value, preserving the wire distinction between an omitted nullable field and an explicitly empty string. These markers and length prefixes prevent adjacent field values from creating ambiguous input. Hashing keeps even the operator-safe values out of the header. The tag changes when any represented field changes and is deterministic across replicas for the same representation. It is intentionally weak because the contract validates semantic status equivalence rather than byte-for-byte transfer encoding. + +`Cache-Control: no-store` remains authoritative: intermediaries and clients must not persist the tenant-scoped representation for reuse. The validator supports an explicit authenticated conditional request and does not turn the status endpoint into a publicly cacheable resource. + ## Security and privacy boundary -The polling field contains only a bounded integer. It exposes no request payload, authenticated principal, idempotency key, internal hash, job or lease identifier, SQL, exception message, target identity, or queue depth. +The polling delay contains only a bounded integer. The entity tag contains only a one-way digest of the complete operator-safe status representation. Neither header contains the request payload, raw authenticated principal, idempotency key, internal principal hash, lease identifier, SQL, exception message, target identity, or queue depth. + +The response still requires authenticated owner scope. A malformed, absent, or foreign-owned identifier returns the same owner-safe not-found problem and receives neither a status validator nor an active-job polling advisory. Conditional evaluation occurs after that lookup, so `If-None-Match`, including `*`, cannot become a cross-principal existence oracle. -The response still requires authenticated owner scope. A malformed, absent, or foreign-owned identifier returns the same owner-safe not-found problem and does not receive an active-job polling advisory. The advice is scoped to `EtlJobController` and modifies only `EtlJobStatusResponse` bodies. +The polling advice is scoped to `EtlJobController` and modifies only `EtlJobStatusResponse` bodies. The entity tag is created only by the controller's single-job status method. Submission, list, problem, and unrelated responses remain outside both mechanisms. ## Client guidance Clients should: -1. treat `Retry-After` as the minimum delay before the next status request; -2. add bounded jitter when many jobs are polled concurrently; -3. apply a larger local backoff after transport failures or `429`/`503` responses; -4. stop polling when the returned state is `SUCCEEDED` or `FAILED`; -5. treat an absent field on an active job as a maintenance or externally managed execution condition and use an operator-approved fallback interval; -6. retain their own overall timeout and operator escalation policy. +1. retain the most recent status `ETag` only in process memory when permitted by their own security policy; +2. send that value in `If-None-Match` on the next authenticated status request; +3. treat `304 Not Modified` as confirmation that the previously received representation is still current, not as a new representation; +4. treat `Retry-After` on a `200` active-status representation as the minimum delay before the next request; +5. retain the last applicable local cadence when a bodyless `304` does not repeat `Retry-After`; +6. treat an absent cadence on an active job as maintenance or externally managed execution and use an operator-approved fallback interval; +7. add bounded jitter when many jobs are polled concurrently; +8. apply a larger local backoff after transport failures or `429`/`503` responses; +9. stop polling when the returned state is `SUCCEEDED` or `FAILED`; +10. retain their own overall timeout and operator escalation policy. -Clients must not interpret the field as a guarantee that the job will finish within one interval. +Clients must not interpret either header as a guarantee that the job will finish within one interval or as permission to access another principal's resource. -## Rollback +## Compatibility and rollback -The slice adds no database object, migration, persisted state, or API body field. Rolling back the application removes the advisory header while preserving submission, list, status, worker, and lease behavior. Older clients already operate without the header; newer clients must tolerate its absence because HTTP response fields are optional unless a separate client contract makes them mandatory. +This slice adds no database object, migration, persisted state, API body field, shared cache, or mutation precondition. Existing clients that do not send `If-None-Match` continue receiving the same `200` JSON representation plus ignorable response headers. Conditional clients must tolerate a `200` response with a replacement tag whenever the representation changes or the validator is unavailable after rollback. + +Rolling back the application removes the entity-tag builder and conditional response behavior. Rolling back the preceding polling-advisory slice also removes `Retry-After`. Submission, list, status body, worker, lease, authorization, and persistence behavior remain intact. No database rollback is required. ## Standards basis RFC 9110 Section 10.2.3 defines `Retry-After` as either an HTTP-date or a non-negative decimal number of seconds indicating how long a user agent ought to wait before a follow-up request. mightyETL uses the bounded `delay-seconds` form because it is deterministic, timezone-independent, and directly derived from an enabled worker's configured cadence. -### Reference +RFC 9110 Sections 8.8 and 13.1.2 define entity tags and require weak comparison for `If-None-Match`. For GET and HEAD, a false `If-None-Match` precondition produces `304 Not Modified`. Spring MVC documents that an `ETag` supplied through `ResponseEntity` participates in ordinary conditional request processing and yields an empty `304` response when unchanged. The Spring Framework 6.2.19 primary source also establishes the exact wildcard limitation compensated by the controller. + +### References Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://www.rfc-editor.org/rfc/rfc9110 + +Spring Framework. (2025). *ServletWebRequest.java* (Version 6.2.19) [Source code]. GitHub. https://github.com/spring-projects/spring-framework/blob/v6.2.19/spring-web/src/main/java/org/springframework/web/context/request/ServletWebRequest.java + +Spring Framework. (2026). *HTTP caching: Spring Web MVC*. https://docs.spring.io/spring-framework/reference/6.2/web/webmvc/mvc-caching.html From 0ea5474a8eb976924d68ff3d4e5be70f4b1f3e08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 14:19:22 +0900 Subject: [PATCH 4/7] docs(changelog): record conditional status validators --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a6e5b44..c734963d 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 +- Owner-scoped durable-job status responses now emit deterministic weak SHA-256 `ETag` validators; ordinary and wildcard `If-None-Match` requests return an empty RFC 9110 `304 Not Modified` response only after authenticated owner-safe lookup, while `Cache-Control: no-store` remains unchanged. - Active durable-job status responses now emit an RFC 9110 `Retry-After` delay for `PENDING` and `RUNNING` states only when local worker execution is enabled, derived from the bounded worker fixed-delay configuration with upward whole-second rounding; terminal states and intake-only maintenance mode omit the advisory. - 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. @@ -37,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Deterministic ordinary, wildcard, changed-state, changed-failure-code, null-versus-empty, and unrelated-response conditional polling tests, complete controller Javadoc, privacy and rollback guidance, and APA 7th standards evidence in `docs/etl/durable-job-polling.md`. - Controller-scoped polling advice, deterministic active/terminal lifecycle tests, disabled-worker fail-closed behavior, sub-second rounding coverage, rollback guidance, and APA 7th standards evidence in `docs/etl/durable-job-polling.md`. - 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`. @@ -151,7 +153,7 @@ Through code analysis, identified the platform as: - Microservices-based architecture using Spring Cloud - Real-time Change Data Capture using Debezium - Event streaming via Apache Kafka -- Service discovery with Netflix Eureka +- Service discovery and registration - Distributed tracing with Zipkin #### Key Components Documented @@ -173,7 +175,7 @@ Through code analysis, identified the platform as: - API Gateway with routing - JWT authentication filter - Load balancing - - Request routing to services + - Request routing to ETL/CDC services 4. **Eureka Server** (Port 8761) - Service discovery @@ -267,4 +269,4 @@ This changelog will be updated: - When documentation is significantly updated - For each release or milestone ---- \ No newline at end of file +--- From 46bbdb8da007e2127049a76f263c2f4c0f922e32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 14:22:43 +0900 Subject: [PATCH 5/7] fix(docs): restore unrelated historical changelog text --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c734963d..2fe984b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,7 +111,7 @@ existing xtrmETL platform. - Complete data model specifications - API specifications with examples - Deployment architecture - - Use cases and scenarios + - Use cases - Future enhancements roadmap - Success metrics and KPIs - Risk assessment and mitigation strategies @@ -153,7 +153,7 @@ Through code analysis, identified the platform as: - Microservices-based architecture using Spring Cloud - Real-time Change Data Capture using Debezium - Event streaming via Apache Kafka -- Service discovery and registration +- Service discovery with Netflix Eureka - Distributed tracing with Zipkin #### Key Components Documented @@ -175,7 +175,7 @@ Through code analysis, identified the platform as: - API Gateway with routing - JWT authentication filter - Load balancing - - Request routing to ETL/CDC services + - Request routing to services 4. **Eureka Server** (Port 8761) - Service discovery @@ -269,4 +269,4 @@ This changelog will be updated: - When documentation is significantly updated - For each release or milestone ---- +--- \ No newline at end of file From 7b8fbe70051418a214b1515dd60f74514ea06234 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 14:24:04 +0900 Subject: [PATCH 6/7] fix(docs): restore historical PRD wording --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fe984b6..0291941b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,7 +111,7 @@ existing xtrmETL platform. - Complete data model specifications - API specifications with examples - Deployment architecture - - Use cases + - Use cases and scenarios - Future enhancements roadmap - Success metrics and KPIs - Risk assessment and mitigation strategies @@ -244,7 +244,7 @@ This release addresses the GitHub issue requesting reverse-engineering of the pr For more information, see: -- [README.md](README.md) - Quick start guide +- [README.md](README.md) - Quick start guide and project overview - [PRD.md](PRD.md) - Product Requirements Document - [ARCHITECTURE.md](ARCHITECTURE.md) - Technical architecture - [SUMMARY_KR.md](SUMMARY_KR.md) - Korean summary From 9e4d69e0bb33ab57627c697a5d028b5309eca2bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 14:25:34 +0900 Subject: [PATCH 7/7] fix(docs): restore historical README wording --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0291941b..c5e12622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -244,7 +244,7 @@ This release addresses the GitHub issue requesting reverse-engineering of the pr For more information, see: -- [README.md](README.md) - Quick start guide and project overview +- [README.md](README.md) - Quick start guide - [PRD.md](PRD.md) - Product Requirements Document - [ARCHITECTURE.md](ARCHITECTURE.md) - Technical architecture - [SUMMARY_KR.md](SUMMARY_KR.md) - Korean summary