Skip to content

Commit 64fb386

Browse files
committed
test: cover proxy cache miss and stale fallback
Update README and SPEC for the per-register cache design, then add proxy-level tests for miss -> fetch -> store -> hit and stale fallback on upstream errors. Introduce a small upstream client interface so tests can use a mock upstream without a real Modbus connection.
1 parent 707abd4 commit 64fb386

5 files changed

Lines changed: 207 additions & 31 deletions

File tree

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,12 @@ docker run --rm -v $(pwd):/app -w /app golang:1.24 go test ./...
132132

133133
## Cache Behavior
134134

135-
- **Key format**: `{slave_id}:{function_code}:{start_address}:{quantity}`
136-
- **Read requests**: Served from cache if available and not expired
137-
- **Write requests**: Forwarded to upstream (if allowed), exact matching cache entries invalidated
138-
- **Request coalescing**: Multiple identical requests during a cache miss share a single upstream fetch
135+
- **Key format**: values are cached per register/coil as `{slave_id}:{function_code}:{address}`
136+
- **Read requests**: Served from cache only if every register/coil in the requested range is present and not expired
137+
- **Cache misses**: If any value in the requested range is missing or expired, the full range is fetched from upstream and decomposed into per-register/coil cache entries
138+
- **Write requests**: Forwarded to upstream (if allowed), then invalidate the written address range so overlapping cached reads cannot return stale values
139+
- **Request coalescing**: Multiple identical range requests during a cache miss share a single upstream fetch using `{slave_id}:{function_code}:{start_address}:{quantity}` as the coalescing key
140+
- **Stale fallback**: If enabled, expired entries are retained and can be served when upstream requests fail
139141

140142
## License
141143

SPEC.md

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -49,28 +49,36 @@ Many Modbus devices (inverters, meters, battery systems) have limited polling ca
4949
### 3. In-Memory Cache
5050

5151
#### Cache Key Structure
52+
53+
Values are cached per register/coil:
54+
```
55+
{slave_id}:{function_code}:{address}
56+
```
57+
58+
Request coalescing still uses the requested range as its key:
5259
```
5360
{slave_id}:{function_code}:{start_address}:{quantity}
5461
```
5562

5663
#### Cache Entry
5764
```go
5865
type CacheEntry struct {
59-
Data []byte
66+
Data []byte // one register (2 bytes) or one coil/input bit (1 byte: 0 or 1)
6067
Timestamp time.Time
6168
TTL time.Duration
6269
}
6370
```
6471

6572
#### Cache Behavior
66-
- **Read Operations**: Check cache first, return if valid (not expired)
67-
- **Write Operations**: Always forward to device, invalidate exact matching cache entries (same slave_id, function_code, start_address, quantity)
73+
- **Read Operations**: Check the per-register/coil cache first. Return from cache only if every value in the requested range is present and not expired.
74+
- **Cache Misses**: If any value in the requested range is missing or expired, fetch the full requested range from upstream, then decompose the response into per-register/coil cache entries.
75+
- **Write Operations**: Always forward to the device when writes are allowed, then invalidate each cached register/coil in the written address range. This prevents overlapping cached read ranges from serving stale values after frequent writes.
6876
- **TTL**: Configurable (default: 10 seconds)
69-
- **Cleanup**: Time-based expiration (entries removed when TTL expires)
70-
- **Staleness**: Option to serve stale data on upstream failure (default: off)
77+
- **Cleanup**: Time-based expiration. Expired entries are removed during cleanup unless stale serving is enabled.
78+
- **Staleness**: Option to serve stale data on upstream failure (default: off). When enabled, expired entries are retained so they remain available for fallback.
7179

7280
### Request Coalescing
73-
- Identical in-flight requests are coalesced (same slave_id, function, address, quantity)
81+
- Identical in-flight range requests are coalesced (same slave_id, function, address, quantity)
7482
- Second request arriving while first is pending will wait for and share the first's response
7583
- Prevents thundering herd on cache miss
7684

@@ -142,47 +150,82 @@ type CachingHandler struct {
142150

143151
```go
144152
type Cache struct {
145-
mu sync.RWMutex
146-
entries map[string]*CacheEntry
147-
ttl time.Duration // default: 10 * time.Second
153+
mu sync.RWMutex
154+
entries map[string]*CacheEntry
155+
defaultTTL time.Duration
156+
keepStale bool
157+
158+
// Request coalescing for identical range requests.
159+
inflight map[string]*inflightRequest
160+
inflightMu sync.Mutex
161+
}
162+
163+
func RegKey(slaveID, functionCode byte, address uint16) string {
164+
return fmt.Sprintf("%d:%d:%d", slaveID, functionCode, address)
148165
}
149166

150-
func (c *Cache) Get(key string) ([]byte, bool) {
167+
func RangeKey(slaveID, functionCode byte, address, quantity uint16) string {
168+
return fmt.Sprintf("%d:%d:%d:%d", slaveID, functionCode, address, quantity)
169+
}
170+
171+
func (c *Cache) GetRange(slaveID, functionCode byte, address, quantity uint16) ([][]byte, bool) {
172+
if quantity == 0 {
173+
return nil, false
174+
}
175+
151176
c.mu.RLock()
152177
defer c.mu.RUnlock()
153-
154-
entry, ok := c.entries[key]
155-
if !ok || time.Since(entry.Timestamp) > entry.TTL {
156-
return nil, false
178+
179+
values := make([][]byte, quantity)
180+
for i := uint16(0); i < quantity; i++ {
181+
entry, ok := c.entries[RegKey(slaveID, functionCode, address+i)]
182+
if !ok || entry.IsExpired() {
183+
return nil, false
184+
}
185+
values[i] = append([]byte(nil), entry.Data...)
186+
}
187+
return values, true
188+
}
189+
190+
func (c *Cache) SetRange(slaveID, functionCode byte, address uint16, values [][]byte) {
191+
c.mu.Lock()
192+
defer c.mu.Unlock()
193+
194+
now := time.Now()
195+
for i, value := range values {
196+
c.entries[RegKey(slaveID, functionCode, address+uint16(i))] = &CacheEntry{
197+
Data: append([]byte(nil), value...),
198+
Timestamp: now,
199+
TTL: c.defaultTTL,
200+
}
157201
}
158-
return entry.Data, true
159202
}
160203

161-
func (c *Cache) Set(key string, data []byte, ttl time.Duration) {
204+
func (c *Cache) DeleteRange(slaveID, functionCode byte, address, quantity uint16) {
162205
c.mu.Lock()
163206
defer c.mu.Unlock()
164-
165-
c.entries[key] = &CacheEntry{
166-
Data: data,
167-
Timestamp: time.Now(),
168-
TTL: ttl,
207+
208+
for i := uint16(0); i < quantity; i++ {
209+
delete(c.entries, RegKey(slaveID, functionCode, address+i))
169210
}
170211
}
171212
```
172213

214+
The cache also exposes `Coalesce(ctx, rangeKey, fetch)` for request coalescing. It does not read or write cache entries directly; the proxy performs cache lookups and stores decomposed responses.
215+
173216
### Request Flow
174217

175218
1. Client sends Modbus TCP request
176219
2. Parse request: extract slave ID, function code, address, quantity
177220
3. **For reads**:
178-
- Build cache key
179-
- Check cache → if hit & valid, return cached data
180-
- On miss: forward to upstream device
181-
- Store response in cache
221+
- Check every per-register/coil cache key in the requested range
222+
- If all values are present and valid, reassemble and return the Modbus response
223+
- On any miss or expired value: coalesce identical in-flight range requests, then forward to upstream device
224+
- Decompose successful upstream responses into per-register/coil cache entries
182225
- Return response to client
183226
4. **For writes**:
184227
- Check readonly mode
185-
- If allowed: forward to upstream, optionally invalidate cache
228+
- If allowed: forward to upstream, then invalidate every cached register/coil in the written address range
186229
- Return response
187230

188231
## Logging

internal/cache/cache_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,18 @@ func TestCache_GetRange(t *testing.T) {
125125
}
126126
}
127127

128+
func TestCache_GetRangeZeroQuantityMiss(t *testing.T) {
129+
c := New(time.Second, false)
130+
defer c.Close()
131+
132+
if _, ok := c.GetRange(1, 0x03, 10, 0); ok {
133+
t.Error("expected zero-quantity range to miss")
134+
}
135+
if _, ok := c.GetRangeStale(1, 0x03, 10, 0); ok {
136+
t.Error("expected zero-quantity stale range to miss")
137+
}
138+
}
139+
128140
func TestCache_SetRange(t *testing.T) {
129141
c := New(time.Second, false)
130142
defer c.Close()

internal/proxy/proxy.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,18 @@ import (
1313
"github.com/tma/mbproxy/internal/modbus"
1414
)
1515

16+
type upstreamClient interface {
17+
Connect() error
18+
Close() error
19+
Execute(context.Context, *modbus.Request) ([]byte, error)
20+
}
21+
1622
// Proxy is a caching Modbus proxy server.
1723
type Proxy struct {
1824
cfg *config.Config
1925
logger *slog.Logger
2026
server *modbus.Server
21-
client *modbus.Client
27+
client upstreamClient
2228
cache *cache.Cache
2329
}
2430

internal/proxy/proxy_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package proxy
22

33
import (
4+
"bytes"
45
"context"
6+
"errors"
57
"io"
68
"log/slog"
79
"testing"
@@ -19,6 +21,10 @@ type mockClient struct {
1921
calls int
2022
}
2123

24+
func (m *mockClient) Connect() error { return nil }
25+
26+
func (m *mockClient) Close() error { return nil }
27+
2228
func (m *mockClient) Execute(ctx context.Context, req *modbus.Request) ([]byte, error) {
2329
m.calls++
2430
return m.response, m.err
@@ -69,6 +75,113 @@ func TestProxy_HandleReadCacheHit(t *testing.T) {
6975
}
7076
}
7177

78+
func TestProxy_HandleReadMissFetchesAndCaches(t *testing.T) {
79+
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
80+
c := cache.New(time.Second, false)
81+
defer c.Close()
82+
83+
upstream := &mockClient{
84+
response: []byte{0x03, 0x04, 0x00, 0x0A, 0x00, 0x0B},
85+
}
86+
p := &Proxy{
87+
cfg: &config.Config{
88+
CacheTTL: time.Second,
89+
CacheServeStale: false,
90+
ReadOnly: config.ReadOnlyOn,
91+
},
92+
logger: logger,
93+
client: upstream,
94+
cache: c,
95+
}
96+
97+
req := &modbus.Request{
98+
SlaveID: 1,
99+
FunctionCode: modbus.FuncReadHoldingRegisters,
100+
Address: 10,
101+
Quantity: 2,
102+
}
103+
104+
resp, err := p.HandleRequest(context.Background(), req)
105+
if err != nil {
106+
t.Fatalf("unexpected error: %v", err)
107+
}
108+
109+
expected := []byte{0x03, 0x04, 0x00, 0x0A, 0x00, 0x0B}
110+
if !bytes.Equal(resp, expected) {
111+
t.Fatalf("first response: expected %v, got %v", expected, resp)
112+
}
113+
if upstream.calls != 1 {
114+
t.Fatalf("expected 1 upstream call after miss, got %d", upstream.calls)
115+
}
116+
117+
values, ok := c.GetRange(1, modbus.FuncReadHoldingRegisters, 10, 2)
118+
if !ok {
119+
t.Fatal("expected fetched response to be cached per register")
120+
}
121+
if !bytes.Equal(values[0], []byte{0x00, 0x0A}) || !bytes.Equal(values[1], []byte{0x00, 0x0B}) {
122+
t.Fatalf("unexpected cached values: %v", values)
123+
}
124+
125+
// Change the upstream response. The second request should be served from cache,
126+
// so the upstream should not be called again and the response should stay the same.
127+
upstream.response = []byte{0x03, 0x04, 0x00, 0xFF, 0x00, 0xFF}
128+
resp, err = p.HandleRequest(context.Background(), req)
129+
if err != nil {
130+
t.Fatalf("unexpected error on cached read: %v", err)
131+
}
132+
if !bytes.Equal(resp, expected) {
133+
t.Fatalf("cached response: expected %v, got %v", expected, resp)
134+
}
135+
if upstream.calls != 1 {
136+
t.Fatalf("expected cached read to avoid upstream call, got %d calls", upstream.calls)
137+
}
138+
}
139+
140+
func TestProxy_HandleReadServesStaleOnUpstreamError(t *testing.T) {
141+
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
142+
c := cache.New(10*time.Millisecond, true)
143+
defer c.Close()
144+
145+
c.SetRange(1, modbus.FuncReadHoldingRegisters, 20, [][]byte{
146+
{0x00, 0x01},
147+
{0x00, 0x02},
148+
})
149+
time.Sleep(20 * time.Millisecond)
150+
151+
upstreamErr := errors.New("upstream unavailable")
152+
upstream := &mockClient{err: upstreamErr}
153+
p := &Proxy{
154+
cfg: &config.Config{
155+
CacheTTL: 10 * time.Millisecond,
156+
CacheServeStale: true,
157+
ReadOnly: config.ReadOnlyOn,
158+
},
159+
logger: logger,
160+
client: upstream,
161+
cache: c,
162+
}
163+
164+
req := &modbus.Request{
165+
SlaveID: 1,
166+
FunctionCode: modbus.FuncReadHoldingRegisters,
167+
Address: 20,
168+
Quantity: 2,
169+
}
170+
171+
resp, err := p.HandleRequest(context.Background(), req)
172+
if err != nil {
173+
t.Fatalf("expected stale response, got error: %v", err)
174+
}
175+
if upstream.calls != 1 {
176+
t.Fatalf("expected one failed upstream call before serving stale, got %d", upstream.calls)
177+
}
178+
179+
expected := []byte{0x03, 0x04, 0x00, 0x01, 0x00, 0x02}
180+
if !bytes.Equal(resp, expected) {
181+
t.Fatalf("stale response: expected %v, got %v", expected, resp)
182+
}
183+
}
184+
72185
func TestProxy_HandleWriteReadOnlyMode(t *testing.T) {
73186
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
74187

0 commit comments

Comments
 (0)