feat: implement control history and mutation APIs

This commit is contained in:
2026-09-06 10:18:37 +00:00
parent ef43e9592c
commit 8df56263fb
11 changed files with 1515 additions and 3 deletions
+156
View File
@@ -0,0 +1,156 @@
package store
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/klauspost/compress/zstd"
"github.com/rvbox/rvbox/internal/domain"
)
// EventView is a validated persisted command event. Payload is the stored
// event bytes (normally deterministic protobuf bytes); callers that need the
// uncompressed form should use DecodeEventPayload.
type EventView struct {
IssueUUID domain.UUID
EventSeq uint64
ObservedUnixNano int64
ReceiptUnixNano int64
EventType uint16
Stream uint16
Compression uint16
RawLength uint64
Payload []byte
ImmutableSHA256 [32]byte
}
// ReadCommandEvents returns at most limit events after afterEventSeq in strict
// sequence order. Segment references are revalidated at read time; a malformed
// or replaced segment is an error rather than silently returning partial data.
func (store *Store) ReadCommandEvents(ctx context.Context, issue domain.UUID, afterEventSeq uint64, limit uint32) ([]EventView, error) {
if issue == (domain.UUID{}) || limit == 0 || limit > 1000 {
return nil, errors.New("invalid command event read")
}
database, err := store.openDatabase()
if err != nil {
return nil, err
}
rows, err := database.QueryContext(ctx, `SELECT
ce.event_seq, ce.observed_at, ce.server_receipt_time, ce.event_type,
ce.compression, ce.raw_bytes, ce.payload, ce.segment_ordinal,
ce.segment_record_offset, ce.segment_record_length, os.path,
ce.immutable_sha256
FROM command_events ce LEFT JOIN output_segments os
ON os.issue_uuid = ce.issue_uuid AND os.ordinal = ce.segment_ordinal
WHERE ce.issue_uuid = ? AND ce.event_seq > ? ORDER BY ce.event_seq LIMIT ?`, issue[:], afterEventSeq, limit)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]EventView, 0, limit)
for rows.Next() {
var event EventView
var inline []byte
var ordinal, offset, length sql.NullInt64
var path sql.NullString
var digest []byte
if err := rows.Scan(&event.EventSeq, &event.ObservedUnixNano, &event.ReceiptUnixNano, &event.EventType, &event.Compression, &event.RawLength, &inline, &ordinal, &offset, &length, &path, &digest); err != nil {
return nil, err
}
if len(digest) != 32 || event.EventSeq == 0 || event.EventType == 0 {
return nil, ErrInvalidSegmentRecord
}
copy(event.IssueUUID[:], issue[:])
copy(event.ImmutableSHA256[:], digest)
switch {
case inline != nil && !ordinal.Valid && !offset.Valid && !length.Valid && !path.Valid:
event.Payload = append([]byte(nil), inline...)
case inline == nil && ordinal.Valid && offset.Valid && length.Valid && path.Valid:
if ordinal.Int64 < 0 || offset.Int64 < 0 || length.Int64 <= 0 || ordinal.Int64 > int64(^uint32(0)) || uint64(length.Int64) > uint64(^uint(0)>>1) {
return nil, ErrInvalidSegmentRecord
}
payload, stream, readErr := store.readEventSegment(path.String, issue, event.EventSeq, uint32(ordinal.Int64), uint64(offset.Int64), uint64(length.Int64))
if readErr != nil {
return nil, readErr
}
event.Payload = payload
event.Stream = stream
default:
return nil, ErrInvalidSegmentRecord
}
if uint64(len(event.Payload)) > DefaultSegmentLimit || event.Compression < 1 || event.Compression > 2 || event.RawLength > DefaultSegmentLimit {
return nil, ErrInvalidSegmentRecord
}
result = append(result, event)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
func (store *Store) readEventSegment(name string, issue domain.UUID, sequence uint64, ordinal uint32, offset, length uint64) ([]byte, uint16, error) {
if filepath.Base(name) != name {
return nil, 0, ErrUnsafeSegmentReference
}
owner, parsedOrdinal, err := parseSegmentName(name)
if err != nil || owner != [16]byte(issue) || parsedOrdinal != ordinal {
return nil, 0, ErrUnsafeSegmentReference
}
path := filepath.Join(store.segmentDirectory(), name)
info, err := os.Lstat(path)
if err != nil {
return nil, 0, err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o077 != 0 {
return nil, 0, ErrUnsafeSegmentReference
}
if length > uint64(^uint(0)>>1) || offset > uint64(^uint(0)>>1) || offset+length < offset || offset+length > uint64(info.Size()) {
return nil, 0, ErrCommittedRangeMissing
}
file, err := os.Open(path)
if err != nil {
return nil, 0, err
}
defer file.Close()
reader := io.NewSectionReader(file, int64(offset), int64(length))
record, encodedLength, err := DecodeSegmentRecord(reader, DefaultSegmentLimits())
if err != nil {
return nil, 0, fmt.Errorf("decode command event segment: %w", err)
}
if encodedLength != length || record.OwnerUUID != [16]byte(issue) || record.Sequence != sequence || record.Kind != PayloadKindCommandEvent {
return nil, 0, ErrInvalidSegmentRecord
}
return append([]byte(nil), record.Payload...), record.Stream, nil
}
// DecodeEventPayload validates and boundedly decompresses a stored event
// payload. It is intentionally generic so output/follow handlers share the
// same decompression ceiling.
func DecodeEventPayload(event EventView) ([]byte, error) {
if event.Compression == 1 {
if event.RawLength != uint64(len(event.Payload)) {
return nil, ErrInvalidSegmentRecord
}
return append([]byte(nil), event.Payload...), nil
}
if event.Compression != 2 || event.RawLength > DefaultSegmentLimit {
return nil, ErrInvalidSegmentRecord
}
decoder, err := zstd.NewReader(bytes.NewReader(event.Payload), zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxMemory(DefaultSegmentLimit+1))
if err != nil {
return nil, ErrInvalidSegmentRecord
}
defer decoder.Close()
decoded, err := io.ReadAll(io.LimitReader(decoder, int64(event.RawLength)+1))
if err != nil || uint64(len(decoded)) != event.RawLength {
return nil, ErrInvalidSegmentRecord
}
return decoded, nil
}