Skip to content

refactor: extract backpressure-default constants + clarify dual-CLOSED path#110

Merged
eschizoid merged 1 commit into
arch/facade-start-helperfrom
arch/low-priority-cleanup
May 15, 2026
Merged

refactor: extract backpressure-default constants + clarify dual-CLOSED path#110
eschizoid merged 1 commit into
arch/facade-start-helperfrom
arch/low-priority-cleanup

Conversation

@eschizoid

@eschizoid eschizoid commented May 15, 2026

Copy link
Copy Markdown
Owner

Summary

LOW-priority fixes from the stack review:

  1. Default watermarks duplicatedKPipeConsumer.Builder.withBackpressure() (no-arg) and the constructor's no-controller fallback each hard-coded (10_000, 7_000). Moved to two public constants on BackpressureController: DEFAULT_HIGH_WATERMARK and DEFAULT_LOW_WATERMARK. Single source of truth.
  2. Dual state.set(CLOSED) — both the consumer thread's terminal finally and close() run state.set(CLOSED) + shutdownLatch.countDown(). Both paths are idempotent and the second countdown is a no-op, but it wasn't obvious why. Added a brief comment explaining the thread-finally path catches uncaught-exception cases where close() is never called externally.

Not done: the agent's registerEnumregisterOperatorEnum rename suggestion. §7 of CLAUDE.md explicitly documents the asymmetry as intentional (operators unsuffixed = primary, sinks suffixed = secondary), so renaming would break the convention rather than enforce it.

Stack

Stacks on top of #112 (facade-start-helper). Top of the stack.

…D path

Review follow-ups (LOW):

1. `KPipeConsumer.Builder.withBackpressure()` (no-arg) and the constructor's
   no-controller fallback each hard-coded `(10_000, 7_000)`. Two sites,
   same numbers, drift risk. Moved to two public constants on
   `BackpressureController` — `DEFAULT_HIGH_WATERMARK` and
   `DEFAULT_LOW_WATERMARK` — so the default lives in one place.

2. The consumer thread's terminal `finally` and `close()` both run
   `state.set(CLOSED) + shutdownLatch.countDown()`. Both paths are
   idempotent and the countDown-past-zero is a no-op, but it wasn't
   obvious without context why the duplication is safe and necessary.
   Added a brief inline comment explaining that the thread-finally path
   catches the uncaught-exception case where close() is never called.

Skipped the `registerEnum` → `registerOperatorEnum` rename: §7 of
CLAUDE.md explicitly documents the asymmetry (operators unsuffixed =
primary surface, sinks suffixed = secondary), so renaming would break
that intentional convention.
@eschizoid eschizoid changed the base branch from main to arch/facade-start-helper May 15, 2026 23:29
@eschizoid eschizoid changed the title Arch/low priority cleanup refactor: extract backpressure-default constants + clarify dual-CLOSED path May 15, 2026
@codecov

codecov Bot commented May 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 70.58%. Comparing base (a41a509) to head (f3450f9).

Files with missing lines Patch % Lines
...rc/main/java/org/kpipe/consumer/KPipeConsumer.java 80.00% 1 Missing ⚠️
Additional details and impacted files
@@                     Coverage Diff                     @@
##             arch/facade-start-helper     #110   +/-   ##
===========================================================
  Coverage                       70.58%   70.58%           
  Complexity                        592      592           
===========================================================
  Files                              63       63           
  Lines                            2346     2346           
  Branches                          301      301           
===========================================================
  Hits                             1656     1656           
  Misses                            545      545           
  Partials                          145      145           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@eschizoid eschizoid merged commit 06961bd into arch/facade-start-helper May 15, 2026
4 checks passed
eschizoid added a commit that referenced this pull request May 15, 2026
…D path (#110)

Review follow-ups (LOW):

1. `KPipeConsumer.Builder.withBackpressure()` (no-arg) and the constructor's
   no-controller fallback each hard-coded `(10_000, 7_000)`. Two sites,
   same numbers, drift risk. Moved to two public constants on
   `BackpressureController` — `DEFAULT_HIGH_WATERMARK` and
   `DEFAULT_LOW_WATERMARK` — so the default lives in one place.

2. The consumer thread's terminal `finally` and `close()` both run
   `state.set(CLOSED) + shutdownLatch.countDown()`. Both paths are
   idempotent and the countDown-past-zero is a no-op, but it wasn't
   obvious without context why the duplication is safe and necessary.
   Added a brief inline comment explaining that the thread-finally path
   catches the uncaught-exception case where close() is never called.

Skipped the `registerEnum` → `registerOperatorEnum` rename: §7 of
CLAUDE.md explicitly documents the asymmetry (operators unsuffixed =
primary surface, sinks suffixed = secondary), so renaming would break
that intentional convention.
eschizoid added a commit that referenced this pull request May 16, 2026
…/MEDIUM/LOW review fixes (#113)

* refactor(consumer): collapse Processor+Sink reporters into EntryMetricsReporter

ProcessorMetricsReporter and SinkMetricsReporter were structurally
identical records — same fields, same iterate-and-format loop, only the
log-message prefix differed ("Processor" vs "Sink"). 1.13.0 collapses
them into one parametric EntryMetricsReporter that carries the prefix
explicitly and exposes namespace-specific factory methods:

    EntryMetricsReporter.forProcessors(registry)
    EntryMetricsReporter.forProcessors(registry, keys)
    EntryMetricsReporter.forSinks(registry)
    EntryMetricsReporter.forSinks(registry, keys)

ConsumerMetricsReporter is left alone — it computes an aggregate snapshot
(uptime, per-second rates, backpressure totals), not a per-entry loop, so
the shapes don't unify.

Tests: ProcessorMetricsReporterTest + SinkMetricsReporterTest collapsed
into EntryMetricsReporterTest with named test groups for each namespace
plus the shared empty/exception/fluent behavior. CLAUDE.md §9 and §10
updated; README example bumped.

Per §16 no-deprecation — callers migrated in this commit.

* refactor(consumer): fold KPipeRunner + MessageTracker into KPipeConsumer

The 1.12 lifecycle path was Handle → KPipeRunner → KPipeConsumer → MessageTracker
— three lifecycle layers + one in-flight-counting helper, each exposing close /
isHealthy / awaitShutdown / inflight concerns. 1.13.0 collapses everything onto
KPipeConsumer directly.

New on KPipeConsumer:
  - waitForInFlightDrain(Duration) — replaces MessageTracker.waitForCompletion(),
    uses the live totalInFlight() counter (which includes buffered batch records)
    rather than re-deriving received - processed - errors from metrics
  - awaitShutdown() / awaitShutdown(Duration) — backed by a CountDownLatch
    counted down at the end of close() and in the consumer thread's terminal
    finally (so an uncaught exception also unblocks waiters)
  - shutdownGracefully(Duration) — pause → drain (bounded) → close, returns
    whether the drain finished cleanly
  - withMetricsReporters(Collection<KPipeMetricsReporter>) / withMetricsInterval(Duration)
    on the builder — adds a daemon thread that fires each reporter at the
    configured cadence while the consumer is alive
  - withShutdownHook(boolean) — installs Runtime.getRuntime().addShutdownHook(close)

Deleted:
  - KPipeRunner.java + KPipeRunnerTest.java
  - MessageTracker.java + MessageTrackerTest.java
  - createMessageTracker() from KPipeConsumer (was used internally only)

Migrated:
  - DefaultHandle is now a record over KPipeConsumer alone
  - DefaultSink / DefaultBatchSink / MultiBuilder call consumer.start() directly
  - Handle / Sink javadoc, KPipeConsumer class-level docs
  - README §"KPipeRunner" rewritten as §"Consumer lifecycle"; full-app example
    migrated; module catalog rows updated
  - CLAUDE.md §13 + §17 capability table (Lifecycle handle, periodic metrics
    reporting, in-flight drain rows)

Per §16 — no deprecation shim. All callers migrated in this commit.

* fix(consumer): close() releases shutdownLatch for unstarted consumers + awaitShutdown rethrows InterruptedException

Review follow-ups from PR #107:

1. close() on a CREATED-state consumer (built but never started) used to
   exit via the transitionToClosing() early-return without ever calling
   shutdownLatch.countDown(). Any awaitShutdown() caller blocked forever.
   Now close() handles CREATED → CLOSED directly and releases the latch.
   (HIGH #1 from the code review.)

2. KPipeConsumer.awaitShutdown() previously caught InterruptedException
   internally and returned false / void. That contradicted the
   Handle.awaitShutdown() interface declaration of `throws
   InterruptedException`, silently dropping the exception that the
   contract told callers to handle. Now the no-arg awaitShutdown
   actually throws (after restoring the interrupt flag); the bounded
   awaitShutdown(Duration) keeps its boolean return for the
   interruption case. (HIGH #2 from the code review.)

* fix(consumer): shutdown-hook leak + shutdownGracefully race + stale close() docs

Review follow-ups from PR #107 (MEDIUM):

1. JVM shutdown hook was added in the constructor but never removed. For a
   process that builds and closes many consumers, hooks accumulated in the
   shutdown-hook set for the JVM's lifetime — each holding a strong reference
   to the consumer. close() now removes its hook via Runtime.removeShutdownHook,
   ignoring the IllegalStateException that fires when removing during shutdown
   (the hook is currently running on the very path that called us).

2. shutdownGracefully(Duration) used a plain state.get() == CLOSED read and
   fell through to pause()/close() on every non-CLOSED state. Under race with
   another thread already mid-shutdown (state = CLOSING), it would call pause
   on a closing consumer and return a misleading 'drained = true'. Now it
   snapshots the state once and treats CLOSING as 'await the other shutdown,
   report its drain status' rather than starting a new shutdown sequence.

3. close()'s Javadoc still listed 'Creating a message tracker' as step 2 — a
   dead reference from before the KPipeRunner+MessageTracker fold. Replaced
   the step list to reflect what close() actually does today (CREATED→CLOSED
   fast-path, stop metrics thread, pause + drain, etc.).

* refactor(metrics): drop null-default trick + add LOGGING constant (#108)

Review follow-ups from PR #106 (MEDIUM):

1. The canonical constructor silently replaced a null `reporter` with
   `this::logMetrics`, breaking record transparency — `new
   EntryMetricsReporter(..., null).reporter()` returned a non-null
   method ref, not null. Now the constructor rejects null; callers
   asking for log-based output use the new public `LOGGING` constant
   (a `Consumer<String>` that logs each line at INFO).

2. The four static factories (`forProcessors`, `forProcessors(_, keys)`,
   `forSinks`, `forSinks(_, keys)`) now wire `LOGGING` explicitly
   instead of relying on the null trick — same call sites, but the
   record's transparency contract is honored.

Tests: `forProcessors_defaultsToLoggerWhenConsumerIsNull` renamed and
sharpened to assert the wiring uses the public `LOGGING` constant.
Added `canonicalConstructorRejectsNullReporter` for the new invariant.

* refactor(api): extract DefaultHandle.startAndWrap to dedupe start-and-close

Review follow-up from PR #107 (MEDIUM):

DefaultSink, DefaultBatchSink, and MultiBuilder each implemented the same
"call consumer.start(); on RuntimeException close + addSuppressed + rethrow;
return new DefaultHandle(consumer)" pattern. Promoted that to a static
`DefaultHandle.startAndWrap(KPipeConsumer<byte[]>)` so the three sites
collapse to one-liners. The error-handling logic is now in one place — fix
once, fixed everywhere.

* refactor: extract backpressure-default constants + clarify dual-CLOSED path (#110)

Review follow-ups (LOW):

1. `KPipeConsumer.Builder.withBackpressure()` (no-arg) and the constructor's
   no-controller fallback each hard-coded `(10_000, 7_000)`. Two sites,
   same numbers, drift risk. Moved to two public constants on
   `BackpressureController` — `DEFAULT_HIGH_WATERMARK` and
   `DEFAULT_LOW_WATERMARK` — so the default lives in one place.

2. The consumer thread's terminal `finally` and `close()` both run
   `state.set(CLOSED) + shutdownLatch.countDown()`. Both paths are
   idempotent and the countDown-past-zero is a no-op, but it wasn't
   obvious without context why the duplication is safe and necessary.
   Added a brief inline comment explaining that the thread-finally path
   catches the uncaught-exception case where close() is never called.

Skipped the `registerEnum` → `registerOperatorEnum` rename: §7 of
CLAUDE.md explicitly documents the asymmetry (operators unsuffixed =
primary surface, sinks suffixed = secondary), so renaming would break
that intentional convention.

* docs(plan): add Result<T> sealed type as P5 2.0 candidate

Track the §12 type-level enforcement idea — make filter vs fail
distinct types rather than enforcing 'null = filter, throw = fail'
through discipline. Sealed Result<T> { Passed | Filtered | Failed }
upgrades the convention to a compile-time guarantee. Deferred until
the 2.0 window because (a) it's a hard API break on MessagePipeline
return type, (b) the per-record allocation cost needs JMH evidence,
and (c) §12 has been holding fine under current discipline.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant