Raxol.Harness.CadencePolicy (Raxol v2.6.1)

View Source

The streaming-flush cadence policy: a pure decision function that tells a caller whether to paint now, wait, or stand aside for input.

Design laws

Same discipline as Raxol.Harness.StallDetector: this module owns no processes, no timers, no clocks. The caller passes timestamps in (decide/4,5) and owns the actual scheduling (Process.send_after/3 or equivalent) -- identical inputs always produce identical outputs, which is what makes this testable with plain integers instead of Process.sleep/1.

  1. Input wins, within a budget. decide/4,5 checks input_pending? first, before even asking whether the cadence window is open -- a live agent session must never let a token flood delay keystroke handling. But the win is bounded: after max_consecutive_yields/0 consecutive yields (~one frame at the 1ms retry) the decision falls through to the cadence rules, so continuous input can never starve rendering indefinitely either.

  2. First paint is unconditional. A stream's first delta (last_flush_ms == nil) flushes immediately. Perceived latency matters most at the start of a response; there is no prior cadence to respect yet.

  3. Deterministic coalescing. Every decision inside the same cadence window derefs to the same deadline (last_flush_ms + interval), not to "interval from now". This is what lets a burst of hundreds of deltas collapse into one scheduled flush instead of one timer reset per delta.

Summary

Functions

Decides what a caller holding pending_count buffered deltas should do right now.

The number of pending items to drain into a single flush batch: the lesser of pending_count and the configured max drain.

The number of pending items a caller must shed (from the OLDEST end) to get back under the :max_pending watermark: max(pending_count - max_pending, 0).

The minimum interval between token flushes, in milliseconds.

The recheck delay, in milliseconds, after yielding to pending input.

The maximum consecutive :yield_to_input verdicts between flushes.

The maximum number of pending items drained into a single flush batch.

The default pending-queue watermark above which the oldest deltas are shed.

Types

verdict()

@type verdict() :: :flush_now | {:defer, pos_integer()} | :yield_to_input

Functions

decide(now_ms, last_flush_ms, pending_count, input_pending?, opts \\ [])

@spec decide(
  now_ms :: integer(),
  last_flush_ms :: integer() | nil,
  pending_count :: pos_integer(),
  input_pending? :: boolean(),
  opts :: keyword()
) :: verdict()

Decides what a caller holding pending_count buffered deltas should do right now.

now_ms and last_flush_ms are caller-supplied timestamps on the same clock (last_flush_ms is nil when nothing has flushed yet). pending_count must be positive -- calling this with nothing pending is a caller bug and crashes loudly via FunctionClauseError rather than silently returning a no-op verdict.

Decision order (the order IS the design, not an implementation detail):

  1. input_pending? true AND yields_since_flush < max_consecutive_yields -> :yield_to_input -- input is scheduled ahead of token flushes, even when the cadence gate is fully open or this is the very first delta. Once the yield budget is exhausted, the decision FALLS THROUGH to the cadence rules below (so continuous input holds a flush for at most max_consecutive_yields x input_yield_retry_ms ~= one frame before forward progress is forced; a defer landing with the budget still exhausted falls through again and flushes at the window edge).
  2. last_flush_ms is nil -> :flush_now -- the first delta of a stream paints immediately.
  3. now_ms - last_flush_ms >= interval -> :flush_now.
  4. otherwise -> {:defer, remaining_ms}, where remaining_ms is always >= 1 by construction (case 3 already claimed the >= interval region).

Options

  • :flush_interval_ms -- override the module's default cadence interval. This is both the deterministic-test seam (set it to 0 to force :flush_now on every decision) and the per-instance config seam.
  • :yields_since_flush (default 0) -- how many consecutive :yield_to_input verdicts the caller has already acted on since its last flush. This is one piece of CALLER STATE deliberately carried in opts to preserve the positional-arg shape of this function; the policy itself stays stateless. Callers reset their counter to 0 on every flushed batch.
  • :max_consecutive_yields -- override the module's default yield budget.

drain_count(pending_count, opts \\ [])

@spec drain_count(pending_count :: non_neg_integer(), opts :: keyword()) ::
  non_neg_integer()

The number of pending items to drain into a single flush batch: the lesser of pending_count and the configured max drain.

The configured cap is clamped to at least 1: a batch that drains nothing makes no forward progress, and StreamCadence's forced full drain loops until pending hits zero -- a zero cap would spin it forever. Zero pending still drains zero; only the cap has a floor.

Options

  • :max_drain_per_flush -- override the module's default per-flush cap (clamped to a minimum of 1).

drop_count(pending_count, opts \\ [])

@spec drop_count(pending_count :: non_neg_integer(), opts :: keyword()) ::
  non_neg_integer()

The number of pending items a caller must shed (from the OLDEST end) to get back under the :max_pending watermark: max(pending_count - max_pending, 0).

Zero at or below the watermark. Callers drop from the queue front -- the newest deltas are the live tail's value; the oldest are already history.

Options

  • :max_pending -- override the module's default watermark.

flush_interval_ms()

@spec flush_interval_ms() :: pos_integer()

The minimum interval between token flushes, in milliseconds.

input_yield_retry_ms()

@spec input_yield_retry_ms() :: pos_integer()

The recheck delay, in milliseconds, after yielding to pending input.

max_consecutive_yields()

@spec max_consecutive_yields() :: pos_integer()

The maximum consecutive :yield_to_input verdicts between flushes.

max_drain_per_flush()

@spec max_drain_per_flush() :: pos_integer()

The maximum number of pending items drained into a single flush batch.

max_pending()

@spec max_pending() :: pos_integer()

The default pending-queue watermark above which the oldest deltas are shed.