Merge branch 'fix/g11' into fix/integrated
# Conflicts: # internal/store/migrations.go
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ecosystemTraceRetention is how many trace rows are kept. Traces are
|
||||
// diagnostics with a short useful life, and they arrive at machine rate, so
|
||||
// the table is bounded rather than append-only. The facts table is the audit
|
||||
// trail; this one is not.
|
||||
const ecosystemTraceRetention = 5000
|
||||
|
||||
// EcosystemTrace is one hop of a cross-service call: which service, which
|
||||
// operation, how it ended, how long it took, and the ids that stitch the hops
|
||||
// of one turn together. Fields carries the hop-specific detail (entity id,
|
||||
// capability, failure class) as a JSON object.
|
||||
type EcosystemTrace struct {
|
||||
ID int64 `json:"id"`
|
||||
Ts time.Time `json:"ts"`
|
||||
Service string `json:"service"`
|
||||
Operation string `json:"operation"`
|
||||
Status string `json:"status"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
CorrelationID string `json:"correlation_id"`
|
||||
CausationID string `json:"causation_id"`
|
||||
HTTPStatus int `json:"http_status"`
|
||||
Fields map[string]any `json:"fields"`
|
||||
}
|
||||
|
||||
// WriteEcosystemTrace appends one trace row and keeps the table bounded.
|
||||
func (s *Store) WriteEcosystemTrace(ctx context.Context, tr EcosystemTrace) (int64, error) {
|
||||
fields := "{}"
|
||||
if len(tr.Fields) > 0 {
|
||||
b, err := json.Marshal(tr.Fields)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal trace fields: %w", err)
|
||||
}
|
||||
fields = string(b)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO ecosystem_traces
|
||||
(ts, service, operation, status, duration_ms, correlation_id, causation_id, http_status, fields)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
tr.Ts.UnixMilli(), tr.Service, tr.Operation, tr.Status, tr.DurationMs,
|
||||
tr.CorrelationID, tr.CausationID, tr.HTTPStatus, fields)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("write ecosystem trace: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("last insert id: %w", err)
|
||||
}
|
||||
// Prune rarely: the cost of the delete is not worth paying on every hop,
|
||||
// and the bound is a ceiling, not an exact size.
|
||||
if id%256 == 0 {
|
||||
if err := s.PruneEcosystemTraces(ctx, ecosystemTraceRetention); err != nil {
|
||||
return id, err
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// PruneEcosystemTraces drops all but the newest keep rows.
|
||||
func (s *Store) PruneEcosystemTraces(ctx context.Context, keep int) error {
|
||||
if keep <= 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
DELETE FROM ecosystem_traces
|
||||
WHERE id <= (SELECT MAX(id) FROM ecosystem_traces) - ?`, keep)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prune ecosystem traces: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecentEcosystemTraces returns the newest n traces, newest first.
|
||||
func (s *Store) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, ts, service, operation, status, duration_ms, correlation_id, causation_id, http_status, fields
|
||||
FROM ecosystem_traces
|
||||
ORDER BY id DESC
|
||||
LIMIT ?`, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recent ecosystem traces: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []EcosystemTrace
|
||||
for rows.Next() {
|
||||
var tr EcosystemTrace
|
||||
var tsMilli int64
|
||||
var fields string
|
||||
if err := rows.Scan(&tr.ID, &tsMilli, &tr.Service, &tr.Operation, &tr.Status,
|
||||
&tr.DurationMs, &tr.CorrelationID, &tr.CausationID, &tr.HTTPStatus, &fields); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr.Ts = time.UnixMilli(tsMilli).UTC()
|
||||
if fields != "" {
|
||||
_ = json.Unmarshal([]byte(fields), &tr.Fields)
|
||||
}
|
||||
out = append(out, tr)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -177,6 +177,29 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
`ALTER TABLE tasks ADD COLUMN ext_id TEXT;
|
||||
ALTER TABLE tasks ADD COLUMN resolved_by TEXT NOT NULL DEFAULT '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_ext_id ON tasks (ext_id) WHERE ext_id IS NOT NULL;`,
|
||||
// #16 — ecosystem call traces (Vikunja #273). Deliberately NOT facts.
|
||||
// Traces are written at machine rate, one act turn produces three or four,
|
||||
// while facts are written at human rate. Sharing the facts table made every
|
||||
// bounded reader of facts (the habit profile's 2000-row window, memeval's
|
||||
// prompt snapshot, /dash's 50 and /history's 200) read mostly traces after
|
||||
// a day of ecosystem use, pushing the rows that matter out of range.
|
||||
// Retention is enforced on write (PruneEcosystemTraces) because nothing
|
||||
// here is an audit trail: a trace answers "did this hop work" for as long
|
||||
// as anyone is still asking.
|
||||
`CREATE TABLE IF NOT EXISTS ecosystem_traces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
correlation_id TEXT NOT NULL DEFAULT '',
|
||||
causation_id TEXT NOT NULL DEFAULT '',
|
||||
http_status INTEGER NOT NULL DEFAULT 0,
|
||||
fields TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_eco_traces_ts ON ecosystem_traces (ts DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_eco_traces_correlation ON ecosystem_traces (correlation_id);`,
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
Reference in New Issue
Block a user