Summary
FailedEventPublications/EventPublicationRepository has no way to permanently remove a publication from the FAILED resubmission pool without marking it COMPLETED — the same status a genuinely successful listener invocation gets. This makes it impossible to distinguish "processed successfully" from "gave up after N attempts" using the publication's status alone, and it also causes a starvation bug: publications that exhaust an application-defined attempts cap can permanently occupy the head of the FAILED batch selection and block eligible publications behind them.
Details
1. No terminal "abandoned"/dead-letter status
EventPublication.Status has exactly five values: PUBLISHED, PROCESSING, COMPLETED, FAILED, RESUBMITTED (confirmed against 2.1.0). There's no way to record "this publication was retried until an application-defined limit and will not be retried again" as distinct from "this listener invocation succeeded."
ResubmissionOptions.withFilter(...) lets an application stop resubmitting a publication past some completionAttempts threshold, but the only way to actually get that publication out of the FAILED set (and out of any backlog/gauge derived from it) is to call markCompleted(...) on it — which is indistinguishable, after the fact, from a listener that actually ran successfully. completionAttempts is preserved on the row, so it's possible to reconstruct which COMPLETED rows were really abandoned by cross-referencing that count against whatever threshold the application happens to be using today — but that's fragile (the threshold isn't persisted per-row) and undiscoverable by anyone who doesn't already know the convention.
This was raised with no maintainer response in #1359, and is adjacent to (but not resolved by) the lifecycle work in #796, which added FAILED but stopped short of a disposal/dead-letter concept.
2. Starvation: exhausted publications block eligible ones
JdbcEventPublicationRepositoryV2.findFailedPublications (and the JPA equivalent) selects the resubmission batch via WHERE STATUS = 'FAILED' ... ORDER BY PUBLICATION_DATE ASC LIMIT <batchSize>, entirely in SQL, before ResubmissionOptions.withFilter(...) ever runs. The filter only prunes the batch that was already read — it has no influence over which rows SQL selects.
Consequence: if an application uses withFilter to stop resubmitting publications past N attempts, those publications don't disappear from the FAILED set — they just get filtered out of every batch after being read. Since selection is strictly oldest-first with no attempts-awareness, a publication that will never again pass the filter still occupies a slot at the head of the ordering forever, and if enough such rows accumulate (in our case: exactly batchSize of them), no eligible publication is ever read at all — the resubmission job appears to run successfully every cycle while doing nothing, and both app.events.resubmitted-style metrics and a raw FAILED count backlog gauge look identical to a stuck worker with no rows moving.
Suggested direction
Something like:
- An
EventPublication.Status.ABANDONED (or similar) terminal state, settable via EventPublicationRepository, distinct from COMPLETED, so applications can implement a max-attempts policy without lying about success.
findFailedPublications/the equivalent batch-selection query taking an attempts-aware predicate (or accepting ResubmissionOptions' filter) at the SQL level, so exhausted rows don't occupy LIMIT-bounded batch slots that eligible rows need.
Workaround we're using today
In our application we run a scheduled "abandon phase" ahead of the normal resubmit phase: read the same head window via EventPublicationRepository.findFailedPublications(FailedCriteria.ALL.withItemsToRead(batchSize)), and for any row at/past our own attempts cap, call markCompleted(id, now) directly, emit a WARN log + a dedicated metric (app.events.abandoned) so it's distinguishable operationally, then run the normal resubmit phase against what's left. It works, but it's exactly the kind of per-application reimplementation that suggests this belongs in the framework — every user who hits sustained listener failures (not just crash-restart-driven staleness) needs this same abandon step or their retry job silently stops making progress.
Environment
- Spring Modulith 2.0.0 (bug confirmed via bytecode inspection of
spring-modulith-events-jdbc-2.0.0.jar)
- Verified
Status enum and ResubmissionOptions behavior unchanged through 2.1.0 GA release notes / current reference docs
Summary
FailedEventPublications/EventPublicationRepositoryhas no way to permanently remove a publication from theFAILEDresubmission pool without marking itCOMPLETED— the same status a genuinely successful listener invocation gets. This makes it impossible to distinguish "processed successfully" from "gave up after N attempts" using the publication'sstatusalone, and it also causes a starvation bug: publications that exhaust an application-defined attempts cap can permanently occupy the head of theFAILEDbatch selection and block eligible publications behind them.Details
1. No terminal "abandoned"/dead-letter status
EventPublication.Statushas exactly five values:PUBLISHED,PROCESSING,COMPLETED,FAILED,RESUBMITTED(confirmed against 2.1.0). There's no way to record "this publication was retried until an application-defined limit and will not be retried again" as distinct from "this listener invocation succeeded."ResubmissionOptions.withFilter(...)lets an application stop resubmitting a publication past somecompletionAttemptsthreshold, but the only way to actually get that publication out of theFAILEDset (and out of any backlog/gauge derived from it) is to callmarkCompleted(...)on it — which is indistinguishable, after the fact, from a listener that actually ran successfully.completionAttemptsis preserved on the row, so it's possible to reconstruct whichCOMPLETEDrows were really abandoned by cross-referencing that count against whatever threshold the application happens to be using today — but that's fragile (the threshold isn't persisted per-row) and undiscoverable by anyone who doesn't already know the convention.This was raised with no maintainer response in #1359, and is adjacent to (but not resolved by) the lifecycle work in #796, which added
FAILEDbut stopped short of a disposal/dead-letter concept.2. Starvation: exhausted publications block eligible ones
JdbcEventPublicationRepositoryV2.findFailedPublications(and the JPA equivalent) selects the resubmission batch viaWHERE STATUS = 'FAILED' ... ORDER BY PUBLICATION_DATE ASC LIMIT <batchSize>, entirely in SQL, beforeResubmissionOptions.withFilter(...)ever runs. The filter only prunes the batch that was already read — it has no influence over which rows SQL selects.Consequence: if an application uses
withFilterto stop resubmitting publications past N attempts, those publications don't disappear from theFAILEDset — they just get filtered out of every batch after being read. Since selection is strictly oldest-first with no attempts-awareness, a publication that will never again pass the filter still occupies a slot at the head of the ordering forever, and if enough such rows accumulate (in our case: exactlybatchSizeof them), no eligible publication is ever read at all — the resubmission job appears to run successfully every cycle while doing nothing, and bothapp.events.resubmitted-style metrics and a rawFAILEDcount backlog gauge look identical to a stuck worker with no rows moving.Suggested direction
Something like:
EventPublication.Status.ABANDONED(or similar) terminal state, settable viaEventPublicationRepository, distinct fromCOMPLETED, so applications can implement a max-attempts policy without lying about success.findFailedPublications/the equivalent batch-selection query taking an attempts-aware predicate (or acceptingResubmissionOptions' filter) at the SQL level, so exhausted rows don't occupyLIMIT-bounded batch slots that eligible rows need.Workaround we're using today
In our application we run a scheduled "abandon phase" ahead of the normal resubmit phase: read the same head window via
EventPublicationRepository.findFailedPublications(FailedCriteria.ALL.withItemsToRead(batchSize)), and for any row at/past our own attempts cap, callmarkCompleted(id, now)directly, emit aWARNlog + a dedicated metric (app.events.abandoned) so it's distinguishable operationally, then run the normal resubmit phase against what's left. It works, but it's exactly the kind of per-application reimplementation that suggests this belongs in the framework — every user who hits sustained listener failures (not just crash-restart-driven staleness) needs this same abandon step or their retry job silently stops making progress.Environment
spring-modulith-events-jdbc-2.0.0.jar)Statusenum andResubmissionOptionsbehavior unchanged through 2.1.0 GA release notes / current reference docs