Skip to content

server: observe request delivery, pre-dispatch expiry, and claim outcomes - #118

Open
u9g wants to merge 9 commits into
mainfrom
r1-claim-observability
Open

server: observe request delivery, pre-dispatch expiry, and claim outcomes#118
u9g wants to merge 9 commits into
mainfrom
r1-claim-observability

Conversation

@u9g

@u9g u9g commented Jul 27, 2026

Copy link
Copy Markdown

Three server-side request lifecycle events are unobservable today, and they are the ones that matter when a client reports a failure:

  1. A request read off the bus isn't counted.
  2. A request arriving after its expiry is dropped in the read loop with no counter, no log, and no else branch.
  3. A server that bids and is never granted the claim records nothing — the client has already returned ErrNoResponse upstream, so the request exists in no log, metric or trace on either side.

Interceptors can't cover these: they wrap the handler, which in all three cases is never invoked.

Change

Adds RequestObserver, installed with WithServerObserver, with events for delivery, pre-dispatch expiry, and claim settlement (granted / lost_to_peer / abandoned) carrying the time spent waiting on the client's decision.

A callback interface rather than metrics, since psrpc has no metrics dependency — publishing stays the caller's concern. No logging added, so log levels also stay the caller's concern.

srv := server.NewRPCServer(sd, bus, psrpc.WithServerObserver(myObserver))

Nil observer is the default and every call site is nil-checked, so this is inert unless opted into. Additive to ServerOpts; MetricsObserver is untouched, so existing implementers are unaffected.

Why abandoned is the useful one

It makes two previously identical failures distinguishable. A client ErrNoResponse with a matching abandoned claim means the request was delivered and bid for; one with no delivery event means it never arrived. Those have different causes and different fixes, and currently look the same from every vantage point.

lost_to_peer is expected on broadcast RPCs, but on a queue RPC it means more than one member received the same request — also worth surfacing.

Tests

internal/test/observer_test.go covers the granted and abandoned paths, using a slow affinity function to delay the bid past the client's selection window deterministically. The abandoned test also asserts the handler was never invoked — the property that makes retrying ErrNoResponse safe, which had no regression test before.

Note: internal/test/my_service fails go vet on main already (undefined: MyServiceServer, needs codegen); unrelated to this change.

…omes

Three server-side request lifecycle events are currently unobservable, and
they are exactly the ones that matter when a client reports a failure:

  1. A request read off the bus is not counted anywhere.
  2. A request that arrives after its expiry is dropped with no counter, no
     log and no else branch.
  3. A server that bids and is never granted the claim records nothing. The
     client has already returned ErrNoResponse to its caller, so the request
     exists in no log, metric or trace on either side.

Interceptors cannot cover these: they wrap the handler, which in all three
cases is never invoked.

Adds RequestObserver, installed via WithServerObserver, with events for
delivery, pre-dispatch expiry, and claim settlement (granted / lost_to_peer /
abandoned) carrying the time spent waiting on the client's decision. psrpc has
no metrics dependency, so this is a callback interface for the caller to
publish; no logging is added here, keeping levels the caller's concern.

The abandoned outcome makes two previously indistinguishable failures
distinguishable: a client ErrNoResponse with a matching abandoned claim means
the request was delivered and bid for, while one with no delivery event means
it never arrived. Also lets a queue RPC detect lost_to_peer, which implies
more than one member received the same request.

Tests cover the granted and abandoned paths, and assert that an abandoned
claim leaves the handler un-invoked -- the property that makes retrying
ErrNoResponse safe.
@u9g
u9g force-pushed the r1-claim-observability branch from d68224c to a493586 Compare July 27, 2026 20:17
Lifecycle events fire outside the interceptor chain, so the observer has to
reach the server itself. Requiring a separate WithServerObserver call meant
every service had to opt in individually, while already passing an observer to
WithServerMetrics.

WithServerMetrics now type-asserts RequestObserver on the observer it is given
and wires it when present. Callers using WithServerMetrics -- or a wrapper such
as protocol's WithServerObservability -- get delivery, expiry and claim events
by implementing three methods on their existing observer, with no call-site
change. RequestObserver stays optional: a metrics-only observer is unaffected.

WithServerObserver remains for callers that want the events without metrics.

@paulwe paulwe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mostly ok. fwiw this probably isn't where dispatch is failing

Comment thread server.go Outdated
Comment thread pkg/middleware/metrics.go Outdated
@paulwe

paulwe commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

need to bump version in version/version.go

u9g added 7 commits July 27, 2026 19:14
…rver

Drop the RequestObserver type assertion in WithServerMetrics: it installed an
unrelated feature invisibly from the option's signature, and let option order
silently decide which observer won. Collapse the observer tests into one that
covers delivery, both claim outcomes and handler suppression.
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.

2 participants