SessionLease
port.SessionLease is the optional cross-process single-writer seam that
prevents two replicas from driving the same session concurrently. It is the
distributed lock for session ownership.
What it solves
Mecatl stores session state in a SessionStore (JSONL on disk, Redis, or an
in-memory map). In a single-replica deployment with session affinity, this
is safe — only one process ever touches a given session. In a multi-replica
deployment without affinity routing (or after a replica restarts mid-run), two
workers could acquire the same session snapshot, drive independent turns, and
silently diverge. The conversation grows incoherent and neither writer can
detect the collision.
SessionLease is the cross-process lock that closes this gap. Before a run
starts, the service layer acquires a lease on the session id. A competing
replica that arrives while the lease is live gets ErrSessionLeasedElsewhere
(gRPC FAILED_PRECONDITION / HTTP 409) instead of a silently-diverged run. The
in-process mutex stays in place too — the lease sits on top of it, not in place
of it.
The interface
port.SessionLease lives in engine/port/lease.go. The loop never imports it —
acquisition, renewal, and release are all internal/adapter/server.Service
concerns.
// engine/port/lease.go
type SessionLease interface {
// Acquire grants the lease for id to owner. Succeeds when the lease is free,
// expired, or already held by owner. Returns ErrLeaseHeld when a different,
// still-live owner holds it.
Acquire(ctx context.Context, id session.SessionID, owner string) (Lease, error)
// Renew extends the lease the caller still holds, returning a fresh Lease with
// a new Expiry and the same Token. Returns ErrLeaseHeld when the caller no
// longer holds the lease — the loss signal, interpreted as "cancel the run".
Renew(ctx context.Context, l Lease) (Lease, error)
// Release relinquishes the caller's hold. Idempotent: releasing an unheld or
// unknown lease, or one whose owner/token no longer match, is a no-op success.
Release(ctx context.Context, l Lease) error
}
The Lease value type carries four fields:
| Field | Type | Meaning |
|---|---|---|
SessionID | session.SessionID | The session this lease guards |
Owner | string | The holding process's owner-identity string (e.g. <hostname>-<pid>-<build-nonce>); composition builds it once per app.Build |
Token | uint64 | Monotone fencing epoch; advances on every takeover (free/expired/other-owner → new holder), stable across a successful Renew |
Expiry | time.Time | Wall-clock instant the lease lapses if not renewed |
Implementations return port.Lease as an immutable value. Acquire and Renew
return a fresh value — callers store the returned value, never mutate one in
place.
Two sentinel errors:
port.ErrLeaseHeld— a competing, live owner holds the lease. Transient: the holder may release or its lease may expire. The run-entry gate maps this toErrSessionLeasedElsewhere; aRenewreturning this cancels the in-flight run.port.ErrLeaseUnsupported— the backend cannot lease at all (for example, a remote gRPC driver answeringUNIMPLEMENTED). Sticky-disable: composition logs one INFO and stops consulting the seam, degrading to the byte-identical no-lease path.
How it fits in the service layer
The lease is owned entirely by internal/adapter/server.Service. The agent loop
(engine/agent) is lease-agnostic and must never import port.SessionLease.
Key points from the implementation:
Acquireis called afters.runEntryMu.lock(id)so the in-process mutex gate fires first. Both are held for the lifetime of the run.- Release uses a cancel-detached context (the same pattern as
appendEventfor the durable event log) — a dead client's cancelled context cannot abort the lease release. - A
RenewreturningErrLeaseHeldmeans another process took over (the TTL lapsed). The renewer goroutine cancels the run so the rogue-run scenario becomes a clean, recoverable cancellation rather than a diverged write. - The
heldLeasesregistry onServiceensures SIGTERM-time cleanup: the shutdown path iterates the registry and releases all held leases, so survivors take over immediately rather than waiting for TTL expiry.
The four reference implementations
Deploy them in this order as you scale up:
| Backend | Package | Flag | Use case |
|---|---|---|---|
memlease | engine/adapter/memlease | (none — explicit construction) | Tests, offline, single-replica |
flocklease | internal/adapter/flocklease | --session-lease-dir <dir> | Single host, multiple processes |
k8slease | internal/adapter/k8slease | --session-lease-k8s-namespace <ns> | Kubernetes multi-replica (mecak8s) |
grpcdriver | internal/adapter/grpcdriver | --session-lease-url <host:port> | Remote or multi-host lease service |
engine/adapter/memlease — in-process reference
memlease.New(clock port.Clock, ttl time.Duration) *memlease.Lease constructs
the in-process reference implementation. It keeps per-session records in a
mutex-guarded map and derives expiry from an injected port.Clock, so tests
advance a fake clock past the TTL to exercise expiry and takeover without real
sleeps.
This is the backend the conformance suite validates against. The
memstore.NewLease constructor exists for explicit in-process/test construction
when exercising the lease seam; it is not a standard deployment backend selected
by a flag.
memlease is useful for tests and for single-replica deployments where you want
the full lease lifecycle exercised. It is not a cross-process lock — two
distinct OS processes each construct their own map and are invisible to each
other.
internal/adapter/flocklease — single-host file locking
flocklease provides cross-process single-writer enforcement on a single host
by holding flock(2) advisory locks on per-session files under
--session-lease-dir. Two mecated processes sharing the same store directory
can lock-coordinate without a network dependency.
Select it with --session-lease-dir <dir>. The directory must exist and be
writable by all competing processes. Per-session lock files are created on first
acquire and removed on release.
internal/adapter/k8slease — Kubernetes coordination leases
k8slease uses coordination.k8s.io Lease objects in a Kubernetes namespace.
This is the backend mecak8s wires by default.
Select it with --session-lease-k8s-namespace <ns>. The ServiceAccount running
the pod needs get,create,update,delete on leases in coordination.k8s.io in
that namespace (see the RBAC template in
deploy/helm/mecak8s/templates/rbac.yaml).
On SIGTERM, mecak8s iterates Service.heldLeases and releases every held lease
before exiting. A survivor pod acquires the freed leases immediately rather than
waiting for TTL expiry. Interrupted sessions are recoverable from the Redis
snapshot on the successor pod.
The default path
When no --session-lease-* flag is given, a local JSONL --store-dir
automatically uses the flock backend at <store-dir>/.session-leases. This
makes every current local composition participate in the same cross-process
run-entry and maintenance exclusion. A store without a local directory uses its
own port.SessionLease when implemented; otherwise the composition remains
unleased and destructive maintenance fails closed. The in-process per-id mutex
still serializes same-process requests.
memstore deliberately does NOT implement port.SessionLease directly: it is
process-private, so its in-process mutex is already an independent single-writer
proof. The memstore.NewLease constructor exists so the type-assert discovery
path can be exercised, but it is never auto-wired.
The sticky-disable path handles a backend that unexpectedly returns
ErrLeaseUnsupported at runtime (for example, a remote gRPC driver without a
lease implementation). Composition logs a single INFO and stops consulting the
seam, degrading cleanly rather than treating every run as
ErrSessionLeasedElsewhere.
Conformance suite
engine/adapter/leaseconformance validates any port.SessionLease
implementation. Call leaseconformance.Run with a factory that returns a fresh
lease plus an advance(time.Duration) callback that pushes the lease's clock
forward:
leaseconformance.Run(t, func(t *testing.T) (port.SessionLease, func(time.Duration)) {
clk := adapter.NewFakeClock()
l := memlease.New(clk, leaseconformance.TTL)
return l, clk.Advance
})
The suite never sleeps — advance is how it crosses the TTL boundary. A
real-clock backend may implement advance as a short sleep over a small TTL.
The suite exercises:
- Fresh acquire succeeds
- Renew extends expiry, keeps the same token
- Release frees the lease for another owner
- Competing acquire by a different owner returns
ErrLeaseHeld - Same-owner re-acquire keeps the token
- Token is strictly greater on a takeover (release or expiry)
ErrLeaseHeldon Renew after expiry-and-takeover- Release is idempotent
- Token is monotone across a full takeover chain
- Distinct session ids lease independently
When to implement your own
The four reference backends cover in-process testing, single-host file locking,
Kubernetes coordination, and a remote gRPC lease service. Implement your own
port.SessionLease if you have an existing distributed lock service that none
covers — for example, etcd, DynamoDB conditional writes, or a Redis-backed lock
primitive that isn't using the k8s API.
The interface is small (three methods, one value type), the conformance suite
validates the contract mechanically, and the composition layer wires it with
zero loop changes. A custom backend is a sibling of flocklease and k8slease
under internal/adapter/.
What's next
- Pick your deployment shape — when to add a lease backend and which one to choose.
- SessionStore & EventLog — the session persistence port; the lease sits on top of it.
- Cloud-native deployment (mecak8s) — the k8s
topology that wires the
k8sleasebackend and Redis together. - Hook system — other service-layer lifecycle seams.