ingotctl, HTTP layer, and self-instrumentation

Add cmd/ingotctl with four subcommands: blocks (list with stats),
inspect (series/postings dump), chunks (decode raw samples by ref), and
fsck (CRC and index integrity validation across all blocks).

Add cmd/ingothttp with JSON query endpoints: /api/v1/query_range
(Prometheus-style matrix response), /api/v1/read (matcher-based read
requests), and /epi/v1/status (DB stats snapshot). Uses JSON instead of
protobuf to maintain zero dependencies.

Add self-instrumentation via the normal Appender path so metrics are
queryable with the same API: ingot_head_series,
ingot_head_chunks_active, ingot_blocks_total, ingot_compactions_total,
ingot_wal_fsync_duration_seconds.

Supporting changes: block.Validate() and block.ReadMeta() exports,
Head.Stats() for series/chunk counts, WAL.LastSyncDuration() with timed
fsync tracking, DB.Stats() for the HTTP status endpoint.
This commit is contained in:
2026-07-04 17:40:50 -04:00
parent 0356f2e082
commit 30a93a868e
12 changed files with 1878 additions and 2 deletions
+148
View File
@@ -0,0 +1,148 @@
package ingot
import (
"math"
"testing"
"git.dvdt.dev/david/ingot/labels"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCollectMetrics(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, db *DB)
collectCalls int
wantHeadSeries float64 // expected value after last collectMetrics call
wantBlocksTotal float64
wantCompactions float64
wantMetricCount int // total ingot_* series
}{
{
name: "empty_db",
setup: func(t *testing.T, db *DB) {},
// First call snapshots 0 series, writes 5 metric series.
// Second call snapshots 5 series (the metrics themselves).
collectCalls: 2,
wantHeadSeries: 5,
wantBlocksTotal: 0,
wantCompactions: 0,
wantMetricCount: 5,
},
{
name: "with_user_series",
setup: func(t *testing.T, db *DB) {
app := db.Appender()
_, err := app.Append(0, labels.FromStrings("__name__", "temp", "room", "a"), 1000, 1.0)
require.NoError(t, err)
_, err = app.Append(0, labels.FromStrings("__name__", "temp", "room", "b"), 1000, 2.0)
require.NoError(t, err)
require.NoError(t, app.Commit())
},
// 2 user series + 5 metric series = 7
collectCalls: 2,
wantHeadSeries: 7,
wantBlocksTotal: 0,
wantCompactions: 0,
wantMetricCount: 5,
},
{
name: "with_block",
setup: func(t *testing.T, db *DB) {
app := db.Appender()
ref, err := app.Append(0, labels.FromStrings("__name__", "temp"), 0, 0)
require.NoError(t, err)
for i := 1; i < 250; i++ {
_, err = app.Append(ref, nil, int64(i*15000), float64(i))
require.NoError(t, err)
}
require.NoError(t, app.Commit())
_, err = db.FlushOlderThan(math.MaxInt64)
require.NoError(t, err)
},
collectCalls: 2,
wantHeadSeries: 6, // 1 user + 5 metric series
wantBlocksTotal: 1,
wantCompactions: 0,
wantMetricCount: 5,
},
{
name: "idempotent_five_calls",
setup: func(t *testing.T, db *DB) {},
collectCalls: 5,
wantHeadSeries: 5,
wantBlocksTotal: 0,
wantCompactions: 0,
wantMetricCount: 5,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
clock := &testClock{now: 100_000}
db, err := Open(t.TempDir(), Options{Clock: clock.fn()})
require.NoError(t, err)
t.Cleanup(func() { db.Close() })
tc.setup(t, db)
for i := 0; i < tc.collectCalls; i++ {
clock.now = int64(200_000 + i*1000)
db.collectMetrics()
}
// Query each expected metric.
lastValue := func(name string) (float64, bool) {
q, err := db.Querier(math.MinInt64, math.MaxInt64)
require.NoError(t, err)
defer q.Close()
ss := q.Select(labels.MustNewMatcher(labels.MatchEqual, "__name__", name))
found := false
var last float64
for ss.Next() {
it := ss.At().Iterator()
for it.Next() {
_, last = it.At()
}
require.NoError(t, it.Err())
found = true
}
require.NoError(t, ss.Err())
return last, found
}
v, found := lastValue(MetricHeadSeries)
assert.True(t, found, "ingot_head_series not found")
assert.Equal(t, tc.wantHeadSeries, v, "ingot_head_series")
v, found = lastValue(MetricBlocksTotal)
assert.True(t, found, "ingot_blocks_total not found")
assert.Equal(t, tc.wantBlocksTotal, v, "ingot_blocks_total")
v, found = lastValue(MetricCompactionsTotal)
assert.True(t, found, "ingot_compactions_total not found")
assert.Equal(t, tc.wantCompactions, v, "ingot_compactions_total")
// Count total ingot_* series.
q, err := db.Querier(math.MinInt64, math.MaxInt64)
require.NoError(t, err)
defer q.Close()
ss := q.Select(labels.MustNewMatcher(labels.MatchRegexp, "__name__", "ingot_.*"))
count := 0
for ss.Next() {
count++
}
require.NoError(t, ss.Err())
assert.Equal(t, tc.wantMetricCount, count, "metric series count")
})
}
}
type testClock struct {
now int64
}
func (c *testClock) fn() func() int64 {
return func() int64 { return c.now }
}