Repository navigation
TierCache 1.3.0
[1.3.0] - 2026-09-20
Fixed
- Closing the factory now fails outstanding async operations instead of abandoning them: every stage handed out through
AsyncTierCacheviews is tracked and completed withCancellationExceptiononTierCacheFactory.close(), so callers waiting on queued (never-started) operations unblock with a visible failure instead of hanging forever. - The async close protocol is now race-free: submission (state check + registry + executor hand-off) and the close drain share a per-view lifecycle lock, and view creation (
asyncCache) and the factory's CLOSED transition share a factory-level lock — a close landing anywhere in the submit path either rejects the submission or drains the registered stage, and no view can be published after the drain snapshot. Two visible consequences: a submission after close now fails its stage withCancellationException(previouslyRejectedExecutionException), andasyncCache(name)on a closed factory throwsIllegalStateException(previously returned a view nothing would ever drain). - Tag indexes are now bounded under every workload: the per-key reverse index keeps the data entry's exact TTL, each per-tag member set's TTL is extend-only and assigned atomically with the member add (one Lua call) — a shorter-lived entry can no longer shrink the shared index (mixed TTLs), and sets of never-touched-again tags expire within the longest member TTL. Dead members are reclaimed atomically (janitor sampling on writes plus read-time pruning, both check-and-remove in the same Lua call): tag lookups never return phantom entries, a concurrent rewrite can never lose its membership, and steady-state set size stays within the stated statistical bound under uninterrupted writes. See UPGRADING.md for the transition notes (pre-existing shrunken set TTLs do not self-repair; mixed-rollout caveats).
- Replay cursor correctness: the replay position now marks the end of the contiguous applied prefix of the journal — live deliveries are tracked in a bounded (128-version) applied window, and the cursor advances on a bounded cadence over exactly the rows already applied, never to the stream end and never past an unconsumed row (previously, a writer that journaled a row but delayed its publish could have that row skipped after a reconnect; a row written mid-replay could likewise be skipped). Every cursor read is one atomic checked read (integrity proof and range from the same response): if the cursor's own row was trimmed, the receiver takes the flush path with the flush signal instead of mistaking surviving rows for a contiguous prefix; a window overflow behind a delayed row falls back to journal-driven catch-up. Trim detection for a beginning cursor (
0-0) is now exact — counted atomically on every journal-appending path — including the boundary where the stream length equals the capacity exactly (previously misread as "no loss"). A trim-counter read failure takes the flush path with a warning, never "no loss". Operational note: the journal capacity must exceed the cursor cadence (64 events); below that, integrity is unconfirmable on every tick and the flush path fires by design (see docs/sizing-and-ttl.md). - The L1 flush path can no longer swallow an invalidation: the journal baseline is now captured BEFORE the local cache is cleared, so a write journaled between the clear and the baseline establishment stays ahead of the cursor and is replayed instead of being silently accounted (previously the stale re-warmed entry survived). The order is baseline → clear → settle state → notify observers, so a listener/metrics failure cannot cancel the mandatory clear; if the baseline read itself fails, the previous confirmed cursor is kept — the cursor never jumps past unread rows.
- Applied-version tracking is now hard-bounded (at most 768 versions per cache: 512 window hard cap + 256 confirmed, plus one bounded read batch): versions the cursor already passed are recorded in a bounded confirmed-set and late duplicates are applied to L1 without being re-tracked (previously 1,000 re-delivered events were retained forever at a nominal cap of 128); every catch-up pass ends in an explicit CAUGHT_UP / MORE_WORK / FAILED outcome — only a pass that reached the journal end clears the window; and on read failures the service enters a resync-required state (tracking memory freed, retries throttled to one per second, cursor never advanced without confirmed rows). Recovery is deliberately lazy: without new deliveries or a reconnect it stays incomplete and L1 may serve stale data until the next trigger, which then applies the missed rows.
evictAllis now journaled and covered by reconnect replay: theRemoteCacheSPI gained a versionedclear(Version)(defaulting toclear(), so custom transports keep their current behavior), the engine routesevictAllthrough it, and the Redis transport appends the EVICT_ALL journal row atomically with the namespace clear. Previously a receiver that missed the live EVICT_ALL notification kept serving stale L1 entries until TTL.- UPDATE-mode journal replay now deserializes the payload with the value serializer before applying it to L1, matching the live Pub/Sub path; previously a replayed UPDATE stored the raw serialized bytes as the value (typed reads could hit
ClassCastException). - Direct journal appends now serialize the UPDATE payload with the value serializer (previously the key serializer), staying symmetric with replay deserialization when key and value serializers differ. The standard write path (payload framed by the L2 scripts) is byte-identical before and after. Rows misencoded before this fix are not repaired retroactively — they age out within the journal window.
Changed
- Versions moved from millisecond to microsecond resolution (
max(epochMicros, previous + 1)), shrinking the cross-instance simultaneity window a thousandfold: two instances writing within one millisecond now order by real time. Same-microsecond writes remain ordered by the instance-ID tiebreak — the documented last-write-wins trade-off; a strict "later always wins" guarantee would require per-write coordination and stays out of scope by design. Wire format and Redis scripts are unchanged; on platforms with millisecond-granular clocks the scheme degrades to the 1.2.0 behavior.
Verify
Artifacts are signed (Sigstore keyless) and attested:
gh attestation verify <jar> --repo cramen/tier_cache
SBOM attached (tiercache-sbom.json). Maven coordinates: io.github.cramen:<module>:1.3.0.
https://github.com/cramen/tier_cache/actions/runs/35527255199