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 block
import (
"encoding/binary"
"fmt"
"hash/crc32"
"os"
"path/filepath"
"git.dvdt.dev/david/ingot/internal/index"
)
// ValidationError describes a single integrity issue found during validation.
type ValidationError struct {
Block string // ULID or directory name
Section string // "meta", "index", "chunks"
Detail string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("%s: %s: %s", e.Block, e.Section, e.Detail)
}
// ReadMeta reads and returns the BlockMeta for a block directory.
// Exported for use by ingotctl.
func ReadMeta(dir string) (BlockMeta, error) {
return readMeta(dir)
}
// Validate performs integrity checks on a single block directory.
// It checks meta.json consistency, index integrity (magic, version, TOC CRC),
// and CRC validation on every chunk entry.
func Validate(dir string) []ValidationError {
var errs []ValidationError
blockName := filepath.Base(dir)
// 1. Validate meta.json.
meta, err := readMeta(dir)
if err != nil {
errs = append(errs, ValidationError{blockName, "meta", err.Error()})
return errs
}
if meta.Version != 1 {
errs = append(errs, ValidationError{blockName, "meta",
fmt.Sprintf("unsupported version %d", meta.Version)})
}
if meta.MinTime > meta.MaxTime {
errs = append(errs, ValidationError{blockName, "meta",
fmt.Sprintf("minTime %d > maxTime %d", meta.MinTime, meta.MaxTime)})
}
// 2. Validate index file.
indexData, err := os.ReadFile(filepath.Join(dir, "index"))
if err != nil {
errs = append(errs, ValidationError{blockName, "index", err.Error()})
} else {
_, err := index.NewReader(indexData)
if err != nil {
errs = append(errs, ValidationError{blockName, "index", err.Error()})
}
}
// 3. Validate all chunk segment files — iterate every entry and check CRC.
chunksDir := filepath.Join(dir, chunksDirName)
entries, err := os.ReadDir(chunksDir)
if err != nil {
errs = append(errs, ValidationError{blockName, "chunks", err.Error()})
return errs
}
for _, e := range entries {
if e.IsDir() {
continue
}
segIdx := parseSegmentName(e.Name())
if segIdx < 0 {
continue
}
data, err := os.ReadFile(filepath.Join(chunksDir, e.Name()))
if err != nil {
errs = append(errs, ValidationError{blockName, "chunks",
fmt.Sprintf("segment %s: %s", e.Name(), err)})
continue
}
segErrs := validateChunkSegment(data, e.Name())
for _, se := range segErrs {
errs = append(errs, ValidationError{blockName, "chunks", se})
}
}
return errs
}
// validateChunkSegment checks every chunk entry in a segment file for CRC integrity.
func validateChunkSegment(data []byte, name string) []string {
var errs []string
if len(data) < chunkHeaderLen {
return []string{fmt.Sprintf("segment %s: too short for header", name)}
}
magic := binary.BigEndian.Uint32(data[:4])
if magic != chunkMagic {
return []string{fmt.Sprintf("segment %s: invalid magic %#x", name, magic)}
}
if data[4] != chunkVersion {
return []string{fmt.Sprintf("segment %s: unsupported version %d", name, data[4])}
}
off := chunkHeaderLen
entryIdx := 0
for off < len(data) {
if off+chunkEntryHeaderLen > len(data) {
errs = append(errs, fmt.Sprintf("segment %s entry %d at offset %d: truncated header",
name, entryIdx, off))
break
}
dataLen := int(binary.BigEndian.Uint32(data[off : off+4]))
encoding := data[off+4]
off += chunkEntryHeaderLen
end := off + dataLen + chunkEntryCRCLen
if end > len(data) {
errs = append(errs, fmt.Sprintf("segment %s entry %d: truncated data (need %d bytes, have %d)",
name, entryIdx, dataLen+chunkEntryCRCLen, len(data)-off))
break
}
chunkBytes := data[off : off+dataLen]
off += dataLen
wantCRC := binary.BigEndian.Uint32(data[off : off+4])
crc := crc32.New(castagnoliTable)
crc.Write([]byte{encoding})
crc.Write(chunkBytes)
if crc.Sum32() != wantCRC {
errs = append(errs, fmt.Sprintf("segment %s entry %d at offset %d: CRC mismatch",
name, entryIdx, off-dataLen-chunkEntryHeaderLen))
}
off += chunkEntryCRCLen
entryIdx++
}
return errs
}