mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-20 09:21:15 +00:00
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>
203 lines
7.4 KiB
Go
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
|
|
}
|