|
| 1 | +# The retry queue's waiting methods have a declared contract and no runtime guard |
| 2 | + |
| 3 | +<!-- inflight-type: task --> |
| 4 | +<!-- inflight-impact: reliability --> |
| 5 | +<!-- inflight-labels: concurrency --> |
| 6 | +<!-- inflight-state: deferred - the static half has landed; this half needs a decision on what "fail loudly" does inside consumer.poll() --> |
| 7 | + |
| 8 | +`RetryQueue.remove`, `add`, `removeAll` and `clear` take the write lock unconditionally, so a caller that is |
| 9 | +not allowed to wait must never reach them - which in this engine means the controller thread and nothing else. |
| 10 | +That contract is now DECLARED, by |
| 11 | +[`@ControllerThreadOnly`](../../parallel-consumer-core/src/main/java/bz/stub/parallelconsumer/state/ControllerThreadOnly.java), |
| 12 | +and CHECKED statically, by `ArchitectureTest.rebalanceCallbacksMustNotBlock`, which reports a reach into an |
| 13 | +annotated method exactly as it reports a reach into a deny-listed JDK blocking call. This note is the other |
| 14 | +half: the runtime guard that would hold the same contract where a static walk cannot follow. |
| 15 | + |
| 16 | +## The shape, copied from a guard this repo already runs |
| 17 | + |
| 18 | +astubbs/parallel-consumer#393 did it for the consumer, and the pattern is two pieces: |
| 19 | + |
| 20 | +- `ConsumerOffsetCommitter` holds a `volatile Optional<Thread> owningThread`, set once by `claim()` when the |
| 21 | + poll thread's control loop starts, and read by `isOwner()` - `Thread.currentThread().equals(owningThread |
| 22 | + .orElse(null))`. The claim is a LIFECYCLE step taken by the owning thread itself, not a constructor |
| 23 | + argument, because the object is built before the thread that will own it exists. |
| 24 | +- `PCModule.consumerManager()` wraps the user's consumer in `ThreadConfinedConsumer` (grep |
| 25 | + `thread-confinement`), whose own comment states the rule this note copies: ownership is claimed when the |
| 26 | + loop starts, and calls before that are allowed from any thread. |
| 27 | + |
| 28 | +Applied to `RetryQueue`: the controller claims the queue when the control loop starts; every |
| 29 | +`@ControllerThreadOnly` method asserts that `Thread.currentThread()` is the owner and fails loudly instead of |
| 30 | +taking the lock; and the guard is UNARMED while no controller has claimed, so unit tests that drive a |
| 31 | +`RetryQueue` directly - and any init-time use - are unaffected. Unarmed-by-default is the part that makes this |
| 32 | +cheap to land: it changes nothing until a real control loop exists. |
| 33 | + |
| 34 | +## Which annotation, and why the pairing rule makes this note necessary |
| 35 | + |
| 36 | +`parallel-consumer-core/src/main/java/bz/stub/parallelconsumer/AGENTS.md` owns the rule - "Declare thread |
| 37 | +confinement with `@ThreadConfined`, and assert it at the entry point". Infer's `@ThreadConfined` is CONSUMED by |
| 38 | +RacerD and never checked, so an unpaired one silences a detector and is worse than no annotation at all. The |
| 39 | +two existing patterns are named there: `RetryQueue.RetryQueueIterator` carries `@ThreadConfined(ThreadConfined |
| 40 | +.ANY)` plus an `assertOnOwningThread` at every entry point with `RetryQueueIteratorConfinementTest` failing |
| 41 | +when the two disagree, and `ThreadConfinedConsumer` is the older hand-rolled version of the same idea for the |
| 42 | +poll thread. Both were established by astubbs/parallel-consumer#433. |
| 43 | + |
| 44 | +**The guard this note describes is that shape with a NAMED thread rather than `ANY`** - the control thread, |
| 45 | +which the rule says is the right value when the code really does pin one, and which is what gives the |
| 46 | +assertion something specific to compare against. `@ControllerThreadOnly` is deliberately not that annotation: |
| 47 | +no analyser reads it, so it silences nothing and the pairing rule's rationale does not reach it. When the |
| 48 | +runtime guard lands, the marker may fold into the `@ThreadConfined` + assertion pair. |
| 49 | + |
| 50 | +## What it covers that the static rule cannot |
| 51 | + |
| 52 | +The ArchUnit rule's own javadoc enumerates its blind spots, and each one is a way for a poll-thread call to |
| 53 | +arrive at a waiting acquire with the rule green: |
| 54 | + |
| 55 | +- **A stored reference.** ArchUnit's model gives a reference invoked now (a stream stage) the same shape as |
| 56 | + one invoked later (a metrics gauge, an executor task), so the rule cannot say WHEN a reach happens - it is |
| 57 | + conservative about immediate reaches and silent about deferred ones. |
| 58 | +- **A user-supplied `ConsumerRebalanceListener`.** Dynamic dispatch through an interface is out of reach of any |
| 59 | + deny list, and a user listener is exactly the code the walk cannot start from. |
| 60 | +- **A `synchronized` block.** A `MONITORENTER` is not an access, so it is invisible at any depth - the reason |
| 61 | + the rule would not have caught confluentinc#857 itself. |
| 62 | + |
| 63 | +A runtime owner check does not care how the call arrived. It costs one reference compare per call on a path |
| 64 | +that is already taking a lock. |
| 65 | + |
| 66 | +## Open design questions - these are what defer it |
| 67 | + |
| 68 | +- **What "fail loudly" means on the poll thread**, which is the binding one. The call is inside |
| 69 | + `consumer.poll()`, so a thrown exception leaves a Kafka rebalance callback abnormally and its blast radius |
| 70 | + is the group, not the caller; "log an error and decline the removal" is the alternative. Throwing is the |
| 71 | + better signal in a test and the worse one in production, which is the trade to settle - and |
| 72 | + astubbs/parallel-consumer#431 settles the same trade for the static half by declining rather than throwing, |
| 73 | + which is the precedent to weigh rather than a decision already taken here. |
| 74 | +- **Claim and release across a restart.** `ConsumerOffsetCommitter.claim()` is called once and never released. |
| 75 | + A controller that stops and starts again, or a second `ParallelEoSStreamProcessor` in the same JVM, needs a |
| 76 | + decision on whether a claim can be replaced, refused, or dropped at close - and on what an assertion does in |
| 77 | + the window between them. |
| 78 | + |
| 79 | +## Where to look when picking this up |
| 80 | + |
| 81 | +- `ConsumerOffsetCommitter`, grep `owningThread` and `isOwner` - the reference implementation, including why |
| 82 | + the claim is invisible to a grep for `.claim(`. |
| 83 | +- `PCModule`, grep `thread-confinement` - where a wrapper is wired, and the "claimed when the loop starts" |
| 84 | + rule stated in a comment. |
| 85 | +- `docs/inflight/static-archunit-main-code-rules.md`, "the rule now enforces a contract the CODEBASE declares" |
| 86 | + - what the static half does and what it measured. |
0 commit comments