Skip to content

De-genericize HTTP handlers via T-free node modules; add AdminClient#29

Merged
antondalgren merged 1 commit into
mainfrom
degenericize-http-handlers
Jun 12, 2026
Merged

De-genericize HTTP handlers via T-free node modules; add AdminClient#29
antondalgren merged 1 commit into
mainfrom
degenericize-http-handlers

Conversation

@antondalgren

@antondalgren antondalgren commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

`StatusHandler(T)` / `AdminHandler(T)` were generic only because their field was typed `Node(T)` — every method they call is T-free. The genericity forced LavinMQ to wrap them in a concrete class (`RaftHandlerWrapper`) to dodge a Crystal codegen bug: stdlib `HTTP::Handler#call_next` builds a type-id case table over concrete includers; generic instantiations are missing from it, so a generic handler in a chain traps with `brk #1`.

Changes

  1. `Raft::StatusSource` / `Raft::AdminOps` — T-free abstract modules, at node level (not `http/`): `Node` includes them, and core requiring `http/` would invert the ARCHITECTURE.md layering.
  2. `Node(T)` includes both + four new T-free log scalars (`last_log_index`, `last_log_term`, `first_log_index`, `segment_count`) so status consumers stop reaching through the mutable `Node#log`.
  3. Handlers drop `(T)` — fields are typed by capability and named for it too (`@source : StatusSource`, `@ops : AdminOps`), since future non-node implementors (e.g. a channel-marshaling admin wrapper) can include the same modules. Behavior byte-for-byte identical.
  4. `Raft::HTTP::AdminClient.add_server(uri, node_id, address)` — client side of the join wire format. Single attempt, no retry loop (embedders own retry policy). Returns `::HTTP::Status` instead of Bool so retry policies can distinguish rejection (400 → stop) from unavailability (5xx → retry). Basic auth from URI userinfo; URI path prefix respected.

What this is NOT

A typing change only — no change to who may call the node or from where. The single-fiber threading contract is restated on both modules. The thread-safety marshaling discussion (AsyncAdmin) is deliberately out of scope; an `AsyncAdmin` would `include AdminOps`, so nothing here forecloses it.

LavinMQ after this

`RaftHandlerWrapper` can be deleted — handlers are concrete and chain-safe directly. The join flow can use `AdminClient.add_server` instead of hand-rolled HTTP.

Test plan

  • `crystal spec spec/raft/ -Dpreview_mt -Dexecution_context` — 124/124 (was 118; +6 AdminClient)
  • `crystal spec … -Draft_debug` — 131/131
  • Chain-parity spec (both handlers mounted together, both surfaces served) passes
  • AdminClient round-trip against real AdminHandler: peer actually added
  • `crystal tool format --check` clean; ameba `--fail-level error` exit 0 with no new warnings
  • All new/touched files compile standalone with `--error-on-warnings`; both examples compile

🤖 Generated with Claude Code

StatusHandler(T) and AdminHandler(T) were generic only because their
field was typed Node(T) — every method they call is T-free. The
genericity forced embedders to wrap them in concrete classes to dodge
a Crystal codegen bug in stdlib handler-chain dispatch (generic
instantiations missing from call_next's type-id case table; see
LavinMQ's RaftHandlerWrapper for the diagnosis).

- Raft::StatusSource / Raft::AdminOps: T-free abstract modules at node
  level (not under http/ — Node includes them, and core requiring
  http/ would invert the ARCHITECTURE.md layering). Handler fields are
  named for the capability (@source, @ops), not the implementor, since
  future non-node implementations (e.g. a channel-marshaling admin
  wrapper) can include the same modules.
- Node(T) includes both, plus four new T-free log scalars
  (last_log_index, last_log_term, first_log_index, segment_count) so
  status consumers stop reaching through the mutable Node#log.
- StatusHandler / AdminHandler drop (T). Behavior byte-for-byte
  identical: routes, JSON shapes, status codes, error messages, and
  the admin-route rescue all unchanged.
- Raft::HTTP::AdminClient.add_server(uri, node_id, address): client
  side of the join wire format. Single attempt, no retry loop. Returns
  ::HTTP::Status (not Bool) so embedders' retry policies can
  distinguish rejection (400 — stop) from unavailability (5xx — retry).
  Basic auth from URI userinfo; path prefix respected.
- Examples drop the explicit generic args.

This is a typing change only — no change to who may call the node or
from where. The single-fiber threading contract still applies and is
restated on both modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@antondalgren antondalgren force-pushed the degenericize-http-handlers branch from 997630c to 5ffde82 Compare June 12, 2026 11:43
@antondalgren antondalgren merged commit df3e1c9 into main Jun 12, 2026
5 checks passed
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