T-013 — SessionRegistry and heartbeat persistence

Picking this up? Read CONTRIBUTING.md first, 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

  1. HEARTBEAT in LockSql is textually identical to the session-heartbeat statement in C1 #ct1-renew.
  2. grep -rn 'ON CONFLICT\|INSERT' lock-server/src/main/java/dev/lock/server/store/pg/PostgresSessionRegistry.java shows exactly one INSERT and no ON CONFLICT.
  3. No field in PostgresSessionRegistry other than injected collaborators; no Map, no synchronized.
  4. closeSession executes its read and delete inside one transaction (visible as a single transactional boundary in the code).
  5. All eight IT cases exist as separate @Test methods and pass.
  6. The dead-session test asserts no row was created by the failed heartbeat.
  7. lock.session.ttl resolves to 15s with no configuration present, asserted in a test or by the typed properties default.
  8. ./gradlew :lock-server:build spotlessCheck is 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.


Back to top

Apache-2.0. The domain modelled here is fictional; every number is a labelled assumption. Not production-ready — see SECURITY.md.