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.
279 lines
5.9 KiB
Go
279 lines
5.9 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.dvdt.dev/david/ingot/internal/block"
|
|
"git.dvdt.dev/david/ingot/internal/chunkenc"
|
|
"git.dvdt.dev/david/ingot/labels"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func makeChunk(t *testing.T, samples []struct{ t int64; v float64 }) []byte {
|
|
t.Helper()
|
|
c := chunkenc.NewXORChunk()
|
|
a, err := c.Appender()
|
|
require.NoError(t, err)
|
|
for _, s := range samples {
|
|
a.Append(s.t, s.v)
|
|
}
|
|
return append([]byte(nil), c.Bytes()...)
|
|
}
|
|
|
|
func setupTestData(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
|
|
samples := []struct{ t int64; v float64 }{
|
|
{0, 1.0}, {15000, 2.0}, {30000, 3.0},
|
|
}
|
|
chunk := makeChunk(t, samples)
|
|
|
|
series := []block.SeriesFlush{
|
|
{
|
|
Ref: 1,
|
|
Labels: labels.FromStrings("__name__", "temp", "room", "office"),
|
|
Chunks: []block.ChunkData{
|
|
{MinT: 0, MaxT: 30000, Data: chunk},
|
|
},
|
|
},
|
|
{
|
|
Ref: 2,
|
|
Labels: labels.FromStrings("__name__", "humidity", "room", "office"),
|
|
Chunks: []block.ChunkData{
|
|
{MinT: 0, MaxT: 30000, Data: chunk},
|
|
},
|
|
},
|
|
}
|
|
|
|
_, err := block.Flush(dir, series)
|
|
require.NoError(t, err)
|
|
return dir
|
|
}
|
|
|
|
// blockDir returns the first block directory inside dataDir.
|
|
func blockDir(t *testing.T, dataDir string) string {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(dataDir)
|
|
require.NoError(t, err)
|
|
for _, e := range entries {
|
|
if e.IsDir() && e.Name() != "wal" {
|
|
return filepath.Join(dataDir, e.Name())
|
|
}
|
|
}
|
|
t.Fatal("no block directory found")
|
|
return ""
|
|
}
|
|
|
|
// captureStdout calls fn with stdout redirected and returns the output.
|
|
func captureStdout(fn func()) string {
|
|
old := os.Stdout
|
|
r, w, _ := os.Pipe()
|
|
os.Stdout = w
|
|
fn()
|
|
w.Close()
|
|
os.Stdout = old
|
|
out := make([]byte, 16384)
|
|
n, _ := r.Read(out)
|
|
return string(out[:n])
|
|
}
|
|
|
|
func TestCmdBlocks(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
wantErr string // "" = no error expected
|
|
wantOutputs []string
|
|
}{
|
|
{
|
|
name: "list_blocks",
|
|
args: nil, // replaced in loop with setupTestData result
|
|
wantErr: "",
|
|
wantOutputs: []string{"ULID", "1 block(s) total"},
|
|
},
|
|
{
|
|
name: "no_blocks",
|
|
args: nil, // replaced with empty TempDir
|
|
wantErr: "",
|
|
wantOutputs: []string{"no blocks found"},
|
|
},
|
|
{
|
|
name: "missing_args",
|
|
args: []string{},
|
|
wantErr: "usage",
|
|
wantOutputs: nil,
|
|
},
|
|
}
|
|
|
|
// Set up args that need dynamic dirs.
|
|
dataDir := setupTestData(t)
|
|
emptyDir := t.TempDir()
|
|
tests[0].args = []string{dataDir}
|
|
tests[1].args = []string{emptyDir}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var output string
|
|
err := func() error {
|
|
var cmdErr error
|
|
output = captureStdout(func() { cmdErr = cmdBlocks(tc.args) })
|
|
return cmdErr
|
|
}()
|
|
|
|
assert.Equal(t, tc.wantErr != "", err != nil, "error presence")
|
|
assert.Contains(t, errString(err), tc.wantErr)
|
|
for _, want := range tc.wantOutputs {
|
|
assert.Contains(t, output, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCmdInspect(t *testing.T) {
|
|
dataDir := setupTestData(t)
|
|
bd := blockDir(t, dataDir)
|
|
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
wantErr string
|
|
wantOutputs []string
|
|
}{
|
|
{
|
|
name: "valid_block",
|
|
args: []string{bd},
|
|
wantErr: "",
|
|
wantOutputs: []string{"Block Meta", "__name__", "Postings"},
|
|
},
|
|
{
|
|
name: "missing_args",
|
|
args: []string{},
|
|
wantErr: "usage",
|
|
wantOutputs: nil,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var output string
|
|
err := func() error {
|
|
var cmdErr error
|
|
output = captureStdout(func() { cmdErr = cmdInspect(tc.args) })
|
|
return cmdErr
|
|
}()
|
|
|
|
assert.Equal(t, tc.wantErr != "", err != nil, "error presence")
|
|
assert.Contains(t, errString(err), tc.wantErr)
|
|
for _, want := range tc.wantOutputs {
|
|
assert.Contains(t, output, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCmdChunks(t *testing.T) {
|
|
dataDir := setupTestData(t)
|
|
bd := blockDir(t, dataDir)
|
|
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
wantErr string
|
|
wantOutputs []string
|
|
}{
|
|
{
|
|
name: "valid_ref",
|
|
args: []string{bd, "1"},
|
|
wantErr: "",
|
|
wantOutputs: []string{"Series 1", "(3 samples)"},
|
|
},
|
|
{
|
|
name: "ref_not_found",
|
|
args: []string{bd, "999"},
|
|
wantErr: "not found",
|
|
wantOutputs: nil,
|
|
},
|
|
{
|
|
name: "missing_ref_arg",
|
|
args: []string{bd},
|
|
wantErr: "usage",
|
|
wantOutputs: nil,
|
|
},
|
|
{
|
|
name: "missing_all_args",
|
|
args: []string{},
|
|
wantErr: "usage",
|
|
wantOutputs: nil,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var output string
|
|
err := func() error {
|
|
var cmdErr error
|
|
output = captureStdout(func() { cmdErr = cmdChunks(tc.args) })
|
|
return cmdErr
|
|
}()
|
|
|
|
assert.Equal(t, tc.wantErr != "", err != nil, "error presence")
|
|
assert.Contains(t, errString(err), tc.wantErr)
|
|
for _, want := range tc.wantOutputs {
|
|
assert.Contains(t, output, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCmdFsck(t *testing.T) {
|
|
dataDir := setupTestData(t)
|
|
emptyDir := t.TempDir()
|
|
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "valid_data",
|
|
args: []string{dataDir},
|
|
wantErr: "",
|
|
},
|
|
{
|
|
name: "no_blocks",
|
|
args: []string{emptyDir},
|
|
wantErr: "",
|
|
},
|
|
{
|
|
name: "missing_args",
|
|
args: []string{},
|
|
wantErr: "usage",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := func() error {
|
|
var cmdErr error
|
|
captureStdout(func() { cmdErr = cmdFsck(tc.args) })
|
|
return cmdErr
|
|
}()
|
|
|
|
assert.Equal(t, tc.wantErr != "", err != nil, "error presence")
|
|
assert.Contains(t, errString(err), tc.wantErr)
|
|
})
|
|
}
|
|
}
|
|
|
|
// errString returns the error message or "" for nil.
|
|
func errString(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
return strings.ToLower(err.Error())
|
|
}
|