Skip to content

Commit bf3605f

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/ui-design-system-foundation
2 parents 80135e8 + c924a83 commit bf3605f

25 files changed

Lines changed: 2060 additions & 41 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<claude-mem-context>
22
# Memory Context
33

4-
# [otelcontext] recent context, 2026-04-28 6:43am UTC
4+
# [otelcontext] recent context, 2026-04-30 4:05am UTC
55

66
No previous sessions found.
77
</claude-mem-context>

CLAUDE.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ When none are present, `DEFAULT_TENANT` (default `"default"`) is assigned. Every
5959
| GraphRAG (in-memory) | `internal/graphrag/` | Layered graph: 4 typed stores, error chains, root cause analysis, anomaly detection |
6060
| Time Series (in-memory) | `internal/tsdb/` | Ring buffer, sliding windows, pre-computed percentiles |
6161
| Graph (in-memory, legacy) | `internal/graph/` | Simple service topology — **being replaced by GraphRAG** |
62-
| Vector (embedded) | `internal/vectordb/` | TF-IDF index for semantic log search (pure Go, no CGO). Retained as a fallback similarity index for SQLite mode and for `SimilarErrors` ranking within a Drain template cluster. |
63-
| Relational (persistent) | `internal/storage/` | GORM-based, multi-DB, single source of truth. Driven by `RetentionScheduler` (hourly batched purge + daily VACUUM/ANALYZE). `logs.body` is plain TEXT. **Log search**: SQLite uses FTS5 virtual table `logs_fts` (porter+unicode61 tokenizer) ordered by `bm25()`, kept in sync via AFTER INSERT/DELETE/UPDATE triggers; Postgres uses `pg_trgm` GIN on `logs.body` and `logs.service_name`. `AttributesJSON` and `AIInsight` remain `CompressedText`. |
62+
| Vector (embedded) | `internal/vectordb/` | TF-IDF index for semantic log search (pure Go, no CGO). Persisted across restarts via gob+CRC32 snapshot (default `data/vectordb.snapshot`, 5m interval) plus a startup tail-replay from the DB so the index is warm before listeners accept traffic — eliminating the legacy minutes of cold-start blindness. `find_similar_logs` and `SimilarErrors` (within a Drain template cluster) are the read-side consumers. |
63+
| Relational (persistent) | `internal/storage/` | GORM-based, multi-DB, single source of truth. Driven by `RetentionScheduler` (hourly batched purge + daily VACUUM/ANALYZE). `logs.body` is plain TEXT. **Log search**: vectordb (TF-IDF) is the default semantic-search path. Optional SQLite FTS5 (`logs_fts`, porter+unicode61, ordered by `bm25()`, AFTER INSERT/DELETE/UPDATE triggers) is **opt-in via `LOG_FTS_ENABLED=true`** and disabled by default — operators who toggle it off can reclaim the FTS table + indexes via `POST /api/admin/drop_fts`. Postgres uses `pg_trgm` GIN on `logs.body` and `logs.service_name`. `AttributesJSON` and `AIInsight` remain `CompressedText`. The `search_logs` MCP tool and the API `/api/logs?q=…` filter are clamped to the **last 24 hours** to bound the LIKE-fallback worst case. |
6464

6565
## GraphRAG Architecture
6666

@@ -190,7 +190,7 @@ internal/
190190
storage/ # GORM repository, models, migrations, Close() method
191191
telemetry/ # Prometheus metrics + health (19 metrics)
192192
tsdb/ # Time series aggregator + ring buffer (lock-free Windows())
193-
vectordb/ # Embedded TF-IDF vector index (FIFO eviction with copy, clean IDF rebuild)
193+
vectordb/ # Embedded TF-IDF vector index (FIFO eviction with copy, clean IDF rebuild). Persisted via gob+CRC32 snapshot + startup DB tail-replay (snapshot.go, replay.go).
194194
ui/ # Embedded React frontend
195195
ui/ # React frontend (Vite + Mantine)
196196
test/ # Microservice simulation (7 services)
@@ -213,7 +213,8 @@ Key settings in `internal/config/config.go`:
213213
- `METRIC_MAX_CARDINALITY` (10000), `METRIC_MAX_CARDINALITY_PER_TENANT` (0 = unlimited), `API_RATE_LIMIT_RPS` (100). The per-tenant cap is checked first; when set, a noisy tenant cannot exhaust the global pool. Overflow is labeled by tenant via `otelcontext_tsdb_cardinality_overflow_by_tenant_total{tenant_id}` (`__global__` sentinel when the global cap was the trigger).
214214
- `MCP_ENABLED` (true), `MCP_PATH` (/mcp)
215215
- `MCP_MAX_CONCURRENT` (32), `MCP_CALL_TIMEOUT_MS` (30000), `MCP_CACHE_TTL_MS` (5000) — MCP HTTP streamable robustness. Counting semaphore gates concurrent `tools/call` (JSON-RPC `-32000` past the cap), per-call deadlines abort runaway handlers (JSON-RPC `-32001`), and a 5s TTL cache memoizes the cheap in-memory GraphRAG tools (`get_service_map`, `impact_analysis`, `root_cause_analysis`, `get_anomaly_timeline`, `get_service_health`). SSE GET sends a `: keep-alive\n\n` comment every 25s to keep the stream alive across reverse-proxy idle timeouts. Set any to 0 to disable.
216-
- `VECTOR_INDEX_MAX_ENTRIES` (100000)
216+
- `VECTOR_INDEX_MAX_ENTRIES` (100000), `VECTOR_INDEX_SNAPSHOT_PATH` (`data/vectordb.snapshot`), `VECTOR_INDEX_SNAPSHOT_INTERVAL` (`5m`) — vectordb persistence. Empty `VECTOR_INDEX_SNAPSHOT_PATH` or non-positive interval disables the snapshot loop. The snapshot file uses a magic+version+CRC32 wire format with gob payload; corrupt or version-mismatched files are rejected and the loader falls back to a full DB rebuild via `ReplayFromDB`. Watch `otelcontext_vectordb_snapshot_writes_total{result}`, `otelcontext_vectordb_snapshot_load_total{result}`, `otelcontext_vectordb_snapshot_size_bytes`, and `otelcontext_vectordb_replay_logs_total`.
217+
- `LOG_FTS_ENABLED` (false) — when truthy (`true`/`yes`/`on`/`1`), provisions the SQLite FTS5 `logs_fts` virtual table + sync triggers at startup; when false, log-search uses vectordb (semantic) plus a 24h-clamped LIKE fallback. Toggle off and reclaim disk via `POST /api/admin/drop_fts` (refused while the flag is on).
217218
- `DLQ_MAX_FILES` (1000), `DLQ_MAX_DISK_MB` (500), `DLQ_MAX_RETRIES` (10)
218219
- `GRAPHRAG_WORKER_COUNT` (16), `GRAPHRAG_EVENT_QUEUE_SIZE` (100000) — sized for 100–200 services; raise further if `otelcontext_graphrag_events_dropped_total` climbs
219220
- `INGEST_ASYNC_ENABLED` (true), `INGEST_PIPELINE_QUEUE_SIZE` (50000), `INGEST_PIPELINE_WORKERS` (8) — async ingest pipeline (`internal/ingest/pipeline.go`). Hybrid backpressure: <90% accept all, 90–100% drop healthy batches (errors/slow always pass), 100% return gRPC `RESOURCE_EXHAUSTED`. Set `INGEST_ASYNC_ENABLED=false` to revert to synchronous DB writes inside `Export()`. Drops surface as `otelcontext_ingest_pipeline_dropped_total{signal,reason}`.
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
# Plan — Storage rebalance: drop FTS5, persist vectordb, cap log search
2+
3+
**Status:** Approved scope, ready for implementation
4+
**Date:** 2026-04-30
5+
**Reviewers:** codex (vectordb persistence design, 2026-04-30); user (scope sign-off, 2026-04-30)
6+
7+
## Problem
8+
9+
Three coupled inefficiencies surfaced while sizing for the 150-service × 7-day production target on SQLite:
10+
11+
1. **FTS5 inverted index consumes 30-40% of DB disk.** At the current 4GB sample, ~1.5GB; at full production scale, tens of GB. The UI never queries it (verified: `ui/src/hooks/useLogs.ts:21` calls `/api/logs` with no `q`); only the `search_logs` MCP tool depends on it, and that has a working LIKE fallback already wired at `internal/storage/log_repo.go:105`.
12+
2. **vectordb is not persisted.** Daemon restart empties the in-memory TF-IDF index. `find_similar_logs` and GraphRAG `SimilarErrors` return degraded results until ERROR/WARN logs flow back in naturally — minutes to hours, depending on error rate.
13+
3. **`search_logs` has no time-range cap.** Tolerable with FTS5; without it, an unscoped 7-day keyword query is a 30+ minute table scan. Worst case must be bounded.
14+
15+
## Goal
16+
17+
Cut SQLite footprint by 30-40%, eliminate vectordb cold-start blindness, and bound `search_logs` worst case to single-digit seconds — without changing `find_similar_logs`, GraphRAG, or any non-search MCP tool surface.
18+
19+
## Non-goals
20+
21+
- Inverted index / sublinear cosine search (requires raising 100k cap, separate change)
22+
- Dense embeddings / ONNX
23+
- Per-tenant snapshot sharding, multi-process flock
24+
- Raising `VECTOR_INDEX_MAX_ENTRIES`
25+
- Removing `search_logs` (kept, degraded via LIKE)
26+
- Changes to Postgres `pg_trgm` path (SQLite-only)
27+
28+
---
29+
30+
## Phase 0a — `LOG_FTS_ENABLED` config flag
31+
32+
Default `false`. New SQLite deploys skip FTS5 entirely.
33+
34+
**Files:**
35+
- `internal/config/config.go` — add `LogFTSEnabled bool`, env `LOG_FTS_ENABLED`, default false
36+
- `internal/storage/fts5.go``fts5Available()` returns false when flag is off; `EnsureLogsFTS5()` becomes no-op
37+
- `internal/storage/log_repo.go` — Repository gains `ftsEnabled bool`; `useFTS5` checks both driver + flag
38+
- `internal/storage/factory.go` (or wherever `NewRepository` is) — thread `cfg.LogFTSEnabled` through
39+
- `CLAUDE.md` — update FTS5 description, note opt-in
40+
41+
LIKE fallback already exists at `log_repo.go:105-108`; flipping the flag silently routes `filter.Search` queries through it. No new fallback code.
42+
43+
## Phase 0b — One-shot FTS5 reclaim
44+
45+
For existing DBs with `logs_fts`:
46+
47+
- New admin endpoint `POST /api/admin/drop_fts` (auth-gated, alongside existing `/api/admin/purge` + `/api/admin/vacuum`)
48+
- Body: `DROP TRIGGER IF EXISTS logs_fts_ai; logs_fts_ad; logs_fts_au; DROP TABLE IF EXISTS logs_fts; VACUUM;`
49+
- Returns `{"reclaimed_bytes": N, "elapsed_ms": M}`
50+
- Refuses (405) if `LOG_FTS_ENABLED=true` — won't drop while in active use
51+
52+
**Files:**
53+
- `internal/api/admin_handlers.go` (or wherever `handlePurge`/`handleVacuum` live) — add `handleDropFTS`
54+
- `internal/api/server.go:108-109` — register route
55+
- Test: integration test creates `logs_fts`, calls handler, asserts gone + bytes reclaimed
56+
57+
VACUUM blocks writes 10-60min on a 4GB DB — opt-in only, document for off-hours.
58+
59+
## Phase 0c — 24h cap on `search_logs`
60+
61+
**Description update at `internal/mcp/tools.go` ~line 37:**
62+
63+
> "Search log bodies by keyword. Limited to the last 24 hours. Strongly recommend setting `service_name` and/or `severity` to scope the search; unscoped keyword queries scan large row counts without FTS5. Returns up to `limit` results ordered by timestamp desc."
64+
65+
**Helper at `internal/mcp/tools.go` (or new `internal/mcp/clamp.go`):**
66+
67+
```go
68+
func clampTo24h(start, end, now time.Time) (time.Time, time.Time, error) {
69+
if end.IsZero() { end = now }
70+
if start.IsZero() { start = end.Add(-24 * time.Hour) }
71+
if end.After(now) { end = now }
72+
cutoff := now.Add(-24 * time.Hour)
73+
if end.Before(cutoff) { return time.Time{}, time.Time{}, errors.New("search window must be within the last 24h") }
74+
if start.Before(cutoff) { start = cutoff }
75+
if !start.Before(end) { return time.Time{}, time.Time{}, errors.New("start_time must be before end_time") }
76+
return start, end, nil
77+
}
78+
```
79+
80+
**Apply at:**
81+
- `internal/mcp/tools.go::toolSearchLogs` (~line 445) — after arg parse
82+
- `internal/api/log_handlers.go::handleGetLogs` — when `q` param non-empty (defense in depth; UI doesn't hit this with `q` but direct HTTP callers shouldn't bypass)
83+
84+
**Tests:** 4 cases for `clampTo24h` (defaults, oversize window clamped, out-of-cap rejected, invalid range rejected) + 1 integration test each for MCP and HTTP.
85+
86+
**Behavior matrix:**
87+
88+
| Input | Effective window |
89+
|---|---|
90+
| nothing | now-24h → now |
91+
| start = 5d ago | now-24h → now (start clamped) |
92+
| start = 5d ago, end = 4d ago | rejected |
93+
| end = future | start → now (end clamped) |
94+
| `q=""` (no search) | unconstrained — cap only fires when search is set |
95+
96+
---
97+
98+
## Phase 1-5 — vectordb persistence
99+
100+
### Format
101+
102+
```
103+
bytes[0:4] magic "VDB1" (ASCII)
104+
bytes[4:8] format version uint32 BE
105+
bytes[8:12] payload CRC32-IEEE uint32 BE (over bytes[12:])
106+
bytes[12:] gob payload Snapshot
107+
```
108+
109+
```go
110+
type Snapshot struct {
111+
LastIndexedID uint // max Log.ID seen by Add()
112+
MaxSize int
113+
Docs []LogVector
114+
IDF map[string]float64
115+
WrittenAt int64 // unix seconds
116+
}
117+
```
118+
119+
### Tail-replay key
120+
121+
**Use `Log.ID` (auto-increment PK), not `Log.Timestamp`.** Codex caught: `Index.Add()` has no dedup; timestamp-based replay would double-index boundary entries on every restart. ID is monotonic, dedup-free, DB-agnostic.
122+
123+
Query: `WHERE id > ? AND severity IN ('ERROR','WARN','WARNING','FATAL','CRITICAL') ORDER BY id ASC LIMIT 10000`. Paged loop until empty.
124+
125+
### Atomic write
126+
127+
`writeAtomic(path, data)`: write to `path+".tmp"` → fsync → close → rename. On `EXDEV` (cross-device): log warn once, fall back to `os.WriteFile` directly. On fsync error: delete `.tmp`, return error.
128+
129+
### Lifecycle
130+
131+
**Startup** (in `cmd/main.go` after DB open, before ingest accept):
132+
```go
133+
idx := vectordb.New(cfg.VectorIndexMaxEntries)
134+
if cfg.VectorIndexSnapshotPath != "" {
135+
_ = idx.LoadSnapshot(cfg.VectorIndexSnapshotPath) // tolerates missing/corrupt
136+
_ = idx.ReplayFromDB(ctx, repo) // catches tail since LastIndexedID
137+
go idx.SnapshotLoop(ctx, path, cfg.VectorIndexSnapshotInterval)
138+
}
139+
```
140+
141+
**Shutdown:** final `idx.WriteSnapshot()` between gRPC/HTTP `Shutdown()` returning and `graphrag.Stop()` — captures every `Add()` that completed before ingest stopped.
142+
143+
### Phases
144+
145+
- **Phase 1**: `internal/vectordb/snapshot.go` + tests — encode/decode, magic/version/CRC, atomic write, EXDEV fallback. Pure isolation, no behavior change.
146+
- **Phase 2**: wire into `Index` — add `lastIndexedID` field tracked in `Add()`; methods `LoadSnapshot`, `WriteSnapshot`, `LastIndexedID()`. Config fields added but no goroutine yet.
147+
- **Phase 3**: DB tail replay — `Repository.LogsForVectorReplay(ctx, sinceID, limit) ([]Log, error)` (paged); `Index.ReplayFromDB(ctx, repo)` walks pages calling `Add()`.
148+
- **Phase 4**: `Index.SnapshotLoop(ctx, path, interval)` background goroutine; wire startup/shutdown in `cmd/main.go`.
149+
- **Phase 5**: 5 Prometheus metrics (`snapshot_writes_total{result}`, `snapshot_duration_seconds`, `snapshot_size_bytes`, `snapshot_load_total{result}`, `replay_logs_total`); CLAUDE.md vectordb section + config table.
150+
151+
---
152+
153+
## Files (combined)
154+
155+
### New
156+
- `internal/vectordb/snapshot.go`
157+
- `internal/vectordb/snapshot_test.go`
158+
- `internal/mcp/clamp.go` + `clamp_test.go`
159+
160+
### Modified
161+
- `internal/vectordb/index.go``lastIndexedID`, `LoadSnapshot`, `WriteSnapshot`, `SnapshotLoop`, `ReplayFromDB`
162+
- `internal/storage/fts5.go` — gate on flag
163+
- `internal/storage/log_repo.go` — Repository ftsEnabled field
164+
- `internal/storage/repository.go``LogsForVectorReplay`
165+
- `internal/storage/factory.go` — config wiring
166+
- `internal/api/log_handlers.go` — 24h cap symmetry on `q=`
167+
- `internal/api/admin_handlers.go` (or equivalent) — `handleDropFTS`
168+
- `internal/api/server.go``/api/admin/drop_fts` route
169+
- `internal/mcp/tools.go` — description update + `clampTo24h` call in `toolSearchLogs`
170+
- `internal/config/config.go``LOG_FTS_ENABLED`, `VECTOR_INDEX_SNAPSHOT_PATH` (default `data/vectordb.snapshot`), `VECTOR_INDEX_SNAPSHOT_INTERVAL` (default `5m`)
171+
- `cmd/main.go` (or `internal/ui/ui.go` — wherever vectordb is constructed) — startup/shutdown wiring
172+
- `internal/telemetry/metrics.go` — 5 vectordb metrics
173+
- `CLAUDE.md` — FTS5 description, vectordb persistence note, config table additions
174+
175+
## Acceptance criteria
176+
177+
### Phase 0 (FTS5 + cap)
178+
1. `LOG_FTS_ENABLED=false` (default): new SQLite deploy has no `logs_fts` table; `search_logs` returns results via LIKE fallback.
179+
2. `LOG_FTS_ENABLED=true`: existing FTS5 path works unchanged (regression test).
180+
3. `POST /api/admin/drop_fts` reclaims expected bytes; returns 405 when flag is true.
181+
4. `toolSearchLogs` with no times → defaults to last 24h.
182+
5. start_time = 7d ago → clamped to now-24h.
183+
6. Window entirely outside cap → rejected with explicit error.
184+
7. HTTP `GET /api/logs?q=...` applies same cap.
185+
8. Updated tool description visible in MCP `tools/list`.
186+
187+
### Phase 1-5 (vectordb persistence)
188+
1. Daemon restart → `find_similar_logs` returns useful results in <1s (vs hours pre-change).
189+
2. Tail replay correctness: seed N → snapshot → seed M → restart → exactly N+M entries, no duplicate `LogID`.
190+
3. `kill -9` between snapshots: previous snapshot loads, no `.tmp` left behind.
191+
4. CRC mismatch / wrong magic / version mismatch: warning logged, full rebuild kicks in.
192+
5. 5 new metrics present at `/metrics`.
193+
6. Manual: oteliq daemon shows `vectordb: loaded N entries from snapshot, replayed M from DB`.
194+
195+
## Risks
196+
197+
| Risk | Mitigation |
198+
|---|---|
199+
| Flag flipped mid-deployment leaves stale `logs_fts` | Stale table is harmless until 0b dropped. |
200+
| VACUUM on 4GB blocks writes 10-60min | Document; admin endpoint is opt-in, not automatic. |
201+
| 24h cap rejects legitimate historical queries | Explicit error; direct DB access remains for one-offs. |
202+
| LIKE fallback slower than expected at scale | Accepted trade-off. Escalate to v2 vectordb design if painful. |
203+
| Snapshot format breaks across Go versions | Magic + version + CRC → full rebuild on any decode error. Bump version on `LogVector` struct change. |
204+
| Cross-device rename loses atomicity | EXDEV detected, fall back to direct write. Constraint documented. |
205+
206+
## Phasing for shipping
207+
208+
5 PRs, in order:
209+
210+
- **PR1**: Phase 0a + 0b + 0c (FTS5 disable + reclaim helper + 24h cap). Single PR — tightly related.
211+
- **PR2**: Phase 1 + 2 (snapshot encode/decode + Index integration).
212+
- **PR3**: Phase 3 (DB tail replay).
213+
- **PR4**: Phase 4 (snapshot loop + shutdown hook).
214+
- **PR5**: Phase 5 (metrics + docs).
215+
216+
PR1 ships standalone. PR2-5 are sequential. Phase 0 can land before any vectordb work.
217+
218+
## Verification
219+
220+
```bash
221+
# Phase 0
222+
go test ./internal/storage/... ./internal/mcp/... ./internal/api/...
223+
224+
# Phase 1-5
225+
go test ./internal/vectordb/...
226+
227+
# Integration smoke
228+
go vet ./...
229+
go build -o /tmp/otelcontext .
230+
LOG_FTS_ENABLED=false /tmp/otelcontext &
231+
# Expect: "logs_fts: disabled (LOG_FTS_ENABLED=false)" on first start
232+
# After restart: "vectordb: loaded N entries from snapshot, replayed M from DB"
233+
```
234+
235+
## Resolved scope decisions
236+
237+
- Snapshot path: flat `data/vectordb.snapshot`
238+
- zstd compression: not in v1 — ship uncompressed gob
239+
- Phase split: 5 PRs (above)
240+
- FTS5 default: off (`LOG_FTS_ENABLED=false`)
241+
- 24h cap interpretation: search window must overlap with last 24h; window entirely older is rejected, not silently emptied

internal/api/admin_handlers.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import (
44
"encoding/json"
55
"log/slog"
66
"net/http"
7+
"os"
78
"strconv"
9+
"strings"
810
"time"
911
)
1012

@@ -66,3 +68,51 @@ func (s *Server) handleVacuum(w http.ResponseWriter, _ *http.Request) {
6668
w.Header().Set("Content-Type", "application/json")
6769
_ = json.NewEncoder(w).Encode(map[string]string{"status": "vacuumed"})
6870
}
71+
72+
// handleDropFTS handles POST /api/admin/drop_fts. Drops the SQLite FTS5
73+
// virtual table + AFTER INSERT/DELETE/UPDATE triggers and runs VACUUM so the
74+
// freed pages are returned to the OS. One-shot reclaim for existing deploys
75+
// after LOG_FTS_ENABLED is set to false — typically reclaims 30-40% of DB disk.
76+
//
77+
// Refused (405) when LOG_FTS_ENABLED is currently truthy, because triggers
78+
// fire on every log INSERT and dropping them mid-flight would silently break
79+
// FTS5 sync until restart.
80+
//
81+
// VACUUM blocks writes for ~10-60 minutes on a multi-GB DB. Run this during
82+
// a maintenance window.
83+
func (s *Server) handleDropFTS(w http.ResponseWriter, r *http.Request) {
84+
if v, ok := os.LookupEnv("LOG_FTS_ENABLED"); ok {
85+
if b, err := strconv.ParseBool(strings.TrimSpace(v)); err == nil && b {
86+
http.Error(w, "drop_fts refused: LOG_FTS_ENABLED is currently true; set it to false and restart before dropping", http.StatusMethodNotAllowed)
87+
return
88+
}
89+
switch strings.ToLower(strings.TrimSpace(v)) {
90+
case "yes", "y", "on":
91+
http.Error(w, "drop_fts refused: LOG_FTS_ENABLED is currently true; set it to false and restart before dropping", http.StatusMethodNotAllowed)
92+
return
93+
}
94+
}
95+
96+
started := time.Now()
97+
sizeBefore := s.repo.HotDBSizeBytes()
98+
99+
if err := s.repo.DropLogsFTS(r.Context()); err != nil {
100+
slog.Error("drop_fts failed", "error", err)
101+
http.Error(w, err.Error(), http.StatusInternalServerError)
102+
return
103+
}
104+
105+
sizeAfter := s.repo.HotDBSizeBytes()
106+
elapsed := time.Since(started)
107+
reclaimed := sizeBefore - sizeAfter
108+
if reclaimed < 0 {
109+
reclaimed = 0
110+
}
111+
slog.Info("drop_fts completed", "elapsed_ms", elapsed.Milliseconds(), "reclaimed_bytes", reclaimed)
112+
113+
w.Header().Set("Content-Type", "application/json")
114+
_ = json.NewEncoder(w).Encode(map[string]any{
115+
"reclaimed_bytes": reclaimed,
116+
"elapsed_ms": elapsed.Milliseconds(),
117+
})
118+
}

0 commit comments

Comments
 (0)