T-013 — SessionRegistry and heartbeat persistence
Picking this up? Read
CONTRIBUTING.mdfirst, then claim the matching issue and work on a branch. Finished means all six definition-of-done gates, not five. If anything below disagrees with a contract, the contract wins — open a contract change issue instead of implementing either version. Update this task’s row in the ledger in the same pull request.
Milestone M1 (Postgres lock backend) · Estimate 30 min
Preconditions — T-010…T-012 done: lockdb migrations apply, and PostgresLockStore implements
tryInsert, extend, deleteIfOwner, read with PgExceptionTranslator in place. Sessions are
currently inserted only by test fixtures — nothing in production code creates a lock_session row yet.
Goal — Implement dev.lock.server.core / store.pg session lifecycle: one session per client
whatever its lock count, locks attached to that session, and session death releasing every lock.
1. Why this task exists
Without sessions, a client crash leaves its locks pinned until each lease lapses independently, and a
client holding eight locks would run eight heartbeats. The design collapses that to one liveness signal
per client: lock_entry.session_id is NOT NULL and cascades, so FR-04 — session death releases locks
— is a single DELETE FROM lock_session. This is also where “session is dead” becomes terminal: a
false heartbeat voids every handle the client holds, and no code path may resurrect it.
2. Contracts to obey
| What | Pinned at |
|---|---|
lock_session columns, the lock_session_ttl_ck check, the cascade, lock_entry_session_idx |
docs/contracts/C1-database-schemas.md#ct1-lockdb |
| The heartbeat statement, verbatim, and the zero-rows obligation | C1 #ct1-renew |
SessionRegistry signatures; heartbeat false is terminal, do not retry |
C2 #ct2-spi |
LockLostException semantics for a reaped session |
C2 #ct2-exceptions |
lock.session.ttl default 15s, owner of the key is lock-server (SessionRegistry) |
C5 #ct5-config |
| Thread-safety and nullability of the registry | C2 #ct2-threading |
Precedence: if this spec and a contract disagree, the CONTRACT wins — stop and report.
3. Deliverables
| Path | What |
|---|---|
lock-api/src/main/java/dev/lock/api/SessionRegistry.java |
The SPI interface if not already present, exactly per C2 #ct2-spi |
lock-server/src/main/java/dev/lock/server/store/pg/LockSql.java |
Add OPEN_SESSION, HEARTBEAT (verbatim from C1 #ct1-renew), LOCKS_OF, CLOSE_SESSION |
lock-server/src/main/java/dev/lock/server/store/pg/PostgresSessionRegistry.java |
The implementation; four methods, no scheduler |
lock-server/src/main/java/dev/lock/server/store/pg/PgStoreConfiguration.java |
Add the registry bean alongside the store, same lock.backend=pg condition |
lock-server/src/main/java/dev/lock/server/core/SessionTtlProperties.java |
Typed binding for lock.session.ttl with the contract default |
lock-server/src/test/java/dev/lock/server/store/pg/PostgresSessionRegistryIT.java |
Integration test, cases below |
tasks/README.md |
Ledger row for T-013 marked done |
4. Specification
openSession(ownerId, ttl) — inserts one lock_session row and returns its session_id as a
string. The UUID is generated by the application (UUID.randomUUID()), the timestamps by the database:
created_at default, expires_at = now() + interval. Never both from Java — lock_session_ttl_ck
compares them and a clock-skewed client can violate it. A null or blank ownerId is an
IllegalArgumentException. The method does not deduplicate by owner: one client process opens one
session and remembers it; the server does not maintain an owner→session map. Document that in Javadoc,
because “one session per client” is a client-side invariant enforced by the SDK (M4), not a unique
index here — several processes may legitimately share an owner_id.
heartbeat(sessionId, ttl) — one execution of the pinned heartbeat statement. One row → true.
Zero rows → false, meaning the session is gone: the row was reaped, or never existed, or its
expires_at already passed. false is terminal — no retry, no re-insert, no upsert. The registry must
not create a session as a side effect of a heartbeat; if it did, a reaped client would resurrect a
session whose locks are already gone and hold handles the database no longer backs. Transient JDBC
faults go through PgExceptionTranslator (T-012) and surface as ContentionException, never false.
closeSession(sessionId) — returns the number of locks released. One transaction: read the keys
under the session using lock_entry_session_idx, then delete the lock_session row and let
ON DELETE CASCADE remove the grants; the count from the read is the return value. One transaction is
required so the count cannot miss a grant inserted between read and delete. Idempotent: closing an
unknown or already-closed session returns 0 and is not an error.
locksOf(sessionId) — an unmodifiable Set<String> of lock keys, index-driven, empty for an
unknown session. Non-authoritative like read; say so in Javadoc.
Threading — the registry is stateless and thread-safe by holding only a DataSource; no
synchronized, no caches, no in-memory session table. Per C2 #ct2-threading all returns are non-null
and Optional/collection-empty rather than null.
PostgresSessionRegistryIT — one container per class. Cases: open then heartbeat returns true and
expires_at moves forward; heartbeat on a random UUID returns false; heartbeat after the TTL lapsed
returns false, and a second heartbeat still returns false with no row created (assert the row count in
lock_session is unchanged); acquiring two locks on one session then closeSession returns 2 and both
lock_entry rows are gone; closeSession twice returns 2 then 0; locksOf reflects both keys before
close and is empty after; a lock cannot be acquired against a session id that does not exist (the FK
rejects it — assert the failure is not translated into Optional.empty() by tryInsert); after
closeSession, a fresh session can acquire the same keys and receives strictly greater tokens.
5. Acceptance criteria
HEARTBEATinLockSqlis textually identical to the session-heartbeat statement in C1#ct1-renew.grep -rn 'ON CONFLICT\|INSERT' lock-server/src/main/java/dev/lock/server/store/pg/PostgresSessionRegistry.javashows exactly oneINSERTand noON CONFLICT.- No field in
PostgresSessionRegistryother than injected collaborators; noMap, nosynchronized. closeSessionexecutes its read and delete inside one transaction (visible as a single transactional boundary in the code).- All eight IT cases exist as separate
@Testmethods and pass. - The dead-session test asserts no row was created by the failed heartbeat.
lock.session.ttlresolves to 15s with no configuration present, asserted in a test or by the typed properties default../gradlew :lock-server:build spotlessCheckis green.
6. Verification
./gradlew :lock-server:test --tests '*PostgresSessionRegistryIT'
./gradlew :lock-server:test # whole M1 suite: acquire, renew/release, translator, sessions, migration
./gradlew :lock-server:spotlessCheck
Expected: every lock-server test class green; the session IT shows the cascade removing lock rows
without any DELETE FROM lock_entry statement in the codebase.
7. Out of scope
The client-side heartbeat scheduler, onLockLost, and the monotonic client deadline — C2 #ct2-sdk,
M4. The session reaper loop and reapExpired (later M1). revoke. The HTTP session endpoints and
lock.session.lost / session_expired telemetry (C3 #ct3-lock, C4 #ct4-metrics — later M1/M6).
The etcd lease equivalent of a session (M3).
8. Hazards
The tempting bug is an upsert heartbeat: INSERT … ON CONFLICT DO UPDATE on lock_session makes
heartbeat always succeed, so a client whose session was reaped keeps renewing a new session while its
locks — cascaded away — are already held by someone else. C2 #ct2-spi states false is terminal for
exactly this reason. Second trap: computing expires_at in Java, which can trip
lock_session_ttl_ck or manufacture liveness from a skewed clock. Third: deleting lock_entry rows
explicitly in closeSession instead of relying on the cascade — it works until a second index or
constraint diverges between the two code paths.
9. On completion
Mark the T-013 row done in tasks/README.md and note that M1’s store layer is complete except
revoke / reapExpired. Record any contract silence you had to stop on.