Files
3d12266595 fix(#1608): address PR #1609 follow-up findings — config doc, receipt-time liveness, buffer stop/clamp warn (#1623)
Follow-up to #1609 / #1608.

Addresses the 5 unresolved findings from the PR #1609 round-1 polish
review.

## Findings addressed

| Tag | Severity | Fix | Commits |
|-----|----------|-----|---------|
| **B1** | BLOCKER | Document `ingestBufferSize` in
`config.example.json` near other ingestor knobs. Default `50000`,
comment text from review. | `f0b4e411` |
| **M1** | MAJOR (option 1 from review) | Split receipt-time vs
post-write liveness: add `SourceLivenessState.LastReceiptUnix` +
`MarkReceipt`, stamp at the MQTT receipt callback, leave
`LastMessageUnix` post-write only. Drop the double-stamp at receipt that
masked write-path stalls. Surface both clocks via the ingestor stats
file (`source_liveness`) and the server's `/api/healthz`
(`ingest_liveness`, additive — older builds unaffected). | RED
`fa78233d` / GREEN `bc81b544` |
| **M1 (drop-log)** | MAJOR | Log every drop when buffer is at capacity.
Removes the `n==1 \|\| n%1000` throttle that hid the first stall behind
1000 lost packets. The Submit drop branch only fires when the channel is
at cap so volume is naturally bounded by the stall, not by an arbitrary
modulo. | RED `a468763e` / GREEN `7b24fce5` |
| **m1** | MINOR | Add `IngestBuffer.Stop()` and `Done()` so tests stop
leaking the consumer goroutine that `Start()` spawns. Existing tests
gain `t.Cleanup(b.Stop)`. Drain semantics: stop-before-Ready exits
immediately; stop-after-Ready best-effort drains queued jobs. | RED
`8430c822` / GREEN `78c9b223` |
| **m2** | MINOR | `NewIngestBuffer(<1)` now logs a `[ingest-buffer]
WARN` line on clamp so misconfigured `ingestBufferSize` values are
visible instead of silently running a 1-slot queue. Test captures log
output. | RED `62119ab4` / GREEN `815bfd02` |
| **m3** | MINOR | Add godoc to `Submit` and `Ready` documenting the
Start-before-Submit / Start-before-Ready ordering invariant. |
`564a813b` |

## TDD discipline

Each behavioral fix (M1, M1-drop-log, m1, m2) lands as a red-then-green
pair. Red commits compile + run + fail on assertion, verified locally
before the green commit. Per-finding red→green pairs are visible in the
commit graph above.

B1 and m3 are docs-only and ship as single commits (preflight script
accepts them under the docs/comments exemption).

## Schema compatibility

`/api/healthz` change is purely additive: `ingest_liveness` is only
included when the ingestor publishes the new `source_liveness` field, so
older ingestor + newer server combos are unaffected. Field order in the
response stays stable for prior consumers.

## Test output

- `go test -count=1 -timeout 180s ./cmd/ingestor/...` → green (160s)
- `go test -count=1 -timeout 300s ./cmd/server/...` → green (48s)
- Race-mode runs of the touched packages
(`IngestBuffer|Liveness|Watchdog|Receipt|Healthz`) → green
- Full-package race runs locally exceed the brief's 120s timeout on
pre-existing slow integration tests (TestObsTimestampIndexMigration,
TestNeighborEdgesBuilderDeltaScan); CI has the headroom.

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ all hard gates pass, no warnings.

## Files changed

- `config.example.json` — B1
- `cmd/ingestor/ingest_buffer.go` — m1, m2, M1-drop-log, m3
- `cmd/ingestor/ingest_buffer_test.go` — m1, m2, M1-drop-log
- `cmd/ingestor/mqtt_watchdog.go` — M1
- `cmd/ingestor/mqtt_watchdog_m1_test.go` — M1 (new)
- `cmd/ingestor/main.go` — M1 (receipt callsite)
- `cmd/ingestor/stats_file.go` — M1 (publish `source_liveness`)
- `cmd/server/perf_io.go` — M1 (type + reader)
- `cmd/server/healthz.go` — M1 (surface `ingest_liveness`)

Original review reference: PR #1609 polish review by the M-axis bot.

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-06-07 09:28:51 -07:00

275 lines
7.2 KiB
Go

package main
import (
"bytes"
"log"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestIngestBuffer_BuffersUntilReady(t *testing.T) {
b := NewIngestBuffer(10)
t.Cleanup(b.Stop)
var ran atomic.Int64
b.Start()
for i := 0; i < 3; i++ {
b.Submit(func() { ran.Add(1) })
}
time.Sleep(30 * time.Millisecond)
if ran.Load() != 0 {
t.Fatalf("jobs ran before Ready(): %d", ran.Load())
}
b.Ready()
deadline := time.Now().Add(time.Second)
for ran.Load() < 3 && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
if ran.Load() != 3 {
t.Fatalf("want 3 ran after Ready, got %d", ran.Load())
}
}
func TestIngestBuffer_FIFOOrder(t *testing.T) {
b := NewIngestBuffer(10)
t.Cleanup(b.Stop)
out := make(chan int, 5)
b.Start()
for i := 0; i < 5; i++ {
i := i
b.Submit(func() { out <- i })
}
b.Ready()
for want := 0; want < 5; want++ {
select {
case got := <-out:
if got != want {
t.Fatalf("order: want %d got %d", want, got)
}
case <-time.After(time.Second):
t.Fatalf("timeout waiting for job %d", want)
}
}
}
func TestIngestBuffer_DropsWhenFull(t *testing.T) {
b := NewIngestBuffer(2)
t.Cleanup(b.Stop) // never Ready()'d -> nothing drains
for i := 0; i < 5; i++ {
b.Submit(func() {})
}
if got := b.Dropped(); got != 3 {
t.Fatalf("want 3 dropped (cap 2, 5 submitted), got %d", got)
}
}
func TestIngestBuffer_ProcessesAfterReady(t *testing.T) {
b := NewIngestBuffer(10)
t.Cleanup(b.Stop)
b.Start()
b.Ready()
done := make(chan struct{})
b.Submit(func() { close(done) })
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("job submitted after Ready was not processed")
}
}
func TestIngestBuffer_SerialExecution(t *testing.T) {
b := NewIngestBuffer(50)
t.Cleanup(b.Stop)
var inFlight atomic.Int32
var overlap atomic.Bool
var wg sync.WaitGroup
b.Start()
const n = 20
wg.Add(n)
for i := 0; i < n; i++ {
b.Submit(func() {
if inFlight.Add(1) > 1 {
overlap.Store(true)
}
time.Sleep(time.Millisecond)
inFlight.Add(-1)
wg.Done()
})
}
b.Ready()
wg.Wait()
if overlap.Load() {
t.Fatal("jobs overlapped — consumer is not serial (violates single-writer)")
}
}
func TestIngestBuffer_ConcurrentSubmitSafe(t *testing.T) {
b := NewIngestBuffer(20000)
t.Cleanup(b.Stop)
b.Start()
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 1000; i++ {
b.Submit(func() {})
}
}()
}
wg.Wait()
b.Ready()
// Assertion is the absence of a race/panic; run under -race in CI.
}
// TestIngestBuffer_StopUnblocksConsumer guards the consumer-goroutine leak
// described in PR #1609 review m1: Start() blocks on <-b.ready forever if
// Ready() is never called, leaking the goroutine in test runs. Stop() must
// signal the consumer to exit cleanly without requiring Ready().
func TestIngestBuffer_StopUnblocksConsumer(t *testing.T) {
b := NewIngestBuffer(10)
t.Cleanup(b.Stop)
b.Start()
// Do NOT call Ready(). The consumer must exit purely because of Stop().
b.Stop()
select {
case <-b.Done():
// good — consumer goroutine returned
case <-time.After(time.Second):
t.Fatal("Stop() did not unblock the consumer goroutine within 1s (Done() never closed)")
}
}
// TestNewIngestBuffer_WarnsOnSubOneClamp asserts that constructing the
// buffer with a non-positive capacity emits a WARN log line. Silent
// clamping (PR #1609 review m2) hid misconfigurations like
// ingestBufferSize=-1 or 0-from-default-not-applied paths.
func TestNewIngestBuffer_WarnsOnSubOneClamp(t *testing.T) {
var buf bytes.Buffer
oldOut := log.Writer()
oldFlags := log.Flags()
log.SetOutput(&buf)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(oldOut)
log.SetFlags(oldFlags)
})
b := NewIngestBuffer(0)
t.Cleanup(b.Stop)
got := buf.String()
if !strings.Contains(got, "WARN") || !strings.Contains(got, "ingest-buffer") {
t.Fatalf("expected WARN log on sub-one clamp, got %q", got)
}
}
// TestIngestBuffer_DropLogThrottle asserts the time-based throttle (PR
// #1623 round-1 fix to #1609 M1): the FIRST drop of a stall logs
// immediately (loud), then subsequent drops within the same stall are
// rate-limited to at most one summary line per second, and a recovery
// line is emitted when Submit succeeds again. This prevents log-flood
// under sustained stalls (potentially hundreds of MB/min) while
// preserving "loud the instant the stall starts".
func TestIngestBuffer_DropLogThrottle(t *testing.T) {
var buf bytes.Buffer
oldOut := log.Writer()
oldFlags := log.Flags()
log.SetOutput(&buf)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(oldOut)
log.SetFlags(oldFlags)
})
b := NewIngestBuffer(2)
t.Cleanup(b.Stop)
// Fill to capacity (no Ready() — nothing drains).
for i := 0; i < 2; i++ {
b.Submit(func() {})
}
// 100 drops in tight loop (well under 1s).
for i := 0; i < 100; i++ {
b.Submit(func() {})
}
got := buf.String()
lines := strings.Count(got, "buffer full")
if lines < 1 {
t.Fatalf("expected the FIRST drop to log immediately; got 0 'buffer full' lines:\n%s", got)
}
if lines > 2 {
t.Fatalf("expected at most 2 'buffer full' lines for 100 drops in <1s (first + at-most-one summary), got %d:\n%s", lines, got)
}
// Every line must include the capacity for operator triage.
if !strings.Contains(got, "cap 2") {
t.Fatalf("expected every drop log line to include 'cap 2', got:\n%s", got)
}
}
// TestIngestBuffer_DropLogFirstAlwaysImmediate guards the "loud the
// instant the stall starts" half of the throttle contract from PR
// #1623: even a single drop must log immediately, not be silently
// absorbed by the per-second summary window.
func TestIngestBuffer_DropLogFirstAlwaysImmediate(t *testing.T) {
var buf bytes.Buffer
oldOut := log.Writer()
oldFlags := log.Flags()
log.SetOutput(&buf)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(oldOut)
log.SetFlags(oldFlags)
})
b := NewIngestBuffer(1)
t.Cleanup(b.Stop)
b.Submit(func() {}) // fills cap=1
b.Submit(func() {}) // first drop
got := buf.String()
if !strings.Contains(got, "buffer full") {
t.Fatalf("expected FIRST drop to log immediately; got:\n%s", got)
}
}
// TestIngestBuffer_DropLogRecoveryAfterDrain guards the recovery-line
// half of the throttle contract: once Submit succeeds again after one
// or more drops, a "recovered" / "drained" line must be emitted so
// operators can quantify the burst (PR #1623).
func TestIngestBuffer_DropLogRecoveryAfterDrain(t *testing.T) {
var buf bytes.Buffer
oldOut := log.Writer()
oldFlags := log.Flags()
log.SetOutput(&buf)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(oldOut)
log.SetFlags(oldFlags)
})
b := NewIngestBuffer(1)
t.Cleanup(b.Stop)
b.Submit(func() {}) // fills cap=1
for i := 0; i < 3; i++ {
b.Submit(func() {}) // drops
}
// Drain: start consumer and Ready(), wait for queue to empty.
b.Start()
b.Ready()
deadline := time.Now().Add(time.Second)
for b.Pending() > 0 && time.Now().Before(deadline) {
time.Sleep(2 * time.Millisecond)
}
// Now a successful Submit should trigger the recovery line.
b.Submit(func() {})
// Give the goroutine + log a moment.
time.Sleep(20 * time.Millisecond)
got := buf.String()
if !strings.Contains(got, "drained") && !strings.Contains(got, "recovered") {
t.Fatalf("expected a 'drained'/'recovered' log line after stall ended; got:\n%s", got)
}
}