Repository navigation
General-purpose timer API design #233
Replies: 4 comments
Follow-up: Can a single facility handle all three timer use cases?After posting the design above, I started thinking about what it would look like if lori had one unified timer mechanism that could express idle timeout, connection timeout, and query timeout. Idle timeout auto-resets on I/O activity and re-arms after firing. Connection timeout fires during the connecting phase, before the connection is even open. Query timeout fires unconditionally after a fixed duration. Three different behaviors. Can one API express all of them? I looked at how five networking libraries handle this: Netty, Tokio, libuv/Node.js, Go The libraries split into two camps. The "integrated" camp (Netty, Go, Node.js) builds timer functionality into the connection's API surface. The "composed" camp (Tokio, Boost.Asio) keeps timers as independent objects that the user composes with connection I/O. Lori is firmly in the integrated camp because The closest model to what lori has is Node.js/libuv: Two instructive data points from other libraries. Go replaced Why unification failsI explored several unification approaches and each one falls apart for a specific reason. Policy enum ( Application callback ( Idle timeout as external helper class: Better than raw callbacks, but still around 20 lines vs 6. And the user has to remember to call The fundamental tension is that idle timeout's I/O-reset behavior is inherently library-level. The reset points are inside Connection timeout has a different problem entirely. It must work during Where this landsThree distinct features, each designed for its purpose:
The common cases get specific support. Everything else gets the building block. This validates the direction from the design above, with two adjustments: connection timeout is explicitly out of scope for the general timer (it deserves its own design), and the single-slot alternative from the end of the original post is the better starting point. |
Implementation plan: single-slot general-purpose timerGoing with the single-slot approach from the bottom of the design above. One user timer slot per connection, mirroring the idle timeout's field structure. The multi-timer array is parked. If someone demonstrates a genuine need for concurrent user timers on a single connection, the single-slot design extends to a small array without API changes (the token already identifies which timer fired).
|
Future refinement: shared timerfd for idle and user timersThe plan above gives each timer its own ASIO timer event, which means its own A shared timerfd approach would use one timer fd per connection for both logical timers. Track absolute deadlines (monotonic clock + duration) for each, arm the fd to the nearest deadline, and on fire check which timer(s) expired. Re-arm for the remaining one. The cost is complexity: every idle timer reset (which happens on every This doesn't affect the public API, so it's a pure internal optimization. The plan implements separate timerfds first (matching the existing idle/connect timer pattern), and the shared-fd optimization can be done as a follow-up without any API changes. |
Implementation: PR #241PR #241 implements the plan from the comment above. One adjustment during implementation: Test 9 changed: The original plan had a test that set a 2-second timer, called The replacement test ( Everything else from the plan is implemented as specified. |
Uh oh!
There was an error while loading. Please reload this page.
Origin: #232
The idle timeout API is really two things welded together: an ASIO timer and a policy of resetting that timer on I/O activity. That works great for detecting dead connections, but it doesn't work at all when you need a timer that fires regardless of what the wire is doing. The postgres driver's query timeout is the concrete case: a query streaming rows for 30 seconds should absolutely trigger a 10-second query timeout, but the idle timeout happily resets on every received chunk and never fires.
The alternatives aren't great. Raw ASIO timer events mean depending on runtime internals that could change. The stdlib
Timersactor has the scheduler livelock issue that the idle timeout was specifically designed to avoid by using per-connection ASIO events.Lori already has all the infrastructure for this. The idle timeout uses
PonyAsio.create_timer_eventandPonyAsio.set_timerunder the hood. A general-purpose timer API would expose that same mechanism without the I/O-reset behavior.The Design
One-shot timers on
TCPConnection, identified by an opaqueTimerToken, fired via an_on_timer(token)callback. No I/O-reset behavior. No automatic re-arming. The timer fires once after the configured duration, delivers the callback, and cleans itself up. If you want repetition, callset_timeragain from inside_on_timer. The idle timeout stays exactly as it is.New Types
TimerToken— Opaque identifier for a timer, following theSendTokenpattern.class valwith aUSizeid, private constructor, structural equality viaEquatable[TimerToken].TimerDuration— Constrained millisecond value using theconstrained_typesstdlib package. Same validation range asIdleTimeout(1 toU64.max_value() / 1_000_000milliseconds, preventing nanosecond overflow). Separate type because the semantics differ:TimerDurationcarries no I/O-reset policy.SetTimerError— Single-variant type alias wrappingSetTimerNotOpen. The type alias is forward-compatible with adding more error variants later without breaking callers.API on TCPConnection
Creates a one-shot ASIO timer. Returns a
TimerTokenon success orSetTimerNotOpenif the connection isn't open. Unlikeidle_timeout(), there's no pre-connection storage. The timer can only be set after_on_connected/_on_started.Cancels an active timer. No-op if the token doesn't match any active timer (already fired, already cancelled, never existed). Doesn't check connection state, so timers can be cancelled during graceful shutdown (
_Closing) since they remain active untilhard_close().Lifecycle Callback
Added to both
ServerLifecycleEventReceiverandClientLifecycleEventReceiver:Fires synchronously within
_event_notify(not deferred like_on_sent). The timer is consumed before the callback. No automatic re-arming.Internals
New fields on
TCPConnection:Small array of (token, event) pairs. Linear scan for dispatch and cancellation. The expected concurrent timer count per connection is small (1-3 in realistic use), so a hash map would be overhead without benefit.
Event dispatch in
_event_notifyinserts a scan between the idle timer identity check and theis_own_eventcapture. If a matchingAsioEventIDis found in_timers, the entry is removed, the ASIO event is unsubscribed, and_on_timer(token)fires. Removal before dispatch prevents aliasing when the callback callsset_timerorcancel_timer.Cleanup:
_cancel_all_timers()iterates the array, unsubscribes each ASIO event, and clears it. Called from both hard-close paths alongside_cancel_idle_timer(). No_on_timercallbacks fire during cleanup. Timers are NOT cancelled onclose()(graceful shutdown) because the application might use a timer during shutdown (e.g., a shutdown deadline).Stale events after cancellation: if a cancel runs after the timer's fire notification was queued but before it's processed, the stale fire notification doesn't match any entry in
_timers, falls through toforeign_event(which ignores non-writeable flags), and is silently dropped. The disposable notification from the unsubscribe is destroyed by the existing catch-all handler.What Doesn't Change
The idle timeout stays separate. It has I/O-reset behavior deeply integrated into
_read,_read_completed, andsend. It supports pre-connection configuration and auto-re-arms. Rewriting it on top of the general timer API would be a large refactor for no user-visible benefit. The two mechanisms share the same underlying ASIO timer primitives but are otherwise independent.The connection state machine (
_ConnectionStatetrait) doesn't gain new methods.set_timerchecksis_open()directly (likeidle_timeout), not routed through state classes.Usage Example
The postgres query-timeout pattern that motivated the discussion:
Files
New:
lori/timer_token.pony—TimerToken,SetTimerNotOpen,SetTimerErrorlori/timer_duration.pony—TimerDurationValidator,TimerDuration,MakeTimerDurationexamples/timer/timer.pony— query-timeout simulation exampleModified:
lori/lifecycle_event_receiver.pony— add_on_timerto both traitslori/tcp_connection.pony— fields,set_timer,cancel_timer,_fire_timer,_cancel_all_timers, event dispatchlori/_test.pony— new testslori/lori.pony— package docstring updateexamples/README.md— new example entryTests
SetTimerNotOpenwhen not connectedhard_closecancels active timersTimerDurationvalidation (zero rejects, boundary values)Not Included
_on_timer. Additive later.reset_timer(token, duration)— cancel + create achieves the same effect. Most likely future addition if cancel+create proves too expensive.set_timerrequiresis_open(). Connect timeouts are a different feature.Open Questions
ASIO timer one-shot behavior: The plan assumes
PonyAsio.create_timer_eventfires once and goes quiescent until re-armed viaset_timer. This matches how idle timeout uses it, but should be verified against ponyc's ASIO implementation.Timer event flags: The stale-event cleanup path depends on timer events not having the writeable flag set (so
foreign_eventignores them). Timer events useAsioEvent.timer()flags, which shouldn't include readable/writeable, but worth confirming.Alternative: Single-Slot Design
The design above supports multiple concurrent timers per connection. But thinking through the realistic use cases: connection timeout happens during
_ClientConnecting(beforeis_open(), so this API doesn't cover it), idle timeout already has its own dedicated slot, and query timeouts are sequential on a single postgres connection. The realistic concurrent user timer count is one.A single-slot design would replace the
_timersarray with a single_user_timer_eventfield, exactly mirroring the idle timeout's_timer_event. This eliminates the array, the linear scan in_event_notify, and the_cancel_all_timersiteration. Event dispatch becomes one more identity check instead of a loop. The API surface stays the same (set_timer/cancel_timer/_on_timer), just with the constraint that only one timer can be active at a time (setting a new one while one is active would either error or implicitly cancel the previous one).The tradeoff: more restrictive, but it's additive to expand to multiple timers later if someone demonstrates the need. And it avoids building a mini timer manager inside TCPConnection when the motivating use case only needs one slot.
All reactions