Stage 7 · Master
Phase 12 — Visitor Service
Deployment
Deploy visitor check-in for flaky gate networks, tune the service for evening bursts, and roll the new tables out additively so old traffic never breaks mid-release.
What happens when the database commits but the tablet never sees the 200
This is the deployment problem unique to physical gates: the check-in can succeed on the server, the access point can drop the response, and the guard immediately taps retry because they never saw confirmation. Storage already protects you from double-admission — the second attempt cannot win the atomic UPDATE — but the operator experience still matters. Without more work, the guard sees a scary replay error even though their own first attempt probably succeeded.
The fix is a short-lived idempotency cache keyed by the hashed token plus guard_device_id. If the same device retries within a small TTL, the service returns the prior success response instead of surfacing it as replay. A different device using the same token still gets the replay outcome, which preserves the security signal for copied credentials.
| Second request shape | Cache key match | Expected outcome | Why |
|---|---|---|---|
| Same token, same guard device, response was probably dropped | Yes | Return cached success payload | This is most likely the guard retrying their own committed check-in |
| Same token, different device or cache TTL expired | No | Return replay outcome from the atomic redemption path | A separate device should not be allowed to masquerade as a harmless retry |
package visitor
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type RecentCheckInCache interface {
Get(ctx context.Context, key string) (VisitRecord, bool, error)
Put(ctx context.Context, key string, visit VisitRecord, ttl time.Duration) error
}
type CheckInHandler struct {
service *HistoryService
recentCheckIns RecentCheckInCache
timeout time.Duration
}
type CheckInRequest struct {
Token string
GuardDeviceID string
}
func (h *CheckInHandler) CheckIn(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), h.timeout)
defer cancel()
var req CheckInRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid check-in payload"})
return
}
orgID := c.MustGet("org_id").(uuid.UUID)
guardUserID := c.MustGet("user_id").(uuid.UUID)
cacheKey := fmt.Sprintf("%s:%s", hashToken(req.Token), req.GuardDeviceID)
if visit, found, err := h.recentCheckIns.Get(ctx, cacheKey); err == nil && found {
c.JSON(http.StatusOK, gin.H{"status": "checked_in", "visit": visit, "source": "recent_retry_cache"})
return
}
visit, err := h.service.CheckInByToken(ctx, orgID, guardUserID, req.Token)
switch {
case err == nil:
_ = h.recentCheckIns.Put(ctx, cacheKey, visit, 90*time.Second)
c.JSON(http.StatusOK, gin.H{"status": "checked_in", "visit": visit, "source": "database"})
case errors.Is(err, ErrInvitationAlreadyUsed):
c.JSON(http.StatusConflict, gin.H{"error": "invitation already redeemed"})
case errors.Is(err, ErrInvitationExpired):
c.JSON(http.StatusGone, gin.H{"error": "invitation expired"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "check-in failed"})
}
}The endpoint is safe to retry because the write path is single-use by construction, while the same-device cache removes false replay alarms after a dropped response.
How pool sizing and statement timeouts handle the evening rush
Check-in traffic is spiky. At 7 PM a large society can have dozens of visitors, cabs, and deliveries arriving in a narrow window. The deployment concern is not just CPU; it is how quickly each replica can acquire a PostgreSQL connection, finish the atomic update, and release the slot before the next request times out on the tablet. Visitor check-in is tiny work, so long statement timeouts are a liability. If a query hangs on a saturated database, the guard queue grows immediately.
package config
import (
"strconv"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type VisitorConfig struct {
CheckInTimeout time.Duration
RecentRetryTTL time.Duration
DBMaxConns int32
DBMinConns int32
DBStatementTimeout time.Duration
}
func (c VisitorConfig) ApplyToPool(poolConfig *pgxpool.Config) {
poolConfig.MaxConns = c.DBMaxConns
poolConfig.MinConns = c.DBMinConns
poolConfig.ConnConfig.RuntimeParams["statement_timeout"] = strconv.FormatInt(c.DBStatementTimeout.Milliseconds(), 10)
}A short statement timeout keeps check-in from waiting forever behind a degraded database. A failed request is safer than an unknown long-hanging request at the gate.
visitor:
checkInTimeout: 2s
recentRetryTtl: 90s
approvalTimeout: 5m
postgres:
maxConns: 60
minConns: 10
statementTimeout: 1500ms
deployment:
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
extraEnv:
- name: VISITOR_CHECKIN_TIMEOUT
value: 2s
- name: VISITOR_RECENT_RETRY_TTL
value: 90s
- name: VISITOR_APPROVAL_TIMEOUT
value: 5m
- name: POSTGRES_MAX_CONNS
value: "60"
- name: POSTGRES_MIN_CONNS
value: "10"
- name: POSTGRES_STATEMENT_TIMEOUT
value: 1500msThe rollout is additive: first ship migrations, then ship application code that starts reading and writing the new tables, all under a zero-unavailable rolling strategy.
Why the rollout is additive instead of a flag day migration
Visitor is a good example of backward-compatible rollout discipline. The tables land first and do not affect existing endpoints. Only after 0053, 0054, and 0055 are present do you deploy binaries that create invitations, approval requests, and visit rows. That means an older pod can keep serving complaints, notices, and payments while a newer pod starts serving visitor traffic. Nothing in the release requires a moment where every pod must already understand the new schema.
A concrete failure mode here is deploying the new handler before the migrations complete on one environment. In that case a guard check-in request can hit code that expects visitor_visits while the table does not exist yet. The release checklist is therefore ordered, not just present: migrate first, then roll application pods.
Which local checks prove the deploy shape is safe to retry
Applied exercise
Harden deployment for a tower with unreliable gate Wi-Fi
A 900-flat complex reports that gate tablets often lose Wi-Fi for a few seconds during the evening rush, producing duplicate taps and confused guards.
- Design the cache key and TTL that let the same device safely retry a recently committed check-in without hiding real replays from other devices.
- Choose check-in timeout, statement timeout, min connections, and max connections values that fit short, high-volume requests.
- Order the release steps for migrations, application rollout, and smoke checks so that no pod serves visitor traffic before the new tables exist.
- Describe the exact behavior a second device should see if it retries the same token after the first device already succeeded.
Deliverable
A deployment runbook covering retry handling, pool and timeout tuning, migration order, and the operator-visible difference between same-device retry and true replay.
Completion checks
- Your idempotency key includes guard_device_id and does not store the raw token in the cache key.
- Your rollout sequence applies migrations before new handlers receive traffic.
- Your second-device outcome preserves the replay signal instead of collapsing everything into generic success.
Why is visitor check-in considered safe to retry after a dropped response?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.