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
+278
View File
@@ -0,0 +1,278 @@
package main
import (
"encoding/json"
"fmt"
"math"
"net/http"
"strconv"
"git.dvdt.dev/david/ingot"
"git.dvdt.dev/david/ingot/labels"
)
type handler struct {
db *ingot.DB
}
func newHandler(db *ingot.DB) *handler {
return &handler{db: db}
}
// --- /api/v1/query_range ---
// queryRange handles GET /api/v1/query_range?query=<name>&start=<ms>&end=<ms>
// Returns Prometheus-style JSON matrix results.
func (h *handler) queryRange(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
httpError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
query := r.URL.Query().Get("query")
if query == "" {
httpError(w, http.StatusBadRequest, "missing query parameter")
return
}
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
mint := int64(math.MinInt64)
maxt := int64(math.MaxInt64)
if startStr != "" {
v, err := strconv.ParseInt(startStr, 10, 64)
if err != nil {
httpError(w, http.StatusBadRequest, "invalid start: "+err.Error())
return
}
mint = v
}
if endStr != "" {
v, err := strconv.ParseInt(endStr, 10, 64)
if err != nil {
httpError(w, http.StatusBadRequest, "invalid end: "+err.Error())
return
}
maxt = v
}
matcher := labels.MustNewMatcher(labels.MatchEqual, "__name__", query)
result, err := h.executeQuery(mint, maxt, []*labels.Matcher{matcher})
if err != nil {
httpError(w, http.StatusInternalServerError, err.Error())
return
}
resp := queryRangeResponse{
Status: "success",
Data: queryRangeData{
ResultType: "matrix",
Result: result,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// --- /api/v1/read ---
// ReadRequest is a JSON-encoded query for the read endpoint.
type ReadRequest struct {
Queries []ReadQuery `json:"queries"`
}
// ReadQuery describes a single read query.
type ReadQuery struct {
StartTimestampMs int64 `json:"startTimestampMs"`
EndTimestampMs int64 `json:"endTimestampMs"`
Matchers []ReadMatcher `json:"matchers"`
}
// ReadMatcher is a label matcher in a read request.
type ReadMatcher struct {
Type string `json:"type"` // "=", "!=", "=~", "!~"
Name string `json:"name"`
Value string `json:"value"`
}
// ReadResponse is the JSON response for the read endpoint.
type ReadResponse struct {
Results []ReadResult `json:"results"`
}
// ReadResult contains the timeseries for a single query.
type ReadResult struct {
Timeseries []timeseriesResult `json:"timeseries"`
}
// read handles POST /api/v1/read with a JSON ReadRequest body.
func (h *handler) read(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
httpError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req ReadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
resp := ReadResponse{
Results: make([]ReadResult, len(req.Queries)),
}
for i, rq := range req.Queries {
matchers, err := convertMatchers(rq.Matchers)
if err != nil {
httpError(w, http.StatusBadRequest, err.Error())
return
}
result, err := h.executeQuery(rq.StartTimestampMs, rq.EndTimestampMs, matchers)
if err != nil {
httpError(w, http.StatusInternalServerError, err.Error())
return
}
ts := make([]timeseriesResult, len(result))
for j, sr := range result {
ts[j] = timeseriesResult{
Labels: sr.Metric,
Samples: sr.Values,
}
}
resp.Results[i] = ReadResult{Timeseries: ts}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// --- /api/v1/status ---
type statusResponse struct {
HeadSeries int `json:"headSeries"`
HeadChunks int `json:"headChunks"`
Blocks int `json:"blocks"`
Compactions int `json:"compactions"`
}
func (h *handler) status(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
httpError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
stats := h.db.Stats()
resp := statusResponse{
HeadSeries: stats.HeadSeries,
HeadChunks: stats.HeadChunks,
Blocks: stats.Blocks,
Compactions: stats.Compactions,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// --- shared helpers ---
type queryRangeResponse struct {
Status string `json:"status"`
Data queryRangeData `json:"data"`
}
type queryRangeData struct {
ResultType string `json:"resultType"`
Result []seriesResult `json:"result"`
}
type seriesResult struct {
Metric map[string]string `json:"metric"`
Values [][2]interface{} `json:"values"` // [timestamp_ms, "value"]
}
type timeseriesResult struct {
Labels map[string]string `json:"labels"`
Samples [][2]interface{} `json:"samples"`
}
func (h *handler) executeQuery(mint, maxt int64, matchers []*labels.Matcher) ([]seriesResult, error) {
q, err := h.db.Querier(mint, maxt)
if err != nil {
return nil, err
}
defer q.Close()
ss := q.Select(matchers...)
var results []seriesResult
for ss.Next() {
s := ss.At()
ls := s.Labels()
metric := make(map[string]string, len(ls))
for _, l := range ls {
metric[l.Name] = l.Value
}
var values [][2]interface{}
it := s.Iterator()
for it.Next() {
t, v := it.At()
values = append(values, [2]interface{}{t, fmt.Sprintf("%g", v)})
}
if it.Err() != nil {
return nil, it.Err()
}
if len(values) > 0 {
results = append(results, seriesResult{
Metric: metric,
Values: values,
})
}
}
if ss.Err() != nil {
return nil, ss.Err()
}
return results, nil
}
func convertMatchers(rms []ReadMatcher) ([]*labels.Matcher, error) {
matchers := make([]*labels.Matcher, len(rms))
for i, rm := range rms {
var matchType labels.MatchType
switch rm.Type {
case "=":
matchType = labels.MatchEqual
case "!=":
matchType = labels.MatchNotEqual
case "=~":
matchType = labels.MatchRegexp
case "!~":
matchType = labels.MatchNotRegexp
default:
return nil, fmt.Errorf("unsupported matcher type: %q", rm.Type)
}
m, err := labels.NewMatcher(matchType, rm.Name, rm.Value)
if err != nil {
return nil, fmt.Errorf("invalid matcher: %w", err)
}
matchers[i] = m
}
return matchers, nil
}
func httpError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{
"status": "error",
"error": msg,
})
}
+279
View File
@@ -0,0 +1,279 @@
package main
import (
"bytes"
"encoding/json"
"math"
"net/http"
"net/http/httptest"
"testing"
"git.dvdt.dev/david/ingot"
"git.dvdt.dev/david/ingot/labels"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func openTestDB(t *testing.T) *ingot.DB {
t.Helper()
db, err := ingot.Open(t.TempDir(), ingot.Options{})
require.NoError(t, err)
t.Cleanup(func() { db.Close() })
return db
}
func seedDB(t *testing.T, db *ingot.DB) {
t.Helper()
app := db.Appender()
ref, err := app.Append(0, labels.FromStrings("__name__", "temp", "room", "office"), 1000, 71.3)
require.NoError(t, err)
_, err = app.Append(ref, nil, 2000, 71.4)
require.NoError(t, err)
_, err = app.Append(0, labels.FromStrings("__name__", "humidity", "room", "office"), 1000, 55.0)
require.NoError(t, err)
require.NoError(t, app.Commit())
}
func TestQueryRange(t *testing.T) {
tests := []struct {
name string
method string
query string
start string
end string
wantStatus int
wantCount int // number of result series; 0 for error responses
}{
{
name: "match_temp",
method: http.MethodGet,
query: "temp",
wantStatus: http.StatusOK,
wantCount: 1,
},
{
name: "match_humidity",
method: http.MethodGet,
query: "humidity",
wantStatus: http.StatusOK,
wantCount: 1,
},
{
name: "no_match",
method: http.MethodGet,
query: "pressure",
wantStatus: http.StatusOK,
wantCount: 0,
},
{
name: "missing_query",
method: http.MethodGet,
query: "",
wantStatus: http.StatusBadRequest,
wantCount: 0,
},
{
name: "with_time_range",
method: http.MethodGet,
query: "temp",
start: "1500",
end: "3000",
wantStatus: http.StatusOK,
wantCount: 1,
},
{
name: "time_range_miss",
method: http.MethodGet,
query: "temp",
start: "5000",
end: "6000",
wantStatus: http.StatusOK,
wantCount: 0,
},
{
name: "method_not_allowed",
method: http.MethodPost,
query: "temp",
wantStatus: http.StatusMethodNotAllowed,
wantCount: 0,
},
}
db := openTestDB(t)
seedDB(t, db)
h := newHandler(db)
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
url := "/api/v1/query_range?query=" + tc.query
if tc.start != "" {
url += "&start=" + tc.start
}
if tc.end != "" {
url += "&end=" + tc.end
}
req := httptest.NewRequest(tc.method, url, nil)
rec := httptest.NewRecorder()
h.queryRange(rec, req)
assert.Equal(t, tc.wantStatus, rec.Code)
// Always decode — error responses produce zero-valued struct.
var resp queryRangeResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
assert.Equal(t, tc.wantCount, len(resp.Data.Result))
})
}
}
func TestRead(t *testing.T) {
tests := []struct {
name string
method string
request ReadRequest
wantStatus int
wantSeries int // in first result; 0 for error responses
}{
{
name: "single_query",
method: http.MethodPost,
request: ReadRequest{
Queries: []ReadQuery{
{
StartTimestampMs: math.MinInt64,
EndTimestampMs: math.MaxInt64,
Matchers: []ReadMatcher{
{Type: "=", Name: "__name__", Value: "temp"},
},
},
},
},
wantStatus: http.StatusOK,
wantSeries: 1,
},
{
name: "regex_matcher",
method: http.MethodPost,
request: ReadRequest{
Queries: []ReadQuery{
{
StartTimestampMs: math.MinInt64,
EndTimestampMs: math.MaxInt64,
Matchers: []ReadMatcher{
{Type: "=~", Name: "__name__", Value: "temp|humidity"},
},
},
},
},
wantStatus: http.StatusOK,
wantSeries: 2,
},
{
name: "invalid_matcher_type",
method: http.MethodPost,
request: ReadRequest{
Queries: []ReadQuery{
{
Matchers: []ReadMatcher{
{Type: "??", Name: "a", Value: "b"},
},
},
},
},
wantStatus: http.StatusBadRequest,
wantSeries: 0,
},
{
name: "method_not_allowed",
method: http.MethodGet,
request: ReadRequest{},
wantStatus: http.StatusMethodNotAllowed,
wantSeries: 0,
},
}
db := openTestDB(t)
seedDB(t, db)
h := newHandler(db)
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body, err := json.Marshal(tc.request)
require.NoError(t, err)
req := httptest.NewRequest(tc.method, "/api/v1/read", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.read(rec, req)
assert.Equal(t, tc.wantStatus, rec.Code)
// Always decode — error responses produce zero-valued struct.
var resp ReadResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
firstResultLen := 0
if len(resp.Results) > 0 {
firstResultLen = len(resp.Results[0].Timeseries)
}
assert.Equal(t, tc.wantSeries, firstResultLen)
})
}
}
func TestStatus(t *testing.T) {
tests := []struct {
name string
method string
seed bool
wantStatus int
wantHeadSeries int
wantBlocks int
}{
{
name: "seeded_db",
method: http.MethodGet,
seed: true,
wantStatus: http.StatusOK,
wantHeadSeries: 2,
wantBlocks: 0,
},
{
name: "empty_db",
method: http.MethodGet,
seed: false,
wantStatus: http.StatusOK,
wantHeadSeries: 0,
wantBlocks: 0,
},
{
name: "method_not_allowed",
method: http.MethodPost,
seed: false,
wantStatus: http.StatusMethodNotAllowed,
wantHeadSeries: 0,
wantBlocks: 0,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
db := openTestDB(t)
if tc.seed {
seedDB(t, db)
}
h := newHandler(db)
req := httptest.NewRequest(tc.method, "/api/v1/status", nil)
rec := httptest.NewRecorder()
h.status(rec, req)
assert.Equal(t, tc.wantStatus, rec.Code)
// Always decode — error responses produce zero-valued struct.
var resp statusResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
assert.Equal(t, tc.wantHeadSeries, resp.HeadSeries)
assert.Equal(t, tc.wantBlocks, resp.Blocks)
})
}
}
+58
View File
@@ -0,0 +1,58 @@
// Command ingothttp serves a minimal HTTP query API for an ingot database.
//
// Endpoints:
//
// GET /api/v1/query_range?query={name}&start={unix_ms}&end={unix_ms}
// POST /api/v1/read (JSON ReadRequest body)
// GET /api/v1/status (DB summary stats)
//
// This is a demo/bridge for Grafana integration, not a full PromQL engine.
// The query parameter is a metric name (matched as __name__=<query>).
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"git.dvdt.dev/david/ingot"
)
func main() {
var (
dataDir = flag.String("data", "./data", "ingot data directory")
addr = flag.String("addr", ":9001", "HTTP listen address")
)
flag.Parse()
db, err := ingot.Open(*dataDir, ingot.Options{})
if err != nil {
log.Fatalf("failed to open db: %v", err)
}
defer db.Close()
h := newHandler(db)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/query_range", h.queryRange)
mux.HandleFunc("/api/v1/read", h.read)
mux.HandleFunc("/api/v1/status", h.status)
srv := &http.Server{Addr: *addr, Handler: mux}
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
fmt.Fprintln(os.Stderr, "\nshutting down...")
srv.Close()
}()
log.Printf("ingothttp listening on %s (data: %s)", *addr, *dataDir)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}