Skip to content

fix(plugins/db/elasticsearch): advertise host-reachable publish address (sniff fix)#15

Merged
ikeikeikeike merged 1 commit into
mainfrom
fix/es-publish-host
Jun 17, 2026
Merged

fix(plugins/db/elasticsearch): advertise host-reachable publish address (sniff fix)#15
ikeikeikeike merged 1 commit into
mainfrom
fix/es-publish-host

Conversation

@ikeikeikeike

Copy link
Copy Markdown
Member

Summary

auba-api crashed at ES init even though `http://127.0.0.1:56205\` was
reachable from the host:

[ELASTIC] elastic: http://172.17.0.4:9200 joined the cluster
[ERROR]   elastic: http://172.17.0.4:9200 is dead
[PANIC]   failed to get ESConn: no Elasticsearch node available

`172.17.0.4` is the elasticsearch container's bridge IP. The
`olivere/elastic` client opened the host-reachable port, sniffed
`_nodes/http`, learned the node was at `172.17.0.4:9200` (= the
container-internal HTTP address, unreachable from the host), swapped
to that URL, and crashed.

Fix

Two new env vars on the ES container so it advertises the loopback
the operator already uses:

network.publish_host=127.0.0.1
http.publish_port=<bough-allocated host port>

After the fix `_nodes/http` reports
`publish_address: 127.0.0.1:` and sniffing clients dial
the mapping bough already wires.

Tests

`buildDockerEnv(heap, hostPort)` extracted from `dockerUp` so the
regression guards do not need a real Docker daemon.

  • `TestBuildDockerEnv_PublishHostAndPort` — both new lines plus
    every pre-fix must-have (single-node, security off, disk
    threshold, ES_JAVA_OPTS) are present.
  • `TestBuildDockerEnv_HeapOverride` — `extras["es.heap"]` still
    flows through to the JVM heap arg.

All existing tests pass; `golangci-lint run ./...` 0 issues.

auba-api boot crashed at ES init even though
http://127.0.0.1:56205 was reachable from the host:

  [ELASTIC] elastic: http://172.17.0.4:9200 joined the cluster
  [ERROR]   elastic: http://172.17.0.4:9200 is dead
  [PANIC]   failed to get ESConn: no Elasticsearch node available

172.17.0.4 is the elasticsearch container's Docker bridge IP. The
olivere/elastic client opened the host-reachable
http://127.0.0.1:56205, then sniffed the cluster via `_nodes/http`
and learned the node was at 172.17.0.4:9200 — the container-internal
HTTP address, which is unreachable from the host. The client
swapped to that URL for the next round-trip and crashed.

Fix: tell ES to advertise the loopback the operator already uses,
and override the HTTP publish port to the bough-allocated host
port. Two new env vars on the elasticsearch container:

  network.publish_host=127.0.0.1
  http.publish_port=<hostPort>

After the fix `_nodes/http` reports
`publish_address: 127.0.0.1:<hostPort>` and sniffing clients dial
the host-reachable mapping bough already wires.

Refactor: extract `buildDockerEnv(heap, hostPort)` so the regression
guard tests can assert both new lines without spinning up a real
Docker daemon. Two unit tests cover the contract:

- TestBuildDockerEnv_PublishHostAndPort — the two new lines AND the
  pre-existing must-haves (single-node, security off, disk
  threshold, ES_JAVA_OPTS) are all present.
- TestBuildDockerEnv_HeapOverride — the caller's heap choice still
  reaches ES_JAVA_OPTS so `extras["es.heap"]=512m` keeps working.
@ikeikeikeike
ikeikeikeike merged commit e1835c5 into main Jun 17, 2026
1 check passed
@ikeikeikeike
ikeikeikeike deleted the fix/es-publish-host branch June 17, 2026 04:50
ikeikeikeike added a commit that referenced this pull request Jun 20, 2026
…regression tests

Round 3 code review (= post-PR-open review against the v0.5
implementation plan) surfaced 5 CRITICAL + 6 HIGH + 6 MEDIUM + 5
LOW issues. This commit lands the CRITICAL fixes, the highest-
priority HIGH fixes, the supporting unit tests, and the few LOW
items that piggyback for free.

CRITICAL fixes

1. plugins/memory/sqlite/sqlite.go Store — the previous
   INSERT-OR-REPLACE fall-through on a dedupe match with
   `UpsertSemantics: false` could either insert a duplicate row
   sharing the dedupe_key (silent contract violation) or wipe out
   the existing row's hits / last_hit_at / evidence_refs.
   We now reject the call explicitly with an actionable error so
   the caller can either set UpsertSemantics=true or change the
   rule. Regression test: TestStore_DedupeWithoutUpsertReturnsError.

2. plugins/memory/sqlite/sqlite.go Query / normalizeFTSTerm —
   stripping only ASCII `"` left FTS5 query-language injection
   open via unicode quotes, NEAR, column filters, and null bytes.
   normalizeFTSTerm now keeps only Letters, Digits, Spaces,
   hyphen, and underscore — none of which carry FTS5 special
   meaning inside a phrase quote. An empty post-normalisation
   term falls through to the scope-only query path. Regression
   test: TestQuery_FTSNormalisation.

3. internal/instinct/coordinator.go Close — added an explicit
   `c.events != nil` guard so a future surface change to the
   writer cannot silently reintroduce the nil-deref hazard.
   Regression test: TestCoordinator_Close_NilEvents.

4. internal/instinct/decay_scheduler.go RunOnce — Query now uses
   MaxTokens=0 (no token cap) so the decay walk does not stop
   short on a token-budget boundary; MaxResults=1000 still bounds
   it. The Store reinforce path (CRITICAL #1 sibling fix) now
   propagates the incoming state so an active row can transition
   to archived through Store. Regression test:
   TestStore_UpsertUpdatesState + TestDecayScheduler_CandidateExpiry.

5. internal/instinct/builtin_minter.go Mint — when a TraceBundle
   carries its own non-zero Scope, the minter now prefers it over
   the batch-level scope argument. Without this fix a coordinator
   wiring multiple worktrees through one Ingest call would
   collapse all candidates to a single scope and lose isolation.
   Regression test: TestBuiltinMinter_PrefersBundleScope.

HIGH fixes

7. plugins/memory/sqlite/sqlite.go Query — the previous
   semantics included the over-cap row with Truncated=true, which
   (a) let UsedTokens exceed MaxTokens on the host's budget
   aggregator and (b) conflated "this row was elided" with
   "iteration stopped here". The new semantics stop iteration
   without appending the row that would breach the cap. Regression
   test: TestQuery_MaxTokensDoesNotExceedCap.

8. internal/cli/instinct_memory_helpers.go — discoverMemoryBackend
   now honours `instinct.plugin_security.allowlist` in warn-only
   mode. An empty allowlist resolves to "v0.5 bundled plugins
   only" (= sqlite reference-fallback); anything else triggers a
   stderr NOTICE every spawn so users see "untrusted third-party
   plugin running" the SECURITY.md warned about.

10. internal/observer/claude_jsonl.go — `admitted` is now an
    atomic.Int64. The previous plain int was read on return and
    written from the time.AfterFunc-driven flush goroutine, which
    the race detector flagged.

MEDIUM / LOW piggybacks

* sqlite.go: removed the dead `_ = filepath.Join` import guard
  and the unused `_ = domainJSON` after the bug fix; fixed the
  `workreeFromScopeID` → `worktreeFromScopeID` typo; threaded
  `source_event_id` through Scan as a named local (was binding
  via `new(string)` and discarding).
* internal/instinct/redaction.go: removed the misleading
  `var _ sync.Locker = (*sync.Mutex)(nil)` "compile-time guard"
  that referenced an unused package.

New unit coverage

* internal/instinct/instinct_test.go: 9 tests covering
  Coordinator.Close (nil events), ConfidencePolicy ceiling +
  reinforce, PoisoningGuard mode dispatch + active cap + TTL,
  BuiltinMinter scope override, Promote chain validation +
  backend-pair behaviour + invalid transition rejection,
  DecayScheduler candidate expiry.
* plugins/memory/sqlite/sqlite_regression_test.go: 4 tests pinning
  the CRITICAL fixes against the SQLite Provider directly.

Verifying

  go test -race -count=1 ./internal/instinct/... ./plugins/memory/sqlite/...
  # → 11 new tests + existing tests all green
  go build -o dist/bough-plugin-memory-sqlite ./cmd/bough-plugin-memory-sqlite
  BOUGH_CONFORMANCE_MEMORY_PLUGIN_BIN=$PWD/dist/bough-plugin-memory-sqlite \
      go test -tags=conformance -race -count=1 ./plugins/memory/sqlite/...
  # → Lifecycle + Bloat + Concurrency green against the real binary

Outstanding (= follow-up issues, not blocker for v0.5 ship)

* HIGH #11 file watch rotation fd leak — analysed and is not an
  actual leak (Go defers bind to variable, not value), kept as
  comment for clarity. No code change.
* MEDIUM #15 fallback_on_error field set but unread — YAML
  vocabulary frozen for v0.6; documented in INSTINCTS.md.
* MEDIUM #17 Import counts rows but does not re-Store — explicit
  v0.6 deferral per CONTRACT.md.
ikeikeikeike added a commit that referenced this pull request Jun 20, 2026
…#15 + #17)

MEDIUM #15: coordinator.Query now consumes instinct.fallback_on_error.
On a primary backend error with the flag set and a fallback
backend wired in, the same QueryReq is replayed against the
fallback and a query_fallback audit event is emitted. v0.5 ships
only SQLite so callers pass nil for fallback (= no-op pass-through);
v0.6 wires mem0 + SQLite together and the flag starts mattering.

MEDIUM #17: sqlite.Import used to walk the YAML / JSONL payload
and increment counters without ever re-Storing the rows, so an
Import after Forget left the table empty. The new
parseExportedYAML / parseExportedJSONL helpers reconstruct
memapi.Instinct records and route each through the same Store
path the host uses for fresh ingest (UpsertSemantics=true). The
CLI reports imported / upserted / skipped so an operator can
confirm the round-trip.

LOW #18 follow-on: loadInstinctCoordinator anchors the default
.bough/memory/events.jsonl against the monorepo root the
loadConfigAndRoot helper resolves. Combined with the
NewEventWriter absolute-path requirement landed in the previous
commit, the CLI now writes to the same file regardless of cwd.

instinct.New takes a fourth parameter (fallback) — the package is
internal so callers outside the repo are not expected; v0.5.1
stays a drop-in upgrade for plugin authors and end users.
ikeikeikeike added a commit that referenced this pull request Jun 21, 2026
… propagation / cache gen / cache namespace / Export pagination

#10 instinctToMetadata + writeInstinct skip zero-value time.Time
so a freshly constructed Instinct does not serialise a
-6795364578871 epoch into mem0 metadata or the JSONL export
(which would round-trip back as a garbage "year 0001" timestamp).

#11 The YAML / JSONL Export now carries dedupe_key +
source_event_id alongside each row, and Import's parser returns
an importRow struct that preserves both tokens. The host's
Store-by-dedupe contract therefore survives sqlite Export → mem0
Import round trips end-to-end.

#12 queryCache gains a monotonic invalidation generation. Query
captures gen before the HTTP roundtrip and hands it to put; put
silently drops the write when invalidateScope has bumped gen in
the interim. A concurrent Store / Forget / Import can no longer
race a slow Query and pin a stale response in cache for cacheTTL.

#13 cacheKey gains a namespace field, populated via the new
Provider.cacheKeyFor method, so two tenants sharing one Provider
with different namespaces never share cache entries even when
Scope shape matches.

#15 Export now paginates: it loops GET /api/v1/memories/?page=N
until a less-than-page-size batch comes back. Long scopes (>100
instincts) no longer silently truncate at the first page.

Build clean, plugins/memory/mem0 unit tests pass.
ikeikeikeike added a commit that referenced this pull request Jun 21, 2026
… propagation / cache gen / cache namespace / Export pagination

#10 instinctToMetadata + writeInstinct skip zero-value time.Time
so a freshly constructed Instinct does not serialise a
-6795364578871 epoch into mem0 metadata or the JSONL export
(which would round-trip back as a garbage "year 0001" timestamp).

#11 The YAML / JSONL Export now carries dedupe_key +
source_event_id alongside each row, and Import's parser returns
an importRow struct that preserves both tokens. The host's
Store-by-dedupe contract therefore survives sqlite Export → mem0
Import round trips end-to-end.

#12 queryCache gains a monotonic invalidation generation. Query
captures gen before the HTTP roundtrip and hands it to put; put
silently drops the write when invalidateScope has bumped gen in
the interim. A concurrent Store / Forget / Import can no longer
race a slow Query and pin a stale response in cache for cacheTTL.

#13 cacheKey gains a namespace field, populated via the new
Provider.cacheKeyFor method, so two tenants sharing one Provider
with different namespaces never share cache entries even when
Scope shape matches.

#15 Export now paginates: it loops GET /api/v1/memories/?page=N
until a less-than-page-size batch comes back. Long scopes (>100
instincts) no longer silently truncate at the first page.

Build clean, plugins/memory/mem0 unit tests pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant