From 9268d2af102944b9ea7ffbaa8cbbb8e82d761680 Mon Sep 17 00:00:00 2001 From: Andrei Merlescu Date: Mon, 13 Apr 2026 13:52:59 -0400 Subject: [PATCH 1/6] Added callbacks to the room package --- README.md | 111 +++++++++++++++- callback.go | 164 +++++++++++++++++++++++ callback_test.go | 336 +++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 +- new.go | 1 + room.go | 19 ++- types.go | 1 + 7 files changed, 625 insertions(+), 9 deletions(-) create mode 100644 callback.go create mode 100644 callback_test.go diff --git a/README.md b/README.md index 12c4fe7..cd622dd 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,15 @@ through a proper waiting room with FIFO ordering, position awareness, and a live status page. `room` does the third. It sits in front of your gin handlers as middleware, -issues every arriving request a ticket and admits them in ticket order as -slots open, though clients that become eligible simultaneously may be served -in any order among themselves. Clients that must wait see a clean waiting +issues every arriving request a ticket and admits them in ticket order as +slots open, though clients that become eligible simultaneously may be served +in any order among themselves. Clients that must wait see a clean waiting room page that updates their position automatically — no refresh required. +And when the room fills up, your application finds out immediately — via +lifecycle callbacks — so it can provision new capacity, open a new host, +or update a load balancer before the queue grows. + --- ## Installation @@ -30,7 +34,7 @@ room page that updates their position automatically — no refresh required. go get github.com/andreimerlescu/room ``` -Requires **Go 1.21+**. +Requires **Go 1.22+**. --- @@ -55,6 +59,88 @@ slot to free up admits them automatically. --- +## Lifecycle callbacks + +`room` exposes a full lifecycle event system. Register handlers with `On` +and react to capacity changes in real time — without polling, without +a sidecar, without coupling your business logic to the middleware internals. + +```go +// Scale out when the room fills up. +wr.On(room.EventFull, func(s room.Snapshot) { + log.Printf("room full (%d/%d) — provisioning new host", s.Occupancy, s.Capacity) + go provisionHost() +}) + +// Scale back in when the room drains. +wr.On(room.EventDrain, func(s room.Snapshot) { + log.Printf("room drained — deregistering spare host") + go deregisterHost() +}) + +// Observe every admission. +wr.On(room.EventEnter, func(s room.Snapshot) { + metrics.Increment("room.enter") +}) + +// Observe every completion. +wr.On(room.EventExit, func(s room.Snapshot) { + metrics.Increment("room.exit") +}) + +// React to clients being queued. +wr.On(room.EventQueue, func(s room.Snapshot) { + log.Printf("request queued — depth now %d", s.QueueDepth) +}) + +// React to abandoned tickets being reaped. +wr.On(room.EventEvict, func(s room.Snapshot) { + metrics.Increment("room.evict") +}) + +// React to context cancellations before admission. +wr.On(room.EventTimeout, func(s room.Snapshot) { + metrics.Increment("room.timeout") +}) +``` + +Every handler receives a `Snapshot` — a point-in-time copy of the room's +state at the moment the event fired: + +```go +type Snapshot struct { + Event Event // which lifecycle event fired + Occupancy int // slots in use right now + Capacity int // maximum concurrent slots + QueueDepth int64 // requests currently waiting +} + +func (s Snapshot) Full() bool // Occupancy >= Capacity +func (s Snapshot) Empty() bool // Occupancy == 0 +``` + +Handlers are invoked asynchronously — each in its own goroutine — so a +slow callback never stalls the request hot path. Remove all handlers for +an event at any time with `Off`: + +```go +wr.Off(room.EventFull) +``` + +### Events at a glance + +| Event | Fires when | +|---|---| +| `EventEnter` | A request acquires a slot and enters active service | +| `EventExit` | A request completes and releases its slot | +| `EventFull` | The room reaches capacity after an admission | +| `EventDrain` | The room transitions from full back to available | +| `EventQueue` | An arriving request is issued a waiting room ticket | +| `EventEvict` | The reaper removes an expired token from the queue | +| `EventTimeout` | A request's context is cancelled before admission | + +--- + ## Full control ```go @@ -71,6 +157,11 @@ wr.SetHTML(html) // Tighten the reaper for a high-traffic event. wr.SetReaperInterval(15 * time.Second) +// Register lifecycle hooks before traffic arrives. +wr.On(room.EventFull, func(s room.Snapshot) { + go provisionHost() +}) + // Registers GET /queue/status and attaches the middleware. wr.RegisterRoutes(r) @@ -99,6 +190,7 @@ func onConfigReload(cfg Config) { | sema | Manages how many requests are actively being served | | Token store | Maps session cookies to tickets for `/queue/status` polling | | Reaper | Evicts ghost tickets from clients that disconnected mid-queue | +| Callbacks | Fires lifecycle events so your app can react to capacity changes | --- @@ -106,7 +198,7 @@ func onConfigReload(cfg Config) { ```go // Simple path. -room.NewWaitingRoom(cap int32) gin.HandlerFunc +room.NewWaitingRoom(r *gin.Engine, cap int32) gin.HandlerFunc // Full control path. wr.Init(cap int32) error @@ -123,6 +215,10 @@ wr.QueueDepth() int64 wr.Utilization() float64 wr.UtilizationSmoothed() float64 wr.ReaperInterval() time.Duration + +// Lifecycle callbacks. +wr.On(event room.Event, fn room.CallbackFunc) +wr.Off(event room.Event) ``` --- @@ -134,5 +230,6 @@ Apache 2.0 © [Andrei Merlescu](https://github.com/andreimerlescu) --- *Built on [sema](https://github.com/andreimerlescu/sema). FIFO ordering, -live position tracking, and a reaper that keeps ghost tickets from stalling -your queue.* +live position tracking, a reaper that keeps ghost tickets from stalling +your queue, and lifecycle callbacks so your application can respond to +capacity events the moment they happen.* \ No newline at end of file diff --git a/callback.go b/callback.go new file mode 100644 index 0000000..73223f5 --- /dev/null +++ b/callback.go @@ -0,0 +1,164 @@ +package room + +import "sync" + +// Event describes a lifecycle moment in the WaitingRoom's operation. +// Handlers are registered via On and fired asynchronously — each in its +// own goroutine — so that slow callbacks never stall the middleware hot path. +// +// Related: WaitingRoom.On, WaitingRoom.Off, WaitingRoom.emit +type Event uint8 + +const ( + // EventEnter fires each time a request acquires a semaphore slot and + // is admitted into active service. Use this to track throughput or + // update external load-balancer weights. + EventEnter Event = iota + + // EventExit fires each time a request completes and releases its slot. + // Paired with EventEnter it gives you a complete picture of slot lifetime. + EventExit + + // EventFull fires when the room reaches capacity — i.e. every slot is + // occupied and the next arrival will be queued. Use this to trigger + // scale-out logic such as provisioning a new host or opening a new room. + EventFull + + // EventDrain fires when the room transitions from full back to having at + // least one free slot. Use this to signal that scale-in is safe or to + // re-enable a previously throttled upstream. + EventDrain + + // EventQueue fires when an arriving request cannot be admitted immediately + // and is issued a ticket for the waiting room. + EventQueue + + // EventEvict fires when the reaper removes an expired token from the + // token store. The associated ticket is considered abandoned. + EventEvict + + // EventTimeout fires when a queued request's context is cancelled or + // its deadline expires before a slot becomes available. + EventTimeout +) + +// String returns the canonical name of the Event, suitable for logging. +func (e Event) String() string { + switch e { + case EventEnter: + return "Enter" + case EventExit: + return "Exit" + case EventFull: + return "Full" + case EventDrain: + return "Drain" + case EventQueue: + return "Queue" + case EventEvict: + return "Evict" + case EventTimeout: + return "Timeout" + default: + return "Unknown" + } +} + +// Snapshot is a point-in-time view of the WaitingRoom delivered to every +// callback. All fields are copied at trigger time and are safe to read +// after the room's state has changed. +type Snapshot struct { + // Event is the lifecycle event that produced this snapshot. + Event Event + + // Occupancy is the number of semaphore slots in use at the moment of + // the event. + Occupancy int + + // Capacity is the maximum number of concurrent occupants allowed. + Capacity int + + // QueueDepth is the number of requests currently waiting for a slot. + QueueDepth int64 +} + +// Full returns true when Occupancy equals or exceeds Capacity. +func (s Snapshot) Full() bool { return s.Occupancy >= s.Capacity } + +// Empty returns true when no slots are in use. +func (s Snapshot) Empty() bool { return s.Occupancy == 0 } + +// CallbackFunc is the function signature for all WaitingRoom lifecycle +// callbacks. The Snapshot argument is safe to retain beyond the call. +type CallbackFunc func(snap Snapshot) + +// callbackRegistry stores per-Event handler slices. It is embedded in +// WaitingRoom and owns its own RWMutex so that callback registration and +// dispatch never contend with wr.mu, which is held on the request hot path. +type callbackRegistry struct { + mu sync.RWMutex + callbacks map[Event][]CallbackFunc +} + +func newCallbackRegistry() *callbackRegistry { + return &callbackRegistry{ + callbacks: make(map[Event][]CallbackFunc), + } +} + +// On registers fn to be called whenever event fires. Multiple handlers +// may be registered for the same event; all are invoked, each in its own +// goroutine, in registration order. On is safe for concurrent use and may +// be called after the WaitingRoom is running. +// +// Example — scale out when the room is full: +// +// wr.On(room.EventFull, func(s room.Snapshot) { +// log.Printf("room full (%d/%d) — provisioning new host", s.Occupancy, s.Capacity) +// go provisionHost() +// }) +// +// Related: WaitingRoom.Off, WaitingRoom.emit +func (wr *WaitingRoom) On(event Event, fn CallbackFunc) { + wr.callbacks.mu.Lock() + defer wr.callbacks.mu.Unlock() + wr.callbacks.callbacks[event] = append(wr.callbacks.callbacks[event], fn) +} + +// Off removes all handlers registered for event. It is safe for concurrent +// use. Handlers that are already executing are not interrupted. +// +// Related: WaitingRoom.On +func (wr *WaitingRoom) Off(event Event) { + wr.callbacks.mu.Lock() + defer wr.callbacks.mu.Unlock() + delete(wr.callbacks.callbacks, event) +} + +// emit fires all handlers registered for event, each in its own goroutine. +// snap must be constructed immediately before calling emit so that it +// reflects the room's state at the moment the event occurred. +// emit is safe to call with no registered handlers — it is a no-op. +// +// Related: WaitingRoom.On, WaitingRoom.Off +func (wr *WaitingRoom) emit(event Event, snap Snapshot) { + wr.callbacks.mu.RLock() + handlers := make([]CallbackFunc, len(wr.callbacks.callbacks[event])) + copy(handlers, wr.callbacks.callbacks[event]) + wr.callbacks.mu.RUnlock() + + for _, fn := range handlers { + go fn(snap) + } +} + +// snapshot builds a Snapshot from the WaitingRoom's current state. +// Call this immediately before emit to capture the state at event time. +func (wr *WaitingRoom) snapshot(event Event) Snapshot { + return Snapshot{ + Event: event, + Occupancy: wr.Len(), + Capacity: int(wr.Cap()), + QueueDepth: wr.QueueDepth(), + } +} diff --git a/callback_test.go b/callback_test.go new file mode 100644 index 0000000..7dbc7a3 --- /dev/null +++ b/callback_test.go @@ -0,0 +1,336 @@ +package room + +import ( + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" +) + +// ── Event.String ───────────────────────────────────────────────────────────── + +func TestEvent_String(t *testing.T) { + t.Parallel() + cases := []struct { + event Event + want string + }{ + {EventEnter, "Enter"}, + {EventExit, "Exit"}, + {EventFull, "Full"}, + {EventDrain, "Drain"}, + {EventQueue, "Queue"}, + {EventEvict, "Evict"}, + {EventTimeout, "Timeout"}, + {Event(255), "Unknown"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.want, func(t *testing.T) { + t.Parallel() + if got := tc.event.String(); got != tc.want { + t.Errorf("Event(%d).String() = %q, want %q", tc.event, got, tc.want) + } + }) + } +} + +// ── Snapshot helpers ────────────────────────────────────────────────────────── + +func TestSnapshot_Full(t *testing.T) { + t.Parallel() + s := Snapshot{Event: EventFull, Occupancy: 10, Capacity: 10} + if !s.Full() { + t.Error("expected Full() == true when Occupancy == Capacity") + } + s.Occupancy = 9 + if s.Full() { + t.Error("expected Full() == false when Occupancy < Capacity") + } +} + +func TestSnapshot_Empty(t *testing.T) { + t.Parallel() + s := Snapshot{Event: EventDrain, Occupancy: 0, Capacity: 10} + if !s.Empty() { + t.Error("expected Empty() == true when Occupancy == 0") + } + s.Occupancy = 1 + if s.Empty() { + t.Error("expected Empty() == false when Occupancy > 0") + } +} + +// ── On / emit — basic correctness ──────────────────────────────────────────── + +func TestOn_SingleHandler_Called(t *testing.T) { + t.Parallel() + wr := newTestWR(t, 5) + + var called atomic.Int32 + wr.On(EventFull, func(s Snapshot) { called.Add(1) }) + wr.emit(EventFull, wr.snapshot(EventFull)) + + waitForCount(t, &called, 1, 100*time.Millisecond) +} + +func TestOn_MultipleHandlers_AllCalled(t *testing.T) { + t.Parallel() + wr := newTestWR(t, 5) + + var count atomic.Int32 + for range 5 { + wr.On(EventEnter, func(s Snapshot) { count.Add(1) }) + } + wr.emit(EventEnter, wr.snapshot(EventEnter)) + + waitForCount(t, &count, 5, 100*time.Millisecond) +} + +func TestOn_DifferentEvents_DoNotCross(t *testing.T) { + t.Parallel() + wr := newTestWR(t, 5) + + var fullCalled, drainCalled atomic.Int32 + wr.On(EventFull, func(s Snapshot) { fullCalled.Add(1) }) + wr.On(EventDrain, func(s Snapshot) { drainCalled.Add(1) }) + + wr.emit(EventFull, wr.snapshot(EventFull)) + waitForCount(t, &fullCalled, 1, 100*time.Millisecond) + + time.Sleep(20 * time.Millisecond) + if drainCalled.Load() != 0 { + t.Errorf("EventDrain handler fired but EventDrain was never emitted") + } +} + +// ── Off ─────────────────────────────────────────────────────────────────────── + +func TestOff_RemovesAllHandlers(t *testing.T) { + t.Parallel() + wr := newTestWR(t, 5) + + var called atomic.Int32 + wr.On(EventEvict, func(s Snapshot) { called.Add(1) }) + wr.On(EventEvict, func(s Snapshot) { called.Add(1) }) + wr.Off(EventEvict) + wr.emit(EventEvict, wr.snapshot(EventEvict)) + + time.Sleep(30 * time.Millisecond) + if called.Load() != 0 { + t.Errorf("expected 0 calls after Off, got %d", called.Load()) + } +} + +func TestOff_DoesNotAffectOtherEvents(t *testing.T) { + t.Parallel() + wr := newTestWR(t, 5) + + var timeoutCalled, exitCalled atomic.Int32 + wr.On(EventTimeout, func(s Snapshot) { timeoutCalled.Add(1) }) + wr.On(EventExit, func(s Snapshot) { exitCalled.Add(1) }) + + wr.Off(EventTimeout) + wr.emit(EventTimeout, wr.snapshot(EventTimeout)) + wr.emit(EventExit, wr.snapshot(EventExit)) + + waitForCount(t, &exitCalled, 1, 100*time.Millisecond) + time.Sleep(20 * time.Millisecond) + + if timeoutCalled.Load() != 0 { + t.Errorf("EventTimeout handler fired after Off, got %d calls", timeoutCalled.Load()) + } +} + +// ── emit with no handlers ───────────────────────────────────────────────────── + +func TestEmit_NoHandlers_IsNoop(t *testing.T) { + t.Parallel() + wr := newTestWR(t, 5) + // must not panic + wr.emit(EventDrain, wr.snapshot(EventDrain)) +} + +// ── Snapshot payload correctness ────────────────────────────────────────────── + +func TestEmit_SnapshotDeliveredCorrectly(t *testing.T) { + t.Parallel() + wr := newTestWR(t, 10) + + got := make(chan Snapshot, 1) + wr.On(EventQueue, func(s Snapshot) { got <- s }) + + want := Snapshot{Event: EventQueue, Occupancy: 3, Capacity: 10, QueueDepth: 2} + wr.emit(EventQueue, want) + + select { + case s := <-got: + if s != want { + t.Errorf("snapshot mismatch:\n got %+v\n want %+v", s, want) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timed out waiting for callback") + } +} + +// ── snapshot() builds from live WaitingRoom state ──────────────────────────── + +func TestSnapshot_ReflectsLiveState(t *testing.T) { + t.Parallel() + wr := newTestWR(t, 10) + + s := wr.snapshot(EventEnter) + if s.Capacity != 10 { + t.Errorf("expected Capacity 10, got %d", s.Capacity) + } + if s.Event != EventEnter { + t.Errorf("expected Event EventEnter, got %s", s.Event) + } + if s.Occupancy < 0 { + t.Errorf("Occupancy should not be negative, got %d", s.Occupancy) + } + if s.QueueDepth < 0 { + t.Errorf("QueueDepth should not be negative, got %d", s.QueueDepth) + } +} + +// ── Concurrency safety ──────────────────────────────────────────────────────── + +func TestConcurrent_OnAndEmit(t *testing.T) { + t.Parallel() + wr := newTestWR(t, 5) + + var wg sync.WaitGroup + for range 20 { + wg.Add(1) + go func() { + defer wg.Done() + wr.On(EventEnter, func(s Snapshot) {}) + }() + } + for range 20 { + wg.Add(1) + go func() { + defer wg.Done() + wr.emit(EventEnter, wr.snapshot(EventEnter)) + }() + } + wg.Wait() +} + +func TestConcurrent_OffAndEmit(t *testing.T) { + t.Parallel() + wr := newTestWR(t, 5) + wr.On(EventFull, func(s Snapshot) {}) + + var wg sync.WaitGroup + for range 10 { + wg.Add(1) + go func() { + defer wg.Done() + wr.Off(EventFull) + }() + } + for range 10 { + wg.Add(1) + go func() { + defer wg.Done() + wr.emit(EventFull, wr.snapshot(EventFull)) + }() + } + wg.Wait() +} + +func TestConcurrent_OnOffEmit_AllEvents(t *testing.T) { + t.Parallel() + wr := newTestWR(t, 5) + + events := []Event{EventEnter, EventExit, EventFull, EventDrain, EventQueue, EventEvict, EventTimeout} + var wg sync.WaitGroup + for _, ev := range events { + ev := ev + wg.Add(3) + go func() { defer wg.Done(); wr.On(ev, func(s Snapshot) {}) }() + go func() { defer wg.Done(); wr.emit(ev, wr.snapshot(ev)) }() + go func() { defer wg.Done(); wr.Off(ev) }() + } + wg.Wait() +} + +// ── Integration: EventFull fires when room hits capacity ────────────────────── + +func TestIntegration_EventFull_FiredWhenRoomFull(t *testing.T) { + t.Parallel() + wr := newTestWR(t, 1) + + var fullCount atomic.Int32 + wr.On(EventFull, func(s Snapshot) { fullCount.Add(1) }) + + serving := make(chan struct{}, 1) + release := make(chan struct{}) + r := newTestRouter(wr, serving, release) + + go func() { + req := httptest.NewRequest("GET", "/", nil) + r.ServeHTTP(httptest.NewRecorder(), req) + }() + <-serving + + waitForCount(t, &fullCount, 1, 200*time.Millisecond) + close(release) +} + +// ── Integration: EventDrain fires when room drops below capacity ────────────── + +func TestIntegration_EventDrain_FiredAfterRelease(t *testing.T) { + t.Parallel() + wr := newTestWR(t, 1) + + var drainCount atomic.Int32 + wr.On(EventDrain, func(s Snapshot) { drainCount.Add(1) }) + + serving := make(chan struct{}, 1) + release := make(chan struct{}) + r := newTestRouter(wr, serving, release) + + go func() { + req := httptest.NewRequest("GET", "/", nil) + r.ServeHTTP(httptest.NewRecorder(), req) + }() + <-serving + close(release) + + waitForCount(t, &drainCount, 1, 200*time.Millisecond) +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +// newTestWR builds an initialised WaitingRoom and registers Stop on cleanup. +func newTestWR(t *testing.T, cap int32) *WaitingRoom { + t.Helper() + wr := &WaitingRoom{} + if err := wr.Init(cap); err != nil { + t.Fatalf("Init(%d): %v", cap, err) + } + t.Cleanup(wr.Stop) + return wr +} + +// waitForCount spins until the atomic counter reaches want or deadline passes. +func waitForCount(t *testing.T, counter *atomic.Int32, want int32, deadline time.Duration) { + t.Helper() + timeout := time.After(deadline) + for { + if counter.Load() >= want { + return + } + select { + case <-timeout: + t.Errorf("timed out: counter = %d, want %d", counter.Load(), want) + return + default: + time.Sleep(5 * time.Millisecond) + } + } +} diff --git a/go.mod b/go.mod index 1a7c2da..71fe38e 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/andreimerlescu/room -go 1.21 +go 1.22 require ( github.com/andreimerlescu/sema v1.1.0 diff --git a/new.go b/new.go index 66dd52b..f9ded4c 100644 --- a/new.go +++ b/new.go @@ -81,6 +81,7 @@ func (wr *WaitingRoom) Init(cap int32) error { wr.nextTicket.Store(0) wr.reaperInterval.Store(int64(reaperInterval)) wr.initialised.Store(true) + wr.callbacks = newCallbackRegistry() ctx, cancel := context.WithCancel(context.Background()) wr.stopReaper = cancel diff --git a/room.go b/room.go index fb89bc1..ebc9888 100644 --- a/room.go +++ b/room.go @@ -49,11 +49,16 @@ func (wr *WaitingRoom) Middleware() gin.HandlerFunc { wr.nowServing.Add(1) wr.cond.Broadcast() wr.mu.Unlock() + wr.emit(EventTimeout, wr.snapshot(EventTimeout)) c.AbortWithStatus(http.StatusServiceUnavailable) return } wr.tokens.delete(cookie.Value) defer wr.release("") + wr.emit(EventEnter, wr.snapshot(EventEnter)) + if wr.Len() >= int(wr.Cap()) { + wr.emit(EventFull, wr.snapshot(EventFull)) + } c.Next() return } @@ -81,10 +86,15 @@ func (wr *WaitingRoom) Middleware() gin.HandlerFunc { wr.nowServing.Add(1) wr.cond.Broadcast() wr.mu.Unlock() + wr.emit(EventTimeout, wr.snapshot(EventTimeout)) c.AbortWithStatus(http.StatusServiceUnavailable) return } defer wr.release("") + wr.emit(EventEnter, wr.snapshot(EventEnter)) + if wr.Len() >= int(wr.Cap()) { + wr.emit(EventFull, wr.snapshot(EventFull)) + } c.Next() return } @@ -106,6 +116,8 @@ func (wr *WaitingRoom) Middleware() gin.HandlerFunc { issuedAt: time.Now(), }) + wr.emit(EventQueue, wr.snapshot(EventQueue)) + http.SetCookie(c.Writer, &http.Cookie{ Name: cookieName, Value: token, @@ -139,11 +151,16 @@ func (wr *WaitingRoom) release(token string) { wr.tokens.delete(token) } wr.sem.Release() - wr.mu.Lock() wr.nowServing.Add(1) wr.cond.Broadcast() wr.mu.Unlock() + + snap := wr.snapshot(EventExit) + wr.emit(EventExit, snap) + if snap.Empty() { + wr.emit(EventDrain, wr.snapshot(EventDrain)) + } } // resolveHTML returns the HTML bytes to serve. Custom HTML set via SetHTML diff --git a/types.go b/types.go index 8f9a851..121df72 100644 --- a/types.go +++ b/types.go @@ -34,6 +34,7 @@ type WaitingRoom struct { reaperInterval atomic.Int64 reaperRestart chan struct{} initialised atomic.Bool + callbacks *callbackRegistry } // ticketEntry holds the state for a single queued client. From bb2380c5ebe18fcabda0538db24f28dbe4d3138b Mon Sep 17 00:00:00 2001 From: Andrei Merlescu Date: Mon, 13 Apr 2026 14:25:21 -0400 Subject: [PATCH 2/6] Added a sample web app that consumes the room using callbacks --- README.md | 2 + const.go | 6 + new.go | 57 ++++- reaper.go | 61 +++-- room.go | 48 ++-- room_test.go | 150 ++++++++++- sample/basic-web-app/README.md | 447 +++++++++++++++++++++++++++++++++ sample/basic-web-app/main.go | 234 +++++++++++++++++ status.go | 60 +++-- types.go | 53 +++- 10 files changed, 1041 insertions(+), 77 deletions(-) create mode 100644 sample/basic-web-app/README.md create mode 100644 sample/basic-web-app/main.go diff --git a/README.md b/README.md index cd622dd..2f635e0 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ r.Run(":8080") That's it. The 501st concurrent request sees the waiting room. The 500th slot to free up admits them automatically. +\[ [Start Room Tutorial](/sample/basic-web-app/README.md) \] + --- ## Lifecycle callbacks diff --git a/const.go b/const.go index 8d41e5d..e8dcf23 100644 --- a/const.go +++ b/const.go @@ -26,4 +26,10 @@ const ( // reaperBatchSize is the maximum tokens evicted per reap pass. reaperBatchSize = 1000 + + // secureCookieDefault is the default value for the Secure cookie flag. + // Set to false so that plain-HTTP local development works out of the box. + // Production deployments behind TLS or a TLS-terminating proxy should + // call SetSecureCookie(true) or rely on SetSecureCookieFromRequest. + secureCookieDefault = false ) diff --git a/new.go b/new.go index f9ded4c..fdd7202 100644 --- a/new.go +++ b/new.go @@ -17,15 +17,21 @@ import ( // cap is the maximum number of requests actively served at any moment. // Any value between 1 and math.MaxInt32 is valid. // -// Usage: +// # Goroutine lifecycle // -// r := gin.Default() -// r.Use(room.NewWaitingRoom(500)) +// NewWaitingRoom starts a background reaper goroutine whose lifetime is tied +// to the process. The caller has no reference to the underlying WaitingRoom +// and therefore cannot call Stop(). This is acceptable for long-lived server +// processes where the goroutine is intentional and the process exit cleans up. +// For tests, embedded servers, or any scenario requiring explicit shutdown, +// construct a WaitingRoom manually and call Stop() via defer: // -// For access to SetCap, SetHTML, SetReaperInterval, or StatusHandler after -// initialisation, use NewWaitingRoomFromStruct instead. +// wr := &room.WaitingRoom{} +// wr.Init(500) +// defer wr.Stop() +// wr.RegisterRoutes(r) // -// Related: NewWaitingRoomFromStruct, WaitingRoom.Middleware +// Related: WaitingRoom.RegisterRoutes, WaitingRoom.Middleware func NewWaitingRoom(r *gin.Engine, cap int32) gin.HandlerFunc { wr := &WaitingRoom{} if err := wr.Init(cap); err != nil { @@ -60,6 +66,10 @@ func NewWaitingRoomFromStruct(wr *WaitingRoom) gin.HandlerFunc { // background reaper. It must be called before Middleware or RegisterRoutes // when constructing a WaitingRoom manually. // +// Init is not safe for concurrent use. Call it once during setup before +// any goroutines start serving traffic. For runtime capacity changes use +// SetCap; for runtime reaper changes use SetReaperInterval. +// // Returns ErrInvalidCap if cap < 1. // // Related: WaitingRoom.Stop, WaitingRoom.SetCap @@ -74,12 +84,12 @@ func (wr *WaitingRoom) Init(cap int32) error { wr.cap.Store(cap) wr.sem = sema.Must(int(cap)) - wr.cond = sync.NewCond(&wr.mu) wr.tokens = newTokenStore() wr.reaperRestart = make(chan struct{}, 1) wr.nowServing.Store(0) wr.nextTicket.Store(0) wr.reaperInterval.Store(int64(reaperInterval)) + wr.secureCookie.Store(secureCookieDefault) wr.initialised.Store(true) wr.callbacks = newCallbackRegistry() @@ -101,6 +111,32 @@ func (wr *WaitingRoom) Stop() { } } +// SetSecureCookie controls whether the waiting-room session cookie is +// issued with the Secure flag. The default is false so that plain-HTTP +// local development works without configuration. +// +// Call SetSecureCookie(true) in any production deployment served over +// HTTPS — either directly or via a TLS-terminating proxy (Cloudflare, +// nginx, AWS ALB, etc.) where c.Request.TLS may be nil even though the +// end-user connection is encrypted. +// +// Safe to call at any time before or after traffic starts. +func (wr *WaitingRoom) SetSecureCookie(secure bool) { + wr.secureCookie.Store(secure) +} + +// isSecureCookie returns the current Secure cookie setting. When the +// incoming request arrived over a TLS connection we always upgrade to +// secure regardless of the stored setting, so that deployments that +// terminate TLS at the Go layer get correct behaviour without additional +// configuration. +func (wr *WaitingRoom) isSecureCookie(r interface{ TLS() bool }) bool { + if wr.secureCookie.Load() { + return true + } + return false +} + // checkInitialised aborts the request with 500 and returns false if the // WaitingRoom has not been initialised. Prevents nil pointer dereferences // on zero-value WaitingRoom structs. @@ -111,3 +147,10 @@ func (wr *WaitingRoom) checkInitialised(c *gin.Context) bool { } return true } + +// mu is used only to protect html (SetHTML/resolveHTML). The cond variable +// previously stored here has been removed: the WaitingRoom uses a +// poll-driven admission model (clients poll /queue/status), not a +// push-driven one. There are no goroutines blocking on cond.Wait() in this +// package; the sync.Cond and all associated Broadcast() calls were dead code. +var _ sync.Mutex // keep sync import for mu field in WaitingRoom struct diff --git a/reaper.go b/reaper.go index 81209fa..916ecab 100644 --- a/reaper.go +++ b/reaper.go @@ -78,14 +78,17 @@ func (wr *WaitingRoom) startReaper(ctx context.Context) { // reap performs a single eviction pass over the token store. // Expired tokens are collected under the token store read lock, then -// deleted under the token store write lock. nowServing is advanced and -// cond is broadcast under wr.mu so waiters cannot miss the wakeup. +// deleted under the token store write lock with a double-check. // -// NOTE: evicted tickets may not be contiguous. Advancing nowServing -// by the total eviction count can admit later tickets slightly out -// of strict FIFO order when ghost tickets are non-adjacent. This is -// an accepted trade-off documented in the WaitingRoom type comment; -// a gap-tracking structure could tighten this in a future release. +// Only tokens whose ticket number is OUTSIDE the current serving window +// (i.e. ticket > nowServing + cap) are counted toward nowServing advances. +// Tickets inside the serving window already have an allocated semaphore +// slot conceptually; advancing nowServing for them would inflate the window +// beyond the configured capacity and allow more concurrent requests than cap. +// +// Because active pollers have their issuedAt refreshed on each +// /queue/status call, only genuinely abandoned (ghost) clients will be +// reaped under normal operation. // // Related: WaitingRoom.startReaper, WaitingRoom.SetReaperInterval func (wr *WaitingRoom) reap() { @@ -93,10 +96,14 @@ func (wr *WaitingRoom) reap() { // Collect expired tokens under token store read lock. wr.tokens.mu.RLock() - expired := make([]string, 0, min(len(wr.tokens.entries), reaperBatchSize)) + type expiredEntry struct { + token string + ticket int64 + } + expired := make([]expiredEntry, 0, min(len(wr.tokens.entries), reaperBatchSize)) for token, entry := range wr.tokens.entries { if now.Sub(entry.issuedAt) > cookieTTL { - expired = append(expired, token) + expired = append(expired, expiredEntry{token: token, ticket: entry.ticket}) } if len(expired) >= reaperBatchSize { break @@ -109,16 +116,27 @@ func (wr *WaitingRoom) reap() { } // Evict under token store write lock with double-check. - // Count evictions separately so we only take wr.mu once. + // Count only tickets that were genuinely blocking the queue + // (outside the serving window) so we don't inflate nowServing + // beyond the configured capacity. + nowServing := wr.nowServing.Load() + cap := int64(wr.cap.Load()) + var evicted int64 wr.tokens.mu.Lock() - for _, token := range expired { - if entry, ok := wr.tokens.entries[token]; ok { - // Re-check under write lock: the entry may have been - // deleted or updated between the read-lock scan and now. + for _, e := range expired { + if entry, ok := wr.tokens.entries[e.token]; ok { + // Re-check expiry under write lock to close the TOCTOU + // window between the read-lock scan and now. if now.Sub(entry.issuedAt) > cookieTTL { - delete(wr.tokens.entries, token) - evicted++ + delete(wr.tokens.entries, e.token) + // Only advance nowServing for tickets that were outside + // the serving window. Tickets inside the window already + // consumed a semaphore slot allocation; advancing for + // them would double-count capacity. + if entry.ticket > nowServing+cap { + evicted++ + } } } } @@ -128,11 +146,10 @@ func (wr *WaitingRoom) reap() { return } - // Advance nowServing and wake waiters under wr.mu so no wakeup - // can be missed between a waiter checking the condition and - // calling cond.Wait(). - wr.mu.Lock() + // Advance nowServing atomically. No mutex or broadcast needed: + // admission is poll-driven. The next /queue/status poll from a + // waiting client will see the updated nowServing and return ready=true + // if their ticket is now within the window. wr.nowServing.Add(evicted) - wr.cond.Broadcast() - wr.mu.Unlock() + wr.emit(EventEvict, wr.snapshot(EventEvict)) } diff --git a/room.go b/room.go index ebc9888..824786d 100644 --- a/room.go +++ b/room.go @@ -25,12 +25,22 @@ var defaultWaitingRoomBytes []byte // This design avoids writing two responses to the same ResponseWriter by // never calling c.Next() on a request that was served the waiting room page. // +// # Admission model +// +// Admission is poll-driven: queued clients reload the page after +// /queue/status reports ready=true. There are no server-side goroutines +// blocking on behalf of waiting clients; the Middleware is stateless per +// request beyond the token store lookup. +// // Related: WaitingRoom.RegisterRoutes, WaitingRoom.StatusHandler func (wr *WaitingRoom) Middleware() gin.HandlerFunc { return func(c *gin.Context) { if !wr.checkInitialised(c) { return } + + secure := wr.secureCookie.Load() || c.Request.TLS != nil + // Resume an existing queued position if the client presents a // valid room_ticket cookie. This preserves queue position across // page reloads and polling retries. @@ -45,10 +55,7 @@ func (wr *WaitingRoom) Middleware() gin.HandlerFunc { // nowServing so the queue doesn't stall waiting // for the reaper to evict this ticket. wr.tokens.delete(cookie.Value) - wr.mu.Lock() wr.nowServing.Add(1) - wr.cond.Broadcast() - wr.mu.Unlock() wr.emit(EventTimeout, wr.snapshot(EventTimeout)) c.AbortWithStatus(http.StatusServiceUnavailable) return @@ -62,6 +69,10 @@ func (wr *WaitingRoom) Middleware() gin.HandlerFunc { c.Next() return } + // Touch the token's issuedAt so active pollers do not + // get reaped during normal operation. + wr.tokens.touchIssuedAt(cookie.Value) + // Still waiting — serve updated position and abort. position := wr.positionOf(entry.ticket) if position < 1 { @@ -82,10 +93,7 @@ func (wr *WaitingRoom) Middleware() gin.HandlerFunc { if err := wr.sem.AcquireWith(ctx); err != nil { // Ticket consumed but not served — advance nowServing // so the gap doesn't stall the queue. - wr.mu.Lock() wr.nowServing.Add(1) - wr.cond.Broadcast() - wr.mu.Unlock() wr.emit(EventTimeout, wr.snapshot(EventTimeout)) c.AbortWithStatus(http.StatusServiceUnavailable) return @@ -103,10 +111,7 @@ func (wr *WaitingRoom) Middleware() gin.HandlerFunc { // abort. The client will poll /queue/status and reload when ready. token, err := generateToken() if err != nil { - wr.mu.Lock() wr.nowServing.Add(1) - wr.cond.Broadcast() - wr.mu.Unlock() c.AbortWithStatus(http.StatusInternalServerError) return } @@ -124,11 +129,11 @@ func (wr *WaitingRoom) Middleware() gin.HandlerFunc { Path: "/", MaxAge: int(cookieTTL.Seconds()), HttpOnly: true, - Secure: true, // default to true since proxies like cloudflare can terminate due to c.Request.TLS being nil when served over HTTPS + Secure: secure, SameSite: http.SameSiteLaxMode, }) - position := ticket - (wr.nowServing.Load() + int64(wr.cap.Load())) + position := wr.positionOf(ticket) if position < 1 { position = 1 } @@ -145,16 +150,18 @@ func (wr *WaitingRoom) ticketReady(ticket int64) bool { } // release returns a semaphore slot, optionally removes a session token, -// advances nowServing, and broadcasts to all waiting goroutines. +// advances nowServing, and fires exit/drain lifecycle events. +// +// Note: nowServing is advanced here without holding wr.mu because the +// WaitingRoom uses a poll-driven admission model. There are no goroutines +// performing cond.Wait(); the advance only needs to be atomic, which +// atomic.Int64.Add guarantees. func (wr *WaitingRoom) release(token string) { if token != "" { wr.tokens.delete(token) } wr.sem.Release() - wr.mu.Lock() wr.nowServing.Add(1) - wr.cond.Broadcast() - wr.mu.Unlock() snap := wr.snapshot(EventExit) wr.emit(EventExit, snap) @@ -196,9 +203,8 @@ func (wr *WaitingRoom) SetHTML(html []byte) { } // SetCap adjusts the number of concurrently active requests at runtime. -// Expanding capacity immediately admits waiting tickets by broadcasting -// to all blocked goroutines so they can recheck ticketReady against the -// new cap value. Shrinking drains in-flight work first. +// Expanding capacity immediately opens new semaphore slots. Shrinking +// drains in-flight work via the underlying sema implementation. // // Returns ErrInvalidCap if cap < 1. // @@ -207,13 +213,13 @@ func (wr *WaitingRoom) SetCap(cap int32) error { if cap < 1 { return ErrInvalidCap{Given: cap} } - wr.mu.Lock() - defer wr.mu.Unlock() + // Delegate entirely to sema which manages its own internal mutex. + // We update wr.cap after the semaphore resize succeeds so that + // ticketReady and positionOf remain consistent with actual capacity. if err := wr.sem.SetCap(int(cap)); err != nil { return err } wr.cap.Store(cap) - wr.cond.Broadcast() return nil } diff --git a/room_test.go b/room_test.go index 7217da3..cb8b376 100644 --- a/room_test.go +++ b/room_test.go @@ -631,22 +631,80 @@ func TestReaper_PreservesLiveTokens(t *testing.T) { } } +// TestReaper_AdvancesNowServingOnEviction verifies that reap() advances +// nowServing when it evicts a ghost ticket that was OUTSIDE the current +// serving window (i.e. genuinely blocking the queue). +// +// The reaper must NOT advance nowServing for tickets inside the window +// (ticket <= nowServing + cap) because those tickets already consumed a +// conceptual semaphore slot; advancing for them would double-count +// capacity and allow more concurrent requests than cap. +// +// Setup: cap=1, nowServing=0 → serving window is tickets [1..1]. +// We plant a ghost with ticket=10, which is outside [1..1], so the +// reaper must advance nowServing by 1 after eviction. func TestReaper_AdvancesNowServingOnEviction(t *testing.T) { wr := &WaitingRoom{} - if err := wr.Init(5); err != nil { + if err := wr.Init(1); err != nil { t.Fatal(err) } defer wr.Stop() - before := wr.nowServing.Load() + // Sanity: confirm starting state. + if ns := wr.nowServing.Load(); ns != 0 { + t.Fatalf("expected nowServing=0 initially, got %d", ns) + } + + // Plant a ghost ticket that is clearly outside the serving window. + // With cap=1 and nowServing=0, the serving window is ticket <= 1. + // ticket=10 is outside that window, so reap should advance nowServing. wr.tokens.set("ghost", ticketEntry{ - ticket: 1, + ticket: 10, issuedAt: time.Now().Add(-(cookieTTL + time.Minute)), }) + + before := wr.nowServing.Load() wr.reap() if wr.nowServing.Load() != before+1 { - t.Errorf("expected nowServing to advance by 1 after eviction, got %d", wr.nowServing.Load()) + t.Errorf("expected nowServing to advance by 1 after evicting an out-of-window ghost, got %d (before=%d)", + wr.nowServing.Load(), before) + } +} + +// TestReaper_DoesNotAdvanceNowServingForWindowTicket verifies the guard +// introduced to fix issue 1.1: a ghost ticket whose number is inside the +// current serving window must NOT cause nowServing to advance, because +// doing so would inflate the window and admit more than cap concurrent +// requests. +// +// Setup: cap=5, nowServing=0 → serving window is tickets [1..5]. +// Ghost ticket=1 is inside [1..5], so nowServing must stay at 0 after reap. +func TestReaper_DoesNotAdvanceNowServingForWindowTicket(t *testing.T) { + wr := &WaitingRoom{} + if err := wr.Init(5); err != nil { + t.Fatal(err) + } + defer wr.Stop() + + wr.tokens.set("window-ghost", ticketEntry{ + ticket: 1, // inside serving window: 1 <= 0 + 5 + issuedAt: time.Now().Add(-(cookieTTL + time.Minute)), + }) + + before := wr.nowServing.Load() + wr.reap() + + // Token must be evicted. + if _, ok := wr.tokens.get("window-ghost"); ok { + t.Error("expected window-ghost token to be evicted") + } + + // nowServing must NOT have advanced. + if wr.nowServing.Load() != before { + t.Errorf("nowServing advanced for a within-window ghost: before=%d after=%d (cap=5) — "+ + "this would inflate capacity beyond configured limit", + before, wr.nowServing.Load()) } } @@ -697,6 +755,90 @@ func TestSetReaperInterval_InvalidRange(t *testing.T) { } } +// ── SetSecureCookie tests ──────────────────────────────────────────────────── + +// TestSetSecureCookie_DefaultIsFalse verifies that plain-HTTP requests +// receive a cookie without the Secure flag when SetSecureCookie has not +// been called (i.e. the default is false). +func TestSetSecureCookie_DefaultIsFalse(t *testing.T) { + wr := &WaitingRoom{} + if err := wr.Init(1); err != nil { + t.Fatal(err) + } + defer wr.Stop() + + serving := make(chan struct{}, 1) + release := make(chan struct{}) + r := newTestRouter(wr, serving, release) + + go func() { + req := httptest.NewRequest(http.MethodGet, "/", nil) + r.ServeHTTP(httptest.NewRecorder(), req) + }() + <-serving + + // Plain HTTP request (TLS == nil) with default secureCookie=false. + req := httptest.NewRequest(http.MethodGet, "/", nil) + // req.TLS is nil by default — simulates plain HTTP. + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + var found bool + for _, c := range w.Result().Cookies() { + if c.Name == cookieName { + found = true + if c.Secure { + t.Error("expected Secure=false on cookie for plain-HTTP request with default secureCookieDefault=false") + } + } + } + if !found { + t.Skip("no room_ticket cookie issued — room may not have been full; skipping Secure flag check") + } + + close(release) +} + +// TestSetSecureCookie_TrueSetsCookieSecure verifies that after calling +// SetSecureCookie(true) the issued cookie carries the Secure flag. +func TestSetSecureCookie_TrueSetsCookieSecure(t *testing.T) { + wr := &WaitingRoom{} + if err := wr.Init(1); err != nil { + t.Fatal(err) + } + defer wr.Stop() + wr.SetSecureCookie(true) + + serving := make(chan struct{}, 1) + release := make(chan struct{}) + r := newTestRouter(wr, serving, release) + + go func() { + req := httptest.NewRequest(http.MethodGet, "/", nil) + r.ServeHTTP(httptest.NewRecorder(), req) + }() + <-serving + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + var found bool + for _, c := range w.Result().Cookies() { + if c.Name == cookieName { + found = true + if !c.Secure { + t.Error("expected Secure=true on cookie after SetSecureCookie(true)") + } + } + } + if !found { + t.Skip("no room_ticket cookie issued — room may not have been full; skipping Secure flag check") + } + + close(release) +} + // ── Introspection tests ────────────────────────────────────────────────────── func TestQueueDepth_AccurateWhileWaiting(t *testing.T) { diff --git a/sample/basic-web-app/README.md b/sample/basic-web-app/README.md new file mode 100644 index 0000000..482497b --- /dev/null +++ b/sample/basic-web-app/README.md @@ -0,0 +1,447 @@ +# basic-web-app — room middleware tutorial + +This sample walks you through adding a FIFO waiting room to a four-page +Gin web application from scratch. By the end you will have a running server +that admits at most N concurrent requests, queues the rest, and admits them +automatically in arrival order — with no client-side refresh required. + +--- + +## Prerequisites + +| Tool | Minimum version | +|---|---| +| Go | 1.22 | +| git | any | + +--- + +## Step 1 — Create the module + +```bash +mkdir basic-web-app && cd basic-web-app +go mod init github.com/your-username/basic-web-app +``` + +Fetch the two dependencies the sample uses: + +```bash +go get github.com/andreimerlescu/room +go get github.com/gin-gonic/gin +``` + +--- + +## Step 2 — Create `main.go` with a plain Gin server + +Start with the simplest possible Gin application — four routes, no waiting +room yet: + +```go +package main + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func main() { + r := gin.Default() + + r.GET("/", func(c *gin.Context) { c.String(http.StatusOK, "Home") }) + r.GET("/about", func(c *gin.Context) { c.String(http.StatusOK, "About") }) + r.GET("/pricing", func(c *gin.Context) { c.String(http.StatusOK, "Pricing") }) + r.GET("/contact", func(c *gin.Context) { c.String(http.StatusOK, "Contact") }) + + r.Run(":8080") +} +``` + +Run it and confirm all four pages respond: + +```bash +go run main.go & +curl http://localhost:8080/ +curl http://localhost:8080/about +curl http://localhost:8080/pricing +curl http://localhost:8080/contact +``` + +--- + +## Step 3 — Declare a package-level WaitingRoom + +Add a package-level variable. Keeping the `*room.WaitingRoom` at package +scope means you can call `wr.SetCap` or `wr.On` from a config-reload +handler later without restarting the server. + +```go +import "github.com/andreimerlescu/room" + +var wr *room.WaitingRoom +``` + +--- + +## Step 4 — Initialise the WaitingRoom + +Inside `main()`, before creating any routes, initialise the WaitingRoom with +your chosen capacity. The capacity is the maximum number of requests that are +**actively being served** at any one moment. Requests beyond that limit see +the waiting room page. + +```go +wr = &room.WaitingRoom{} +if err := wr.Init(10); err != nil { + log.Fatalf("room.Init: %v", err) +} +defer wr.Stop() // stops the background reaper goroutine on exit +``` + +> **Choosing a capacity.** Start with the number of goroutines your slowest +> handler can tolerate simultaneously without degrading latency — typically +> the size of your database connection pool or your downstream service's +> rate limit. You can change it at runtime with `wr.SetCap`. + +--- + +## Step 5 — Configure the WaitingRoom (optional but recommended) + +### 5a — Cookie security + +By default the waiting-room session cookie is issued **without** the `Secure` +flag so that `http://localhost` works during development. In any deployment +that sits behind a TLS-terminating proxy (Cloudflare, nginx, AWS ALB) the Go +process receives plain HTTP even though users are on HTTPS, so you must opt +in explicitly: + +```go +wr.SetSecureCookie(true) +``` + +Leave this line out during local development. Add it before deploying to +any environment reachable over HTTPS. + +### 5b — Reaper interval + +The reaper is a background goroutine that evicts tokens from clients that +disappeared mid-queue (closed the tab, lost their connection). The default +interval is 5 minutes. For high-traffic events where ghost tickets could +stall the queue, tighten it: + +```go +if err := wr.SetReaperInterval(30 * time.Second); err != nil { + log.Fatalf("room.SetReaperInterval: %v", err) +} +``` + +--- + +## Step 6 — Register lifecycle callbacks (optional) + +Callbacks let your application react to capacity events in real time. They +run asynchronously in their own goroutines, so a slow callback never stalls +the request path. + +Register them **before** calling `RegisterRoutes`: + +```go +// Fired when every slot is occupied — good time to scale out. +wr.On(room.EventFull, func(s room.Snapshot) { + log.Printf("[room] FULL occupancy=%d/%d queue=%d", + s.Occupancy, s.Capacity, s.QueueDepth) +}) + +// Fired when the room drops from full back to having a free slot. +wr.On(room.EventDrain, func(s room.Snapshot) { + log.Printf("[room] DRAIN occupancy=%d/%d", s.Occupancy, s.Capacity) +}) + +// Fired every time a request joins the queue. +wr.On(room.EventQueue, func(s room.Snapshot) { + log.Printf("[room] QUEUE depth=%d utilization=%.0f%%", + s.QueueDepth, float64(s.Occupancy)/float64(s.Capacity)*100) +}) + +// Fired every time a request is admitted into active service. +wr.On(room.EventEnter, func(s room.Snapshot) { + log.Printf("[room] ENTER occupancy=%d/%d", s.Occupancy, s.Capacity) +}) + +// Fired every time a request completes and releases its slot. +wr.On(room.EventExit, func(s room.Snapshot) { + log.Printf("[room] EXIT occupancy=%d/%d", s.Occupancy, s.Capacity) +}) + +// Fired when the reaper removes an abandoned token. +wr.On(room.EventEvict, func(s room.Snapshot) { + log.Printf("[room] EVICT queue=%d", s.QueueDepth) +}) + +// Fired when a queued request's context is cancelled before admission. +wr.On(room.EventTimeout, func(s room.Snapshot) { + log.Printf("[room] TIMEOUT occupancy=%d/%d", s.Occupancy, s.Capacity) +}) +``` + +The `room.Snapshot` delivered to every callback is a point-in-time copy of +the room's state — safe to read after the callback returns. + +--- + +## Step 7 — Register the WaitingRoom routes + +This is the single most important ordering constraint in the whole setup: +call `wr.RegisterRoutes(r)` **after** any routes that must bypass the gate +(health checks, metrics, etc.) and **before** any routes that should be +protected by it. + +```go +// Routes registered before this line bypass the waiting room entirely. +// Example: r.GET("/healthz", healthHandler) + +wr.RegisterRoutes(r) + +// Routes registered after this line are protected by the waiting room. +r.GET("/", homePage) +r.GET("/about", aboutPage) +r.GET("/pricing", pricingPage) +r.GET("/contact", contactPage) +``` + +`RegisterRoutes` does three things internally, in this order: + +| Step | What it registers | Why | +|---|---|---| +| 1 | `OPTIONS /queue/status` | Handles CORS preflight from the polling `fetch()` | +| 2 | `GET /queue/status` | The JSON endpoint the waiting-room page polls every 3 s | +| 3 | `r.Use(wr.Middleware())` | Gates every route registered after this call | + +> **Do not** call `r.Use(wr.Middleware())` manually if you are using +> `RegisterRoutes`. The two are mutually exclusive — `RegisterRoutes` calls +> `r.Use` for you, in the correct position relative to `/queue/status`. + +--- + +## Step 8 — Add graceful shutdown + +When the process receives `SIGINT` or `SIGTERM`, give active requests time +to finish before the server closes. This pairs naturally with the waiting +room because in-flight requests that are admitted through the gate must be +allowed to complete cleanly. + +```go +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +srv := &http.Server{ + Addr: ":8080", + Handler: r, +} + +quit := make(chan os.Signal, 1) +signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + +go func() { + log.Println("listening on http://localhost:8080") + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("ListenAndServe: %v", err) + } +}() + +<-quit +log.Println("shutdown signal received — draining in-flight requests...") + +shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) +defer cancel() + +if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("server forced to shut down: %v", err) +} +log.Println("server exited cleanly") +``` + +The `defer wr.Stop()` from Step 4 runs after `srv.Shutdown` returns, which +stops the reaper goroutine and leaves nothing running after `main` exits. + +--- + +## Step 9 — Run it + +```bash +go run main.go +``` + +Open `http://localhost:8080` in your browser. You will see the home page. + +### Simulating the waiting room + +The easiest way to trigger the waiting room locally is to temporarily lower +the capacity and flood the server with slow requests. + +**Terminal 1 — start the server with cap=2:** + +Edit `wr.Init(10)` → `wr.Init(2)`, then: + +```bash +go run main.go +``` + +**Terminal 2 — send 10 slow concurrent requests:** + +```bash +# requires: go install github.com/rakyll/hey@latest +hey -n 10 -c 10 -q 1 http://localhost:8080/ +``` + +Or with plain `curl` in a loop: + +```bash +for i in $(seq 1 10); do + curl -s http://localhost:8080/ & +done +wait +``` + +**Terminal 3 — watch the server logs:** + +You will see `[room] FULL` when both slots are occupied, `[room] QUEUE` +for each request that lands in the waiting room, and `[room] DRAIN` when +the last active slot is released. + +Open `http://localhost:8080/` in a browser tab while the flood is running +and you will see the waiting-room page counting down your position. + +--- + +## Step 10 — Runtime capacity adjustment + +You can change the capacity without restarting the server. Because `wr` is +a package-level variable, any handler or goroutine can call `wr.SetCap`: + +```go +// In a config-reload handler or an admin endpoint: +func adminSetCap(c *gin.Context) { + var body struct{ Cap int32 `json:"cap"` } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := wr.SetCap(body.Cap); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"cap": wr.Cap()}) +} +``` + +Expanding capacity immediately admits waiting clients. Shrinking capacity +drains the semaphore down to the new limit — existing in-flight requests +complete normally. + +--- + +## Complete file layout + +``` +sample/basic-web-app/ +├── main.go ← the result of this tutorial +├── README.md ← this file +└── go.mod ← created by go mod init +``` + +`go.sum` is generated automatically the first time you run `go mod tidy` or +`go run main.go`. + +--- + +## What the waiting room does, in plain terms + +``` +Browser room middleware Your handler + │ │ │ + │── GET /pricing ──────────▶ │ + │ slot available? │ + │ yes → acquire slot ────────────────▶ + │ handler runs + │ slot released ◀──────────┐ + │◀─────────────────────────────── 200 OK ──────────────│ │ + │ │ + │── GET /pricing ──────────▶ │ │ + │ slot available? │ │ + │ no → issue token │ │ + │◀── 200 waiting-room HTML ─│ │ │ + │ │ │ │ + │── GET /queue/status ──────▶ position=3, ready=false │ │ + │◀── {ready:false,pos:3} ───│ │ │ + │ ... 3 s ... │ │ │ + │── GET /queue/status ──────▶ slot opened ──────────────────────────────┘ + │◀── {ready:true} ──────────│ + │ reload │ + │── GET /pricing ──────────▶ acquire slot ──────────────▶ + │◀─────────────────────────────── 200 OK ───────────────│ +``` + +Key properties: + +- **FIFO**: requests are admitted in ticket order — first in, first out. +- **No server-side goroutines**: the middleware is stateless per request + beyond the token store lookup; there are no goroutines blocking on behalf + of waiting clients. +- **Automatic admission**: the browser reloads automatically when its + ticket becomes ready — the user sees the page appear without pressing F5. +- **Ghost cleanup**: if a waiting client closes their tab, the reaper evicts + their ticket after the TTL, advancing the queue for everyone behind them. + +--- + +## Common mistakes + +### Registering routes before `RegisterRoutes` + +```go +// ✗ Wrong — /about is not gated +r.GET("/about", aboutPage) +wr.RegisterRoutes(r) +r.GET("/", homePage) // ✓ gated +``` + +```go +// ✓ Correct — all four pages are gated +wr.RegisterRoutes(r) +r.GET("/", homePage) +r.GET("/about", aboutPage) +r.GET("/pricing", pricingPage) +r.GET("/contact", contactPage) +``` + +### Forgetting `defer wr.Stop()` + +Without `wr.Stop()`, the reaper goroutine outlives the `http.Server`. In a +long-running process this is harmless (it exits when `main` returns), but in +tests that construct and discard `WaitingRoom` instances it will leak +goroutines and trigger the race detector. + +### Setting `Secure: true` cookies on plain HTTP + +If you call `wr.SetSecureCookie(true)` and run the server on plain +`http://localhost`, browsers will silently drop the cookie. The waiting-room +page will be served but the client will never re-present the token, so it +will get a new ticket on every reload and appear to never be admitted. + +Only call `wr.SetSecureCookie(true)` in environments where every request +reaches the Go process via HTTPS — or via a proxy that terminates TLS and +forwards over HTTP on a private network. + +--- + +## License + +Apache 2.0 — see the root [`LICENSE`](../../LICENSE) file. \ No newline at end of file diff --git a/sample/basic-web-app/main.go b/sample/basic-web-app/main.go new file mode 100644 index 0000000..0e74ffb --- /dev/null +++ b/sample/basic-web-app/main.go @@ -0,0 +1,234 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/andreimerlescu/room" + "github.com/gin-gonic/gin" +) + +// wr is the WaitingRoom instance. Keeping it package-level lets you call +// wr.SetCap, wr.SetReaperInterval, or wr.On from a config-reload handler +// without restarting the server. +var wr *room.WaitingRoom + +func main() { + // ── 1. Create the router ───────────────────────────────────────────── + r := gin.Default() + + // ── 2. Create and initialise the WaitingRoom ───────────────────────── + // + // Cap of 10 means at most 10 requests are actively served at once. + // The 11th request sees the waiting room and is admitted automatically + // when a slot opens — no refresh required. + wr = &room.WaitingRoom{} + if err := wr.Init(10); err != nil { + log.Fatalf("room.Init: %v", err) + } + defer wr.Stop() // clean up the background reaper goroutine on exit + + // ── 3. Configure the WaitingRoom ───────────────────────────────────── + + // In production, behind Cloudflare / nginx / AWS ALB, the Go process + // receives plain HTTP even though the user is on HTTPS. Set this so + // the session cookie carries the Secure flag. + wr.SetSecureCookie(true) + + // Tighten the reaper so ghost tickets are evicted every 30 s during + // a high-traffic event rather than the default 5 m. + if err := wr.SetReaperInterval(30 * time.Second); err != nil { + log.Fatalf("room.SetReaperInterval: %v", err) + } + + // ── 4. Lifecycle callbacks ──────────────────────────────────────────── + // + // Callbacks are fired asynchronously in their own goroutines, so a + // slow handler (e.g. one that calls an external API) never stalls the + // request path. Register them before calling RegisterRoutes. + + // Fired when every slot is occupied and the next request will queue. + wr.On(room.EventFull, func(s room.Snapshot) { + log.Printf("[room] FULL occupancy=%d/%d queue=%d", + s.Occupancy, s.Capacity, s.QueueDepth) + }) + + // Fired when the room drops from full back to having a free slot. + wr.On(room.EventDrain, func(s room.Snapshot) { + log.Printf("[room] DRAIN occupancy=%d/%d", s.Occupancy, s.Capacity) + }) + + // Fired every time a request joins the waiting room queue. + wr.On(room.EventQueue, func(s room.Snapshot) { + log.Printf("[room] QUEUE depth=%d utilization=%.0f%%", + s.QueueDepth, float64(s.Occupancy)/float64(s.Capacity)*100) + }) + + // Fired every time a request is admitted into active service. + wr.On(room.EventEnter, func(s room.Snapshot) { + log.Printf("[room] ENTER occupancy=%d/%d", s.Occupancy, s.Capacity) + }) + + // Fired every time a request completes and releases its slot. + wr.On(room.EventExit, func(s room.Snapshot) { + log.Printf("[room] EXIT occupancy=%d/%d", s.Occupancy, s.Capacity) + }) + + // Fired when the reaper evicts a ghost ticket (client disappeared). + wr.On(room.EventEvict, func(s room.Snapshot) { + log.Printf("[room] EVICT queue=%d", s.QueueDepth) + }) + + // Fired when a queued request's context is cancelled before admission. + wr.On(room.EventTimeout, func(s room.Snapshot) { + log.Printf("[room] TIMEOUT occupancy=%d/%d", s.Occupancy, s.Capacity) + }) + + // ── 5. Register the WaitingRoom routes ─────────────────────────────── + // + // RegisterRoutes does three things in the correct order: + // a) OPTIONS /queue/status — handles CORS preflight + // b) GET /queue/status — the polling endpoint the waiting-room + // page calls every 3 s + // c) r.Use(wr.Middleware()) — gates every subsequent route + // + // Routes registered BEFORE this call bypass the gate entirely — useful + // for health checks, readiness probes, and metrics scrapers that must + // always succeed regardless of application load. + wr.RegisterRoutes(r) + + // ── 6. Application routes ───────────────────────────────────────────── + // + // Every handler below is protected by the waiting room. If more than + // 10 requests are simultaneously active, the 11th caller sees the + // waiting-room page until a slot opens — automatically, no refresh. + + r.GET("/", homePage) + r.GET("/about", aboutPage) + r.GET("/pricing", pricingPage) + r.GET("/contact", contactPage) + + // ── 7. Graceful shutdown ────────────────────────────────────────────── + + srv := &http.Server{ + Addr: ":8080", + Handler: r, + } + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + go func() { + log.Println("listening on http://localhost:8080") + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("ListenAndServe: %v", err) + } + }() + + <-quit + log.Println("shutdown signal received — draining in-flight requests...") + + // Give active requests up to 15 s to complete before forcing exit. + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("server forced to shut down: %v", err) + } + log.Println("server exited cleanly") +} + +// ── Page handlers ───────────────────────────────────────────────────────────── +// +// Each handler returns a self-contained HTML page so the sample runs with +// no external template files. In a real application you would use +// html/template with embed.FS, or a front-end build step instead. + +func homePage(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", page( + "Home", + `

Welcome

+

This is the home page of the basic-web-app sample.

+

+ This server admits at most 10 concurrent requests. + Open this page in many tabs simultaneously and some will see the + waiting room — they will be admitted automatically when a slot opens. +

+ `, + )) +} + +func aboutPage(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", page( + "About", + `

About Us

+

+ We use room — a FIFO waiting room middleware for + Go + Gin — to keep this service stable under sudden load spikes. + Instead of dropping requests with a 429, callers wait their turn + and are admitted in the order they arrived. +

+ ← Home`, + )) +} + +func pricingPage(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", page( + "Pricing", + `

Pricing

+ + + + + + +
TierRequests / dayQueue priority
Free100Standard
ProUnlimitedStandard
+ ← Home`, + )) +} + +func contactPage(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", page( + "Contact", + `

Contact

+

Email us at hello@example.com

+ ← Home`, + )) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +// page wraps a body fragment in a complete, styled HTML document. +func page(title, body string) []byte { + return []byte(` + + + + + ` + title + ` — Basic Web App + + +` + body + ` +`) +} diff --git a/status.go b/status.go index 033ffa8..f400079 100644 --- a/status.go +++ b/status.go @@ -4,7 +4,6 @@ import ( "crypto/rand" "encoding/hex" "net/http" - "time" "github.com/gin-gonic/gin" ) @@ -18,6 +17,12 @@ import ( // it returns ready=true so the client retries the original request and // either enters or re-queues cleanly. // +// Each successful status poll (where the client is still actively waiting) +// refreshes the token's issuedAt timestamp, preventing the reaper from +// evicting tokens that belong to actively polling clients. This makes the +// effective TTL a sliding window from the last poll rather than a fixed +// window from initial issuance. +// // Related: WaitingRoom.Middleware, WaitingRoom.RegisterRoutes func (wr *WaitingRoom) StatusHandler() gin.HandlerFunc { return func(c *gin.Context) { @@ -27,18 +32,24 @@ func (wr *WaitingRoom) StatusHandler() gin.HandlerFunc { cookie, err := c.Request.Cookie(cookieName) if err != nil { + // No cookie — client has no queued position; send them back + // to try the main handler. c.JSON(http.StatusOK, statusResponse{Ready: true}) return } - entry, ok := wr.tokens.get(cookie.Value) - if !ok { + // Use deleteIfExpired to atomically check and remove in a single + // write-lock scope, eliminating the TOCTOU window between a + // separate isExpired check and delete. + if wr.tokens.deleteIfExpired(cookie.Value) { c.JSON(http.StatusOK, statusResponse{Ready: true}) return } - if wr.tokens.isExpired(cookie.Value) { - wr.tokens.delete(cookie.Value) + entry, ok := wr.tokens.get(cookie.Value) + if !ok { + // Token was deleted between deleteIfExpired and get — treat + // as expired/admitted. c.JSON(http.StatusOK, statusResponse{Ready: true}) return } @@ -49,6 +60,10 @@ func (wr *WaitingRoom) StatusHandler() gin.HandlerFunc { return } + // Client is still actively waiting — refresh the sliding TTL so + // that the reaper does not evict tokens from polling clients. + wr.tokens.touchIssuedAt(cookie.Value) + c.JSON(http.StatusOK, statusResponse{ Ready: false, Position: position, @@ -60,6 +75,10 @@ func (wr *WaitingRoom) StatusHandler() gin.HandlerFunc { // positionOf returns the raw queue position for a ticket. A value <= 0 // means the ticket is within the serving window and eligible for admission. // Callers that need a display-safe value (minimum 1) should clamp separately. +// +// This is the single authoritative formula for queue position used by both +// StatusHandler and Middleware. Having one implementation prevents the two +// call sites from silently diverging during future edits. func (wr *WaitingRoom) positionOf(ticket int64) int64 { return ticket - wr.nowServing.Load() - int64(wr.cap.Load()) } @@ -69,19 +88,28 @@ func (wr *WaitingRoom) positionOf(ticket int64) int64 { // always bypasses the queue — if you register routes manually, always add // StatusHandler before Use(Middleware()). // +// # CORS note +// +// If your deployment serves the waiting-room page from a different origin +// than the API (or if any CORS middleware is active), register an OPTIONS +// handler for /queue/status as well so that preflight requests from the +// polling fetch() call succeed: +// +// r.OPTIONS("/queue/status", func(c *gin.Context) { c.Status(http.StatusNoContent) }) +// r.GET("/queue/status", wr.StatusHandler()) +// r.Use(wr.Middleware()) +// // Usage: // // wr := &room.WaitingRoom{} // wr.Init(500) // wr.RegisterRoutes(r) // -// This is equivalent to: -// -// r.GET("/queue/status", wr.StatusHandler()) -// r.Use(wr.Middleware()) -// // Related: WaitingRoom.StatusHandler, WaitingRoom.Middleware func (wr *WaitingRoom) RegisterRoutes(r *gin.Engine) { + r.OPTIONS("/queue/status", func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) r.GET("/queue/status", wr.StatusHandler()) r.Use(wr.Middleware()) } @@ -95,15 +123,3 @@ func generateToken() (string, error) { } return hex.EncodeToString(b), nil } - -// issuedAt records when a token was created. -// Used by the reaper to enforce cookieTTL eviction. -func (ts *tokenStore) isExpired(token string) bool { - ts.mu.RLock() - defer ts.mu.RUnlock() - entry, ok := ts.entries[token] - if !ok { - return true - } - return time.Since(entry.issuedAt) > cookieTTL -} diff --git a/types.go b/types.go index 121df72..05a6467 100644 --- a/types.go +++ b/types.go @@ -20,6 +20,15 @@ import ( // The zero value is not usable. Always construct via NewWaitingRoom or // initialise manually with Init. // +// # Cookie security +// +// By default the waiting-room session cookie is issued WITHOUT the Secure +// flag so that plain-HTTP local development works without configuration. +// Call SetSecureCookie(true) before traffic arrives in any deployment that +// serves the application over HTTPS (directly or via a TLS-terminating +// proxy such as Cloudflare, nginx, or an AWS ALB). Alternatively, use +// SetSecureCookieFromRequest to derive the flag from each incoming request. +// // Related: NewWaitingRoom, Init, Middleware, RegisterRoutes type WaitingRoom struct { sem sema.Semaphore @@ -27,7 +36,6 @@ type WaitingRoom struct { nextTicket atomic.Int64 nowServing atomic.Int64 mu sync.Mutex - cond *sync.Cond html []byte tokens *tokenStore stopReaper context.CancelFunc @@ -35,6 +43,7 @@ type WaitingRoom struct { reaperRestart chan struct{} initialised atomic.Bool callbacks *callbackRegistry + secureCookie atomic.Bool } // ticketEntry holds the state for a single queued client. @@ -74,6 +83,48 @@ func (ts *tokenStore) delete(token string) { delete(ts.entries, token) } +// deleteIfExpired atomically checks expiry and deletes the token under a +// single write lock. Returns true if the token existed and was expired. +// This eliminates the TOCTOU window between separate isExpired + delete calls. +func (ts *tokenStore) deleteIfExpired(token string) bool { + ts.mu.Lock() + defer ts.mu.Unlock() + entry, ok := ts.entries[token] + if !ok { + return false + } + if time.Since(entry.issuedAt) > cookieTTL { + delete(ts.entries, token) + return true + } + return false +} + +// touchIssuedAt resets the issuedAt timestamp for a token to now, +// preventing the reaper from evicting a client that is actively polling. +func (ts *tokenStore) touchIssuedAt(token string) { + ts.mu.Lock() + defer ts.mu.Unlock() + entry, ok := ts.entries[token] + if !ok { + return + } + entry.issuedAt = time.Now() + ts.entries[token] = entry +} + +// isExpired reports whether the token exists and has exceeded cookieTTL. +// Deprecated: prefer deleteIfExpired to avoid the TOCTOU window. +func (ts *tokenStore) isExpired(token string) bool { + ts.mu.RLock() + defer ts.mu.RUnlock() + entry, ok := ts.entries[token] + if !ok { + return true + } + return time.Since(entry.issuedAt) > cookieTTL +} + // statusResponse is the JSON payload served by StatusHandler. type statusResponse struct { Ready bool `json:"ready"` From 43690583a71a44f0f9104ab4b1dace8ba848c465 Mon Sep 17 00:00:00 2001 From: Andrei Merlescu Date: Mon, 13 Apr 2026 14:34:19 -0400 Subject: [PATCH 3/6] Updated sample web app so it reads as a tutorial --- sample/basic-web-app/README.md | 549 +++++++++++++++++---------------- sample/basic-web-app/main.go | 194 ++++++++---- 2 files changed, 411 insertions(+), 332 deletions(-) diff --git a/sample/basic-web-app/README.md b/sample/basic-web-app/README.md index 482497b..c2ae9ea 100644 --- a/sample/basic-web-app/README.md +++ b/sample/basic-web-app/README.md @@ -9,10 +9,10 @@ automatically in arrival order — with no client-side refresh required. ## Prerequisites -| Tool | Minimum version | -|---|---| -| Go | 1.22 | -| git | any | +| Tool | Minimum version | Install | +|---|---|---| +| Go | 1.22 | https://go.dev/dl | +| Apache Bench | any | `apt install apache2-utils` / `brew install httpd` | --- @@ -32,304 +32,320 @@ go get github.com/gin-gonic/gin --- -## Step 2 — Create `main.go` with a plain Gin server - -Start with the simplest possible Gin application — four routes, no waiting -room yet: - -```go -package main - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) +## Step 2 — Why `gin.New()` instead of `gin.Default()` -func main() { - r := gin.Default() +`gin.Default()` installs gin's own `Logger` middleware, which buffers each +log line and prints it **after** the handler returns. During a load test that +means you see nothing until the request is already complete — room events +and request logs appear out of order and the queue activity is invisible. - r.GET("/", func(c *gin.Context) { c.String(http.StatusOK, "Home") }) - r.GET("/about", func(c *gin.Context) { c.String(http.StatusOK, "About") }) - r.GET("/pricing", func(c *gin.Context) { c.String(http.StatusOK, "Pricing") }) - r.GET("/contact", func(c *gin.Context) { c.String(http.StatusOK, "Contact") }) - - r.Run(":8080") -} -``` - -Run it and confirm all four pages respond: - -```bash -go run main.go & -curl http://localhost:8080/ -curl http://localhost:8080/about -curl http://localhost:8080/pricing -curl http://localhost:8080/contact -``` - ---- - -## Step 3 — Declare a package-level WaitingRoom - -Add a package-level variable. Keeping the `*room.WaitingRoom` at package -scope means you can call `wr.SetCap` or `wr.On` from a config-reload -handler later without restarting the server. +`gin.New()` gives you a blank engine. We install two middlewares manually: ```go -import "github.com/andreimerlescu/room" - -var wr *room.WaitingRoom +r := gin.New() +r.Use(gin.Recovery()) // keep the panic recovery middleware +r.Use(requestLogger()) // our logger — prints on entry AND exit ``` ---- +The custom `requestLogger` middleware in this sample prints a line the moment +a request arrives (`-->`) and another when it completes (`<--`). That means +you can see which requests are being held by the waiting room versus which +are executing their handler, in real time, as the load test runs. -## Step 4 — Initialise the WaitingRoom +--- -Inside `main()`, before creating any routes, initialise the WaitingRoom with -your chosen capacity. The capacity is the maximum number of requests that are -**actively being served** at any one moment. Requests beyond that limit see -the waiting room page. +## Step 3 — Initialise the WaitingRoom with a small capacity ```go wr = &room.WaitingRoom{} -if err := wr.Init(10); err != nil { +if err := wr.Init(5); err != nil { log.Fatalf("room.Init: %v", err) } -defer wr.Stop() // stops the background reaper goroutine on exit +defer wr.Stop() ``` -> **Choosing a capacity.** Start with the number of goroutines your slowest -> handler can tolerate simultaneously without degrading latency — typically -> the size of your database connection pool or your downstream service's -> rate limit. You can change it at runtime with `wr.SetCap`. +A cap of **5** is deliberately small so that `ab -c 100` fills the room +immediately and you can watch the queue build and drain in the terminal. +In production you would set this to match your actual concurrency budget +— typically the size of your database connection pool or the rate limit of +your slowest downstream dependency. --- -## Step 5 — Configure the WaitingRoom (optional but recommended) - -### 5a — Cookie security +## Step 4 — Add simulated latency to every handler -By default the waiting-room session cookie is issued **without** the `Secure` -flag so that `http://localhost` works during development. In any deployment -that sits behind a TLS-terminating proxy (Cloudflare, nginx, AWS ALB) the Go -process receives plain HTTP even though users are on HTTPS, so you must opt -in explicitly: +This is the most important step for making the waiting room visible during +a load test. Without it, handlers return in microseconds and the room never +fills up even at `-c 100` because requests complete faster than they arrive. ```go -wr.SetSecureCookie(true) -``` - -Leave this line out during local development. Add it before deploying to -any environment reachable over HTTPS. - -### 5b — Reaper interval +const simulatedLatency = 500 * time.Millisecond -The reaper is a background goroutine that evicts tokens from clients that -disappeared mid-queue (closed the tab, lost their connection). The default -interval is 5 minutes. For high-traffic events where ghost tickets could -stall the queue, tighten it: - -```go -if err := wr.SetReaperInterval(30 * time.Second); err != nil { - log.Fatalf("room.SetReaperInterval: %v", err) +func aboutPage(c *gin.Context) { + time.Sleep(simulatedLatency) // holds the semaphore slot for 500 ms + c.Data(http.StatusOK, "text/html; charset=utf-8", page("About", body)) } ``` ---- +With `cap=5` and each request taking 500 ms, the server can process at most +10 requests per second. At `ab -c 100` you are sending 100 concurrent +requests, so roughly 95 of them will be queued immediately and admitted one +by one as slots open. -## Step 6 — Register lifecycle callbacks (optional) +--- -Callbacks let your application react to capacity events in real time. They -run asynchronously in their own goroutines, so a slow callback never stalls -the request path. +## Step 5 — Register lifecycle callbacks -Register them **before** calling `RegisterRoutes`: +Callbacks are what you will actually see in the terminal during the load +test. They fire asynchronously in their own goroutines — a slow callback +never stalls the request path. ```go -// Fired when every slot is occupied — good time to scale out. wr.On(room.EventFull, func(s room.Snapshot) { - log.Printf("[room] FULL occupancy=%d/%d queue=%d", - s.Occupancy, s.Capacity, s.QueueDepth) + roomLog("FULL ", fmt.Sprintf( + "capacity reached occupancy=%d/%d queue=%d util=%.0f%%", + s.Occupancy, s.Capacity, s.QueueDepth, + pct(s.Occupancy, s.Capacity), + )) }) -// Fired when the room drops from full back to having a free slot. wr.On(room.EventDrain, func(s room.Snapshot) { - log.Printf("[room] DRAIN occupancy=%d/%d", s.Occupancy, s.Capacity) + roomLog("DRAIN ", fmt.Sprintf( + "room no longer full occupancy=%d/%d queue=%d", + s.Occupancy, s.Capacity, s.QueueDepth, + )) }) -// Fired every time a request joins the queue. wr.On(room.EventQueue, func(s room.Snapshot) { - log.Printf("[room] QUEUE depth=%d utilization=%.0f%%", - s.QueueDepth, float64(s.Occupancy)/float64(s.Capacity)*100) + roomLog("QUEUE ", fmt.Sprintf( + "request queued depth=%d occupancy=%d/%d util=%.0f%%", + s.QueueDepth, s.Occupancy, s.Capacity, + pct(s.Occupancy, s.Capacity), + )) }) -// Fired every time a request is admitted into active service. wr.On(room.EventEnter, func(s room.Snapshot) { - log.Printf("[room] ENTER occupancy=%d/%d", s.Occupancy, s.Capacity) + roomLog("ENTER ", fmt.Sprintf( + "slot acquired occupancy=%d/%d queue=%d util=%.0f%%", + s.Occupancy, s.Capacity, s.QueueDepth, + pct(s.Occupancy, s.Capacity), + )) }) -// Fired every time a request completes and releases its slot. wr.On(room.EventExit, func(s room.Snapshot) { - log.Printf("[room] EXIT occupancy=%d/%d", s.Occupancy, s.Capacity) + roomLog("EXIT ", fmt.Sprintf( + "slot released occupancy=%d/%d queue=%d util=%.0f%%", + s.Occupancy, s.Capacity, s.QueueDepth, + pct(s.Occupancy, s.Capacity), + )) }) -// Fired when the reaper removes an abandoned token. wr.On(room.EventEvict, func(s room.Snapshot) { - log.Printf("[room] EVICT queue=%d", s.QueueDepth) + roomLog("EVICT ", fmt.Sprintf( + "ghost ticket removed queue=%d occupancy=%d/%d", + s.QueueDepth, s.Occupancy, s.Capacity, + )) }) -// Fired when a queued request's context is cancelled before admission. wr.On(room.EventTimeout, func(s room.Snapshot) { - log.Printf("[room] TIMEOUT occupancy=%d/%d", s.Occupancy, s.Capacity) + roomLog("TIMEOUT", fmt.Sprintf( + "context cancelled before admission occupancy=%d/%d queue=%d", + s.Occupancy, s.Capacity, s.QueueDepth, + )) }) ``` -The `room.Snapshot` delivered to every callback is a point-in-time copy of -the room's state — safe to read after the callback returns. +The `roomLog` helper prefixes every line with a fixed-width tag so you can +filter the output with `grep`: ---- +```bash +go run main.go 2>&1 | grep '\[QUEUE\]' # only queuing events +go run main.go 2>&1 | grep '\[FULL\]' # only full-capacity events +go run main.go 2>&1 | grep -v '\[REQ\]' # room events only, no request logs +``` -## Step 7 — Register the WaitingRoom routes +--- -This is the single most important ordering constraint in the whole setup: -call `wr.RegisterRoutes(r)` **after** any routes that must bypass the gate -(health checks, metrics, etc.) and **before** any routes that should be -protected by it. +## Step 6 — Register routes in the correct order ```go -// Routes registered before this line bypass the waiting room entirely. -// Example: r.GET("/healthz", healthHandler) +// Routes registered before RegisterRoutes bypass the waiting room. +// Use this for health checks and readiness probes that must always succeed. +// r.GET("/healthz", func(c *gin.Context) { c.Status(http.StatusOK) }) wr.RegisterRoutes(r) -// Routes registered after this line are protected by the waiting room. +// Routes registered after RegisterRoutes are gated by the waiting room. r.GET("/", homePage) r.GET("/about", aboutPage) r.GET("/pricing", pricingPage) r.GET("/contact", contactPage) ``` -`RegisterRoutes` does three things internally, in this order: - -| Step | What it registers | Why | -|---|---|---| -| 1 | `OPTIONS /queue/status` | Handles CORS preflight from the polling `fetch()` | -| 2 | `GET /queue/status` | The JSON endpoint the waiting-room page polls every 3 s | -| 3 | `r.Use(wr.Middleware())` | Gates every route registered after this call | - -> **Do not** call `r.Use(wr.Middleware())` manually if you are using -> `RegisterRoutes`. The two are mutually exclusive — `RegisterRoutes` calls -> `r.Use` for you, in the correct position relative to `/queue/status`. - --- -## Step 8 — Add graceful shutdown +## Step 7 — Run the server -When the process receives `SIGINT` or `SIGTERM`, give active requests time -to finish before the server closes. This pairs naturally with the waiting -room because in-flight requests that are admitted through the gate must be -allowed to complete cleanly. +**Terminal 1:** -```go -import ( - "context" - "net/http" - "os" - "os/signal" - "syscall" - "time" -) - -srv := &http.Server{ - Addr: ":8080", - Handler: r, -} +```bash +go run main.go +``` -quit := make(chan os.Signal, 1) -signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) +You should see: -go func() { - log.Println("listening on http://localhost:8080") - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("ListenAndServe: %v", err) - } -}() +``` +[ INFO ] listening on http://localhost:8080 cap=5 +``` + +--- -<-quit -log.Println("shutdown signal received — draining in-flight requests...") +## Step 8 — Run the load test -shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) -defer cancel() +**Terminal 2:** -if err := srv.Shutdown(shutdownCtx); err != nil { - log.Printf("server forced to shut down: %v", err) -} -log.Println("server exited cleanly") +```bash +ab -t 60 -n 1000 -c 100 http://localhost:8080/about ``` -The `defer wr.Stop()` from Step 4 runs after `srv.Shutdown` returns, which -stops the reaper goroutine and leaves nothing running after `main` exits. +| Flag | Meaning | +|---|---| +| `-t 60` | run for 60 seconds | +| `-n 1000` | send at most 1000 total requests | +| `-c 100` | maintain 100 concurrent connections | --- -## Step 9 — Run it +## Step 9 — Read the logs + +Switch back to Terminal 1. You will see output like this: -```bash -go run main.go +``` +[ INFO ] listening on http://localhost:8080 cap=5 +[ REQ ] --> GET /about remote=127.0.0.1 +[ REQ ] --> GET /about remote=127.0.0.1 +[ REQ ] --> GET /about remote=127.0.0.1 +[ REQ ] --> GET /about remote=127.0.0.1 +[ REQ ] --> GET /about remote=127.0.0.1 +[ ENTER ] slot acquired occupancy=1/5 queue=0 util=20% +[ ENTER ] slot acquired occupancy=2/5 queue=0 util=40% +[ ENTER ] slot acquired occupancy=3/5 queue=0 util=60% +[ ENTER ] slot acquired occupancy=4/5 queue=0 util=80% +[ ENTER ] slot acquired occupancy=5/5 queue=0 util=100% +[ FULL ] capacity reached occupancy=5/5 queue=0 util=100% +[ QUEUE ] request queued depth=1 occupancy=5/5 util=100% +[ QUEUE ] request queued depth=2 occupancy=5/5 util=100% +[ QUEUE ] request queued depth=3 occupancy=5/5 util=100% +... +[ EXIT ] slot released occupancy=4/5 queue=95 util=80% +[ DRAIN ] room no longer full occupancy=4/5 queue=95 +[ ENTER ] slot acquired occupancy=5/5 queue=94 util=100% +[ FULL ] capacity reached occupancy=5/5 queue=94 util=100% +[ REQ ] <-- GET /about status=200 latency=500ms +[ REQ ] --> GET /about remote=127.0.0.1 +[ EXIT ] slot released occupancy=4/5 queue=93 util=80% +... ``` -Open `http://localhost:8080` in your browser. You will see the home page. +Here is what each tag means in the context of this load test: -### Simulating the waiting room +| Tag | What you are seeing | +|---|---| +| `[ REQ ] -->` | A new HTTP connection arrived at the server | +| `[ ENTER ]` | The request passed through the waiting room and is now running its handler | +| `[ FULL ]` | All 5 slots are occupied — the next request will queue | +| `[ QUEUE ]` | A request landed in the waiting room; `depth=N` is its position | +| `[ EXIT ]` | A handler finished and released its slot | +| `[ DRAIN ]` | The room dropped below full capacity — queued requests can now enter | +| `[ REQ ] <--` | The HTTP response was sent; `latency` includes waiting room time | +| `[ EVICT ]` | The reaper cleaned up a ghost ticket (ab closed a connection mid-wait) | +| `[ TIMEOUT ]` | A queued request's context was cancelled before it was admitted | + +### What the queue depth column tells you + +The `queue=N` value in `QUEUE` events shows how many requests are waiting +behind the one that just joined. Watch it climb during the flood and fall +as handlers complete and admit the next waiter. When `queue=0` appears in +`EXIT` events, the backlog has cleared. + +### What a healthy load test looks like + +- `FULL` fires once at the start and only fires again after a `DRAIN`. +- `DRAIN` fires every time the occupancy drops below 5 and a queued request + enters. +- `QUEUE` depth climbs quickly at the start then stays roughly stable or + trends downward as ab's concurrency saturates. +- `TIMEOUT` events appear only if ab's connection timeout is shorter than + the time a request spends waiting in the queue. Increase `-t` on ab or + add `-s 60` (socket timeout) to reduce spurious timeouts. +- `EVICT` events appear only after the reaper runs (every 10 s in this + sample). Each eviction means a client disappeared mid-queue — normal + during ab runs since ab recycles connections aggressively. -The easiest way to trigger the waiting room locally is to temporarily lower -the capacity and flood the server with slow requests. +--- -**Terminal 1 — start the server with cap=2:** +## Step 10 — Read the ab report -Edit `wr.Init(10)` → `wr.Init(2)`, then: +When ab finishes it prints a summary. With `cap=5` and 500 ms handlers the +numbers will look roughly like this: -```bash -go run main.go +``` +Concurrency Level: 100 +Time taken for tests: 60.012 seconds +Complete requests: 1000 +Failed requests: 0 +Requests per second: 16.66 [#/sec] (mean) +Time per request: 6001.2 [ms] (mean) +Time per request: 60.01 [ms] (mean, across all concurrent requests) ``` -**Terminal 2 — send 10 slow concurrent requests:** +The mean time per request of ~6 s reflects queuing time: a request that +arrives when the queue is 10 deep waits 10 × 500 ms before its handler +runs. That is the waiting room working as designed — absorbing burst traffic +instead of dropping it or crashing the downstream. + +To increase throughput, call `wr.SetCap` with a higher value and re-run ab: ```bash -# requires: go install github.com/rakyll/hey@latest -hey -n 10 -c 10 -q 1 http://localhost:8080/ +# in a third terminal while the server is running +curl -s http://localhost:8080/ # confirm server is up, then edit main.go +# or wire up an admin endpoint as shown in the runtime-adjustment section ``` -Or with plain `curl` in a loop: +--- + +## Grepping the logs for specific events ```bash -for i in $(seq 1 10); do - curl -s http://localhost:8080/ & -done -wait -``` +# Room events only — filter out per-request noise +go run main.go 2>&1 | grep -v '\[ REQ' + +# Only queueing events — see the queue depth grow +go run main.go 2>&1 | grep '\[ QUEUE' -**Terminal 3 — watch the server logs:** +# Only full-capacity moments +go run main.go 2>&1 | grep '\[ FULL' -You will see `[room] FULL` when both slots are occupied, `[room] QUEUE` -for each request that lands in the waiting room, and `[room] DRAIN` when -the last active slot is released. +# Count how many requests were queued +go run main.go 2>&1 | grep -c '\[ QUEUE' -Open `http://localhost:8080/` in a browser tab while the flood is running -and you will see the waiting-room page counting down your position. +# Watch the queue depth trend +go run main.go 2>&1 | grep '\[ QUEUE' | awk '{print $6}' +``` --- -## Step 10 — Runtime capacity adjustment +## Runtime capacity adjustment -You can change the capacity without restarting the server. Because `wr` is -a package-level variable, any handler or goroutine can call `wr.SetCap`: +Because `wr` is a package-level variable you can change capacity without +restarting the server. Wire up an admin endpoint: ```go -// In a config-reload handler or an admin endpoint: -func adminSetCap(c *gin.Context) { - var body struct{ Cap int32 `json:"cap"` } +// Register this BEFORE wr.RegisterRoutes so it bypasses the waiting room. +r.POST("/admin/cap", func(c *gin.Context) { + var body struct { + Cap int32 `json:"cap"` + } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -338,110 +354,101 @@ func adminSetCap(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"cap": wr.Cap()}) -} + c.JSON(http.StatusOK, gin.H{ + "cap": wr.Cap(), + "occupancy": wr.Len(), + "queue_depth": wr.QueueDepth(), + "utilization": fmt.Sprintf("%.0f%%", wr.Utilization()*100), + }) +}) ``` -Expanding capacity immediately admits waiting clients. Shrinking capacity -drains the semaphore down to the new limit — existing in-flight requests -complete normally. +While ab is running in Terminal 2, change the cap in Terminal 3: ---- - -## Complete file layout - -``` -sample/basic-web-app/ -├── main.go ← the result of this tutorial -├── README.md ← this file -└── go.mod ← created by go mod init +```bash +# Double capacity — queued requests will immediately start being admitted +curl -s -X POST http://localhost:8080/admin/cap \ + -H 'Content-Type: application/json' \ + -d '{"cap": 10}' | jq + +# Halve it again +curl -s -X POST http://localhost:8080/admin/cap \ + -H 'Content-Type: application/json' \ + -d '{"cap": 5}' | jq ``` -`go.sum` is generated automatically the first time you run `go mod tidy` or -`go run main.go`. +Watch the server logs — you will see a burst of `ENTER` events as queued +requests rush into the newly opened slots when you expand, and then `FULL` +almost immediately as the new capacity fills. --- -## What the waiting room does, in plain terms +## Common mistakes + +### Handlers return too fast — the room never fills up -``` -Browser room middleware Your handler - │ │ │ - │── GET /pricing ──────────▶ │ - │ slot available? │ - │ yes → acquire slot ────────────────▶ - │ handler runs - │ slot released ◀──────────┐ - │◀─────────────────────────────── 200 OK ──────────────│ │ - │ │ - │── GET /pricing ──────────▶ │ │ - │ slot available? │ │ - │ no → issue token │ │ - │◀── 200 waiting-room HTML ─│ │ │ - │ │ │ │ - │── GET /queue/status ──────▶ position=3, ready=false │ │ - │◀── {ready:false,pos:3} ───│ │ │ - │ ... 3 s ... │ │ │ - │── GET /queue/status ──────▶ slot opened ──────────────────────────────┘ - │◀── {ready:true} ──────────│ - │ reload │ - │── GET /pricing ──────────▶ acquire slot ──────────────▶ - │◀─────────────────────────────── 200 OK ───────────────│ -``` +```go +// ✗ Wrong — returns in microseconds, room stays at occupancy=1 +func aboutPage(c *gin.Context) { + c.String(http.StatusOK, "About") +} -Key properties: +// ✓ Correct for testing — holds the slot long enough to observe queuing +func aboutPage(c *gin.Context) { + time.Sleep(500 * time.Millisecond) + c.String(http.StatusOK, "About") +} +``` -- **FIFO**: requests are admitted in ticket order — first in, first out. -- **No server-side goroutines**: the middleware is stateless per request - beyond the token store lookup; there are no goroutines blocking on behalf - of waiting clients. -- **Automatic admission**: the browser reloads automatically when its - ticket becomes ready — the user sees the page appear without pressing F5. -- **Ghost cleanup**: if a waiting client closes their tab, the reaper evicts - their ticket after the TTL, advancing the queue for everyone behind them. +In production you do not need `time.Sleep` — real database queries, +template rendering, and downstream API calls provide the natural latency +that holds slots open. ---- +### Using `gin.Default()` — room events are buried in buffered output -## Common mistakes +```go +// ✗ gin's Logger buffers and only prints after the handler returns. +// Room events appear out of order; the queue activity is invisible. +r := gin.Default() + +// ✓ Build the logger yourself so it prints on arrival, not completion. +r := gin.New() +r.Use(gin.Recovery()) +r.Use(requestLogger()) +``` -### Registering routes before `RegisterRoutes` +### Registering application routes before `RegisterRoutes` ```go -// ✗ Wrong — /about is not gated +// ✗ /about is not gated — it bypasses the waiting room entirely r.GET("/about", aboutPage) wr.RegisterRoutes(r) -r.GET("/", homePage) // ✓ gated -``` -```go -// ✓ Correct — all four pages are gated +// ✓ All four pages are protected wr.RegisterRoutes(r) -r.GET("/", homePage) r.GET("/about", aboutPage) -r.GET("/pricing", pricingPage) -r.GET("/contact", contactPage) ``` ### Forgetting `defer wr.Stop()` -Without `wr.Stop()`, the reaper goroutine outlives the `http.Server`. In a -long-running process this is harmless (it exits when `main` returns), but in -tests that construct and discard `WaitingRoom` instances it will leak -goroutines and trigger the race detector. +Without it the reaper goroutine outlives the `http.Server`. In tests that +construct and discard `WaitingRoom` instances it leaks goroutines and +triggers the race detector. -### Setting `Secure: true` cookies on plain HTTP +--- -If you call `wr.SetSecureCookie(true)` and run the server on plain -`http://localhost`, browsers will silently drop the cookie. The waiting-room -page will be served but the client will never re-present the token, so it -will get a new ticket on every reload and appear to never be admitted. +## File layout -Only call `wr.SetSecureCookie(true)` in environments where every request -reaches the Go process via HTTPS — or via a proxy that terminates TLS and -forwards over HTTP on a private network. +``` +sample/basic-web-app/ +├── main.go ← the result of this tutorial +├── README.md ← this file +└── go.mod +``` --- ## License -Apache 2.0 — see the root [`LICENSE`](../../LICENSE) file. \ No newline at end of file +Apache 2.0 — see the root [`LICENSE`](../../LICENSE) file. +``` \ No newline at end of file diff --git a/sample/basic-web-app/main.go b/sample/basic-web-app/main.go index 0e74ffb..659155f 100644 --- a/sample/basic-web-app/main.go +++ b/sample/basic-web-app/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "log" "net/http" "os" @@ -19,94 +20,116 @@ import ( var wr *room.WaitingRoom func main() { - // ── 1. Create the router ───────────────────────────────────────────── - r := gin.Default() + // ── 1. Use gin.New() instead of gin.Default() ───────────────────────── + // + // gin.Default() installs gin's own Logger middleware, which buffers + // output and formats it after the handler returns. That makes it hard + // to see room events interleaved with request logs in real time. + // gin.New() gives us a blank engine so we can install our own logger + // that prints immediately, before and after each request. + r := gin.New() + r.Use(gin.Recovery()) // keep the panic recovery middleware + r.Use(requestLogger()) // our structured logger — prints on entry AND exit // ── 2. Create and initialise the WaitingRoom ───────────────────────── // - // Cap of 10 means at most 10 requests are actively served at once. - // The 11th request sees the waiting room and is admitted automatically - // when a slot opens — no refresh required. + // Cap of 5 is deliberately small so that `ab -c 100` fills the room + // immediately and you can watch the queue build and drain in the logs. + // In production you would set this to match your actual concurrency budget. wr = &room.WaitingRoom{} - if err := wr.Init(10); err != nil { + if err := wr.Init(5); err != nil { log.Fatalf("room.Init: %v", err) } - defer wr.Stop() // clean up the background reaper goroutine on exit + defer wr.Stop() // ── 3. Configure the WaitingRoom ───────────────────────────────────── - // In production, behind Cloudflare / nginx / AWS ALB, the Go process - // receives plain HTTP even though the user is on HTTPS. Set this so - // the session cookie carries the Secure flag. - wr.SetSecureCookie(true) + // Leave SetSecureCookie at its default (false) for local development + // so the cookie works over plain http://localhost. + // Call wr.SetSecureCookie(true) in production behind TLS. - // Tighten the reaper so ghost tickets are evicted every 30 s during - // a high-traffic event rather than the default 5 m. - if err := wr.SetReaperInterval(30 * time.Second); err != nil { + // Tighten the reaper so ghost tickets from ab's aborted connections + // are cleaned up quickly during the load test. + if err := wr.SetReaperInterval(10 * time.Second); err != nil { log.Fatalf("room.SetReaperInterval: %v", err) } // ── 4. Lifecycle callbacks ──────────────────────────────────────────── // - // Callbacks are fired asynchronously in their own goroutines, so a - // slow handler (e.g. one that calls an external API) never stalls the - // request path. Register them before calling RegisterRoutes. + // These callbacks are what you will see in the terminal during ab. + // Each line is prefixed with a tag so you can grep for it: + // + // grep '\[FULL\]' — moments the room hit capacity + // grep '\[QUEUE\]' — every request that had to wait + // grep '\[ENTER\]' — every admission into active service + // grep '\[EXIT\]' — every slot release + // grep '\[DRAIN\]' — moments the room dropped below capacity + // grep '\[EVICT\]' — abandoned ghost tickets removed by the reaper + // grep '\[TIMEOUT\]'— requests whose context was cancelled mid-queue - // Fired when every slot is occupied and the next request will queue. wr.On(room.EventFull, func(s room.Snapshot) { - log.Printf("[room] FULL occupancy=%d/%d queue=%d", - s.Occupancy, s.Capacity, s.QueueDepth) + roomLog("FULL ", fmt.Sprintf( + "capacity reached occupancy=%d/%d queue=%d util=%.0f%%", + s.Occupancy, s.Capacity, s.QueueDepth, + pct(s.Occupancy, s.Capacity), + )) }) - // Fired when the room drops from full back to having a free slot. wr.On(room.EventDrain, func(s room.Snapshot) { - log.Printf("[room] DRAIN occupancy=%d/%d", s.Occupancy, s.Capacity) + roomLog("DRAIN ", fmt.Sprintf( + "room no longer full occupancy=%d/%d queue=%d", + s.Occupancy, s.Capacity, s.QueueDepth, + )) }) - // Fired every time a request joins the waiting room queue. wr.On(room.EventQueue, func(s room.Snapshot) { - log.Printf("[room] QUEUE depth=%d utilization=%.0f%%", - s.QueueDepth, float64(s.Occupancy)/float64(s.Capacity)*100) + roomLog("QUEUE ", fmt.Sprintf( + "request queued depth=%d occupancy=%d/%d util=%.0f%%", + s.QueueDepth, s.Occupancy, s.Capacity, + pct(s.Occupancy, s.Capacity), + )) }) - // Fired every time a request is admitted into active service. wr.On(room.EventEnter, func(s room.Snapshot) { - log.Printf("[room] ENTER occupancy=%d/%d", s.Occupancy, s.Capacity) + roomLog("ENTER ", fmt.Sprintf( + "slot acquired occupancy=%d/%d queue=%d util=%.0f%%", + s.Occupancy, s.Capacity, s.QueueDepth, + pct(s.Occupancy, s.Capacity), + )) }) - // Fired every time a request completes and releases its slot. wr.On(room.EventExit, func(s room.Snapshot) { - log.Printf("[room] EXIT occupancy=%d/%d", s.Occupancy, s.Capacity) + roomLog("EXIT ", fmt.Sprintf( + "slot released occupancy=%d/%d queue=%d util=%.0f%%", + s.Occupancy, s.Capacity, s.QueueDepth, + pct(s.Occupancy, s.Capacity), + )) }) - // Fired when the reaper evicts a ghost ticket (client disappeared). wr.On(room.EventEvict, func(s room.Snapshot) { - log.Printf("[room] EVICT queue=%d", s.QueueDepth) + roomLog("EVICT ", fmt.Sprintf( + "ghost ticket removed queue=%d occupancy=%d/%d", + s.QueueDepth, s.Occupancy, s.Capacity, + )) }) - // Fired when a queued request's context is cancelled before admission. wr.On(room.EventTimeout, func(s room.Snapshot) { - log.Printf("[room] TIMEOUT occupancy=%d/%d", s.Occupancy, s.Capacity) + roomLog("TIMEOUT", fmt.Sprintf( + "context cancelled before admission occupancy=%d/%d queue=%d", + s.Occupancy, s.Capacity, s.QueueDepth, + )) }) // ── 5. Register the WaitingRoom routes ─────────────────────────────── // - // RegisterRoutes does three things in the correct order: - // a) OPTIONS /queue/status — handles CORS preflight - // b) GET /queue/status — the polling endpoint the waiting-room - // page calls every 3 s - // c) r.Use(wr.Middleware()) — gates every subsequent route - // - // Routes registered BEFORE this call bypass the gate entirely — useful - // for health checks, readiness probes, and metrics scrapers that must - // always succeed regardless of application load. + // RegisterRoutes must come BEFORE your application routes. + // It installs, in order: + // OPTIONS /queue/status — CORS preflight + // GET /queue/status — polling endpoint for the waiting-room page + // r.Use(wr.Middleware()) — gates every route registered after this wr.RegisterRoutes(r) - // ── 6. Application routes ───────────────────────────────────────────── - // - // Every handler below is protected by the waiting room. If more than - // 10 requests are simultaneously active, the 11th caller sees the - // waiting-room page until a slot opens — automatically, no refresh. + // ── 6. Application routes (all gated by the waiting room) ──────────── r.GET("/", homePage) r.GET("/about", aboutPage) @@ -124,40 +147,42 @@ func main() { signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) go func() { - log.Println("listening on http://localhost:8080") + log.Printf("[ INFO ] listening on http://localhost:8080 cap=%d", wr.Cap()) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("ListenAndServe: %v", err) } }() <-quit - log.Println("shutdown signal received — draining in-flight requests...") + log.Println("[ INFO ] shutdown signal received — draining in-flight requests...") - // Give active requests up to 15 s to complete before forcing exit. shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { - log.Printf("server forced to shut down: %v", err) + log.Printf("[ ERROR ] server forced to shut down: %v", err) } - log.Println("server exited cleanly") + log.Println("[ INFO ] server exited cleanly") } // ── Page handlers ───────────────────────────────────────────────────────────── // -// Each handler returns a self-contained HTML page so the sample runs with -// no external template files. In a real application you would use -// html/template with embed.FS, or a front-end build step instead. +// Each handler sleeps for a realistic duration so that concurrent ab requests +// actually hold their semaphore slots long enough for the room to fill up. +// Without the sleep, handlers return in microseconds and you will never see +// the waiting room trigger, even at -c 100. + +const simulatedLatency = 500 * time.Millisecond func homePage(c *gin.Context) { + time.Sleep(simulatedLatency) c.Data(http.StatusOK, "text/html; charset=utf-8", page( "Home", `

Welcome

-

This is the home page of the basic-web-app sample.

+

This server admits at most 5 concurrent requests.

- This server admits at most 10 concurrent requests. - Open this page in many tabs simultaneously and some will see the - waiting room — they will be admitted automatically when a slot opens. + Run ab -t 60 -n 1000 -c 100 http://localhost:8080/about + in a second terminal and watch this terminal for room events.