Skip to content

Commit 633d047

Browse files
tmaCopilot
andauthored
fix: bound upstream request failures (#10)
Classify upstream failures before retrying, enforce one end-to-end request budget, and preserve Modbus exception responses. Reads keep one safe transport retry while ambiguous writes fail without being repeated. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2fa557c2-9b1f-4cf7-8e68-3a5141cd5261
1 parent bdf6d35 commit 633d047

14 files changed

Lines changed: 2856 additions & 228 deletions

File tree

README.md

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,28 @@ All configuration is via environment variables:
3333
| `MODBUS_CACHE_TTL` | Cache time-to-live | `10s` |
3434
| `MODBUS_CACHE_SERVE_STALE` | Serve stale data on upstream error | `false` |
3535
| `MODBUS_READONLY` | Read-only mode: `false`, `true`, `deny` | `true` |
36-
| `MODBUS_TIMEOUT` | Upstream connection timeout | `10s` |
37-
| `MODBUS_REQUEST_DELAY` | Delay after each upstream request | `0` (disabled) |
36+
| `MODBUS_ATTEMPT_TIMEOUT` | Per-attempt upstream socket timeout | `10s` |
37+
| `MODBUS_TIMEOUT` | Deprecated alias for `MODBUS_ATTEMPT_TIMEOUT` | unset |
38+
| `MODBUS_REQUEST_TIMEOUT` | Total request budget, including coalescing, queueing, reconnect, retry, and pacing | `30s` |
39+
| `MODBUS_REQUEST_DELAY` | Minimum interval between successful upstream requests | `0` (disabled) |
3840
| `MODBUS_CONNECT_DELAY` | Silent period after connecting to upstream | `0` (disabled) |
3941
| `MODBUS_SHUTDOWN_TIMEOUT` | Graceful shutdown timeout | `30s` |
4042
| `LOG_LEVEL` | Log level: `INFO`, `DEBUG` | `INFO` |
4143

44+
The end-to-end request budget always caps each individual attempt. A full read
45+
retry budget needs room for two attempt timeouts, two connect delays, request
46+
pacing, and any dial time. Pacing is a context-aware pre-wire wait charged to
47+
the next request's budget; it never delays an already received response.
48+
Existing configurations may keep using `MODBUS_TIMEOUT` during migration.
49+
When both attempt timeout variables are set, their parsed durations must match
50+
or startup fails. Neither setting changes `MODBUS_REQUEST_TIMEOUT`.
51+
52+
Downstream exceptions preserve genuine upstream Modbus exception responses with
53+
nonzero exception codes. Upstream transport, framing or malformed exception
54+
failures and total request deadlines map to gateway target failed to respond
55+
(`0x0B`). Local internal failures map to server failure (`0x04`), while local
56+
validation uses the standard validation exception codes.
57+
4258
`/mbproxy -health` performs an internal upstream connectivity check and does not open a separate local TCP health port.
4359

4460
### Read-Only Modes
@@ -77,7 +93,8 @@ services:
7793
MODBUS_CACHE_TTL: "10s"
7894
MODBUS_CACHE_SERVE_STALE: "false"
7995
MODBUS_READONLY: "true"
80-
MODBUS_TIMEOUT: "10s"
96+
MODBUS_ATTEMPT_TIMEOUT: "10s"
97+
MODBUS_REQUEST_TIMEOUT: "30s"
8198
MODBUS_REQUEST_DELAY: "0"
8299
MODBUS_CONNECT_DELAY: "0"
83100
MODBUS_SHUTDOWN_TIMEOUT: "30s"
@@ -137,9 +154,9 @@ docker run --rm -v $(pwd):/app -w /app golang:1.24 go test ./...
137154
- **Key format**: values are cached per register/coil as `{slave_id}:{function_code}:{address}`
138155
- **Read requests**: Served from cache only if every register/coil in the requested range is present and not expired
139156
- **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
140-
- **Write requests**: Forwarded to upstream (if allowed), then invalidate the written address range so overlapping cached reads cannot return stale values
141-
- **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
142-
- **Stale fallback**: If enabled, expired entries are retained and can be served when upstream requests fail
157+
- **Write requests**: Before an allowed write is forwarded, its generation is incremented and the written range is invalidated. The generation is incremented and the range invalidated again after every outcome, so neither older reads nor reads that execute in the write scheduling window can leave pre-write values cached.
158+
- **Request coalescing**: Multiple identical range requests in the same write generation share a single upstream fetch using `{write_generation}:{slave_id}:{function_code}:{start_address}:{quantity}` as the coalescing key
159+
- **Stale fallback**: If enabled, expired entries are retained and can be served when upstream transport requests fail. Upstream Modbus exceptions are never replaced with stale data.
143160

144161
## License
145162

SPEC.md

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ Many Modbus devices (inverters, meters, battery systems) have limited polling ca
4141
- Connect to downstream Modbus device via TCP/IP only
4242
- Support multiple slave IDs through single connection
4343
- Support clients requesting different slave IDs through the proxy
44-
- Auto-reconnect on connection failure (unlimited retries, no backoff)
45-
- Request pacing: configurable delay between upstream requests to prevent overwhelming slow devices
44+
- Reconnect after transport failures; retry reads once but never retry ambiguous writes
45+
- Preserve upstream Modbus exceptions without reconnecting
46+
- Request pacing: configurable minimum interval between successful upstream requests
4647
- TCP keep-alive enabled (30s interval) for connection health monitoring
4748
- Connect delay: optional silent period after establishing connection for device settling
4849

@@ -57,7 +58,7 @@ Values are cached per register/coil:
5758

5859
Request coalescing still uses the requested range as its key:
5960
```
60-
{slave_id}:{function_code}:{start_address}:{quantity}
61+
{write_generation}:{slave_id}:{function_code}:{start_address}:{quantity}
6162
```
6263

6364
#### Cache Entry
@@ -71,23 +72,24 @@ type CacheEntry struct {
7172

7273
#### Cache Behavior
7374
- **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.
75+
- **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 only if the write generation is unchanged.
76+
- **Write Operations**: Before forwarding an allowed write, increment the write generation and invalidate each cached register/coil in the written address range. After every write outcome, increment and invalidate again so a read that entered the new generation but executed before the write cannot leave a pre-write value cached. This preserves ambiguous-write invalidation without holding the cache state lock across upstream I/O.
7677
- **TTL**: Configurable (default: 10 seconds)
7778
- **Cleanup**: Time-based expiration. Expired entries are removed during cleanup unless stale serving is enabled.
7879
- **Staleness**: Option to serve stale data on upstream failure (default: off). When enabled, expired entries are retained so they remain available for fallback.
7980

8081
### Request Coalescing
81-
- Identical in-flight range requests are coalesced (same slave_id, function, address, quantity)
82+
- Identical in-flight range requests are coalesced within the same write generation
8283
- Second request arriving while first is pending will wait for and share the first's response
8384
- Prevents thundering herd on cache miss
8485

8586
### Request Pacing
86-
- Configurable delay after each successful upstream request
87+
- Configurable minimum interval measured from each successful upstream response
8788
- Protects slow Modbus devices that cannot handle rapid-fire requests
88-
- Delay is context-aware: cancelled if the request context is cancelled
89-
- Only applied after successful requests (not during error recovery/reconnection)
90-
- Logged at DEBUG level when applied
89+
- Enforced as a context-aware pre-wire wait for the next request
90+
- Consumes the next request's end-to-end budget and never delays or reclassifies the completed request
91+
- Not reapplied between a failed read attempt and its retry
92+
- Logged at DEBUG level when a request waits for its slot
9193

9294
### 4. Read-Only Mode
9395
Three modes:
@@ -110,12 +112,28 @@ Three modes:
110112
| `MODBUS_CACHE_TTL` | Cache time-to-live | `10s` | `10s`, `1m`, `500ms` |
111113
| `MODBUS_CACHE_SERVE_STALE` | Serve stale data on upstream error | `false` | `true`, `false` |
112114
| `MODBUS_READONLY` | Read-only mode | `true` | `false`, `true`, `deny` |
113-
| `MODBUS_TIMEOUT` | Upstream connection timeout | `10s` | `5s`, `30s` |
114-
| `MODBUS_REQUEST_DELAY` | Delay after each upstream request | `0` (disabled) | `100ms`, `500ms` |
115+
| `MODBUS_ATTEMPT_TIMEOUT` | Per-attempt upstream socket timeout | `10s` | `10s`, `30s` |
116+
| `MODBUS_TIMEOUT` | Deprecated alias for `MODBUS_ATTEMPT_TIMEOUT` | unset | `10s`, `30s` |
117+
| `MODBUS_REQUEST_TIMEOUT` | End-to-end request budget | `30s` | `30s`, `1m` |
118+
| `MODBUS_REQUEST_DELAY` | Minimum interval between successful upstream requests | `0` (disabled) | `100ms`, `500ms` |
115119
| `MODBUS_CONNECT_DELAY` | Silent period after connecting to upstream | `0` (disabled) | `500ms`, `2s` |
116120
| `MODBUS_SHUTDOWN_TIMEOUT` | Graceful shutdown timeout | `30s` | `10s`, `60s` |
117121
| `LOG_LEVEL` | Log level | `INFO` | `INFO`, `DEBUG` |
118122

123+
`MODBUS_ATTEMPT_TIMEOUT` is preferred. `MODBUS_TIMEOUT` remains accepted as a
124+
deprecated migration alias. If both are set, their parsed durations must be
125+
equal or configuration loading fails. These variables do not set or override
126+
`MODBUS_REQUEST_TIMEOUT`.
127+
128+
The end-to-end budget caps every individual attempt. Retaining the read retry
129+
requires enough budget for two attempt timeouts, two connect delays, request
130+
pacing, and dial time. Pacing consumes the next request's budget before its wire
131+
attempt. Genuine upstream Modbus exception responses keep their nonzero
132+
exception code downstream. Upstream transport or framing failures, malformed
133+
exception responses, and total request deadlines map to `0x0B`; local internal
134+
failures map to `0x04`; local validation keeps the standard validation exception
135+
codes.
136+
119137
The container health check runs `mbproxy -health`, which performs an internal upstream connectivity check without binding a separate local TCP port.
120138

121139
## Implementation Details
@@ -222,12 +240,12 @@ The cache also exposes `Coalesce(ctx, rangeKey, fetch)` for request coalescing.
222240
3. **For reads**:
223241
- Check every per-register/coil cache key in the requested range
224242
- If all values are present and valid, reassemble and return the Modbus response
225-
- On any miss or expired value: coalesce identical in-flight range requests, then forward to upstream device
226-
- Decompose successful upstream responses into per-register/coil cache entries
243+
- On any miss or expired value: coalesce identical in-flight range requests within the current write generation, then forward to upstream
244+
- Decompose successful upstream responses into per-register/coil cache entries only if the generation is unchanged
227245
- Return response to client
228246
4. **For writes**:
229247
- Check readonly mode
230-
- If allowed: forward to upstream, then invalidate every cached register/coil in the written address range
248+
- If allowed: increment the write generation and invalidate every cached register/coil in the written address range before forwarding upstream
231249
- Return response
232250

233251
## Logging
@@ -242,7 +260,7 @@ level=INFO msg="starting proxy" listen=:5502 upstream=192.168.1.100:502
242260
level=DEBUG msg="cache hit" slave_id=1 func=0x03 addr=0 qty=10
243261
level=DEBUG msg="cache miss" slave_id=1 func=0x03 addr=0 qty=10
244262
level=DEBUG msg="upstream request completed" slave_id=1 func=0x03 addr=0 qty=10 duration=15ms
245-
level=DEBUG msg="applying request delay" delay=100ms
263+
level=DEBUG msg="waiting for upstream request slot" delay=100ms
246264
level=DEBUG msg="applying connect delay" delay=500ms
247265
level=WARN msg="upstream error, serving stale" slave_id=1 error="timeout"
248266
level=INFO msg="shutting down"

internal/cache/cache.go

Lines changed: 55 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cache
33

44
import (
55
"context"
6+
"errors"
67
"fmt"
78
"sync"
89
"time"
@@ -36,9 +37,10 @@ type Cache struct {
3637
}
3738

3839
type inflightRequest struct {
39-
done chan struct{}
40-
result []byte
41-
err error
40+
done chan struct{}
41+
result []byte
42+
err error
43+
followers int
4244
}
4345

4446
// New creates a new cache with the specified default TTL.
@@ -211,51 +213,65 @@ func (c *Cache) DeleteRange(slaveID byte, functionCode byte, startAddr uint16, q
211213
// Other callers with the same key wait for and share the first caller's result.
212214
// This handles request coalescing only — it does not interact with cache storage.
213215
func (c *Cache) Coalesce(ctx context.Context, key string, fetch func(context.Context) ([]byte, error)) ([]byte, error) {
214-
c.inflightMu.Lock()
215-
if req, ok := c.inflight[key]; ok {
216+
for {
217+
if err := ctx.Err(); err != nil {
218+
return nil, err
219+
}
220+
221+
c.inflightMu.Lock()
222+
req, ok := c.inflight[key]
223+
if !ok {
224+
req = &inflightRequest{done: make(chan struct{})}
225+
c.inflight[key] = req
226+
} else {
227+
req.followers++
228+
}
216229
c.inflightMu.Unlock()
217-
// Wait for the in-flight request to complete
218-
select {
219-
case <-req.done:
220-
if req.err != nil {
221-
return nil, req.err
230+
231+
if ok {
232+
if err := ctx.Err(); err != nil {
233+
return nil, err
234+
}
235+
select {
236+
case <-req.done:
237+
if err := ctx.Err(); err != nil {
238+
return nil, err
239+
}
240+
if req.err != nil {
241+
if errors.Is(req.err, context.Canceled) || errors.Is(req.err, context.DeadlineExceeded) {
242+
continue
243+
}
244+
return nil, req.err
245+
}
246+
data := make([]byte, len(req.result))
247+
copy(data, req.result)
248+
return data, nil
249+
case <-ctx.Done():
250+
return nil, ctx.Err()
222251
}
223-
// Return a copy
224-
data := make([]byte, len(req.result))
225-
copy(data, req.result)
226-
return data, nil
227-
case <-ctx.Done():
228-
return nil, ctx.Err()
229252
}
230-
}
231253

232-
// Create new in-flight request
233-
req := &inflightRequest{
234-
done: make(chan struct{}),
235-
}
236-
c.inflight[key] = req
237-
c.inflightMu.Unlock()
254+
data, err := fetch(ctx)
255+
if err == nil {
256+
err = ctx.Err()
257+
}
238258

239-
// Fetch the data
240-
data, err := fetch(ctx)
259+
req.result = data
260+
req.err = err
241261

242-
// Store result for waiters
243-
req.result = data
244-
req.err = err
262+
c.inflightMu.Lock()
263+
delete(c.inflight, key)
264+
c.inflightMu.Unlock()
265+
close(req.done)
245266

246-
// Clean up and notify waiters
247-
c.inflightMu.Lock()
248-
delete(c.inflight, key)
249-
c.inflightMu.Unlock()
250-
close(req.done)
267+
if err != nil {
268+
return nil, err
269+
}
251270

252-
if err != nil {
253-
return nil, err
271+
result := make([]byte, len(data))
272+
copy(result, data)
273+
return result, nil
254274
}
255-
256-
result := make([]byte, len(data))
257-
copy(result, data)
258-
return result, nil
259275
}
260276

261277
// cleanupOnce runs a single cleanup pass, removing expired entries.

internal/cache/cache_test.go

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package cache
22

33
import (
44
"context"
5+
"errors"
6+
"runtime"
57
"sync"
68
"sync/atomic"
79
"testing"
@@ -310,10 +312,10 @@ func TestCache_ContextCancellation(t *testing.T) {
310312

311313
// Start a slow fetch
312314
go func() {
313-
c.Coalesce(ctx, "key1", func(ctx context.Context) ([]byte, error) {
315+
_, _ = c.Coalesce(ctx, "key1", func(ctx context.Context) ([]byte, error) {
314316
close(fetchStarted)
315-
time.Sleep(time.Second)
316-
return []byte("fetched"), nil
317+
<-ctx.Done()
318+
return nil, ctx.Err()
317319
})
318320
}()
319321

@@ -334,6 +336,88 @@ func TestCache_ContextCancellation(t *testing.T) {
334336
cancel()
335337
}
336338

339+
func TestCache_CanceledFollowerDoesNotReturnReadyResult(t *testing.T) {
340+
c := New(time.Second, false)
341+
defer c.Close()
342+
343+
ctx, cancel := context.WithCancel(context.Background())
344+
cancel()
345+
req := &inflightRequest{
346+
done: make(chan struct{}),
347+
result: []byte("stale success"),
348+
}
349+
close(req.done)
350+
c.inflight["key1"] = req
351+
352+
data, err := c.Coalesce(ctx, "key1", func(context.Context) ([]byte, error) {
353+
return []byte("should not run"), nil
354+
})
355+
if !errors.Is(err, context.Canceled) {
356+
t.Fatalf("expected canceled follower, got data=%q err=%v", data, err)
357+
}
358+
}
359+
360+
func TestCache_LiveFollowerRetriesAfterLeaderDeadline(t *testing.T) {
361+
c := New(time.Second, false)
362+
defer c.Close()
363+
364+
leaderCtx, cancelLeader := context.WithCancel(context.Background())
365+
leaderStarted := make(chan struct{})
366+
leaderDone := make(chan error, 1)
367+
go func() {
368+
_, err := c.Coalesce(leaderCtx, "key1", func(ctx context.Context) ([]byte, error) {
369+
close(leaderStarted)
370+
<-ctx.Done()
371+
return []byte("expired success"), nil
372+
})
373+
leaderDone <- err
374+
}()
375+
<-leaderStarted
376+
377+
followerDone := make(chan struct{})
378+
var followerData []byte
379+
var followerErr error
380+
var followerFetches atomic.Int32
381+
go func() {
382+
followerData, followerErr = c.Coalesce(context.Background(), "key1", func(context.Context) ([]byte, error) {
383+
followerFetches.Add(1)
384+
return []byte("fresh"), nil
385+
})
386+
close(followerDone)
387+
}()
388+
389+
deadline := time.Now().Add(time.Second)
390+
for {
391+
c.inflightMu.Lock()
392+
req := c.inflight["key1"]
393+
joined := req != nil && req.followers == 1
394+
c.inflightMu.Unlock()
395+
if joined {
396+
break
397+
}
398+
if time.Now().After(deadline) {
399+
t.Fatal("second caller did not join the live leader")
400+
}
401+
runtime.Gosched()
402+
}
403+
404+
cancelLeader()
405+
if err := <-leaderDone; !errors.Is(err, context.Canceled) {
406+
t.Fatalf("expected canceled leader, got %v", err)
407+
}
408+
select {
409+
case <-followerDone:
410+
case <-time.After(time.Second):
411+
t.Fatal("live follower did not retry after leader cancellation")
412+
}
413+
if followerErr != nil || string(followerData) != "fresh" {
414+
t.Fatalf("unexpected follower result data=%q err=%v", followerData, followerErr)
415+
}
416+
if followerFetches.Load() != 1 {
417+
t.Fatalf("follower fetch ran %d times", followerFetches.Load())
418+
}
419+
}
420+
337421
func TestCache_DataIsolation(t *testing.T) {
338422
c := New(time.Second, false)
339423
defer c.Close()

0 commit comments

Comments
 (0)