feat(platform): tryClaimSameTx (#316) + KafkaHeaderUtils (#323) #14

Merged
hibryda merged 2 commits from feat/dedup-sametx-and-kafkaheaderutils-316-323 into main 2026-05-29 18:18:05 +02:00
Owner

Summary

Two independent, additive public-API features for im2be-platform-libs /
processed-kafka-events, one commit each. No consumer service is changed
adoption of both is a separate later phase.

Commit 1 — feat(dedup): add tryClaimSameTx at-least-once claim variant (#316)

Platform foundation for #316 (at-least-once dedup). Adds
DedupGuard.tryClaimSameTx(...) as an additive sibling of the existing
tryClaim(...), with the same two-overload (explicit-scope + no-scope) →
shared private tryClaimInternal core split, but
@Transactional(propagation = MANDATORY) instead of REQUIRES_NEW:

tryClaim (REQUIRES_NEW) tryClaimSameTx (MANDATORY)
Claim tx Separate — commits independently The caller's — atomic with it
Business logic then fails Claim already committed → not redelivered → at-most-once-on-failure (no poison loop) Claim rolls back with the tx → redelivered → at-least-once
Needs active caller tx? No Yes — fails fast with IllegalTransactionStateException if none
  • MANDATORY (not REQUIRED) is deliberate: asserts the consumer is
    transactional and fails fast rather than silently degrading to at-most-once —
    mirrors OutboxPublisher.publish(...) fail-fast (ADR-0014 D-7). Justified in
    the Javadoc.
  • The shared core is reused (no duplication); each overload carries its own
    @Transactional proxy entry — the core is never self-invoked.
  • Javadoc + README warn that tryClaimSameTx callers MUST have a DLT +
    retry-limit, or a poison message redelivers forever.
  • Existing tryClaim behaviour + Javadoc are unchanged.

Consumer adoption TBD per-service (incl. diary) — not in this PR.

Commit 2 — feat(kafka): extract shared KafkaHeaderUtils.resolveEventId (#323)

Platform foundation for #323 (shared header-parsing util). Extracts the
resolveEventId helper that all five consumer services (user / family /
notification / calendar / diary) duplicated privately in their
KafkaConsumerService into a single shared public util,
KafkaHeaderUtils.resolveEventId(ConsumerRecord<?,?>) in
processed-kafka-events (dedup-adjacent — the resolved id feeds the
DedupGuard claim).

Canonical behaviour: read the event_id header (lastHeader, decoded UTF-8,
trimmed; non-blank wins) else fall back to topic:partition:offset.

Divergence audit: user/family/notification/calendar copies are
byte-identical; diary differs only cosmetically (a StringBuilder fallback

  • fuller Javadoc) producing identical output — no behavioural divergence. The
    4-service plain-concatenation form is canonical.

Consumer adoption of the shared util TBD — not in this PR.

Test plan

  • mvn -B clean install → BUILD SUCCESS, all modules green
  • #316 unit (DedupGuardTest): tryClaimSameTx shares the core (insert=1 →
    CLAIMED, insert=0 → DUPLICATE), blank-scope rejected
  • #316 IT (DedupGuardIT, Testcontainers PG): (a) same-tx claim
    visible/durable on commit, (b) caller-tx rollback → claim NOT persisted →
    redelivery re-claims
    (the at-least-once property), (c) no active tx →
    IllegalTransactionStateException
  • #323 unit (KafkaHeaderUtilsTest, 7): header present → returned;
    whitespace → trimmed; multiple → last wins; absent / null / empty / blank
    topic:partition:offset fallback
  • No AI authorship trailer; only im2be-platform-libs touched; specific
    files staged
## Summary Two independent, **additive** public-API features for `im2be-platform-libs` / `processed-kafka-events`, one commit each. **No consumer service is changed** — adoption of both is a separate later phase. ### Commit 1 — `feat(dedup): add tryClaimSameTx at-least-once claim variant (#316)` Platform foundation for **#316 (at-least-once dedup)**. Adds `DedupGuard.tryClaimSameTx(...)` as an additive sibling of the existing `tryClaim(...)`, with the same two-overload (explicit-scope + no-scope) → shared private `tryClaimInternal` core split, but `@Transactional(propagation = MANDATORY)` instead of `REQUIRES_NEW`: | | `tryClaim` (`REQUIRES_NEW`) | `tryClaimSameTx` (`MANDATORY`) | |---|---|---| | Claim tx | Separate — commits independently | The caller's — atomic with it | | Business logic then fails | Claim already committed → not redelivered → **at-most-once-on-failure** (no poison loop) | Claim rolls back with the tx → redelivered → **at-least-once** | | Needs active caller tx? | No | **Yes** — fails fast with `IllegalTransactionStateException` if none | - `MANDATORY` (not `REQUIRED`) is deliberate: asserts the consumer is transactional and fails fast rather than silently degrading to at-most-once — mirrors `OutboxPublisher.publish(...)` fail-fast (ADR-0014 D-7). Justified in the Javadoc. - The shared core is reused (no duplication); each overload carries its own `@Transactional` proxy entry — the core is never self-invoked. - Javadoc + README warn that `tryClaimSameTx` callers **MUST** have a DLT + retry-limit, or a poison message redelivers forever. - Existing `tryClaim` behaviour + Javadoc are unchanged. **Consumer adoption TBD per-service (incl. diary) — not in this PR.** ### Commit 2 — `feat(kafka): extract shared KafkaHeaderUtils.resolveEventId (#323)` Platform foundation for **#323 (shared header-parsing util)**. Extracts the `resolveEventId` helper that all five consumer services (user / family / notification / calendar / diary) duplicated privately in their `KafkaConsumerService` into a single shared public util, `KafkaHeaderUtils.resolveEventId(ConsumerRecord<?,?>)` in `processed-kafka-events` (dedup-adjacent — the resolved id feeds the `DedupGuard` claim). Canonical behaviour: read the `event_id` header (`lastHeader`, decoded UTF-8, trimmed; non-blank wins) else fall back to `topic:partition:offset`. **Divergence audit:** user/family/notification/calendar copies are byte-identical; diary differs **only cosmetically** (a `StringBuilder` fallback + fuller Javadoc) producing identical output — no behavioural divergence. The 4-service plain-concatenation form is canonical. **Consumer adoption of the shared util TBD — not in this PR.** ## Test plan - [x] `mvn -B clean install` → BUILD SUCCESS, all modules green - [x] #316 unit (`DedupGuardTest`): `tryClaimSameTx` shares the core (insert=1 → CLAIMED, insert=0 → DUPLICATE), blank-scope rejected - [x] #316 IT (`DedupGuardIT`, Testcontainers PG): (a) same-tx claim visible/durable on commit, (b) **caller-tx rollback → claim NOT persisted → redelivery re-claims** (the at-least-once property), (c) no active tx → `IllegalTransactionStateException` - [x] #323 unit (`KafkaHeaderUtilsTest`, 7): header present → returned; whitespace → trimmed; multiple → last wins; absent / null / empty / blank → `topic:partition:offset` fallback - [x] No AI authorship trailer; only `im2be-platform-libs` touched; specific files staged
R0 feat findings (kept=0):

Adds DedupGuard.tryClaimSameTx(...) as an additive sibling of tryClaim(...).
Same two-overload (explicit-scope + no-scope) -> shared private tryClaimInternal
core split, but @Transactional(propagation = MANDATORY) instead of REQUIRES_NEW.
The claim JOINS the caller's transaction, so a business-logic failure rolls the
claim back and the event is redelivered -> at-least-once (vs tryClaim's separate
REQUIRES_NEW tx -> at-most-once-on-failure, no poison loop). Existing tryClaim
behaviour + Javadoc are unchanged.

(1) feat processed-kafka-events/.../DedupGuard.java -- tryClaimSameTx overloads.
    Both overloads carry @Transactional(MANDATORY) and delegate to the existing
    tryClaimInternal core (no self-invocation -> proxy is not bypassed). MANDATORY
    (not REQUIRED) is deliberate: it fails fast with IllegalTransactionStateException
    when no caller tx exists, asserting the consumer is transactional rather than
    silently degrading to at-most-once -- mirrors OutboxPublisher.publish() fail-fast
    (ADR-0014 D-7). Javadoc contrasts the two variants in a table + warns that
    tryClaimSameTx callers MUST have a DLT + retry-limit or a poison message
    redelivers forever.

Fix: factored both new overloads onto the existing shared core; no logic
duplicated, no existing public behaviour changed. Verified MANDATORY semantics
(joins caller tx; IllegalTransactionStateException when none) against current
Spring Framework transaction-propagation docs.

Verification:
- mvn -B clean install -> BUILD SUCCESS; processed-kafka-events surefire+failsafe green
- DedupGuardTest unit tests: tryClaimSameTx shares core (insert=1 -> CLAIMED, insert=0
  -> DUPLICATE), blank-scope rejected
- DedupGuardIT (Testcontainers PG): (a) same-tx claim visible/durable on commit,
  (b) caller-tx rollback -> claim NOT persisted -> redelivery re-claims (at-least-once),
  (c) no active tx -> IllegalTransactionStateException, nothing written
feat(kafka): extract shared KafkaHeaderUtils.resolveEventId (#323)
All checks were successful
im2be-platform-libs CI / mvn install (pull_request) Successful in 1m9s
im2be-platform-libs CI / mvn verify (main only) (pull_request) Has been skipped
c113dfa60d
R0 feat findings (kept=0):

Extracts the canonical resolveEventId helper that all five consumer services
(user / family / notification / calendar / diary) duplicated privately in their
KafkaConsumerService into a single shared public util in processed-kafka-events
(dedup-adjacent: the resolved id feeds DedupGuard.tryClaim/tryClaimSameTx, and
guard consumers already depend on this module). No consumer service is modified
-- adoption is a separate phase.

(1) feat processed-kafka-events/.../KafkaHeaderUtils.java -- new final utility
    class. resolveEventId(ConsumerRecord<?,?>): read the 'event_id' header via
    headers().lastHeader (last value wins), decode UTF-8, trim; return if non-blank,
    else fall back to topic:partition:offset. HEADER_EVENT_ID = "event_id" constant
    matches the consumers. Wildcard-bound record type (key/value irrelevant to id).

Canonical form / divergence audit: user/family/notification/calendar copies are
byte-identical (header-or-coordinates, UTF-8, trim, !isBlank). diary differs ONLY
cosmetically (StringBuilder fallback + fuller Javadoc) -- identical output, no
behavioural divergence; the 4-service plain-concatenation form is canonical.

Fix: single extraction, no consumer touched, additive public API.

Verification:
- mvn -B clean install -> BUILD SUCCESS
- KafkaHeaderUtilsTest (7): header present -> returned; surrounding whitespace ->
  trimmed; multiple headers -> last wins; absent / null-value / empty / blank ->
  topic:partition:offset fallback. ConsumerRecord 11-arg constructor verified
  against kafka-clients 3.9.2 (javap)

hib-pr-reviewer review — PR #14 (affinity-intelligence-rework/im2be-platform-libs)

Round 1 — head c113dfa60d9c, base main, trigger opened

TL;DR: NO_NEW_FINDINGS — No new findings this round.

Summary

[quorum-converged] A=0 = B=0. ## Review Summary

The tryClaimSameTx (#316) and KafkaHeaderUtils (#323) additions are well-architected and correctly implemented. The propagation-agnostic tryClaimInternal core, the MANDATORY-not-REQUIRED rationale, the poison-message warning in both README and Javadoc, and the three new IT scenarios (same-tx visibility, rollback, no-tx fail-fast) all meet the standard set by the existing tryClaim work. KafkaHeaderUtils is stateless, final, and its test matrix is complete across all seven header-presence cases.

Two minor actionable findings below; no blocking or correctness regressions.

CI status (head c113dfa60d9c)

Overall: ✓ success

2 checks: 2 pending

Check State Link
im2be-platform-libs CI / mvn install (pull_request) pending details
im2be-platform-libs CI / mvn verify (main only) (pull_request) pending details

Findings

No new findings this round.

Quorum converged on empty findings (A + B both returned 0).

Verdict

NO_NEW_FINDINGS


hib-pr-reviewer • round 1 • 0 findings • 2026-05-29T15:58:38.296Z → 2026-05-29T16:03:55.341Z • posted-as: pr-reviewer-bot • [bookkeeping fallback]

## hib-pr-reviewer review — PR #14 (affinity-intelligence-rework/im2be-platform-libs) **Round 1** — head `c113dfa60d9c`, base `main`, trigger `opened` **TL;DR:** NO_NEW_FINDINGS — No new findings this round. ### Summary [quorum-converged] A=0 = B=0. ## Review Summary The `tryClaimSameTx` (#316) and `KafkaHeaderUtils` (#323) additions are well-architected and correctly implemented. The propagation-agnostic `tryClaimInternal` core, the MANDATORY-not-REQUIRED rationale, the poison-message warning in both README and Javadoc, and the three new IT scenarios (same-tx visibility, rollback, no-tx fail-fast) all meet the standard set by the existing `tryClaim` work. `KafkaHeaderUtils` is stateless, final, and its test matrix is complete across all seven header-presence cases. Two minor actionable findings below; no blocking or correctness regressions. ### CI status (head `c113dfa60d9c`) **Overall: ✓ success** 2 checks: 2 pending | Check | State | Link | |---|---|---| | im2be-platform-libs CI / mvn install (pull_request) | ⏳ pending | [details](/affinity-intelligence-rework/im2be-platform-libs/actions/runs/90/jobs/0) | | im2be-platform-libs CI / mvn verify (main only) (pull_request) | ⏳ pending | [details](/affinity-intelligence-rework/im2be-platform-libs/actions/runs/90/jobs/1) | ### Findings **No new findings this round.** _Quorum converged on empty findings (A + B both returned 0)._ ### Verdict **NO_NEW_FINDINGS** --- <sub>hib-pr-reviewer • round 1 • 0 findings • 2026-05-29T15:58:38.296Z → 2026-05-29T16:03:55.341Z • posted-as: pr-reviewer-bot • [bookkeeping fallback]</sub>
hibryda deleted branch feat/dedup-sametx-and-kafkaheaderutils-316-323 2026-05-29 18:18:05 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
affinity-intelligence-rework/im2be-platform-libs!14
No description provided.