Skip to content

refactor: per-register cache to fix stale data on writes#8

Open
tma wants to merge 4 commits intomainfrom
refactor/per-register-cache
Open

refactor: per-register cache to fix stale data on writes#8
tma wants to merge 4 commits intomainfrom
refactor/per-register-cache

Conversation

@tma
Copy link
Copy Markdown
Owner

@tma tma commented Apr 2, 2026

Problem

The cache keyed entries by the full request range (slaveID:fc:startAddr:quantity). Write invalidation only matched exact keys, so a write to register 10 would never invalidate a cached read of registers 0-49 — the keys simply didn't match. In energy management setups where battery power or charge current is written every ~10s, this meant overlapping read ranges silently served stale data until TTL expiry.

Additionally, when MODBUS_CACHE_SERVE_STALE=true, the background cleanup goroutine could delete expired entries needed for stale fallback, making the feature unreliable.

Solution

Switch from range-based cache keys to per-register keys (slaveID:fc:addr). Each register/coil gets its own cache entry. Upstream responses are decomposed into individual register values on store, and reassembled from cached values on hit.

  • Write invalidation now works correctly: writing register 10 deletes the cache entry for register 10, regardless of what read range originally populated it.
  • Partial miss = full miss: if any register in a requested range is missing or expired, the entire range is fetched from upstream. This preserves atomicity — every response comes from a single upstream read.
  • Coalescing unchanged: request coalescing still operates on range keys, so identical concurrent requests share a single upstream fetch.
  • keepStale flag: when CacheServeStale is enabled, the cleanup goroutine skips deletion so stale entries are always available for fallback.
  • Cache miss logging: all callers log cache misses, including coalesced waiters.

Changes

  • internal/cache/cache.goRegKey/RangeKey, GetRange/SetRange/DeleteRange, rename GetOrFetchCoalesce (decoupled from cache storage), keepStale flag
  • internal/proxy/proxy.godecomposeResponse/assembleResponse helpers, range-aware invalidation
  • Tests — roundtrip tests for all 4 function codes, write invalidation of overlapping ranges, keepStale behavior, data isolation

tma added 3 commits April 2, 2026 20:52
Cache keys now address individual registers/coils instead of
request ranges. This fixes stale data when writes hit registers
that are part of a larger cached read range.

Key changes:
- RegKey(slaveID, fc, addr) for per-register storage
- RangeKey(slaveID, fc, addr, qty) for request coalescing
- GetRange/SetRange/DeleteRange for batch operations
- Rename GetOrFetch to Coalesce (no longer interacts with cache)
- Add keepStale flag to prevent cleanup from removing entries
  needed for stale-serve fallback
Decompose upstream responses into per-register cache entries and
reassemble from cache on hits. Write invalidation now correctly
removes individual registers in the written range, fixing stale
data when writes overlap with larger cached read ranges.

New helpers:
- decomposeResponse: extract per-register values from Modbus PDU
- assembleResponse: reconstruct Modbus PDU from cached values
- Roundtrip tests for all function codes (registers + coils)
- Tests verifying write invalidation of overlapping reads
Move the cache miss log before Coalesce so coalesced waiters
also get a log entry. The upstream client already logs request
completion with duration, so fetches vs coalesced waits are
distinguishable.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors the Modbus proxy cache from range-keyed entries to per-register/per-coil entries to prevent stale reads after overlapping writes, while preserving range-level request coalescing and improving stale-serving behavior.

Changes:

  • Replace range-based cache storage with per-register/per-coil storage (RegKey, GetRange/SetRange/DeleteRange), while keeping coalescing on range keys (RangeKey, Coalesce).
  • Update proxy read path to assemble responses from per-register cache on hits, and decompose upstream responses into per-register entries on store; update write invalidation to delete affected registers/coils.
  • Add/adjust tests for decompose/assemble helpers, overlapping write invalidation, range APIs, and coalescing.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

File Description
internal/proxy/proxy.go Switch read caching to per-register storage, add response decompose/assemble helpers, update write invalidation to delete per-register entries.
internal/proxy/proxy_test.go Update existing proxy cache tests and add new helper + invalidation tests for the new per-register behavior.
internal/cache/cache.go Introduce per-register keys and range APIs; split range coalescing into Coalesce; add keepStale to skip eviction for stale-serving.
internal/cache/cache_test.go Update constructor usage and add tests for range APIs, coalescing, data isolation, and keepStale behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/cache/cache.go
Comment thread internal/cache/cache.go
Comment thread internal/cache/cache.go Outdated
Comment thread internal/proxy/proxy.go
Comment thread internal/cache/cache_test.go Outdated
- Guard GetRange/GetRangeStale against quantity=0 (false cache hit)
- Use shared coilOn/coilOff slices in decomposeResponse to reduce
  per-coil allocations
- Extract cleanupOnce so tests exercise the real keepStale guard
  instead of manually simulating cleanup
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants