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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions