Files
meshcore-analyzer/cmd/ingestor/ingest_buffer.go
T
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

203 lines
7.4 KiB
Go

package main
import (
"log"
"sync"
"sync/atomic"
"time"
)
// IngestBuffer decouples MQTT message receipt from DB writes (#1608).
//
// On boot the ingestor must subscribe to MQTT immediately, but the single
// SQLite writer (#1283) can be held for minutes by a startup migration
// (e.g. a large CREATE INDEX) or prune. Without buffering, every QoS-0 packet
// received in that window is lost. IngestBuffer holds received work in a
// bounded FIFO and a single consumer goroutine drains it once Ready() is
// called — i.e. once the write path is free.
//
// A single consumer preserves the single-writer invariant: jobs run one at a
// time, exactly as paho's in-order handler did before. Submit never blocks the
// MQTT delivery goroutine; if the buffer is full it drops and counts (bounded
// memory). Buffering replays the original messages, so it introduces NO
// duplicates (contrast: a QoS-1 broker-queue would).
type IngestBuffer struct {
jobs chan func()
ready chan struct{}
stop chan struct{}
done chan struct{}
dropped atomic.Int64
startOnce sync.Once
readyOnce sync.Once
stopOnce sync.Once
// dropLogMu guards the time-based drop-log throttle (PR #1623
// round-1 fix to #1609 M1). Per-drop logging under sustained
// stalls could flood the log at MQTT inbound rate; instead we
// always log the FIRST drop of a stall and then summarize at
// most once per second until the stall ends.
dropLogMu sync.Mutex
stallActive bool // true between first drop and first successful Submit
stallStart time.Time // when the current stall began
stallStartDrop int64 // dropped() value when stall began
lastSummaryAt time.Time // last time we wrote a summary line
}
// dropLogSummaryInterval is the minimum interval between summary lines
// during a sustained stall. Exposed as a var so tests can shrink it.
var dropLogSummaryInterval = time.Second
// NewIngestBuffer returns a buffer holding up to capacity pending jobs.
// Non-positive capacity is clamped to 1 and a WARN is logged so the
// misconfiguration is visible (PR #1609 m2 — silent clamp hid bad
// ingestBufferSize values).
func NewIngestBuffer(capacity int) *IngestBuffer {
if capacity < 1 {
log.Printf("[ingest-buffer] WARN: requested capacity %d < 1, clamping to 1 — check ingestBufferSize config; default is 50000", capacity)
capacity = 1
}
return &IngestBuffer{
jobs: make(chan func(), capacity),
ready: make(chan struct{}),
stop: make(chan struct{}),
done: make(chan struct{}),
}
}
// Submit enqueues a job without blocking. If the buffer is full the job is
// dropped and the dropped counter is incremented. Safe for concurrent callers.
//
// Ordering invariant: callers MUST call Start() before the first Submit().
// Submit only enqueues — without a running consumer, jobs sit in the channel
// and (once cap is reached) are silently dropped until Start()+Ready() run.
//
// Drop logging (PR #1623 round-1 fix to #1609 M1) uses a time-based
// throttle to stay loud-on-stall-start without flooding under sustained
// stalls:
// - the FIRST drop of a stall logs immediately
// - subsequent drops are summarized at most once per second
// - when the next Submit succeeds, a "drained" recovery line is
// emitted so operators can quantify the burst
//
// All log lines include the buffer capacity for operator triage.
func (b *IngestBuffer) Submit(job func()) {
select {
case b.jobs <- job:
b.maybeLogRecovery()
default:
n := b.dropped.Add(1)
b.logDrop(n)
}
}
// logDrop emits a drop log line under the time-based throttle. The first
// drop of a stall always logs; subsequent drops summarize at most once
// per dropLogSummaryInterval.
func (b *IngestBuffer) logDrop(n int64) {
b.dropLogMu.Lock()
defer b.dropLogMu.Unlock()
now := time.Now()
if !b.stallActive {
b.stallActive = true
b.stallStart = now
b.stallStartDrop = n - 1 // last successful Submit -> this is the 1st drop of the stall
b.lastSummaryAt = now
log.Printf("[ingest-buffer] WARNING: buffer full (cap %d), dropped %d message(s) total — write path stalled, raise ingestBufferSize or investigate slow writer", cap(b.jobs), n)
return
}
if now.Sub(b.lastSummaryAt) >= dropLogSummaryInterval {
b.lastSummaryAt = now
stallDrops := n - b.stallStartDrop
log.Printf("[ingest-buffer] WARNING: buffer full (cap %d), %d drop(s) in current stall, %d total — write path still stalled", cap(b.jobs), stallDrops, n)
}
}
// maybeLogRecovery is called from the success branch of Submit. If a
// stall was active, it logs a recovery line summarizing the burst and
// clears the stall state.
func (b *IngestBuffer) maybeLogRecovery() {
b.dropLogMu.Lock()
defer b.dropLogMu.Unlock()
if !b.stallActive {
return
}
stallDrops := b.dropped.Load() - b.stallStartDrop
dur := time.Since(b.stallStart)
log.Printf("[ingest-buffer] INFO: buffer drained, %d drop(s) over %s (cap %d) — write path recovered", stallDrops, dur.Round(time.Millisecond), cap(b.jobs))
b.stallActive = false
}
// Start launches the consumer goroutine. It blocks until Ready() is called
// (or Stop() fires, whichever comes first), then drains buffered jobs and
// runs newly-submitted ones serially, in FIFO order. Idempotent.
//
// Lifecycle: Stop() closes b.stop, which causes the consumer to exit via
// the stop-select arm (after draining any queued jobs if Ready() had
// already fired). The b.jobs channel is never closed — closing it would
// race with concurrent Submit() callers and panic; instead jobs is
// garbage-collected with the buffer once all references drop. Done() is
// closed when the consumer goroutine returns.
func (b *IngestBuffer) Start() {
b.startOnce.Do(func() {
go func() {
defer close(b.done)
select {
case <-b.ready:
case <-b.stop:
// Stopped before Ready — exit immediately. Pending jobs
// are discarded; the buffer was never authorized to drain.
return
}
for {
select {
case job := <-b.jobs:
job()
case <-b.stop:
// Stop after Ready — drain whatever is queued so
// shutdown is graceful, then exit. b.jobs is never
// closed (see Start godoc), so a default-case
// non-blocking receive is the correct drain idiom.
for {
select {
case job := <-b.jobs:
job()
default:
return
}
}
}
}
}()
})
}
// Ready signals that the write path is available; the consumer begins
// draining. Idempotent.
//
// Ordering invariant: Start() MUST have been called before Ready() takes
// effect. Calling Ready() without a prior Start() simply closes the ready
// channel — nothing drains until a later Start() runs its consumer goroutine.
func (b *IngestBuffer) Ready() {
b.readyOnce.Do(func() { close(b.ready) })
}
// Dropped returns the number of jobs dropped due to a full buffer.
func (b *IngestBuffer) Dropped() int64 { return b.dropped.Load() }
// Pending returns the current queue depth (best-effort; for observability).
func (b *IngestBuffer) Pending() int { return len(b.jobs) }
// Stop signals the consumer goroutine to exit. Test-hygiene helper so unit
// tests don't leak the goroutine that Start() spawns. Idempotent / safe to
// call without a prior Start(). After Stop() the consumer exits and Done()
// is closed.
func (b *IngestBuffer) Stop() {
b.stopOnce.Do(func() { close(b.stop) })
}
// Done returns a channel that is closed after the consumer goroutine has
// exited. If Start() was never called, Done() never closes.
func (b *IngestBuffer) Done() <-chan struct{} {
return b.done
}