Skip to content

No way to distinguish abandoned/dead-lettered publications from COMPLETED, and exhausted publications can starve the FAILED resubmission batch #1764

Description

@saadatrizvi17

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions