T-040 — lock-client: session, heartbeat, conservative expiry
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 M4 (SDK and correctness proof) · Estimate 30 min (tight but honest: no acquire loop here, that is T-041)
Preconditions
- T-008 — M0 complete: Gradle 9.5 Kotlin DSL build, version catalog, the module skeleton incl.
lock-client, Spotless + google-java-format, and a CI workflow authored but not executed (ADR-011). - T-017 — M1 complete:
lock-serverwith the PostgreSQLLockStoreserves L1–L8 against a Testcontainerslockdb;lock-apitypes and the exception hierarchy exist and compile. - T-027 — M2 complete:
payment-resource,payout-executor,rail-proxy,rail-stubrun in the local compose stack. You inherit a repo where the executor obtains locks through direct HTTP calls; it stays that way until T-047 migrates it onto this SDK.
Goal Implement the session half of the SDK — one session, one heartbeat scheduler, and a monotonic, conservative local expiry that declares the lock lost before the server does.
1. Why this task exists
Every caller gets lease arithmetic wrong in the same way: they trust the server’s expiresAt wall-clock instant, compare it against their own clock, and hold a lock they no longer own across an NTP step or a GC pause. Writing the arithmetic once, in one module, is the only way the rest of the project can be honest about liveness. The lease is measured from the moment the request left this process, not from when the response arrived — the network delay on the response leg is time the server has already spent counting down.
2. Contracts to obey
| What | Pinned by |
|---|---|
SDK members: openSession, checkStillHeld, onLockLost — names and signatures. checkStillHeld is void checkStillHeld(LockHandle handle) throws LockLostException — it returns normally or throws; never a boolean |
docs/contracts/C2-java-api.md#ct2-sdk |
LockHandle fields incl. clientDeadlineNanos; LockInfo. LockOutcome is the forceRevoke result record only — never an acquire or held-check result |
C2#ct2-records |
LockLostException is terminal and never retryable |
C2#ct2-exceptions |
| Thread-safety and nullability of SDK types | C2#ct2-threading |
L1 create session, L2 heartbeat, L3 delete session, L5 renew — paths, bodies, status codes (SESSION_UNKNOWN 404, SESSION_EXPIRED 410, LOCK_LOST 409, NOT_LEADER 503) |
docs/contracts/C3-http-surfaces.md#ct3-lock |
| Per-hop timeouts and the lease-vs-timeout arithmetic | C3#ct3-timeouts |
lock.session.ttl, lock.client.safety-margin (0.30) — keys, defaults, env-var spellings |
docs/contracts/C5-config-build-and-naming.md#ct5-config |
Log events session_lost / lease_expired and their required fields; any metric you emit |
docs/contracts/C4-observability.md#ct4-logs, #ct4-metrics |
Precedence: if this spec and a contract disagree, the CONTRACT wins — stop and report the mismatch, quoting both. Do not implement either version.
3. Deliverables
| Path | What |
|---|---|
lock-client/build.gradle.kts |
modify: add the HTTP client and Micrometer/SLF4J deps from the catalog; lock-api stays the only API dependency |
lock-client/src/main/java/dev/lock/client/LockClient.java |
new: entry point holding base URL, timeouts, the scheduler, openSession(String ownerId) |
lock-client/src/main/java/dev/lock/client/LockClientSession.java |
new: AutoCloseable session — handle registry, heartbeat state, checkStillHeld, onLockLost |
lock-client/src/main/java/dev/lock/client/LockClientConfig.java |
new: immutable config record — session TTL, safety margin, heartbeat period, per-request timeouts |
lock-client/src/main/java/dev/lock/client/Clock.java |
new: seam over System.nanoTime() so T-043 can drive it; production impl delegates |
lock-client/src/main/java/dev/lock/client/LockServerHttp.java |
new: thin L1/L2/L3/L5 transport, status→exception mapping |
lock-client/src/test/java/dev/lock/client/… |
new: unit tests on the fake clock plus one Testcontainers-backed session test |
4. Specification
Deadline arithmetic. For every grant and every successful renew: capture sendNanos from the Clock before the request is written, and on success set clientDeadlineNanos = sendNanos + leaseMillis * (1 - safetyMargin) converted to nanos, where leaseMillis is the TTL the server granted (it may clamp down), never the TTL requested. Never derive a deadline from expiresAt; that field is for logs and human display only. checkStillHeld compares clock.nanoTime() against the stored deadline with subtraction, never < on raw values, and throws LockLostException when the deadline has passed rather than returning a verdict (C2 §2.6 pins void … throws).
Heartbeat. One single-threaded scheduled executor per LockClient, daemon threads, named lock-hb-*. Period = min(sessionTtl, shortest live lease) * (1 - margin) / 3 (ASSUMPTION: divisor 3, so two consecutive losses can be tolerated before the server expires the session). Each tick sends L2 for the session and L5 for each live handle whose remaining time is under two periods. On L2 success, extend the session deadline from that tick’s send time. On 404/410, or on the local session deadline passing, the session enters LOST: every handle is marked lost, onLockLost listeners fire once per handle on the scheduler thread, session_lost is logged. NOT_LEADER (503) and I/O errors do not immediately lose the session — they are retried on the next tick; only the deadline decides.
Failure semantics. After a handle is lost, any SDK call taking that handle throws LockLostException — the first call after the deadline passes, with no server round trip required. close() sends L3 best-effort, shuts the scheduler down, and never throws on transport failure. Listener exceptions are caught and logged; one bad listener must not kill the heartbeat thread. Registration order is preserved; a listener registered after loss fires immediately.
Threading. LockClientSession is safe for concurrent use by application threads; handle state lives in a concurrent map keyed by lock key; deadlines are volatile longs. No lock is held while an HTTP call is in flight.
5. Acceptance criteria
./gradlew :lock-client:buildpasses with Spotless clean;lock-clientstill declares no dependency onlock-server.- A unit test with a fake
ClockprovesclientDeadlineNanosequalssendNanos + 0.7 * leaseMillisfor margin 0.30 — and that a 400 ms response-leg delay does not move the deadline later. - A unit test where the server clamps a requested 30 s TTL to 10 s shows the deadline computed from 10 s.
- A test advancing the fake clock past the deadline with the heartbeat stalled shows
checkStillHeldthrowingLockLostException,onLockLostfired exactly once, and the next handle-taking call throwing too. A test also asserts the method’s declared return type isvoid, so no boolean creeps back in. - A test returning 503
NOT_LEADERon one tick and 200 on the next shows the session survives and no listener fires. - A Testcontainers test against a real
lock-serverholds a lock for 3× its TTL with heartbeats running and never loses it; killing the heartbeat scheduler makes it lost withinttl * (1 - margin)+ one period. - No
Thread.sleepin production sources underlock-client/src/main; no wall-clock (System.currentTimeMillis,Instant.now) use in any expiry decision.
6. Verification
./gradlew :lock-client:build :lock-client:test
grep -rn "currentTimeMillis\|Instant.now" lock-client/src/main/java # expect: no hits in deadline code paths
grep -rn "Thread.sleep" lock-client/src/main/java # expect: no hits
./gradlew :lock-client:test --tests '*SessionExpiry*' --info # expect: the seven-scenario suite green
Expected observable result: the test report at lock-client/build/reports/tests/test/index.html lists the clamp, response-delay, stalled-heartbeat, and NOT_LEADER cases as passing.
7. Out of scope
Bounded acquire, backoff, jitter and reentrancy — T-041. Migrating payout-executor off its direct HTTP calls — T-047. The SIGSTOP experiment — T-042. The simulation harness — T-043 (you only provide the Clock seam it needs). Any change to lock-server behaviour: if the server is wrong, report it, do not compensate in the client.
8. Hazards
- Measuring the lease from response arrival is the whole bug this task exists to prevent; it looks correct in every test where the network is fast.
checkStillHeldreturning normally is not permission to perform a side effect — C2 §2.6 says so explicitly; the fencing token is the safety mechanism. Do not add a comment or API that implies otherwise.- Do not give
checkStillHelda boolean return. C2 §2.6 pinsvoid … throws LockLostExceptionprecisely because a boolean invitesif (checkStillHeld(h))with noelse— a silently skipped payout instead of a loud abort. A convenience boolean alongside it is the same defect with an extra name. - Renew must not change the fencing token (
C3#ct3-lockL5). If your code re-reads a token after renew, you have introduced a second token per grant. - A
ScheduledExecutorServicewhose task throws silently stops rescheduling — wrap every tick body.
9. On completion
Mark the T-040 row done in tasks/README.md with the local timestamp. Note any deviation (especially the heartbeat divisor if you changed it) in that row, and open a contract-amendment note in docs/04-contracts.md#c-changelog only if a contract had to move.