feat: competing consumers — leader election for projections and automations #26
Labels
No labels
bug
documentation
enhancement
investigation
nice-to-have
performance
production-ready
testing
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
ash/eskit#26
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Running eskit on N servers means N instances of every projection and automation. This causes:
Currently
EventSubscriptionruns independently per instance with no coordination. There is no competing consumer pattern.Building Blocks We Already Have
LockRegistryinterface —Acquire(ctx, id) (release, error)/TryAcquire(id) (release, bool)PgLockRegistry— Postgres advisory locks, session-scoped, auto-released on disconnect/crashMemoryLockRegistry— single-process (dev/test)SingleWriterMiddleware— uses LockRegistry for command serializationDesign
Core: Add
LockRegistrytosubscription.ConfigSubscription Behavior With LeaderLock
LeaderLock.Acquire(ctx, "sub:"+Name)Automation Behavior
Same pattern.
Automation.ConfiggetsLeaderLockfield. Only the leader node processes the TodoList.Two Levels of Protection
For automations with side effects, we want belt AND suspenders:
"exec:"+item.Key. Even if leader election fails briefly (split-brain during Postgres failover), a specific side effect runs at most once.Failover Scenarios
idle_in_transaction_session_timeoutor TCP keepalive (~30s default)What To Benchmark
advisoryLockIDuses FNV-1a to int64. For M subscription names, probability of collision. Should be astronomically low but verify.Test Plan
Implementation Phases
LeaderLocktosubscription.Config, acquire on Start, release on StopLeaderLocktoautomation.Config, same patternConnection Pool Implications
Each held advisory lock = 1 dedicated PG connection. With 20 projections + 5 automations = 25 connections just for locks. This is fine for most deployments but:
lock_connections = num_projections + num_automationspg_try_advisory_lock(non-blocking) with retry loop to avoid holding connections while waitingNOT In Scope
References
PgLockRegistryinpgstore/cluster.goLockRegistryinterface insinglewriter.goSingleWriterMiddlewarepatternDeferred to post-v1. Deferred: design when we actually need to scale beyond one instance.
Re-prioritized to CRITICAL. YoYoPass requires clustering for production scalability. Not optional.
Why NOT Now — Decision Record (2026-03-01)
Discussion with Axon engineers (Marc Klefter) confirmed: consistent hashing for command routing took Axon 15+ years to get right. The complexity is in rebalancing, failover, partition assignment, and split-brain handling — not the concept itself.
Current Production Baseline (stress tested 2026-03-01)
Why Advisory Locks + Auto-Retry Is Sufficient
pg_advisory_xact_lock(hashtext(streamID))serializes same-stream writes — no clashing, no wasted retries (unlike Ruby approach of optimistic retry loops)When To Build This
Trigger conditions (monitored via #146 lag monitoring):
Consistent Hashing Plan (when needed)
NATS JetStream provides consistent hashing out of the box:
hashtext(streamID) % partitionCountto specific consumersThis avoids building the 15 years of Axon infrastructure — we leverage NATS for the hard parts.
References
c5d75a7(pgstore/stress_test.go)Cluster scaling: partitioned commands, competing projections, rebalancingto feat: competing consumers — leader election for projections and automations