From 92ff96cb017dc8f8da2f2514cdd8f5821c6a9457 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 7 Sep 2026 15:36:14 +0200 Subject: [PATCH 1/4] feat(storage): add a bounded FTWDB history candidate --- .changeset/ftwdb-live-history-candidate.md | 4 + .github/workflows/ftwdb-shadow-contract.yml | 46 + docker-compose.ftwdb-shadow.yml | 31 + docs/architecture.md | 5 + docs/ftwdb-shadow.md | 89 + go/cmd/ftw/main.go | 5 + go/internal/api/api.go | 6 + go/internal/ftwdbshadow/beta.go | 258 +++ go/internal/ftwdbshadow/beta_test.go | 77 + go/internal/ftwdbshadow/beta_unix_test.go | 306 ++++ go/internal/ftwdbshadow/client.go | 636 +++++++ go/internal/ftwdbshadow/client_unix_test.go | 506 ++++++ go/internal/ftwdbshadow/codec.go | 1580 +++++++++++++++++ go/internal/ftwdbshadow/codec_test.go | 418 +++++ go/internal/ftwdbshadow/fixture_test.go | 329 ++++ go/internal/ftwdbshadow/health.go | 46 + .../testdata/shadow-protocol-v1/README.md | 41 + .../testdata/shadow-protocol-v1/SHA256SUMS | 9 + .../commit-ack-response.hex | 1 + .../commit-batch-request.hex | 1 + .../shadow-protocol-v1/error-response.hex | 1 + .../shadow-protocol-v1/flush-ack-response.hex | 1 + .../shadow-protocol-v1/flush-request.hex | 1 + .../shadow-protocol-v1/health-request.hex | 1 + .../shadow-protocol-v1/health-response.hex | 1 + .../shadow-protocol-v1/hello-request.hex | 1 + .../shadow-protocol-v1/hello-response.hex | 1 + go/internal/ftwdbshadow/types.go | 364 ++++ go/internal/state/history_feed.go | 75 + go/internal/state/history_feed_test.go | 57 + go/internal/state/store.go | 3 + go/internal/state/store_ts.go | 6 +- 32 files changed, 4905 insertions(+), 1 deletion(-) create mode 100644 .changeset/ftwdb-live-history-candidate.md create mode 100644 .github/workflows/ftwdb-shadow-contract.yml create mode 100644 docker-compose.ftwdb-shadow.yml create mode 100644 docs/ftwdb-shadow.md create mode 100644 go/internal/ftwdbshadow/beta.go create mode 100644 go/internal/ftwdbshadow/beta_test.go create mode 100644 go/internal/ftwdbshadow/beta_unix_test.go create mode 100644 go/internal/ftwdbshadow/client.go create mode 100644 go/internal/ftwdbshadow/client_unix_test.go create mode 100644 go/internal/ftwdbshadow/codec.go create mode 100644 go/internal/ftwdbshadow/codec_test.go create mode 100644 go/internal/ftwdbshadow/fixture_test.go create mode 100644 go/internal/ftwdbshadow/health.go create mode 100644 go/internal/ftwdbshadow/testdata/shadow-protocol-v1/README.md create mode 100644 go/internal/ftwdbshadow/testdata/shadow-protocol-v1/SHA256SUMS create mode 100644 go/internal/ftwdbshadow/testdata/shadow-protocol-v1/commit-ack-response.hex create mode 100644 go/internal/ftwdbshadow/testdata/shadow-protocol-v1/commit-batch-request.hex create mode 100644 go/internal/ftwdbshadow/testdata/shadow-protocol-v1/error-response.hex create mode 100644 go/internal/ftwdbshadow/testdata/shadow-protocol-v1/flush-ack-response.hex create mode 100644 go/internal/ftwdbshadow/testdata/shadow-protocol-v1/flush-request.hex create mode 100644 go/internal/ftwdbshadow/testdata/shadow-protocol-v1/health-request.hex create mode 100644 go/internal/ftwdbshadow/testdata/shadow-protocol-v1/health-response.hex create mode 100644 go/internal/ftwdbshadow/testdata/shadow-protocol-v1/hello-request.hex create mode 100644 go/internal/ftwdbshadow/testdata/shadow-protocol-v1/hello-response.hex create mode 100644 go/internal/ftwdbshadow/types.go create mode 100644 go/internal/state/history_feed.go create mode 100644 go/internal/state/history_feed_test.go diff --git a/.changeset/ftwdb-live-history-candidate.md b/.changeset/ftwdb-live-history-candidate.md new file mode 100644 index 00000000..332aae0a --- /dev/null +++ b/.changeset/ftwdb-live-history-candidate.md @@ -0,0 +1,4 @@ +--- +"forty-two-watts": minor +--- +Add an optional FTWDB beta sidecar that copies committed live site history through a bounded memory queue. Keep SQLite and Parquet authoritative and report candidate gaps, errors and durable acknowledgements in health. diff --git a/.github/workflows/ftwdb-shadow-contract.yml b/.github/workflows/ftwdb-shadow-contract.yml new file mode 100644 index 00000000..2ce859be --- /dev/null +++ b/.github/workflows/ftwdb-shadow-contract.yml @@ -0,0 +1,46 @@ +name: FTWDB shadow contract + +on: + pull_request: + paths: + - ".github/workflows/ftwdb-shadow-contract.yml" + - "docker-compose.ftwdb-shadow.yml" + - "go/internal/ftwdbshadow/**" + - "go/internal/state/history_feed*" + - "go/internal/state/store_ts.go" + - "go/cmd/ftw/main.go" + workflow_dispatch: + +permissions: + contents: read + +jobs: + go-rust-contract: + name: Go to Rust contract + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out pinned FTWDB + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: srcfl/ftwdb + ref: 4927c1cda891941e77f22122dd225a2dde1d43e3 + path: .ftwdb-contract + - uses: actions/setup-go@v7 + with: + go-version-file: go/go.mod + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master + with: + toolchain: 1.97.1 + - name: Check the frozen v1 contract + run: diff -ru .ftwdb-contract/testdata/shadow-protocol-v1 go/internal/ftwdbshadow/testdata/shadow-protocol-v1 + - name: Build the pinned sidecar and reconcile tool + run: cargo build --locked --manifest-path .ftwdb-contract/Cargo.toml --bin ftwdb-shadow --bin ftwdb-shadow-reconcile + - name: Exercise live copy, lost ACK, limits, SIGKILL, restart and reconcile + working-directory: go + env: + FTWDB_SHADOW_BIN: ${{ github.workspace }}/.ftwdb-contract/target/debug/ftwdb-shadow + FTWDB_RECONCILE_BIN: ${{ github.workspace }}/.ftwdb-contract/target/debug/ftwdb-shadow-reconcile + FTWDB_SHADOW_FIXTURES: ${{ github.workspace }}/.ftwdb-contract/testdata/shadow-protocol-v1 + run: go test -race ./internal/ftwdbshadow ./internal/state -run 'TestBeta|TestHistoryMapping|TestRustSidecarInterop|TestLiveHistory|TestHealthOps|TestV1Golden|TestVendored' -count=1 -v diff --git a/docker-compose.ftwdb-shadow.yml b/docker-compose.ftwdb-shadow.yml new file mode 100644 index 00000000..fe900682 --- /dev/null +++ b/docker-compose.ftwdb-shadow.yml @@ -0,0 +1,31 @@ +# Optional beta overlay. Core and SQLite keep their normal lifecycle. +# Pin matches .github/workflows/ftwdb-shadow-contract.yml. +services: + ftw: + environment: + FTWDB_SHADOW_SOCKET: /run/ftwdb-shadow/shadow.sock + volumes: + - ftwdb-shadow-run:/run/ftwdb-shadow + + ftwdb-shadow: + profiles: [ftwdb-shadow] + build: + context: https://github.com/srcfl/ftwdb.git#4927c1cda891941e77f22122dd225a2dde1d43e3 + image: ftwdb-shadow:4927c1cda891941e77f22122dd225a2dde1d43e3 + user: "100:101" + network_mode: none + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + cpus: 0.25 + mem_limit: 256m + pids_limit: 64 + restart: "no" + stop_grace_period: 30s + volumes: + - ftwdb-shadow-data:/var/lib/ftwdb-shadow + - ftwdb-shadow-run:/run/ftwdb-shadow + +volumes: + ftwdb-shadow-data: + ftwdb-shadow-run: diff --git a/docs/architecture.md b/docs/architecture.md index 579d320a..4333944d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,6 +65,11 @@ The control loop computes a site target, allocates it across capable assets, applies safety constraints, then sends commands through the driver registry. Planner output is an input to that loop, never a direct device command. +The optional [FTWDB beta candidate](ftwdb-shadow.md) copies committed numeric +site history through a bounded memory queue to a local sidecar. It reports +session gaps and durable receipts. SQLite and Parquet keep serving all reads; +the candidate has no role in control, config or forecasting. + ## Drivers The public `srcfl/device-drivers` repo owns editable driver source, versions, diff --git a/docs/ftwdb-shadow.md b/docs/ftwdb-shadow.md new file mode 100644 index 00000000..061c11c5 --- /dev/null +++ b/docs/ftwdb-shadow.md @@ -0,0 +1,89 @@ +# FTWDB beta candidate + +The optional FTWDB sidecar copies five numeric fields from successful live +SQLite history writes: grid power, PV power, battery power, house load and +battery state of charge. Watts keep the site sign convention; SoC stays a +0–1 fraction. SQLite and Parquet still serve history. Config, forecasts, learned +models, schedules and control continue to use their current stores. + +This is a bounded session recording. It does not copy old data, driver samples, +SQL imports, retention deletes, the energy ledger or forecast archives. It is +not a complete replica or a backup. Each Core start has a new session ID. Pending +memory work can be lost on restart; the SQLite source remains available. + +## Enable on a beta test box + +Use a Core beta that contains this integration. From the FTW checkout: + +```sh +docker compose -f docker-compose.yml -f docker-compose.ftwdb-shadow.yml \ + --profile ftwdb-shadow build ftwdb-shadow +docker compose -f docker-compose.yml -f docker-compose.ftwdb-shadow.yml \ + --profile ftwdb-shadow up -d ftw ftwdb-shadow +``` + +The overlay builds a pinned FTWDB commit. It gives the sidecar its own data +volume, no network, a 256 MiB memory limit and a quarter CPU. Only the private +Unix socket volume is shared with Core. Both processes use UID 100, GID 101. +There is no startup or health dependency from Core to FTWDB. + +For a native install, use the FTWDB service example with the same user as Core. +Pass `-ftwdb-shadow-socket /run/ftwdb-shadow/ftwdb-shadow.sock` to Core or set +`FTWDB_SHADOW_SOCKET` to that path. An empty value disables the candidate. +This is an install option; household Settings do not expose an experimental +storage switch. + +## Read the result + +Read `ftwdb_shadow` from `GET /api/health`. Its state is independent of Core +health. Check these fields together: + +- `session`, `started_at` and `scope` identify the covered run. +- `offered_ticks`, `queued_ticks`, `pending_ticks` and `dropped_ticks` show + collection and overload. `gaps` means at least one offered tick was lost. +- `acknowledged_ticks`, `durable_through_sequence` and `last_ack_at` report + durable sidecar receipts. A sent batch is not yet an acknowledgement. + `last_ack_ms` and `max_ack_ms` measure the commit request and durable reply. +- `errors` and `last_error` explain a pause. `sidecar` counters have their own + `sidecar_checked_at`; they can precede the latest batch acknowledgement. + +The queue holds at most 256 small numeric records, plus one pending batch of +at most 128. Core tries a batch every 30 seconds. Connect, encode, socket I/O +and retry happen on a separate goroutine with two-second I/O deadlines. A full +queue drops candidate work and increments its counter. It never waits for the +sidecar from a device or control loop. + +The sequence follows delivery of committed writes, not measurement time. +Late and same-time live history writes therefore remain distinct. Retries keep +one source ID, sequence, commit ID and the exact encoded bytes. The sidecar uses +always-sync durability. Core also pauses new writes once its reported store +size reaches 512 MiB. The sidecar enforces its own space limits. Store limits +are test budgets, not a claim that shared-disk I/O has no effect on control. + +## Stop the experiment + +Stop the sidecar, then recreate Core with the normal Compose file: + +```sh +docker compose -f docker-compose.yml -f docker-compose.ftwdb-shadow.yml \ + --profile ftwdb-shadow stop ftwdb-shadow +docker compose -f docker-compose.yml up -d --no-deps ftw +``` + +Keep the candidate data volume when collecting a report. This flow does not +remove SQLite or Parquet. Do not use `down -v` to disable the experiment. + +## Validation + +The contract workflow pins the same FTWDB commit as the overlay. It compares +shared byte fixtures, sends committed SQLite history to the real Rust process, +drops an acknowledgement, retries exact bytes, kills the process, checks the +reopened durable receipt and reconciles all copied points offline. Tests also +cover absent, unhealthy, non-durable and full sidecars, a full client queue, +failed SQLite commits, late writes, SI units and concurrent status reads. + +Before increasing the scope, measure control latency, CPU, RSS, disk growth, +sync rate and gaps on a real box for at least 72 hours. Test disk pressure and +physical power loss on that hardware. Host tests and SIGKILL do not prove SD-card +power-loss behavior. Keep SQLite/Parquet as the source until those results and +an explicit data migration justify a separate replacement change. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 667b14f7..c021bdce 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -52,6 +52,7 @@ import ( "github.com/srcfl/ftw/go/internal/drivers" "github.com/srcfl/ftw/go/internal/events" "github.com/srcfl/ftw/go/internal/forecast" + "github.com/srcfl/ftw/go/internal/ftwdbshadow" "github.com/srcfl/ftw/go/internal/gatewayidentity" "github.com/srcfl/ftw/go/internal/ha" "github.com/srcfl/ftw/go/internal/loadmodel" @@ -321,6 +322,7 @@ func main() { } configPath := flag.String("config", "config.yaml", "Path to config.yaml") + shadowSocket := flag.String("ftwdb-shadow-socket", os.Getenv("FTWDB_SHADOW_SOCKET"), "Optional local FTWDB beta socket; empty disables the candidate") webDir := flag.String("web", "web", "Path to static web UI directory") driverDirFlag := flag.String("drivers", "", "Path to drivers directory (default: /drivers)") userDriversDirFlag := flag.String("user-drivers", "", "Path to PERSISTENT user-drivers directory (overlay on top of -drivers). Searched first; falls back to -drivers when a file isn't found here. Designed for docker deploys.") @@ -738,6 +740,8 @@ func main() { var forecastConfigMu sync.RWMutex var ocppSrv *ocpp.Server forecastSettings := newForecastSiteConfig(st) + shadow := ftwdbshadow.Start(ctx, st, *shadowSocket, forecastSettings.Snapshot().SiteID, Version) + defer shadow.Close() forecastSettings.identity = func(name string) (string, bool) { if id, ok := runningDeviceID(reg, name); ok { return id, true @@ -2455,6 +2459,7 @@ func main() { ColdDir: coldDir, DataDir: dataDir, StatePath: statePath, + FTWDBShadow: shadow, BackupDir: backupDir, DataMaintenanceMu: dataMaintenanceMu, // Snapshots live next to the rest of the persistent data so diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 560223c7..57cc6282 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -42,6 +42,7 @@ import ( "github.com/srcfl/ftw/go/internal/events" "github.com/srcfl/ftw/go/internal/fleetping" "github.com/srcfl/ftw/go/internal/forecast" + "github.com/srcfl/ftw/go/internal/ftwdbshadow" "github.com/srcfl/ftw/go/internal/ha" "github.com/srcfl/ftw/go/internal/loadmodel" "github.com/srcfl/ftw/go/internal/loadpoint" @@ -75,6 +76,8 @@ const ( // One instance is shared across all handlers; mutations use the contained // mutexes from each package. type Deps struct { + FTWDBShadow *ftwdbshadow.Beta + // MutationPolicy protects every state-changing route at the shared // Handler boundary. Production requires tokens for non-local hostnames; // the zero value retains local/test embedding compatibility. @@ -708,6 +711,9 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { } resp["storage"] = storage } + if s.deps.FTWDBShadow != nil { + resp["ftwdb_shadow"] = s.deps.FTWDBShadow.Status() + } writeJSON(w, 200, resp) } diff --git a/go/internal/ftwdbshadow/beta.go b/go/internal/ftwdbshadow/beta.go new file mode 100644 index 00000000..9822a921 --- /dev/null +++ b/go/internal/ftwdbshadow/beta.go @@ -0,0 +1,258 @@ +package ftwdbshadow + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "log/slog" + "math" + "sync" + "time" + + "github.com/srcfl/ftw/go/internal/state" +) + +const ( + betaBatchTicks = 128 + betaInterval = 30 * time.Second + betaMaxStoreBytes = 512 * 1024 * 1024 +) + +// Beta copies numeric site history from successful live SQLite ticks. Each +// process has a new source ID: this is a measured session, never a full replica. +// The memory queue may lose work on overload or restart; SQLite keeps the data. +type Beta struct { + feed *state.HistoryFeed + mu sync.Mutex + status BetaStatus + cancel context.CancelFunc + done chan struct{} +} + +type BetaStatus struct { + Enabled bool `json:"enabled"` + State string `json:"state"` + Scope string `json:"scope"` + Session string `json:"session,omitempty"` + StartedAt time.Time `json:"started_at"` + state.HistoryFeedStats + Pending int `json:"pending_ticks"` + Acknowledged uint64 `json:"acknowledged_ticks"` + DurableThrough uint64 `json:"durable_through_sequence"` + LastAckAt *time.Time `json:"last_ack_at,omitempty"` + Errors uint64 `json:"errors"` + LastError string `json:"last_error,omitempty"` + Sidecar *HealthOps `json:"sidecar,omitempty"` + SidecarCheckedAt *time.Time `json:"sidecar_checked_at,omitempty"` + LastAckMS float64 `json:"last_ack_ms"` + MaxAckMS float64 `json:"max_ack_ms"` +} + +// Start never dials or writes to the sidecar in the caller. An empty socket +// disables the candidate. A missing sidecar cannot delay Core startup. +func Start(ctx context.Context, st *state.Store, socket, siteID, version string) *Beta { + b := &Beta{status: BetaStatus{Enabled: socket != "", State: "disabled", Scope: "live_site_history_session", StartedAt: time.Now()}, done: make(chan struct{})} + if socket == "" { + close(b.done) + return b + } + var source ID128 + if _, err := rand.Read(source[:]); err != nil || siteID == "" || st == nil { + b.status.State = "unavailable" + b.status.LastError = "shadow session identity unavailable" + close(b.done) + return b + } + b.status.Session = source.String() + b.status.State = "waiting" + b.feed = st.ObserveLiveHistory() + ctx, b.cancel = context.WithCancel(ctx) + go func() { + defer close(b.done) + b.run(ctx, ClientConfig{SocketPath: socket, SourceID: source, NodeID: "ftw-shadow-beta", ClientVersion: version, IOTimeout: 2 * time.Second}, siteID, betaInterval) + }() + return b +} + +func (b *Beta) Close() { + if b.cancel != nil { + b.cancel() + } + <-b.done +} + +func (b *Beta) Status() BetaStatus { + b.mu.Lock() + s := b.status + b.mu.Unlock() + if b.feed != nil { + s.HistoryFeedStats = b.feed.Stats() + } + if s.Dropped > 0 && s.State == "ok" { + s.State = "gaps" + } + return s +} + +func (b *Beta) failure(err error) { + if errors.Is(err, context.Canceled) { + return + } + b.mu.Lock() + changed := b.status.LastError != err.Error() + b.status.Errors++ + b.status.State = "degraded" + b.status.LastError = err.Error() + b.mu.Unlock() + if changed { + slog.Warn("FTWDB shadow copy paused", "err", err) + } +} + +func (b *Beta) run(ctx context.Context, config ClientConfig, siteID string, interval time.Duration) { + timer := time.NewTicker(interval) + defer timer.Stop() + var client *Client + defer func() { + if client != nil { + _ = client.Close() + } + }() + var pending *PreparedCommit + pendingTicks := 0 + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + } + if pending == nil { + ticks := make([]state.CommittedHistory, 0, betaBatchTicks) + drain: + for len(ticks) < betaBatchTicks { + select { + case tick := <-b.feed.Events(): + ticks = append(ticks, tick) + default: + break drain + } + } + if len(ticks) == 0 { + continue + } + prepared, err := prepareHistory(config.SourceID, siteID, ticks) + if err != nil { + b.feed.MarkDropped(uint64(len(ticks))) + b.failure(err) + continue + } + pending = &prepared + pendingTicks = len(ticks) + b.mu.Lock() + b.status.Pending = pendingTicks + b.mu.Unlock() + } + if client == nil { + var err error + client, _, err = Connect(ctx, config) + if err != nil { + b.failure(err) + continue + } + } + health, err := client.Health(ctx, pending.Sequence()) + if err == nil { + checkedAt := time.Now() + b.mu.Lock() + b.status.Sidecar = health.Ops + b.status.SidecarCheckedAt = &checkedAt + b.mu.Unlock() + switch { + case watermarkAtLeast(health.DurableThroughSequence, pending.Sequence()): + // A lost acknowledgement can cross the store limit. Ask for the + // existing receipt without adding data, even while writes are paused. + case health.Ops == nil || health.Ops.SyncPolicy != 1: + err = errors.New("shadow beta requires ops health and always-sync durability") + case health.Ops.DatabaseBytes >= betaMaxStoreBytes: + err = errors.New("shadow store reached the 512 MiB beta limit; stop and archive the candidate") + case health.Status == HealthUnavailable: + err = errors.New("shadow writer is unavailable") + } + } + if err != nil { + _ = client.Close() + client = nil + b.failure(err) + continue + } + started := time.Now() + ack, err := client.CommitDurable(ctx, *pending) + if err != nil { + _ = client.Close() + client = nil + b.failure(err) + continue + } + now := time.Now() + b.mu.Lock() + b.status.State = "ok" + b.status.LastError = "" + b.status.LastAckAt = &now + b.status.DurableThrough = ack.DurableThrough + b.status.Acknowledged += uint64(pendingTicks) + b.status.LastAckMS = float64(time.Since(started)) / float64(time.Millisecond) + b.status.MaxAckMS = max(b.status.MaxAckMS, b.status.LastAckMS) + b.status.Pending = 0 + b.mu.Unlock() + pending = nil + } +} + +func historyID(parts ...string) ID128 { + hash := sha256.New() + for _, part := range parts { + _, _ = fmt.Fprintf(hash, "%d:%s", len(part), part) + } + var id ID128 + copy(id[:], hash.Sum(nil)) + return id +} + +func prepareHistory(source ID128, siteID string, ticks []state.CommittedHistory) (PreparedCommit, error) { + if len(ticks) == 0 || len(ticks) > betaBatchTicks { + return PreparedCommit{}, errors.New("invalid history batch size") + } + last := ticks[len(ticks)-1] + owner := historyID("ftw-site-history-v1", siteID) + commit := historyID("ftw-history-commit-v1", source.String(), fmt.Sprint(last.Sequence)) + batch := CommitBatchRequest{SourceID: source, Sequence: last.Sequence, CommitID: commit} + batch.Entities = []Entity{{ID: owner, Kind: "site", Name: "FTW site", Properties: map[string]PropertyValue{"power_sign": TextProperty("positive_into_site"), "scope": TextProperty("live_site_history_session")}}} + batch.Runs = []Run{{ID: commit, Kind: RunImport, Status: RunSucceeded, CreatedAt: last.CommittedAtMicros, KnowledgeTime: last.CommittedAtMicros, Workflow: "ftw.sqlite.live_history", ModelVersion: "1", Attributes: map[string]PropertyValue{"session": TextProperty(source.String()), "first_sequence": IntegerProperty(int64(ticks[0].Sequence)), "last_sequence": IntegerProperty(int64(last.Sequence)), "ticks": IntegerProperty(int64(len(ticks)))}}} + names := []string{"grid_power", "pv_power", "battery_power", "house_load_power", "battery_soc"} + series := make([]uint64, len(names)) + for i, name := range names { + id := historyID("ftw-site-series-v1", siteID, name) + series[i] = binary.BigEndian.Uint64(id[:8]) + unit, quantity := "W", "power" + if name == "battery_soc" { + unit, quantity = "1", "state_of_charge" + } + gap := int64(15_000_000) + batch.Series = append(batch.Series, SeriesDefinition{ID: series[i], OwnerEntity: &owner, Name: name, PhysicalQuantity: quantity, CanonicalUnit: unit, Semantics: SeriesGauge, MaximumGapMicros: &gap}) + } + var previous uint64 + for _, tick := range ticks { + p := tick.Point + if tick.Sequence <= previous || p.TsMs < 0 || p.TsMs > math.MaxInt64/1000 { + return PreparedCommit{}, errors.New("invalid history sequence or timestamp") + } + previous = tick.Sequence + for i, value := range []float64{p.GridW, p.PVW, p.BatW, p.LoadW, p.BatSoC} { + batch.Points = append(batch.Points, Point{SeriesID: series[i], ValidTime: p.TsMs * 1000, ValidTimeEnd: p.TsMs * 1000, KnowledgeTime: tick.CommittedAtMicros, ChangeTime: tick.CommittedAtMicros, RunID: commit, Value: value}) + } + } + return PrepareCommit(batch) +} diff --git a/go/internal/ftwdbshadow/beta_test.go b/go/internal/ftwdbshadow/beta_test.go new file mode 100644 index 00000000..ade33985 --- /dev/null +++ b/go/internal/ftwdbshadow/beta_test.go @@ -0,0 +1,77 @@ +package ftwdbshadow + +import ( + "context" + "testing" + + "github.com/srcfl/ftw/go/internal/state" +) + +func TestHistoryMappingPreservesSIAndLateVersions(t *testing.T) { + source := mustID(t, "00112233445566778899aabbccddeeff") + ticks := []state.CommittedHistory{ + {Sequence: 1, CommittedAtMicros: 4000, Point: state.HistoryPoint{TsMs: 3, GridW: 42, PVW: -1000, BatW: 200, LoadW: 842, BatSoC: 0.75}}, + {Sequence: 3, CommittedAtMicros: 5000, Point: state.HistoryPoint{TsMs: 1, GridW: -42, PVW: -1000, BatW: 200, LoadW: 758, BatSoC: 0.75}}, + } + prepared, err := prepareHistory(source, "site", ticks) + if err != nil { + t.Fatal(err) + } + decoded, err := Decode(prepared.Bytes()) + if err != nil { + t.Fatal(err) + } + batch := decoded.(CommitBatchRequest) + if batch.Sequence != 3 || len(batch.Points) != 10 { + t.Fatal("timestamp ordering dropped late history") + } + units := map[uint64]string{} + for _, s := range batch.Series { + units[s.ID] = s.CanonicalUnit + } + for i, p := range batch.Points { + want := []float64{42, -1000, 200, 842, 0.75, -42, -1000, 200, 758, 0.75}[i] + if p.Value != want || p.KnowledgeTime != ticks[i/5].CommittedAtMicros || p.ValidTime != ticks[i/5].Point.TsMs*1000 { + t.Fatalf("changed numeric meaning or time: %+v", p) + } + unit := "W" + if i%5 == 4 { + unit = "1" + } + if units[p.SeriesID] != unit { + t.Fatalf("wrong SI unit: %s", units[p.SeriesID]) + } + } +} + +func TestBetaDisabledDoesNotNeedIdentityOrStore(t *testing.T) { + b := Start(context.Background(), nil, "", "", "test") + defer b.Close() + if b.Status().Enabled || b.Status().State != "disabled" { + t.Fatal("candidate enabled itself") + } +} + +func TestHealthOpsPreservesLegacyAndRejectsUnknownPolicy(t *testing.T) { + source := mustID(t, "00112233445566778899aabbccddeeff") + for _, ops := range []*HealthOps{nil, {SyncPolicy: 1}, {SyncPolicy: 2}, {SyncPolicy: 3, SyncEveryBytes: 4096}} { + health := HealthResponse{SourceID: source, Status: HealthHealthy, Ops: ops} + frame, err := Encode(health) + if err != nil { + t.Fatal(err) + } + decoded, err := Decode(frame) + if err != nil { + t.Fatal(err) + } + got := decoded.(HealthResponse).Ops + if (got == nil) != (ops == nil) || (got != nil && *got != *ops) { + t.Fatal("health policy changed") + } + } + for _, ops := range []*HealthOps{{SyncPolicy: 0}, {SyncPolicy: 4}, {SyncPolicy: 1, SyncEveryBytes: 1}, {SyncPolicy: 3}} { + if _, err := Encode(HealthResponse{SourceID: source, Status: HealthHealthy, Ops: ops}); err == nil { + t.Fatal("bad sync policy accepted") + } + } +} diff --git a/go/internal/ftwdbshadow/beta_unix_test.go b/go/internal/ftwdbshadow/beta_unix_test.go new file mode 100644 index 00000000..0138879b --- /dev/null +++ b/go/internal/ftwdbshadow/beta_unix_test.go @@ -0,0 +1,306 @@ +//go:build !windows + +package ftwdbshadow + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/state" +) + +func runTestBeta(t *testing.T, st *state.Store, socket string) *Beta { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + b := &Beta{feed: st.ObserveLiveHistory(), status: BetaStatus{Enabled: true, State: "waiting"}, cancel: cancel, done: make(chan struct{})} + id := mustID(t, "00112233445566778899aabbccddeeff") + go func() { + defer close(b.done) + b.run(ctx, ClientConfig{SocketPath: socket, SourceID: id, NodeID: "test", ClientVersion: "test", IOTimeout: 100 * time.Millisecond}, "test-site", 20*time.Millisecond) + }() + t.Cleanup(b.Close) + return b +} + +func waitBeta(t *testing.T, b *Beta, ready func(BetaStatus) bool) BetaStatus { + t.Helper() + until := time.Now().Add(5 * time.Second) + for time.Now().Before(until) { + status := b.Status() + if ready(status) { + return status + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("shadow did not reach expected state: %+v", b.Status()) + return BetaStatus{} +} + +func TestBetaRefusesUnsafeSidecarWithoutBlockingSQLite(t *testing.T) { + for _, tc := range []struct { + name string + ops *HealthOps + status HealthStatus + }{ + {"unknown-ops", nil, HealthHealthy}, + {"not-durable", &HealthOps{SyncPolicy: 2}, HealthHealthy}, + {"store-limit", &HealthOps{SyncPolicy: 1, DatabaseBytes: betaMaxStoreBytes}, HealthHealthy}, + {"poisoned", &HealthOps{SyncPolicy: 1}, HealthUnavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + listener := listenUnix(t) + source := mustID(t, "00112233445566778899aabbccddeeff") + server := runServer(listener, func(conn net.Conn) error { + if err := serverHello(conn, source); err != nil { + return err + } + message, err := ReadMessage(conn) + if err != nil { + return err + } + health := message.(HealthRequest) + if err := WriteMessage(conn, HealthResponse{SourceID: source, Nonce: health.Nonce, Status: tc.status, Ops: tc.ops}); err != nil { + return err + } + _ = conn.SetReadDeadline(time.Now().Add(time.Second)) + if message, err := ReadMessage(conn); err == nil { + return fmt.Errorf("unsafe sidecar received %T", message) + } + return nil + }) + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + b := runTestBeta(t, st, listener.Addr().String()) + if err := st.RecordTick(state.HistoryPoint{TsMs: 1}, nil); err != nil { + t.Fatal(err) + } + waitBeta(t, b, func(s BetaStatus) bool { return s.Errors > 0 }) + for i := 2; i <= 400; i++ { + if err := st.RecordTick(state.HistoryPoint{TsMs: int64(i)}, nil); err != nil { + t.Fatal(err) + } + } + s := b.Status() + rows, err := st.LoadHistory(0, 500, 0) + if err != nil || len(rows) != 400 || s.Dropped == 0 || s.Acknowledged != 0 { + t.Fatalf("shadow changed source or hid loss: rows=%d status=%+v err=%v", len(rows), s, err) + } + waitServer(t, server) + }) + } +} + +func TestBetaMissingSidecarAndShutdown(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + b := runTestBeta(t, st, "/tmp/ftwdb-does-not-exist-beta.sock") + if err := st.RecordTick(state.HistoryPoint{TsMs: 1}, nil); err != nil { + t.Fatal(err) + } + waitBeta(t, b, func(s BetaStatus) bool { return s.Errors > 0 }) + b.Close() + if b.Status().Acknowledged != 0 { + t.Fatal("missing sidecar reported success") + } +} + +// The proxy loses one acknowledgement after Rust has made it durable. It keeps +// the exact Go commit frames so Rust can reconcile the copied points offline. +func shadowProxy(t *testing.T, target string) (string, func() [][]byte) { + t.Helper() + listener := listenUnix(t) + var mu sync.Mutex + var frames [][]byte + go func() { + for { + front, err := listener.Accept() + if err != nil { + return + } + back, err := net.Dial("unix", target) + if err != nil { + _ = front.Close() + continue + } + func() { + defer front.Close() + defer back.Close() + for { + _ = front.SetDeadline(time.Now().Add(3 * time.Second)) + _ = back.SetDeadline(time.Now().Add(3 * time.Second)) + frame, message, err := readRawFrame(front) + if err != nil { + return + } + commit := false + if _, ok := message.(CommitBatchRequest); ok { + commit = true + mu.Lock() + frames = append(frames, frame) + mu.Unlock() + } + if _, err := back.Write(frame); err != nil { + return + } + reply, err := ReadMessage(back) + if err != nil { + return + } + mu.Lock() + lose := commit && len(frames) == 1 + atLimit := len(frames) == 1 + mu.Unlock() + if lose { + return + } + if health, ok := reply.(HealthResponse); ok && atLimit { + // New writes stop at the cap, but the existing durable receipt + // must still be retrievable after its first ACK went missing. + health.Status = HealthDegraded + health.Ops.DatabaseBytes = betaMaxStoreBytes + reply = health + } + if err := WriteMessage(front, reply); err != nil { + return + } + } + }() + } + }() + return listener.Addr().String(), func() [][]byte { mu.Lock(); defer mu.Unlock(); return append([][]byte(nil), frames...) } +} + +func startRustShadow(t *testing.T, binary, store, socket string) func(os.Signal) { + t.Helper() + command := exec.Command(binary, store, socket) + log, err := os.CreateTemp(t.TempDir(), "sidecar.log") + if err != nil { + t.Fatal(err) + } + command.Stderr = log + if err := command.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- command.Wait() }() + var once sync.Once + stop := func(signal os.Signal) { + once.Do(func() { + _ = command.Process.Signal(signal) + select { + case <-done: + case <-time.After(5 * time.Second): + _ = command.Process.Kill() + t.Error("sidecar did not stop") + } + _ = log.Close() + }) + } + t.Cleanup(func() { stop(syscall.SIGTERM) }) + until := time.Now().Add(5 * time.Second) + for time.Now().Before(until) { + if _, err := os.Stat(socket); err == nil { + return stop + } + select { + case err := <-done: + t.Fatalf("sidecar exited: %v", err) + default: + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("sidecar did not create socket") + return stop +} + +func TestRustSidecarInteropLiveHistory(t *testing.T) { + binary := os.Getenv("FTWDB_SHADOW_BIN") + reconcile := os.Getenv("FTWDB_RECONCILE_BIN") + if binary == "" || reconcile == "" { + t.Skip("set FTWDB_SHADOW_BIN and FTWDB_RECONCILE_BIN for the pinned Rust gate") + } + root, err := os.MkdirTemp("/tmp", "ftw-beta-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(root) + store := filepath.Join(root, "shadow") + socket := filepath.Join(root, "run", "shadow.sock") + stop := startRustShadow(t, binary, store, socket) + proxy, frames := shadowProxy(t, socket) + st, err := state.Open(filepath.Join(root, "state.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + b := runTestBeta(t, st, proxy) + for i, ts := range []int64{3000, 1000, 1000} { + if err := st.RecordTick(state.HistoryPoint{TsMs: ts, GridW: float64(42 + i), PVW: -1000, BatW: 200, LoadW: 842, BatSoC: 0.75}, nil); err != nil { + t.Fatal(err) + } + } + status := waitBeta(t, b, func(s BetaStatus) bool { return s.Acknowledged == 3 }) + if status.Errors == 0 || status.DurableThrough != 3 { + t.Fatalf("lost acknowledgement was not recovered: %+v", status) + } + b.Close() + wire := frames() + if len(wire) != 2 || !bytes.Equal(wire[0], wire[1]) { + t.Fatalf("retry changed exact frame: %d", len(wire)) + } + // SIGKILL after the durable acknowledgement tests the persisted receipt, + // independently of a clean process shutdown. + stop(syscall.SIGKILL) + _ = os.Remove(socket) // Killed processes cannot unlink their socket. + stop = startRustShadow(t, binary, store, socket) + source := mustID(t, "00112233445566778899aabbccddeeff") + client, _, err := Connect(context.Background(), ClientConfig{SocketPath: socket, SourceID: source, NodeID: "reopen", ClientVersion: "test", IOTimeout: time.Second}) + if err != nil { + t.Fatal(err) + } + prepared, err := PreparedCommitFromFrame(wire[0]) + if err != nil { + t.Fatal(err) + } + ack, err := client.CommitDurable(context.Background(), prepared) + if err != nil { + t.Fatal(err) + } + if !ack.Commit.Deduplicated || ack.DurableThrough != 3 { + t.Fatal("restart lost durable receipt") + } + health, err := client.Health(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + if health.Ops == nil || health.Ops.DatabasePoints != 15 || health.Ops.DatabaseCommits != 1 { + t.Fatalf("copy has gaps or duplicates: %+v", health) + } + _ = client.Close() + stop(syscall.SIGTERM) + framePath := filepath.Join(root, "expected.hex") + if err := os.WriteFile(framePath, []byte(hex.EncodeToString(wire[0])+"\n"), 0600); err != nil { + t.Fatal(err) + } + output, err := exec.Command(reconcile, store, framePath).CombinedOutput() + if err != nil || !strings.Contains(string(output), `"content_matches":true`) { + t.Fatalf("reconcile failed: %v\n%s", err, output) + } +} diff --git a/go/internal/ftwdbshadow/client.go b/go/internal/ftwdbshadow/client.go new file mode 100644 index 00000000..548966c2 --- /dev/null +++ b/go/internal/ftwdbshadow/client.go @@ -0,0 +1,636 @@ +package ftwdbshadow + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "sync" + "time" +) + +type FailureKind string + +const ( + FailureTransport FailureKind = "transport" + FailureProtocol FailureKind = "protocol" + FailureContract FailureKind = "contract" + FailureClosed FailureKind = "closed" +) + +// ClientError classifies local failures without parsing text. +type ClientError struct { + Operation string + Kind FailureKind + CanRetry bool + Err error +} + +func (e *ClientError) Error() string { + if e.Err == nil { + return fmt.Sprintf("ftwdb shadow %s failed: %s", e.Operation, e.Kind) + } + return fmt.Sprintf("ftwdb shadow %s failed: %s: %v", e.Operation, e.Kind, e.Err) +} + +func (e *ClientError) Unwrap() error { return e.Err } + +func (e *ClientError) Retryable() bool { return e.CanRetry } + +// RemoteError is the sidecar's stable error code and retry decision. +type RemoteError struct { + Code ErrorCode + CanRetry bool + Message string +} + +func (e *RemoteError) Error() string { + return fmt.Sprintf("ftwdb shadow sidecar error %d: %s", e.Code, e.Message) +} + +func (e *RemoteError) Retryable() bool { return e.CanRetry } + +// ShouldRetry returns the explicit decision carried by a client or sidecar +// error. Unknown errors are not retryable. +func ShouldRetry(err error) bool { + var value interface{ Retryable() bool } + return errors.As(err, &value) && value.Retryable() +} + +type ClientConfig struct { + SocketPath string + SourceID ID128 + NodeID string + ClientVersion string + Capabilities uint64 + IOTimeout time.Duration +} + +func (config ClientConfig) validate() error { + switch { + case config.SocketPath == "": + return fmt.Errorf("socket path is required") + case config.SourceID.IsZero(): + return fmt.Errorf("source id is required") + case config.NodeID == "": + return fmt.Errorf("node id is required") + case config.ClientVersion == "": + return fmt.Errorf("client version is required") + case config.IOTimeout <= 0: + return fmt.Errorf("I/O timeout must be positive") + default: + return nil + } +} + +// Client owns one source-bound sidecar stream. Calls are serialized because +// each request has exactly one response. +type Client struct { + mu sync.Mutex + conn net.Conn + sourceID ID128 + ioTimeout time.Duration + closed bool +} + +// Connect opens a Unix stream and completes HELLO under one absolute deadline. +func Connect(ctx context.Context, config ClientConfig) (*Client, HelloResponse, error) { + if err := config.validate(); err != nil { + return nil, HelloResponse{}, clientFailure("connect", FailureContract, false, err) + } + helloFrame, err := Encode(HelloRequest{ + SourceID: config.SourceID, + NodeID: config.NodeID, + ClientVersion: config.ClientVersion, + Capabilities: config.Capabilities, + }) + if err != nil { + return nil, HelloResponse{}, clientFailure("connect", FailureContract, false, err) + } + deadline, dialContext, cancel, err := operationDeadline(ctx, config.IOTimeout) + if err != nil { + return nil, HelloResponse{}, clientFailure("connect", FailureTransport, false, err) + } + defer cancel() + + var dialer net.Dialer + conn, err := dialer.DialContext(dialContext, "unix", config.SocketPath) + if err != nil { + return nil, HelloResponse{}, clientFailure("connect", FailureTransport, retryableContext(ctx), err) + } + if err := conn.SetDeadline(deadline); err != nil { + _ = conn.Close() + return nil, HelloResponse{}, clientFailure("hello", FailureTransport, true, err) + } + stopCancellation := interruptOnCancel(ctx, conn) + response, err := exchangeEncodedFrame(conn, helloFrame) + stopCancellation() + if err != nil { + _ = conn.Close() + return nil, HelloResponse{}, classifyExchangeError("hello", ctx, err) + } + hello, ok := response.(HelloResponse) + if !ok { + _ = conn.Close() + return nil, HelloResponse{}, unexpectedResponse("hello", response) + } + if hello.SelectedVersion != ProtocolVersion { + _ = conn.Close() + return nil, HelloResponse{}, clientFailure( + "hello", + FailureContract, + false, + fmt.Errorf("sidecar selected protocol %d, want %d", hello.SelectedVersion, ProtocolVersion), + ) + } + if err := conn.SetDeadline(time.Time{}); err != nil { + _ = conn.Close() + return nil, HelloResponse{}, clientFailure("hello", FailureTransport, true, err) + } + return &Client{ + conn: conn, + sourceID: config.SourceID, + ioTimeout: config.IOTimeout, + }, hello, nil +} + +func (client *Client) SourceID() ID128 { + return client.sourceID +} + +// PreparedCommit keeps the exact bytes used as the retry and idempotency key. +// Call PrepareCommit once, then reuse the value until the sidecar accepts it. +type PreparedCommit struct { + sourceID ID128 + sequence uint64 + commitID ID128 + frame []byte +} + +func PrepareCommit(batch CommitBatchRequest) (PreparedCommit, error) { + frame, err := Encode(batch) + if err != nil { + return PreparedCommit{}, err + } + return PreparedCommit{ + sourceID: batch.SourceID, + sequence: batch.Sequence, + commitID: batch.CommitID, + frame: frame, + }, nil +} + +// PreparedCommitFromFrame validates and copies an already encoded commit. +func PreparedCommitFromFrame(frame []byte) (PreparedCommit, error) { + message, err := Decode(frame) + if err != nil { + return PreparedCommit{}, err + } + batch, ok := message.(CommitBatchRequest) + if !ok { + return PreparedCommit{}, protocolError(ProtocolInvalidField, "frame", "is not a commit request") + } + return PreparedCommit{ + sourceID: batch.SourceID, + sequence: batch.Sequence, + commitID: batch.CommitID, + frame: append([]byte(nil), frame...), + }, nil +} + +func (prepared PreparedCommit) SourceID() ID128 { return prepared.sourceID } + +func (prepared PreparedCommit) Sequence() uint64 { return prepared.sequence } + +func (prepared PreparedCommit) CommitID() ID128 { return prepared.commitID } + +func (prepared PreparedCommit) Bytes() []byte { + return append([]byte(nil), prepared.frame...) +} + +// Commit encodes and sends one batch. Use PrepareCommit and CommitPrepared +// when the caller may need an exact retry after an unknown transport result. +func (client *Client) Commit(ctx context.Context, batch CommitBatchRequest) (Ack, error) { + prepared, err := PrepareCommit(batch) + if err != nil { + return Ack{}, clientFailure("commit", FailureProtocol, false, err) + } + return client.CommitPrepared(ctx, prepared) +} + +func (client *Client) CommitPrepared(ctx context.Context, prepared PreparedCommit) (Ack, error) { + client.mu.Lock() + defer client.mu.Unlock() + deadline, stopCancellation, err := client.beginLocked(ctx, "commit") + if err != nil { + return Ack{}, err + } + defer func() { + stopCancellation() + client.endLocked() + }() + return client.commitPreparedLocked(ctx, deadline, prepared) +} + +// DurableCommitResult proves that the sidecar synced through Sequence. +type DurableCommitResult struct { + Commit Ack + Flush *Ack + DurableThrough uint64 +} + +// CommitDurable sends exact prepared bytes and flushes only when the commit +// acknowledgement is not yet durable. The commit and optional flush share one +// absolute deadline. +func (client *Client) CommitDurable(ctx context.Context, prepared PreparedCommit) (DurableCommitResult, error) { + client.mu.Lock() + defer client.mu.Unlock() + deadline, stopCancellation, err := client.beginLocked(ctx, "commit-durable") + if err != nil { + return DurableCommitResult{}, err + } + defer func() { + stopCancellation() + client.endLocked() + }() + + commit, err := client.commitPreparedLocked(ctx, deadline, prepared) + if err != nil { + return DurableCommitResult{}, err + } + if watermarkAtLeast(commit.DurableThroughSequence, prepared.sequence) { + return DurableCommitResult{ + Commit: commit, + DurableThrough: *commit.DurableThroughSequence, + }, nil + } + flush, err := client.flushLocked(ctx, deadline, prepared.sequence) + if err != nil { + return DurableCommitResult{}, err + } + return DurableCommitResult{ + Commit: commit, + Flush: &flush, + DurableThrough: *flush.DurableThroughSequence, + }, nil +} + +func (client *Client) Flush(ctx context.Context, throughSequence uint64) (Ack, error) { + client.mu.Lock() + defer client.mu.Unlock() + deadline, stopCancellation, err := client.beginLocked(ctx, "flush") + if err != nil { + return Ack{}, err + } + defer func() { + stopCancellation() + client.endLocked() + }() + return client.flushLocked(ctx, deadline, throughSequence) +} + +func (client *Client) Health(ctx context.Context, nonce uint64) (HealthResponse, error) { + client.mu.Lock() + defer client.mu.Unlock() + _, stopCancellation, err := client.beginLocked(ctx, "health") + if err != nil { + return HealthResponse{}, err + } + defer func() { + stopCancellation() + client.endLocked() + }() + + response, err := exchangeFrame(client.conn, HealthRequest{Nonce: nonce}) + if err != nil { + return HealthResponse{}, client.exchangeFailedLocked("health", ctx, err) + } + health, ok := response.(HealthResponse) + if !ok { + return HealthResponse{}, client.contractFailedLocked("health", response) + } + if health.Nonce != nonce { + return HealthResponse{}, client.contractErrorLocked( + "health", + fmt.Errorf("nonce %d, want %d", health.Nonce, nonce), + ) + } + if health.SourceID != client.sourceID { + return HealthResponse{}, client.contractErrorLocked( + "health", + fmt.Errorf("source %s, want %s", health.SourceID, client.sourceID), + ) + } + if err := validateWatermarks(health.AcceptedThroughSequence, health.DurableThroughSequence); err != nil { + return HealthResponse{}, client.contractErrorLocked("health", err) + } + return health, nil +} + +func (client *Client) Close() error { + client.mu.Lock() + defer client.mu.Unlock() + if client.closed { + return nil + } + client.closed = true + return client.conn.Close() +} + +func (client *Client) commitPreparedLocked( + ctx context.Context, + deadline time.Time, + prepared PreparedCommit, +) (Ack, error) { + if prepared.sourceID != client.sourceID { + return Ack{}, clientFailure( + "commit", + FailureContract, + false, + fmt.Errorf("prepared source %s, want %s", prepared.sourceID, client.sourceID), + ) + } + if len(prepared.frame) == 0 { + return Ack{}, clientFailure("commit", FailureContract, false, fmt.Errorf("prepared frame is empty")) + } + if err := client.conn.SetDeadline(deadline); err != nil { + return Ack{}, client.transportFailedLocked("commit", ctx, err) + } + response, err := exchangeEncodedFrame(client.conn, prepared.frame) + if err != nil { + return Ack{}, client.exchangeFailedLocked("commit", ctx, err) + } + ack, ok := response.(Ack) + if !ok { + return Ack{}, client.contractFailedLocked("commit", response) + } + if ack.Kind != AckCommitBatch { + return Ack{}, client.contractErrorLocked("commit", fmt.Errorf("ack kind %d, want commit", ack.Kind)) + } + if ack.SourceID != client.sourceID { + return Ack{}, client.contractErrorLocked( + "commit", + fmt.Errorf("source %s, want %s", ack.SourceID, client.sourceID), + ) + } + if ack.Sequence != prepared.sequence { + return Ack{}, client.contractErrorLocked( + "commit", + fmt.Errorf("sequence %d, want %d", ack.Sequence, prepared.sequence), + ) + } + if ack.CommitID != prepared.commitID { + return Ack{}, client.contractErrorLocked( + "commit", + fmt.Errorf("commit id %s, want %s", ack.CommitID, prepared.commitID), + ) + } + if !watermarkAtLeast(ack.AcceptedThroughSequence, prepared.sequence) { + return Ack{}, client.contractErrorLocked( + "commit", + fmt.Errorf("accepted watermark does not cover sequence %d", prepared.sequence), + ) + } + if err := validateWatermarks(ack.AcceptedThroughSequence, ack.DurableThroughSequence); err != nil { + return Ack{}, client.contractErrorLocked("commit", err) + } + if ack.Durable && !watermarkAtLeast(ack.DurableThroughSequence, prepared.sequence) { + return Ack{}, client.contractErrorLocked( + "commit", + fmt.Errorf("durable ack does not cover sequence %d", prepared.sequence), + ) + } + return ack, nil +} + +func (client *Client) flushLocked(ctx context.Context, deadline time.Time, throughSequence uint64) (Ack, error) { + if err := client.conn.SetDeadline(deadline); err != nil { + return Ack{}, client.transportFailedLocked("flush", ctx, err) + } + response, err := exchangeFrame(client.conn, FlushRequest{ + SourceID: client.sourceID, + ThroughSequence: throughSequence, + }) + if err != nil { + return Ack{}, client.exchangeFailedLocked("flush", ctx, err) + } + ack, ok := response.(Ack) + if !ok { + return Ack{}, client.contractFailedLocked("flush", response) + } + if ack.Kind != AckFlush { + return Ack{}, client.contractErrorLocked("flush", fmt.Errorf("ack kind %d, want flush", ack.Kind)) + } + if ack.SourceID != client.sourceID { + return Ack{}, client.contractErrorLocked( + "flush", + fmt.Errorf("source %s, want %s", ack.SourceID, client.sourceID), + ) + } + if ack.Sequence != throughSequence { + return Ack{}, client.contractErrorLocked( + "flush", + fmt.Errorf("sequence %d, want %d", ack.Sequence, throughSequence), + ) + } + if !ack.CommitID.IsZero() { + return Ack{}, client.contractErrorLocked("flush", fmt.Errorf("flush commit id is not zero")) + } + if err := validateWatermarks(ack.AcceptedThroughSequence, ack.DurableThroughSequence); err != nil { + return Ack{}, client.contractErrorLocked("flush", err) + } + if !ack.Durable || !watermarkAtLeast(ack.DurableThroughSequence, throughSequence) { + return Ack{}, client.contractErrorLocked( + "flush", + fmt.Errorf("durable watermark does not cover sequence %d", throughSequence), + ) + } + return ack, nil +} + +func (client *Client) beginLocked( + ctx context.Context, + operation string, +) (time.Time, func(), error) { + if client.closed { + return time.Time{}, nil, clientFailure(operation, FailureClosed, false, net.ErrClosed) + } + deadline, _, cancel, err := operationDeadline(ctx, client.ioTimeout) + if err != nil { + return time.Time{}, nil, clientFailure(operation, FailureTransport, false, err) + } + cancel() + if err := client.conn.SetDeadline(deadline); err != nil { + return time.Time{}, nil, client.transportFailedLocked(operation, ctx, err) + } + return deadline, interruptOnCancel(ctx, client.conn), nil +} + +func (client *Client) endLocked() { + if client.closed { + return + } + if err := client.conn.SetDeadline(time.Time{}); err != nil { + client.closed = true + _ = client.conn.Close() + } +} + +func (client *Client) exchangeFailedLocked(operation string, ctx context.Context, err error) error { + var remote *RemoteError + if errors.As(err, &remote) { + return remote + } + var protocol *ProtocolError + if errors.As(err, &protocol) && protocol.Kind != ProtocolIO && protocol.Kind != ProtocolTruncated { + client.closed = true + _ = client.conn.Close() + return clientFailure(operation, FailureProtocol, false, err) + } + return client.transportFailedLocked(operation, ctx, err) +} + +func (client *Client) transportFailedLocked(operation string, ctx context.Context, err error) error { + client.closed = true + _ = client.conn.Close() + if contextErr := ctx.Err(); contextErr != nil { + err = contextErr + } + return clientFailure(operation, FailureTransport, retryableContext(ctx), err) +} + +func (client *Client) contractFailedLocked(operation string, response Message) error { + return client.contractErrorLocked(operation, fmt.Errorf("unexpected response %T", response)) +} + +func (client *Client) contractErrorLocked(operation string, err error) error { + client.closed = true + _ = client.conn.Close() + return clientFailure(operation, FailureContract, false, err) +} + +func clientFailure(operation string, kind FailureKind, retryable bool, err error) error { + return &ClientError{ + Operation: operation, + Kind: kind, + CanRetry: retryable, + Err: err, + } +} + +func classifyExchangeError(operation string, ctx context.Context, err error) error { + var remote *RemoteError + if errors.As(err, &remote) { + return remote + } + var protocol *ProtocolError + if errors.As(err, &protocol) && protocol.Kind != ProtocolIO && protocol.Kind != ProtocolTruncated { + return clientFailure(operation, FailureProtocol, false, err) + } + if contextErr := ctx.Err(); contextErr != nil { + err = contextErr + } + return clientFailure(operation, FailureTransport, retryableContext(ctx), err) +} + +func unexpectedResponse(operation string, response Message) error { + return clientFailure( + operation, + FailureContract, + false, + fmt.Errorf("unexpected response %T", response), + ) +} + +func exchangeFrame(conn net.Conn, request Message) (Message, error) { + frame, err := Encode(request) + if err != nil { + return nil, err + } + return exchangeEncodedFrame(conn, frame) +} + +func exchangeEncodedFrame(conn net.Conn, frame []byte) (Message, error) { + if err := writeAll(conn, frame); err != nil { + return nil, &ProtocolError{Kind: ProtocolIO, Err: err} + } + response, err := ReadMessage(conn) + if err != nil { + return nil, err + } + if remote, ok := response.(ErrorResponse); ok { + return nil, &RemoteError{ + Code: remote.Code, + CanRetry: remote.Retryable, + Message: remote.Message, + } + } + return response, nil +} + +func writeAll(writer io.Writer, value []byte) error { + for len(value) > 0 { + written, err := writer.Write(value) + if written < 0 || written > len(value) { + return io.ErrShortWrite + } + value = value[written:] + if err != nil { + return err + } + if written == 0 { + return io.ErrNoProgress + } + } + return nil +} + +func operationDeadline( + ctx context.Context, + timeout time.Duration, +) (time.Time, context.Context, context.CancelFunc, error) { + if err := ctx.Err(); err != nil { + return time.Time{}, nil, nil, err + } + deadline := time.Now().Add(timeout) + if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) { + deadline = contextDeadline + } + if !deadline.After(time.Now()) { + return time.Time{}, nil, nil, context.DeadlineExceeded + } + dialContext, cancel := context.WithDeadline(ctx, deadline) + return deadline, dialContext, cancel, nil +} + +func interruptOnCancel(ctx context.Context, conn net.Conn) func() { + done := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { + _ = conn.SetDeadline(time.Now()) + close(done) + }) + return func() { + if !stop() { + <-done + } + } +} + +func retryableContext(ctx context.Context) bool { + return ctx.Err() == nil +} + +func watermarkAtLeast(watermark *uint64, sequence uint64) bool { + return watermark != nil && *watermark >= sequence +} + +func validateWatermarks(accepted, durable *uint64) error { + if accepted == nil || durable == nil { + return nil + } + if *durable > *accepted { + return fmt.Errorf("durable watermark %d exceeds accepted watermark %d", *durable, *accepted) + } + return nil +} diff --git a/go/internal/ftwdbshadow/client_unix_test.go b/go/internal/ftwdbshadow/client_unix_test.go new file mode 100644 index 00000000..c148da86 --- /dev/null +++ b/go/internal/ftwdbshadow/client_unix_test.go @@ -0,0 +1,506 @@ +//go:build !windows + +package ftwdbshadow + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "testing" + "time" +) + +func TestClientHealthBindsSource(t *testing.T) { + t.Parallel() + source := mustID(t, "00112233445566778899aabbccddeeff") + listener := listenUnix(t) + server := runServer(listener, func(conn net.Conn) error { + if err := serverHello(conn, source); err != nil { + return err + } + message, err := ReadMessage(conn) + if err != nil { + return err + } + request, ok := message.(HealthRequest) + if !ok { + return fmt.Errorf("got %T, want HealthRequest", message) + } + accepted := uint64(90) + durable := uint64(80) + return WriteMessage(conn, HealthResponse{ + Nonce: request.Nonce, + SourceID: source, + Status: HealthHealthy, + QueueEntries: 2, + AcceptedThroughSequence: &accepted, + DurableThroughSequence: &durable, + }) + }) + + client := connectTestClient(t, listener.Addr().String(), source, time.Second) + defer client.Close() + health, err := client.Health(context.Background(), 55) + if err != nil { + t.Fatal(err) + } + if health.Nonce != 55 || health.SourceID != source { + t.Fatalf("unexpected health response: %#v", health) + } + if health.DurableThroughSequence == nil || *health.DurableThroughSequence != 80 { + t.Fatalf("durable watermark %#v, want 80", health.DurableThroughSequence) + } + waitServer(t, server) +} + +func TestClientCommitDurableFlushesNonDurableAck(t *testing.T) { + t.Parallel() + source := mustID(t, "00112233445566778899aabbccddeeff") + prepared := prepareTestCommit(t, source, 42) + listener := listenUnix(t) + server := runServer(listener, func(conn net.Conn) error { + if err := serverHello(conn, source); err != nil { + return err + } + frame, message, err := readRawFrame(conn) + if err != nil { + return err + } + if !bytes.Equal(frame, prepared.Bytes()) { + return fmt.Errorf("commit frame differs from prepared bytes") + } + batch, ok := message.(CommitBatchRequest) + if !ok { + return fmt.Errorf("got %T, want CommitBatchRequest", message) + } + accepted := batch.Sequence + if err := WriteMessage(conn, Ack{ + Kind: AckCommitBatch, + SourceID: source, + Sequence: batch.Sequence, + CommitID: batch.CommitID, + AcceptedThroughSequence: &accepted, + Points: 1, + }); err != nil { + return err + } + message, err = ReadMessage(conn) + if err != nil { + return err + } + flush, ok := message.(FlushRequest) + if !ok { + return fmt.Errorf("got %T, want FlushRequest", message) + } + durable := flush.ThroughSequence + return WriteMessage(conn, Ack{ + Kind: AckFlush, + SourceID: source, + Sequence: flush.ThroughSequence, + AcceptedThroughSequence: &accepted, + DurableThroughSequence: &durable, + Durable: true, + }) + }) + + client := connectTestClient(t, listener.Addr().String(), source, time.Second) + defer client.Close() + result, err := client.CommitDurable(context.Background(), prepared) + if err != nil { + t.Fatal(err) + } + if result.Flush == nil { + t.Fatal("non-durable commit did not cause a flush") + } + if result.DurableThrough != prepared.Sequence() { + t.Fatalf("durable through %d, want %d", result.DurableThrough, prepared.Sequence()) + } + waitServer(t, server) +} + +func TestClientRejectsLocalSourceMismatchWithoutWriting(t *testing.T) { + t.Parallel() + source := mustID(t, "00112233445566778899aabbccddeeff") + other := mustID(t, "10112233445566778899aabbccddeeff") + prepared := prepareTestCommit(t, other, 1) + listener := listenUnix(t) + server := runServer(listener, func(conn net.Conn) error { + if err := serverHello(conn, source); err != nil { + return err + } + message, err := ReadMessage(conn) + if err != nil { + return err + } + request, ok := message.(HealthRequest) + if !ok { + return fmt.Errorf("source-mismatched commit reached wire as %T", message) + } + return WriteMessage(conn, HealthResponse{ + Nonce: request.Nonce, + SourceID: source, + Status: HealthHealthy, + }) + }) + + client := connectTestClient(t, listener.Addr().String(), source, time.Second) + defer client.Close() + _, err := client.CommitPrepared(context.Background(), prepared) + var clientError *ClientError + if !errors.As(err, &clientError) || clientError.Kind != FailureContract || ShouldRetry(err) { + t.Fatalf("source mismatch error = %#v, want stable contract failure", err) + } + if _, err := client.Health(context.Background(), 9); err != nil { + t.Fatalf("connection was not reusable after local rejection: %v", err) + } + waitServer(t, server) +} + +func TestClientKeepsRemoteRetryDecisionStable(t *testing.T) { + t.Parallel() + source := mustID(t, "00112233445566778899aabbccddeeff") + prepared := prepareTestCommit(t, source, 4) + listener := listenUnix(t) + server := runServer(listener, func(conn net.Conn) error { + if err := serverHello(conn, source); err != nil { + return err + } + if _, err := ReadMessage(conn); err != nil { + return err + } + if err := WriteMessage(conn, ErrorResponse{ + Code: ErrorOverloaded, + Retryable: true, + Message: "shadow writer overloaded", + }); err != nil { + return err + } + message, err := ReadMessage(conn) + if err != nil { + return err + } + request, ok := message.(HealthRequest) + if !ok { + return fmt.Errorf("got %T, want HealthRequest", message) + } + return WriteMessage(conn, HealthResponse{ + Nonce: request.Nonce, + SourceID: source, + Status: HealthDegraded, + }) + }) + + client := connectTestClient(t, listener.Addr().String(), source, time.Second) + defer client.Close() + _, err := client.CommitPrepared(context.Background(), prepared) + var remote *RemoteError + if !errors.As(err, &remote) || remote.Code != ErrorOverloaded || !ShouldRetry(err) { + t.Fatalf("remote error = %#v, want retryable overload", err) + } + if _, err := client.Health(context.Background(), 3); err != nil { + t.Fatalf("remote rejection broke stream: %v", err) + } + waitServer(t, server) +} + +func TestClientFrameDeadlineIsAbsolute(t *testing.T) { + source := mustID(t, "00112233445566778899aabbccddeeff") + listener := listenUnix(t) + server := runServer(listener, func(conn net.Conn) error { + if err := serverHello(conn, source); err != nil { + return err + } + message, err := ReadMessage(conn) + if err != nil { + return err + } + request, ok := message.(HealthRequest) + if !ok { + return fmt.Errorf("got %T, want HealthRequest", message) + } + frame, err := Encode(HealthResponse{ + Nonce: request.Nonce, + SourceID: source, + Status: HealthHealthy, + }) + if err != nil { + return err + } + for _, value := range frame { + if _, err := conn.Write([]byte{value}); err != nil { + return nil + } + time.Sleep(40 * time.Millisecond) + } + return nil + }) + + const timeout = 150 * time.Millisecond + client := connectTestClient(t, listener.Addr().String(), source, timeout) + defer client.Close() + started := time.Now() + _, err := client.Health(context.Background(), 1) + elapsed := time.Since(started) + if err == nil { + t.Fatal("slow frame passed the absolute deadline") + } + if !ShouldRetry(err) { + t.Fatalf("deadline error is not retryable: %v", err) + } + if elapsed > 5*timeout { + t.Fatalf("frame took %v, want an absolute deadline near %v", elapsed, timeout) + } + waitServer(t, server) +} + +func TestClientContextCancellationInterruptsIO(t *testing.T) { + t.Parallel() + source := mustID(t, "00112233445566778899aabbccddeeff") + listener := listenUnix(t) + requestSeen := make(chan struct{}) + server := runServer(listener, func(conn net.Conn) error { + if err := serverHello(conn, source); err != nil { + return err + } + if _, err := ReadMessage(conn); err != nil { + return err + } + close(requestSeen) + _, err := io.Copy(io.Discard, conn) + return err + }) + + client := connectTestClient(t, listener.Addr().String(), source, 5*time.Second) + defer client.Close() + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { + _, err := client.Health(ctx, 1) + result <- err + }() + <-requestSeen + started := time.Now() + cancel() + select { + case err := <-result: + if err == nil || ShouldRetry(err) || !errors.Is(err, context.Canceled) { + t.Fatalf("cancel error = %v, want non-retryable", err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("context cancellation did not interrupt socket I/O") + } + if elapsed := time.Since(started); elapsed > 500*time.Millisecond { + t.Fatalf("cancellation took %v", elapsed) + } + waitServer(t, server) +} + +func TestPreparedCommitRetriesExactBytesAfterUnknownResult(t *testing.T) { + t.Parallel() + source := mustID(t, "00112233445566778899aabbccddeeff") + prepared := prepareTestCommit(t, source, 73) + listener := listenUnix(t) + server := runServer(listener, func(first net.Conn) error { + if err := serverHello(first, source); err != nil { + return err + } + firstFrame, _, err := readRawFrame(first) + if err != nil { + return err + } + if err := first.Close(); err != nil { + return err + } + + second, err := listener.Accept() + if err != nil { + return err + } + defer second.Close() + if err := serverHello(second, source); err != nil { + return err + } + message, err := ReadMessage(second) + if err != nil { + return err + } + health, ok := message.(HealthRequest) + if !ok { + return fmt.Errorf("got %T, want HealthRequest", message) + } + if err := WriteMessage(second, HealthResponse{ + Nonce: health.Nonce, + SourceID: source, + Status: HealthHealthy, + }); err != nil { + return err + } + secondFrame, message, err := readRawFrame(second) + if err != nil { + return err + } + if !bytes.Equal(firstFrame, secondFrame) || !bytes.Equal(secondFrame, prepared.Bytes()) { + return fmt.Errorf("retry changed prepared frame bytes") + } + batch, ok := message.(CommitBatchRequest) + if !ok { + return fmt.Errorf("got %T, want CommitBatchRequest", message) + } + accepted := batch.Sequence + durable := batch.Sequence + return WriteMessage(second, Ack{ + Kind: AckCommitBatch, + SourceID: source, + Sequence: batch.Sequence, + CommitID: batch.CommitID, + AcceptedThroughSequence: &accepted, + DurableThroughSequence: &durable, + Durable: true, + Points: 1, + }) + }) + + first := connectTestClient(t, listener.Addr().String(), source, time.Second) + if _, err := first.CommitPrepared(context.Background(), prepared); err == nil || !ShouldRetry(err) { + t.Fatalf("unknown first result error = %v, want retryable", err) + } + _ = first.Close() + + second := connectTestClient(t, listener.Addr().String(), source, time.Second) + defer second.Close() + health, err := second.Health(context.Background(), 10) + if err != nil { + t.Fatal(err) + } + if health.DurableThroughSequence != nil { + t.Fatalf("durable watermark %#v, want nil", health.DurableThroughSequence) + } + ack, err := second.CommitPrepared(context.Background(), prepared) + if err != nil { + t.Fatal(err) + } + if !ack.Durable { + t.Fatal("retry did not receive a durable ack") + } + waitServer(t, server) +} + +func listenUnix(t *testing.T) *net.UnixListener { + t.Helper() + directory, err := os.MkdirTemp("/tmp", "ftwdbshadow-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(directory) }) + path := filepath.Join(directory, "shadow.sock") + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + return listener +} + +func connectTestClient(t *testing.T, path string, source ID128, timeout time.Duration) *Client { + t.Helper() + client, hello, err := Connect(context.Background(), ClientConfig{ + SocketPath: path, + SourceID: source, + NodeID: "test-box", + ClientVersion: "go-test", + IOTimeout: timeout, + }) + if err != nil { + t.Fatal(err) + } + if hello.SelectedVersion != ProtocolVersion { + t.Fatalf("selected version %d", hello.SelectedVersion) + } + return client +} + +func prepareTestCommit(t *testing.T, source ID128, sequence uint64) PreparedCommit { + t.Helper() + commit := mustID(t, "ffeeddccbbaa99887766554433221100") + prepared, err := PrepareCommit(CommitBatchRequest{ + SourceID: source, + Sequence: sequence, + CommitID: commit, + Points: []Point{{ + SeriesID: 1, + Value: -100, + }}, + }) + if err != nil { + t.Fatal(err) + } + return prepared +} + +func runServer(listener net.Listener, handler func(net.Conn) error) <-chan error { + result := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + result <- err + return + } + defer conn.Close() + result <- handler(conn) + }() + return result +} + +func waitServer(t *testing.T, result <-chan error) { + t.Helper() + select { + case err := <-result: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("test sidecar did not stop") + } +} + +func serverHello(conn net.Conn, source ID128) error { + message, err := ReadMessage(conn) + if err != nil { + return err + } + hello, ok := message.(HelloRequest) + if !ok { + return fmt.Errorf("got %T, want HelloRequest", message) + } + if hello.SourceID != source { + return fmt.Errorf("hello source %s, want %s", hello.SourceID, source) + } + return WriteMessage(conn, HelloResponse{ + SelectedVersion: ProtocolVersion, + SessionID: source, + }) +} + +func readRawFrame(reader io.Reader) ([]byte, Message, error) { + header := make([]byte, headerBytes) + if _, err := io.ReadFull(reader, header); err != nil { + return nil, nil, err + } + payload := int(binary.BigEndian.Uint32(header[8:12])) + if payload > maxPayloadBytes { + return nil, nil, fmt.Errorf("test received oversized payload %d", payload) + } + frame := make([]byte, headerBytes+payload+checksumBytes) + copy(frame, header) + if _, err := io.ReadFull(reader, frame[headerBytes:]); err != nil { + return nil, nil, err + } + message, err := Decode(frame) + return frame, message, err +} diff --git a/go/internal/ftwdbshadow/codec.go b/go/internal/ftwdbshadow/codec.go new file mode 100644 index 00000000..5d30abb1 --- /dev/null +++ b/go/internal/ftwdbshadow/codec.go @@ -0,0 +1,1580 @@ +package ftwdbshadow + +import ( + "encoding/binary" + "errors" + "fmt" + "hash/crc32" + "io" + "math" + "slices" + "strings" + "unicode/utf8" +) + +const ( + headerBytes = 12 + checksumBytes = 4 + maxPayloadBytes = MaxFrameBytes - headerBytes - checksumBytes + maxKeyBytes = 256 + maxErrorTextBytes = 512 + pointBytes = 72 +) + +type ProtocolErrorKind string + +const ( + ProtocolIO ProtocolErrorKind = "io" + ProtocolTruncated ProtocolErrorKind = "truncated" + ProtocolTrailingBytes ProtocolErrorKind = "trailing-bytes" + ProtocolInvalidMagic ProtocolErrorKind = "invalid-magic" + ProtocolUnsupportedVersion ProtocolErrorKind = "unsupported-version" + ProtocolUnknownMessage ProtocolErrorKind = "unknown-message" + ProtocolReservedBits ProtocolErrorKind = "reserved-bits" + ProtocolFrameTooLarge ProtocolErrorKind = "frame-too-large" + ProtocolChecksum ProtocolErrorKind = "checksum" + ProtocolInvalidField ProtocolErrorKind = "invalid-field" + ProtocolInvalidEnum ProtocolErrorKind = "invalid-enum" +) + +// ProtocolError has a stable Kind that callers and tests can match. +type ProtocolError struct { + Kind ProtocolErrorKind + Field string + Value uint64 + Detail string + Err error +} + +func (e *ProtocolError) Error() string { + switch { + case e.Err != nil: + return fmt.Sprintf("ftwdb shadow protocol %s: %v", e.Kind, e.Err) + case e.Field != "" && e.Detail != "": + return fmt.Sprintf("ftwdb shadow protocol %s for %s: %s", e.Kind, e.Field, e.Detail) + case e.Field != "": + return fmt.Sprintf("ftwdb shadow protocol %s for %s", e.Kind, e.Field) + case e.Detail != "": + return fmt.Sprintf("ftwdb shadow protocol %s: %s", e.Kind, e.Detail) + default: + return fmt.Sprintf("ftwdb shadow protocol %s", e.Kind) + } +} + +func (e *ProtocolError) Unwrap() error { return e.Err } + +func protocolError(kind ProtocolErrorKind, field, detail string) error { + return &ProtocolError{Kind: kind, Field: field, Detail: detail} +} + +// Encode returns one checksummed v1 frame. +func Encode(message Message) ([]byte, error) { + if message == nil { + return nil, protocolError(ProtocolInvalidField, "message", "must not be nil") + } + payload := make([]byte, 0, 256) + kind, err := encodePayload(&payload, message) + if err != nil { + return nil, err + } + if len(payload) > maxPayloadBytes { + return nil, frameTooLarge(uint64(len(payload) + headerBytes + checksumBytes)) + } + frame := make([]byte, 0, headerBytes+len(payload)+checksumBytes) + frame = append(frame, frameMagic[:]...) + frame = binary.BigEndian.AppendUint16(frame, ProtocolVersion) + frame = append(frame, byte(kind), 0) + frame = binary.BigEndian.AppendUint32(frame, uint32(len(payload))) + frame = append(frame, payload...) + frame = binary.BigEndian.AppendUint32(frame, crc32.ChecksumIEEE(frame)) + return frame, nil +} + +// Decode validates one complete v1 frame. +func Decode(frame []byte) (Message, error) { + kind, total, err := parseHeader(frame) + if err != nil { + return nil, err + } + if len(frame) < total { + return nil, &ProtocolError{ + Kind: ProtocolTruncated, + Detail: fmt.Sprintf("expected %d bytes, got %d", total, len(frame)), + } + } + if len(frame) > total { + return nil, &ProtocolError{ + Kind: ProtocolTrailingBytes, + Detail: fmt.Sprintf("%d extra bytes", len(frame)-total), + } + } + actual := binary.BigEndian.Uint32(frame[total-checksumBytes:]) + expected := crc32.ChecksumIEEE(frame[:total-checksumBytes]) + if actual != expected { + return nil, &ProtocolError{ + Kind: ProtocolChecksum, + Detail: fmt.Sprintf("expected %#08x, got %#08x", expected, actual), + } + } + return decodePayload(kind, frame[headerBytes:total-checksumBytes]) +} + +// ReadMessage reads one bounded frame. It checks the header before allocating +// the payload. +func ReadMessage(reader io.Reader) (Message, error) { + var header [headerBytes]byte + if err := readExact(reader, header[:], 0); err != nil { + return nil, err + } + _, total, err := parseHeader(header[:]) + if err != nil { + return nil, err + } + frame := make([]byte, total) + copy(frame, header[:]) + if err := readExact(reader, frame[headerBytes:], headerBytes); err != nil { + return nil, err + } + return Decode(frame) +} + +func WriteMessage(writer io.Writer, message Message) error { + frame, err := Encode(message) + if err != nil { + return err + } + if err := writeAll(writer, frame); err != nil { + return &ProtocolError{Kind: ProtocolIO, Err: err} + } + return nil +} + +func parseHeader(frame []byte) (messageKind, int, error) { + if len(frame) < headerBytes { + return 0, 0, &ProtocolError{ + Kind: ProtocolTruncated, + Detail: fmt.Sprintf("expected %d bytes, got %d", headerBytes, len(frame)), + } + } + if string(frame[:4]) != string(frameMagic[:]) { + return 0, 0, protocolError(ProtocolInvalidMagic, "", "") + } + version := binary.BigEndian.Uint16(frame[4:6]) + if version != ProtocolVersion { + return 0, 0, &ProtocolError{ + Kind: ProtocolUnsupportedVersion, + Value: uint64(version), + Detail: fmt.Sprintf("version %d", version), + } + } + kind := messageKind(frame[6]) + if !validMessageKind(kind) { + return 0, 0, &ProtocolError{ + Kind: ProtocolUnknownMessage, + Value: uint64(kind), + Detail: fmt.Sprintf("kind %d", kind), + } + } + if frame[7] != 0 { + return 0, 0, &ProtocolError{ + Kind: ProtocolReservedBits, + Value: uint64(frame[7]), + Detail: fmt.Sprintf("reserved byte %#02x", frame[7]), + } + } + payloadLength := binary.BigEndian.Uint32(frame[8:12]) + if uint64(payloadLength) > uint64(maxPayloadBytes) { + return 0, 0, frameTooLarge(uint64(payloadLength) + headerBytes + checksumBytes) + } + payload := int(payloadLength) + return kind, payload + headerBytes + checksumBytes, nil +} + +func validMessageKind(kind messageKind) bool { + switch kind { + case kindHelloRequest, kindCommitBatchRequest, kindFlushRequest, kindHealthRequest, + kindHelloResponse, kindAckResponse, kindHealthResponse, kindErrorResponse: + return true + default: + return false + } +} + +func frameTooLarge(size uint64) error { + return &ProtocolError{ + Kind: ProtocolFrameTooLarge, + Detail: fmt.Sprintf("declared %d bytes; maximum is %d", size, MaxFrameBytes), + } +} + +func encodePayload(out *[]byte, message Message) (messageKind, error) { + switch value := message.(type) { + case HelloRequest: + if value.SourceID.IsZero() { + return 0, invalidField("source_id") + } + putID(out, value.SourceID) + if err := putString(out, value.NodeID, 128, "node_id", true); err != nil { + return 0, err + } + if err := putString(out, value.ClientVersion, 64, "client_version", true); err != nil { + return 0, err + } + putUint64(out, value.Capabilities) + return kindHelloRequest, nil + case *HelloRequest: + if value == nil { + return 0, invalidField("message") + } + return encodePayload(out, *value) + case CommitBatchRequest: + if err := validateBatch(value); err != nil { + return 0, err + } + putID(out, value.SourceID) + putUint64(out, value.Sequence) + putID(out, value.CommitID) + putUint32(out, uint32(len(value.Entities))) + for _, entity := range value.Entities { + if err := encodeEntity(out, entity); err != nil { + return 0, err + } + } + putUint32(out, uint32(len(value.Relations))) + for _, relation := range value.Relations { + if err := encodeRelation(out, relation); err != nil { + return 0, err + } + } + putUint32(out, uint32(len(value.Series))) + for _, series := range value.Series { + if err := encodeSeries(out, series); err != nil { + return 0, err + } + } + putUint32(out, uint32(len(value.Runs))) + for _, run := range value.Runs { + if err := encodeRun(out, run); err != nil { + return 0, err + } + } + putUint32(out, uint32(len(value.Plans))) + for _, plan := range value.Plans { + if err := encodePlan(out, plan); err != nil { + return 0, err + } + } + putUint32(out, uint32(len(value.Points))) + for _, point := range value.Points { + encodePoint(out, point) + } + return kindCommitBatchRequest, nil + case *CommitBatchRequest: + if value == nil { + return 0, invalidField("message") + } + return encodePayload(out, *value) + case FlushRequest: + if value.SourceID.IsZero() { + return 0, invalidField("source_id") + } + putID(out, value.SourceID) + putUint64(out, value.ThroughSequence) + return kindFlushRequest, nil + case *FlushRequest: + if value == nil { + return 0, invalidField("message") + } + return encodePayload(out, *value) + case HealthRequest: + putUint64(out, value.Nonce) + return kindHealthRequest, nil + case *HealthRequest: + if value == nil { + return 0, invalidField("message") + } + return encodePayload(out, *value) + case HelloResponse: + if value.SelectedVersion != ProtocolVersion { + return 0, &ProtocolError{ + Kind: ProtocolUnsupportedVersion, + Value: uint64(value.SelectedVersion), + Detail: fmt.Sprintf("version %d", value.SelectedVersion), + } + } + putUint16(out, value.SelectedVersion) + putID(out, value.SessionID) + putInt64(out, value.ServerTimeMicros) + return kindHelloResponse, nil + case *HelloResponse: + if value == nil { + return 0, invalidField("message") + } + return encodePayload(out, *value) + case Ack: + if value.Kind != AckCommitBatch && value.Kind != AckFlush { + return 0, invalidEnum("ack kind", byte(value.Kind)) + } + if value.SourceID.IsZero() { + return 0, invalidField("source_id") + } + *out = append(*out, byte(value.Kind)) + putID(out, value.SourceID) + putUint64(out, value.Sequence) + putID(out, value.CommitID) + putOptionalUint64(out, value.AcceptedThroughSequence) + putOptionalUint64(out, value.DurableThroughSequence) + putBool(out, value.Durable) + putBool(out, value.Deduplicated) + putUint64(out, value.FrameOffset) + putUint32(out, value.Records) + putUint32(out, value.Points) + putUint64(out, value.BytesWritten) + return kindAckResponse, nil + case *Ack: + if value == nil { + return 0, invalidField("message") + } + return encodePayload(out, *value) + case HealthResponse: + if value.SourceID.IsZero() { + return 0, invalidField("source_id") + } + if !validHealthStatus(value.Status) { + return 0, invalidEnum("health status", byte(value.Status)) + } + if value.QueueEntries > MaxQueueEntries { + return 0, invalidField("queue_entries") + } + putUint64(out, value.Nonce) + putID(out, value.SourceID) + *out = append(*out, byte(value.Status)) + putUint32(out, value.QueueEntries) + putOptionalUint64(out, value.AcceptedThroughSequence) + putOptionalUint64(out, value.DurableThroughSequence) + if value.Ops != nil { + if err := encodeHealthOps(out, *value.Ops); err != nil { + return 0, err + } + } + return kindHealthResponse, nil + case *HealthResponse: + if value == nil { + return 0, invalidField("message") + } + return encodePayload(out, *value) + case ErrorResponse: + if !validErrorCode(value.Code) { + return 0, invalidEnum("error code", byte(value.Code)) + } + *out = append(*out, byte(value.Code)) + putBool(out, value.Retryable) + if err := putString(out, value.Message, maxErrorTextBytes, "error message", true); err != nil { + return 0, err + } + return kindErrorResponse, nil + case *ErrorResponse: + if value == nil { + return 0, invalidField("message") + } + return encodePayload(out, *value) + default: + return 0, protocolError(ProtocolInvalidField, "message", fmt.Sprintf("unsupported type %T", message)) + } +} + +func decodePayload(kind messageKind, payload []byte) (Message, error) { + in := input{data: payload} + var message Message + var err error + switch kind { + case kindHelloRequest: + var value HelloRequest + if value.SourceID, err = in.id(); err == nil { + value.NodeID, err = in.string(128, "node_id", true) + } + if err == nil { + value.ClientVersion, err = in.string(64, "client_version", true) + } + if err == nil { + value.Capabilities, err = in.uint64() + } + if err == nil && value.SourceID.IsZero() { + err = invalidField("source_id") + } + message = value + case kindCommitBatchRequest: + var value CommitBatchRequest + value, err = decodeBatch(&in) + message = value + case kindFlushRequest: + var value FlushRequest + if value.SourceID, err = in.id(); err == nil { + value.ThroughSequence, err = in.uint64() + } + if err == nil && value.SourceID.IsZero() { + err = invalidField("source_id") + } + message = value + case kindHealthRequest: + var value HealthRequest + value.Nonce, err = in.uint64() + message = value + case kindHelloResponse: + var value HelloResponse + if value.SelectedVersion, err = in.uint16(); err == nil && value.SelectedVersion != ProtocolVersion { + err = &ProtocolError{ + Kind: ProtocolUnsupportedVersion, + Value: uint64(value.SelectedVersion), + Detail: fmt.Sprintf("version %d", value.SelectedVersion), + } + } + if err == nil { + value.SessionID, err = in.id() + } + if err == nil { + value.ServerTimeMicros, err = in.int64() + } + message = value + case kindAckResponse: + var value Ack + var enum byte + if enum, err = in.byte(); err == nil { + value.Kind = AckKind(enum) + if value.Kind != AckCommitBatch && value.Kind != AckFlush { + err = invalidEnum("ack kind", enum) + } + } + if err == nil { + value.SourceID, err = in.id() + } + if err == nil { + value.Sequence, err = in.uint64() + } + if err == nil { + value.CommitID, err = in.id() + } + if err == nil { + value.AcceptedThroughSequence, err = in.optionalUint64("accepted watermark") + } + if err == nil { + value.DurableThroughSequence, err = in.optionalUint64("durable watermark") + } + if err == nil { + value.Durable, err = in.boolean("durable") + } + if err == nil { + value.Deduplicated, err = in.boolean("deduplicated") + } + if err == nil { + value.FrameOffset, err = in.uint64() + } + if err == nil { + value.Records, err = in.uint32() + } + if err == nil { + value.Points, err = in.uint32() + } + if err == nil { + value.BytesWritten, err = in.uint64() + } + if err == nil && value.SourceID.IsZero() { + err = invalidField("source_id") + } + message = value + case kindHealthResponse: + var value HealthResponse + if value.Nonce, err = in.uint64(); err == nil { + value.SourceID, err = in.id() + } + var enum byte + if err == nil { + enum, err = in.byte() + value.Status = HealthStatus(enum) + if err == nil && !validHealthStatus(value.Status) { + err = invalidEnum("health status", enum) + } + } + if err == nil { + value.QueueEntries, err = in.uint32() + } + if err == nil && value.QueueEntries > MaxQueueEntries { + err = invalidField("queue_entries") + } + if err == nil { + value.AcceptedThroughSequence, err = in.optionalUint64("accepted watermark") + } + if err == nil { + value.DurableThroughSequence, err = in.optionalUint64("durable watermark") + } + if err == nil && value.SourceID.IsZero() { + err = invalidField("source_id") + } + if err == nil && in.remaining() > 0 { + value.Ops, err = decodeHealthOps(&in) + } + message = value + case kindErrorResponse: + var value ErrorResponse + var enum byte + if enum, err = in.byte(); err == nil { + value.Code = ErrorCode(enum) + if !validErrorCode(value.Code) { + err = invalidEnum("error code", enum) + } + } + if err == nil { + value.Retryable, err = in.boolean("retryable") + } + if err == nil { + value.Message, err = in.string(maxErrorTextBytes, "error message", true) + } + message = value + default: + panic("validated message kind was not decoded") + } + if err != nil { + return nil, err + } + if err := in.finish(); err != nil { + return nil, err + } + return message, nil +} + +func validateBatch(value CommitBatchRequest) error { + if value.SourceID.IsZero() { + return invalidField("source_id") + } + metadata := 0 + for _, count := range [...]int{ + len(value.Entities), + len(value.Relations), + len(value.Series), + len(value.Runs), + len(value.Plans), + } { + if count > MaxMetadataRecords-metadata { + return invalidField("too many metadata records") + } + metadata += count + } + if len(value.Points) > MaxBatchPoints { + return invalidField("too many points") + } + if metadata == 0 && len(value.Points) == 0 { + return invalidField("empty transaction") + } + for _, series := range value.Series { + if err := validateSeries(series); err != nil { + return err + } + } + for _, plan := range value.Plans { + if err := validatePlan(plan); err != nil { + return err + } + } + for _, point := range value.Points { + if point.SeriesID == 0 || point.ValidTimeEnd < point.ValidTime || math.IsNaN(point.Value) || math.IsInf(point.Value, 0) { + return invalidField("invalid point") + } + } + return nil +} + +func encodeEntity(out *[]byte, value Entity) error { + putID(out, value.ID) + if err := putString(out, value.Kind, maxKeyBytes, "entity kind", true); err != nil { + return err + } + if err := putString(out, value.Name, MaxTextBytes, "entity name", true); err != nil { + return err + } + putOptionalID(out, value.Parent) + putInt64(out, value.ValidFrom) + putOptionalInt64(out, value.ValidTo) + return encodeProperties(out, value.Properties) +} + +func decodeEntity(in *input) (Entity, error) { + var value Entity + var err error + if value.ID, err = in.id(); err == nil { + value.Kind, err = in.string(maxKeyBytes, "entity kind", true) + } + if err == nil { + value.Name, err = in.string(MaxTextBytes, "entity name", true) + } + if err == nil { + value.Parent, err = in.optionalID("entity parent") + } + if err == nil { + value.ValidFrom, err = in.int64() + } + if err == nil { + value.ValidTo, err = in.optionalInt64("entity valid_to") + } + if err == nil { + value.Properties, err = decodeProperties(in) + } + return value, err +} + +func encodeRelation(out *[]byte, value Relation) error { + putID(out, value.ID) + if err := putString(out, value.Kind, maxKeyBytes, "relation kind", true); err != nil { + return err + } + putID(out, value.Source) + putID(out, value.Target) + putInt64(out, value.ValidFrom) + putOptionalInt64(out, value.ValidTo) + return encodeProperties(out, value.Properties) +} + +func decodeRelation(in *input) (Relation, error) { + var value Relation + var err error + if value.ID, err = in.id(); err == nil { + value.Kind, err = in.string(maxKeyBytes, "relation kind", true) + } + if err == nil { + value.Source, err = in.id() + } + if err == nil { + value.Target, err = in.id() + } + if err == nil { + value.ValidFrom, err = in.int64() + } + if err == nil { + value.ValidTo, err = in.optionalInt64("relation valid_to") + } + if err == nil { + value.Properties, err = decodeProperties(in) + } + return value, err +} + +func encodeSeries(out *[]byte, value SeriesDefinition) error { + if err := validateSeries(value); err != nil { + return err + } + putUint64(out, value.ID) + putOptionalID(out, value.OwnerEntity) + putOptionalID(out, value.OwnerRelation) + if err := putString(out, value.Name, maxKeyBytes, "series name", true); err != nil { + return err + } + if err := putString(out, value.PhysicalQuantity, maxKeyBytes, "physical quantity", true); err != nil { + return err + } + if err := putString(out, value.CanonicalUnit, maxKeyBytes, "canonical unit", true); err != nil { + return err + } + *out = append(*out, byte(value.Semantics)) + putOptionalInt64(out, value.MaximumGapMicros) + return encodeRollupPolicy(out, value.RollupPolicy) +} + +func decodeSeries(in *input) (SeriesDefinition, error) { + var value SeriesDefinition + var err error + if value.ID, err = in.uint64(); err == nil { + value.OwnerEntity, err = in.optionalID("owner entity") + } + if err == nil { + value.OwnerRelation, err = in.optionalID("owner relation") + } + if err == nil { + value.Name, err = in.string(maxKeyBytes, "series name", true) + } + if err == nil { + value.PhysicalQuantity, err = in.string(maxKeyBytes, "physical quantity", true) + } + if err == nil { + value.CanonicalUnit, err = in.string(maxKeyBytes, "canonical unit", true) + } + var enum byte + if err == nil { + enum, err = in.byte() + value.Semantics = SeriesSemantics(enum) + if err == nil && !validSeriesSemantics(value.Semantics) { + err = invalidEnum("series semantics", enum) + } + } + if err == nil { + value.MaximumGapMicros, err = in.optionalInt64("maximum gap") + } + if err == nil { + value.RollupPolicy, err = decodeRollupPolicy(in) + } + if err == nil { + err = validateSeries(value) + } + return value, err +} + +func validateSeries(value SeriesDefinition) error { + if value.ID == 0 { + return invalidField("series id zero is reserved") + } + if strings.TrimSpace(value.Name) == "" { + return invalidField("series name must not be empty") + } + if strings.TrimSpace(value.PhysicalQuantity) == "" || strings.TrimSpace(value.CanonicalUnit) == "" { + return invalidField("physical quantity and canonical unit are required") + } + if (value.OwnerEntity == nil) == (value.OwnerRelation == nil) { + return invalidField("series must belong to exactly one entity or relation") + } + if value.MaximumGapMicros != nil && *value.MaximumGapMicros < 0 { + return invalidField("maximum gap must not be negative") + } + if !validSeriesSemantics(value.Semantics) { + return invalidEnum("series semantics", byte(value.Semantics)) + } + if len(value.RollupPolicy.Tiers) > MaxRollupTiers { + return invalidField("too many rollup tiers") + } + for _, tier := range value.RollupPolicy.Tiers { + switch tier.Resolution.Kind { + case RollupFixedMicros: + if tier.Resolution.FixedMicros <= 0 { + return invalidField("fixed rollup resolution must be positive") + } + case RollupCalendar: + if !validCalendarUnit(tier.Resolution.CalendarUnit) { + return invalidEnum("calendar unit", byte(tier.Resolution.CalendarUnit)) + } + if strings.TrimSpace(tier.Resolution.IANATimezone) == "" { + return invalidField("calendar rollups require an IANA timezone") + } + default: + return invalidEnum("rollup resolution", byte(tier.Resolution.Kind)) + } + if tier.RetainForMicros != nil && *tier.RetainForMicros <= 0 { + return invalidField("rollup retention must be positive or forever") + } + } + return nil +} + +func encodeRollupPolicy(out *[]byte, value RollupPolicy) error { + if len(value.Tiers) > MaxRollupTiers { + return invalidField("too many rollup tiers") + } + putOptionalInt64(out, value.RawRetainForMicros) + putUint32(out, uint32(len(value.Tiers))) + for _, tier := range value.Tiers { + *out = append(*out, byte(tier.Resolution.Kind)) + switch tier.Resolution.Kind { + case RollupFixedMicros: + putInt64(out, tier.Resolution.FixedMicros) + case RollupCalendar: + *out = append(*out, byte(tier.Resolution.CalendarUnit)) + if err := putString(out, tier.Resolution.IANATimezone, maxKeyBytes, "IANA timezone", true); err != nil { + return err + } + default: + return invalidEnum("rollup resolution", byte(tier.Resolution.Kind)) + } + putOptionalInt64(out, tier.RetainForMicros) + } + return nil +} + +func decodeRollupPolicy(in *input) (RollupPolicy, error) { + var value RollupPolicy + var err error + if value.RawRetainForMicros, err = in.optionalInt64("raw retention"); err != nil { + return value, err + } + count, err := in.count(MaxRollupTiers, "rollup tier count") + if err != nil { + return value, err + } + value.Tiers = make([]RollupTier, 0, count) + for range count { + var tier RollupTier + kind, err := in.byte() + if err != nil { + return value, err + } + tier.Resolution.Kind = RollupResolutionKind(kind) + switch tier.Resolution.Kind { + case RollupFixedMicros: + tier.Resolution.FixedMicros, err = in.int64() + case RollupCalendar: + var enum byte + if enum, err = in.byte(); err == nil { + tier.Resolution.CalendarUnit = CalendarUnit(enum) + if !validCalendarUnit(tier.Resolution.CalendarUnit) { + err = invalidEnum("calendar unit", enum) + } + } + if err == nil { + tier.Resolution.IANATimezone, err = in.string(maxKeyBytes, "IANA timezone", true) + } + default: + err = invalidEnum("rollup resolution", kind) + } + if err != nil { + return value, err + } + tier.RetainForMicros, err = in.optionalInt64("tier retention") + if err != nil { + return value, err + } + value.Tiers = append(value.Tiers, tier) + } + return value, nil +} + +func encodeRun(out *[]byte, value Run) error { + if !validRunKind(value.Kind) { + return invalidEnum("run kind", byte(value.Kind)) + } + if !validRunStatus(value.Status) { + return invalidEnum("run status", byte(value.Status)) + } + putID(out, value.ID) + *out = append(*out, byte(value.Kind), byte(value.Status)) + putInt64(out, value.CreatedAt) + putInt64(out, value.KnowledgeTime) + if err := putString(out, value.Workflow, MaxTextBytes, "workflow", true); err != nil { + return err + } + if err := putString(out, value.Model, MaxTextBytes, "model", false); err != nil { + return err + } + if err := putString(out, value.ModelVersion, MaxTextBytes, "model version", false); err != nil { + return err + } + putOptionalID(out, value.ParentRun) + putOptionalID(out, value.InputSnapshot) + return encodeProperties(out, value.Attributes) +} + +func decodeRun(in *input) (Run, error) { + var value Run + var err error + if value.ID, err = in.id(); err == nil { + var enum byte + enum, err = in.byte() + value.Kind = RunKind(enum) + if err == nil && !validRunKind(value.Kind) { + err = invalidEnum("run kind", enum) + } + } + if err == nil { + var enum byte + enum, err = in.byte() + value.Status = RunStatus(enum) + if err == nil && !validRunStatus(value.Status) { + err = invalidEnum("run status", enum) + } + } + if err == nil { + value.CreatedAt, err = in.int64() + } + if err == nil { + value.KnowledgeTime, err = in.int64() + } + if err == nil { + value.Workflow, err = in.string(MaxTextBytes, "workflow", true) + } + if err == nil { + value.Model, err = in.string(MaxTextBytes, "model", false) + } + if err == nil { + value.ModelVersion, err = in.string(MaxTextBytes, "model version", false) + } + if err == nil { + value.ParentRun, err = in.optionalID("parent run") + } + if err == nil { + value.InputSnapshot, err = in.optionalID("input snapshot") + } + if err == nil { + value.Attributes, err = decodeProperties(in) + } + return value, err +} + +func encodePlan(out *[]byte, value Plan) error { + if err := validatePlan(value); err != nil { + return err + } + putID(out, value.ID) + putID(out, value.RunID) + *out = append(*out, byte(value.Status)) + putInt64(out, value.HorizonStart) + putInt64(out, value.HorizonEnd) + putInt64(out, value.ResolutionMicros) + if err := putString(out, value.Scenario, MaxTextBytes, "scenario", true); err != nil { + return err + } + if len(value.ObjectiveTerms) > MaxProperties { + return invalidField("too many objective terms") + } + keys := sortedKeys(value.ObjectiveTerms) + putUint32(out, uint32(len(keys))) + for _, key := range keys { + if err := putString(out, key, maxKeyBytes, "objective key", true); err != nil { + return err + } + if err := putFloat64(out, value.ObjectiveTerms[key], "objective value"); err != nil { + return err + } + } + if err := putOptionalFloat64(out, value.ObjectiveValue, "objective value"); err != nil { + return err + } + putOptionalID(out, value.Supersedes) + return encodeProperties(out, value.Attributes) +} + +func decodePlan(in *input) (Plan, error) { + var value Plan + var err error + if value.ID, err = in.id(); err == nil { + value.RunID, err = in.id() + } + if err == nil { + var enum byte + enum, err = in.byte() + value.Status = PlanStatus(enum) + if err == nil && !validPlanStatus(value.Status) { + err = invalidEnum("plan status", enum) + } + } + if err == nil { + value.HorizonStart, err = in.int64() + } + if err == nil { + value.HorizonEnd, err = in.int64() + } + if err == nil { + value.ResolutionMicros, err = in.int64() + } + if err == nil { + value.Scenario, err = in.string(MaxTextBytes, "scenario", true) + } + if err != nil { + return value, err + } + count, err := in.count(MaxProperties, "objective term count") + if err != nil { + return value, err + } + value.ObjectiveTerms = make(map[string]float64, count) + previous := "" + for index := range count { + key, err := in.string(maxKeyBytes, "objective key", true) + if err != nil { + return value, err + } + if index > 0 && previous >= key { + return value, invalidField("objective keys") + } + previous = key + number, err := in.float64("objective value") + if err != nil { + return value, err + } + value.ObjectiveTerms[key] = number + } + if value.ObjectiveValue, err = in.optionalFloat64("objective value"); err == nil { + value.Supersedes, err = in.optionalID("supersedes") + } + if err == nil { + value.Attributes, err = decodeProperties(in) + } + if err == nil { + err = validatePlan(value) + } + return value, err +} + +func validatePlan(value Plan) error { + if value.ID.IsZero() || value.RunID.IsZero() { + return invalidField("plan and run ids must be non-zero") + } + if !validPlanStatus(value.Status) { + return invalidEnum("plan status", byte(value.Status)) + } + if value.HorizonEnd <= value.HorizonStart { + return invalidField("plan horizon must have positive duration") + } + if value.ResolutionMicros <= 0 { + return invalidField("plan resolution must be positive") + } + if strings.TrimSpace(value.Scenario) == "" { + return invalidField("plan scenario must not be empty") + } + for _, number := range value.ObjectiveTerms { + if math.IsNaN(number) || math.IsInf(number, 0) { + return invalidField("objective value") + } + } + if value.ObjectiveValue != nil && (math.IsNaN(*value.ObjectiveValue) || math.IsInf(*value.ObjectiveValue, 0)) { + return invalidField("objective value") + } + return nil +} + +func encodeProperties(out *[]byte, values map[string]PropertyValue) error { + if len(values) > MaxProperties { + return invalidField("too many properties") + } + keys := sortedKeys(values) + putUint32(out, uint32(len(keys))) + for _, key := range keys { + if err := putString(out, key, maxKeyBytes, "property key", true); err != nil { + return err + } + value := values[key] + *out = append(*out, byte(value.Kind)) + switch value.Kind { + case PropertyNull: + case PropertyBool: + putBool(out, value.Bool) + case PropertyInteger: + putInt64(out, value.Integer) + case PropertyFloat: + if err := putFloat64(out, value.Float, "property float"); err != nil { + return err + } + case PropertyText: + if err := putString(out, value.Text, MaxTextBytes, "property text", false); err != nil { + return err + } + default: + return invalidEnum("property value", byte(value.Kind)) + } + } + return nil +} + +func decodeProperties(in *input) (map[string]PropertyValue, error) { + count, err := in.count(MaxProperties, "property count") + if err != nil { + return nil, err + } + values := make(map[string]PropertyValue, count) + previous := "" + for index := range count { + key, err := in.string(maxKeyBytes, "property key", true) + if err != nil { + return nil, err + } + if index > 0 && previous >= key { + return nil, invalidField("property keys") + } + previous = key + var value PropertyValue + enum, err := in.byte() + if err != nil { + return nil, err + } + value.Kind = PropertyKind(enum) + switch value.Kind { + case PropertyNull: + case PropertyBool: + value.Bool, err = in.boolean("property bool") + case PropertyInteger: + value.Integer, err = in.int64() + case PropertyFloat: + value.Float, err = in.float64("property float") + case PropertyText: + value.Text, err = in.string(MaxTextBytes, "property text", false) + default: + err = invalidEnum("property value", enum) + } + if err != nil { + return nil, err + } + values[key] = value + } + return values, nil +} + +func decodeBatch(in *input) (CommitBatchRequest, error) { + var value CommitBatchRequest + var err error + if value.SourceID, err = in.id(); err == nil { + value.Sequence, err = in.uint64() + } + if err == nil { + value.CommitID, err = in.id() + } + if err != nil { + return value, err + } + remaining := MaxMetadataRecords + if value.Entities, err = decodeCollection(in, &remaining, decodeEntity); err != nil { + return value, err + } + if value.Relations, err = decodeCollection(in, &remaining, decodeRelation); err != nil { + return value, err + } + if value.Series, err = decodeCollection(in, &remaining, decodeSeries); err != nil { + return value, err + } + if value.Runs, err = decodeCollection(in, &remaining, decodeRun); err != nil { + return value, err + } + if value.Plans, err = decodeCollection(in, &remaining, decodePlan); err != nil { + return value, err + } + count, err := in.count(MaxBatchPoints, "point count") + if err != nil { + return value, err + } + if in.remaining() != count*pointBytes { + return value, invalidField("point payload length") + } + value.Points = make([]Point, 0, count) + for range count { + point, err := decodePoint(in) + if err != nil { + return value, err + } + value.Points = append(value.Points, point) + } + if err := validateBatch(value); err != nil { + return value, err + } + return value, nil +} + +func decodeCollection[T any](in *input, remaining *int, decode func(*input) (T, error)) ([]T, error) { + count, err := in.count(*remaining, "metadata record count") + if err != nil { + return nil, err + } + *remaining -= count + values := make([]T, 0, count) + for range count { + value, err := decode(in) + if err != nil { + return nil, err + } + values = append(values, value) + } + return values, nil +} + +func encodePoint(out *[]byte, value Point) { + putUint64(out, value.SeriesID) + putInt64(out, value.ValidTime) + putInt64(out, value.ValidTimeEnd) + putInt64(out, value.KnowledgeTime) + putInt64(out, value.ChangeTime) + putID(out, value.RunID) + putUint64(out, math.Float64bits(value.Value)) + putUint32(out, value.Quality) + putUint32(out, value.Flags) +} + +func decodePoint(in *input) (Point, error) { + var value Point + var err error + if value.SeriesID, err = in.uint64(); err == nil { + value.ValidTime, err = in.int64() + } + if err == nil { + value.ValidTimeEnd, err = in.int64() + } + if err == nil { + value.KnowledgeTime, err = in.int64() + } + if err == nil { + value.ChangeTime, err = in.int64() + } + if err == nil { + value.RunID, err = in.id() + } + var bits uint64 + if err == nil { + bits, err = in.uint64() + value.Value = math.Float64frombits(bits) + } + if err == nil { + value.Quality, err = in.uint32() + } + if err == nil { + value.Flags, err = in.uint32() + } + return value, err +} + +func sortedKeys[V any](values map[string]V) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + slices.Sort(keys) + return keys +} + +func validSeriesSemantics(value SeriesSemantics) bool { + return value >= SeriesGauge && value <= SeriesEvent +} + +func validCalendarUnit(value CalendarUnit) bool { + return value >= CalendarDay && value <= CalendarYear +} + +func validRunKind(value RunKind) bool { + return value >= RunForecast && value <= RunReconciliation +} + +func validRunStatus(value RunStatus) bool { + return value >= RunPending && value <= RunCancelled +} + +func validPlanStatus(value PlanStatus) bool { + return value >= PlanCandidate && value <= PlanCancelled +} + +func validHealthStatus(value HealthStatus) bool { + return value >= HealthHealthy && value <= HealthUnavailable +} + +func validErrorCode(value ErrorCode) bool { + return value >= ErrorInvalidRequest && value <= ErrorIdempotencyConflict +} + +func invalidField(field string) error { + return protocolError(ProtocolInvalidField, field, "") +} + +func invalidEnum(field string, value byte) error { + return &ProtocolError{ + Kind: ProtocolInvalidEnum, + Field: field, + Value: uint64(value), + Detail: fmt.Sprintf("value %d", value), + } +} + +func putString(out *[]byte, value string, maximum int, field string, required bool) error { + if !utf8.ValidString(value) || len(value) > maximum || len(value) > math.MaxUint16 || required && value == "" { + return invalidField(field) + } + added := 2 + len(value) + if len(*out) > maxPayloadBytes-added { + return frameTooLarge(uint64(len(*out) + added + headerBytes + checksumBytes)) + } + putUint16(out, uint16(len(value))) + *out = append(*out, value...) + return nil +} + +func putOptionalID(out *[]byte, value *ID128) { + if value == nil { + *out = append(*out, 0) + return + } + *out = append(*out, 1) + putID(out, *value) +} + +func putOptionalInt64(out *[]byte, value *int64) { + if value == nil { + *out = append(*out, 0) + return + } + *out = append(*out, 1) + putInt64(out, *value) +} + +func putOptionalUint64(out *[]byte, value *uint64) { + if value == nil { + *out = append(*out, 0) + return + } + *out = append(*out, 1) + putUint64(out, *value) +} + +func putOptionalFloat64(out *[]byte, value *float64, field string) error { + if value == nil { + *out = append(*out, 0) + return nil + } + *out = append(*out, 1) + return putFloat64(out, *value, field) +} + +func putFloat64(out *[]byte, value float64, field string) error { + if math.IsNaN(value) || math.IsInf(value, 0) { + return invalidField(field) + } + putUint64(out, math.Float64bits(value)) + return nil +} + +func putBool(out *[]byte, value bool) { + if value { + *out = append(*out, 1) + } else { + *out = append(*out, 0) + } +} + +func putID(out *[]byte, value ID128) { + *out = append(*out, value[:]...) +} + +func putUint16(out *[]byte, value uint16) { + *out = binary.BigEndian.AppendUint16(*out, value) +} + +func putUint32(out *[]byte, value uint32) { + *out = binary.BigEndian.AppendUint32(*out, value) +} + +func putUint64(out *[]byte, value uint64) { + *out = binary.BigEndian.AppendUint64(*out, value) +} + +func putInt64(out *[]byte, value int64) { + putUint64(out, uint64(value)) +} + +func readExact(reader io.Reader, target []byte, base int) error { + read := 0 + for read < len(target) { + count, err := reader.Read(target[read:]) + if count < 0 || count > len(target)-read { + return &ProtocolError{Kind: ProtocolIO, Err: io.ErrShortBuffer} + } + read += count + if read == len(target) { + return nil + } + if err == nil { + if count == 0 { + return &ProtocolError{ + Kind: ProtocolTruncated, + Detail: fmt.Sprintf("expected %d bytes, got %d", base+len(target), base+read), + } + } + continue + } + if errors.Is(err, io.EOF) { + return &ProtocolError{ + Kind: ProtocolTruncated, + Detail: fmt.Sprintf("expected %d bytes, got %d", base+len(target), base+read), + } + } + return &ProtocolError{Kind: ProtocolIO, Err: err} + } + return nil +} + +type input struct { + data []byte + position int +} + +func (in *input) take(count int) ([]byte, error) { + if count < 0 || count > len(in.data)-in.position { + expected := in.position + count + if count < 0 { + expected = math.MaxInt + } + return nil, &ProtocolError{ + Kind: ProtocolTruncated, + Detail: fmt.Sprintf("expected %d bytes, got %d", expected, len(in.data)), + } + } + value := in.data[in.position : in.position+count] + in.position += count + return value, nil +} + +func (in *input) finish() error { + if in.position == len(in.data) { + return nil + } + return &ProtocolError{ + Kind: ProtocolTrailingBytes, + Detail: fmt.Sprintf("%d extra bytes", len(in.data)-in.position), + } +} + +func (in *input) remaining() int { + return len(in.data) - in.position +} + +func (in *input) byte() (byte, error) { + value, err := in.take(1) + if err != nil { + return 0, err + } + return value[0], nil +} + +func (in *input) uint16() (uint16, error) { + value, err := in.take(2) + if err != nil { + return 0, err + } + return binary.BigEndian.Uint16(value), nil +} + +func (in *input) uint32() (uint32, error) { + value, err := in.take(4) + if err != nil { + return 0, err + } + return binary.BigEndian.Uint32(value), nil +} + +func (in *input) uint64() (uint64, error) { + value, err := in.take(8) + if err != nil { + return 0, err + } + return binary.BigEndian.Uint64(value), nil +} + +func (in *input) int64() (int64, error) { + value, err := in.uint64() + return int64(value), err +} + +func (in *input) id() (ID128, error) { + var value ID128 + bytes, err := in.take(len(value)) + if err != nil { + return value, err + } + copy(value[:], bytes) + return value, nil +} + +func (in *input) boolean(field string) (bool, error) { + value, err := in.byte() + if err != nil { + return false, err + } + switch value { + case 0: + return false, nil + case 1: + return true, nil + default: + return false, invalidEnum(field, value) + } +} + +func (in *input) string(maximum int, field string, required bool) (string, error) { + size, err := in.uint16() + if err != nil { + return "", err + } + if int(size) > maximum || required && size == 0 { + return "", invalidField(field) + } + value, err := in.take(int(size)) + if err != nil { + return "", err + } + if !utf8.Valid(value) { + return "", invalidField(field) + } + return string(value), nil +} + +func (in *input) optionalID(field string) (*ID128, error) { + present, err := in.byte() + if err != nil { + return nil, err + } + switch present { + case 0: + return nil, nil + case 1: + value, err := in.id() + return &value, err + default: + return nil, invalidEnum(field, present) + } +} + +func (in *input) optionalInt64(field string) (*int64, error) { + present, err := in.byte() + if err != nil { + return nil, err + } + switch present { + case 0: + return nil, nil + case 1: + value, err := in.int64() + return &value, err + default: + return nil, invalidEnum(field, present) + } +} + +func (in *input) optionalUint64(field string) (*uint64, error) { + present, err := in.byte() + if err != nil { + return nil, err + } + switch present { + case 0: + return nil, nil + case 1: + value, err := in.uint64() + return &value, err + default: + return nil, invalidEnum(field, present) + } +} + +func (in *input) float64(field string) (float64, error) { + bits, err := in.uint64() + if err != nil { + return 0, err + } + value := math.Float64frombits(bits) + if math.IsNaN(value) || math.IsInf(value, 0) { + return 0, invalidField(field) + } + return value, nil +} + +func (in *input) optionalFloat64(field string) (*float64, error) { + present, err := in.byte() + if err != nil { + return nil, err + } + switch present { + case 0: + return nil, nil + case 1: + value, err := in.float64(field) + return &value, err + default: + return nil, invalidEnum(field, present) + } +} + +func (in *input) count(maximum int, field string) (int, error) { + count, err := in.uint32() + if err != nil { + return 0, err + } + if uint64(count) > uint64(maximum) { + return 0, invalidField(field) + } + return int(count), nil +} diff --git a/go/internal/ftwdbshadow/codec_test.go b/go/internal/ftwdbshadow/codec_test.go new file mode 100644 index 00000000..b63e0ef5 --- /dev/null +++ b/go/internal/ftwdbshadow/codec_test.go @@ -0,0 +1,418 @@ +package ftwdbshadow + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "errors" + "hash/crc32" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDecodeRejectsMalformedFramesBeforeUse(t *testing.T) { + t.Parallel() + valid := fixtureFrame(t, "health-request.hex") + tests := []struct { + name string + edit func([]byte) []byte + kind ProtocolErrorKind + }{ + { + name: "short header", + edit: func(frame []byte) []byte { return frame[:headerBytes-1] }, + kind: ProtocolTruncated, + }, + { + name: "bad magic", + edit: func(frame []byte) []byte { + frame[0] ^= 0xff + return frame + }, + kind: ProtocolInvalidMagic, + }, + { + name: "unsupported version", + edit: func(frame []byte) []byte { + binary.BigEndian.PutUint16(frame[4:6], ProtocolVersion+1) + return frame + }, + kind: ProtocolUnsupportedVersion, + }, + { + name: "unknown kind", + edit: func(frame []byte) []byte { + frame[6] = 127 + return frame + }, + kind: ProtocolUnknownMessage, + }, + { + name: "reserved bits", + edit: func(frame []byte) []byte { + frame[7] = 1 + return frame + }, + kind: ProtocolReservedBits, + }, + { + name: "bad checksum", + edit: func(frame []byte) []byte { + frame[len(frame)-1] ^= 1 + return frame + }, + kind: ProtocolChecksum, + }, + { + name: "trailing byte", + edit: func(frame []byte) []byte { return append(frame, 0) }, + kind: ProtocolTrailingBytes, + }, + { + name: "short payload", + edit: func(frame []byte) []byte { return frame[:len(frame)-1] }, + kind: ProtocolTruncated, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + frame := test.edit(append([]byte(nil), valid...)) + _, err := Decode(frame) + requireProtocolKind(t, err, test.kind) + }) + } +} + +func TestDecodeRejectsOversizedHeaderBeforeAllocation(t *testing.T) { + t.Parallel() + header := make([]byte, headerBytes) + copy(header, frameMagic[:]) + binary.BigEndian.PutUint16(header[4:6], ProtocolVersion) + header[6] = byte(kindHealthRequest) + binary.BigEndian.PutUint32(header[8:12], uint32(maxPayloadBytes+1)) + + _, err := ReadMessage(bytes.NewReader(header)) + requireProtocolKind(t, err, ProtocolFrameTooLarge) +} + +func TestReadMessageHandlesShortReads(t *testing.T) { + t.Parallel() + frame := fixtureFrame(t, "commit-batch-request.hex") + message, err := ReadMessage(&oneByteReader{reader: bytes.NewReader(frame)}) + if err != nil { + t.Fatal(err) + } + if _, ok := message.(CommitBatchRequest); !ok { + t.Fatalf("decoded %T, want CommitBatchRequest", message) + } +} + +func TestReadMessageAcceptsFinalBytesWithEOF(t *testing.T) { + t.Parallel() + frame := fixtureFrame(t, "health-response.hex") + message, err := ReadMessage(&eofOnFinalReader{data: frame}) + if err != nil { + t.Fatal(err) + } + if _, ok := message.(HealthResponse); !ok { + t.Fatalf("decoded %T, want HealthResponse", message) + } +} + +func TestWriteMessageCompletesShortWrites(t *testing.T) { + t.Parallel() + message := HealthRequest{Nonce: 42} + want, err := Encode(message) + if err != nil { + t.Fatal(err) + } + writer := &shortWriter{maximum: 3} + if err := WriteMessage(writer, message); err != nil { + t.Fatal(err) + } + if !bytes.Equal(writer.Bytes(), want) { + t.Fatalf("written bytes %x, want %x", writer.Bytes(), want) + } +} + +func TestPreparedCommitCopiesAndValidatesFrame(t *testing.T) { + t.Parallel() + frame := fixtureFrame(t, "commit-batch-request.hex") + prepared, err := PreparedCommitFromFrame(frame) + if err != nil { + t.Fatal(err) + } + frame[0] ^= 0xff + first := prepared.Bytes() + first[1] ^= 0xff + second := prepared.Bytes() + if second[0] != 'F' || second[1] != 'T' { + t.Fatal("prepared commit exposed mutable frame storage") + } + + _, err = PreparedCommitFromFrame(fixtureFrame(t, "health-request.hex")) + requireProtocolKind(t, err, ProtocolInvalidField) +} + +func TestCodecRejectsNonCanonicalOrInvalidValues(t *testing.T) { + t.Parallel() + source := mustID(t, "00112233445566778899aabbccddeeff") + entity := mustID(t, "102030405060708090a0b0c0d0e0f001") + tests := []struct { + name string + message Message + }{ + { + name: "zero source", + message: HelloRequest{NodeID: "box", ClientVersion: "ftw"}, + }, + { + name: "empty batch", + message: CommitBatchRequest{ + SourceID: source, + CommitID: entity, + }, + }, + { + name: "invalid point", + message: CommitBatchRequest{ + SourceID: source, + CommitID: entity, + Points: []Point{{ + SeriesID: 1, + ValidTime: 2, + ValidTimeEnd: 1, + Value: 1, + }}, + }, + }, + { + name: "bad utf8", + message: HelloRequest{ + SourceID: source, + NodeID: string([]byte{0xff}), + ClientVersion: "ftw", + }, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + _, err := Encode(test.message) + requireProtocolKind(t, err, ProtocolInvalidField) + }) + } +} + +func TestDecodeRejectsInvalidBoolean(t *testing.T) { + t.Parallel() + frame := fixtureFrame(t, "error-response.hex") + // Error payload starts with code, then the retryable byte. + frame[headerBytes+1] = 2 + refreshChecksum(frame) + _, err := Decode(frame) + requireProtocolKind(t, err, ProtocolInvalidEnum) +} + +func TestEncodeSortsMapsByWireBytes(t *testing.T) { + t.Parallel() + source := mustID(t, "00112233445566778899aabbccddeeff") + entity := mustID(t, "102030405060708090a0b0c0d0e0f001") + batch := CommitBatchRequest{ + SourceID: source, + CommitID: entity, + Entities: []Entity{{ + ID: entity, + Kind: "site", + Name: "box", + Properties: map[string]PropertyValue{ + "z": IntegerProperty(1), + "a": IntegerProperty(2), + }, + }}, + } + first, err := Encode(batch) + if err != nil { + t.Fatal(err) + } + second, err := Encode(batch) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(first, second) { + t.Fatal("map encoding is not deterministic") + } + if _, err := Decode(first); err != nil { + t.Fatal(err) + } +} + +func TestEveryEnumTagRoundTrips(t *testing.T) { + t.Parallel() + source := mustID(t, "00112233445566778899aabbccddeeff") + for _, status := range []HealthStatus{HealthHealthy, HealthDegraded, HealthUnavailable} { + message := roundTripMessage(t, HealthResponse{ + SourceID: source, + Status: status, + }).(HealthResponse) + if message.Status != status { + t.Fatalf("health status %d became %d", status, message.Status) + } + } + for _, code := range []ErrorCode{ + ErrorInvalidRequest, + ErrorOverloaded, + ErrorInternal, + ErrorUnsupported, + ErrorIdempotencyConflict, + } { + message := roundTripMessage(t, ErrorResponse{ + Code: code, + Message: "error", + }).(ErrorResponse) + if message.Code != code { + t.Fatalf("error code %d became %d", code, message.Code) + } + } + + batch := fixtureMessages(t)["commit-batch-request.hex"].(CommitBatchRequest) + for _, semantics := range []SeriesSemantics{ + SeriesGauge, + SeriesIntervalTotal, + SeriesCounter, + SeriesState, + SeriesEvent, + } { + batch.Series[0].Semantics = semantics + message := roundTripMessage(t, batch).(CommitBatchRequest) + if message.Series[0].Semantics != semantics { + t.Fatalf("series semantics %d became %d", semantics, message.Series[0].Semantics) + } + } + for _, unit := range []CalendarUnit{CalendarDay, CalendarMonth, CalendarYear} { + batch.Series[0].RollupPolicy.Tiers[1].Resolution.CalendarUnit = unit + message := roundTripMessage(t, batch).(CommitBatchRequest) + if message.Series[0].RollupPolicy.Tiers[1].Resolution.CalendarUnit != unit { + t.Fatalf("calendar unit %d changed", unit) + } + } + for _, kind := range []RunKind{ + RunForecast, + RunOptimization, + RunImport, + RunControl, + RunReconciliation, + } { + batch.Runs[0].Kind = kind + message := roundTripMessage(t, batch).(CommitBatchRequest) + if message.Runs[0].Kind != kind { + t.Fatalf("run kind %d changed", kind) + } + } + for _, status := range []RunStatus{ + RunPending, + RunRunning, + RunSucceeded, + RunFailed, + RunCancelled, + } { + batch.Runs[0].Status = status + message := roundTripMessage(t, batch).(CommitBatchRequest) + if message.Runs[0].Status != status { + t.Fatalf("run status %d changed", status) + } + } + for _, status := range []PlanStatus{ + PlanCandidate, + PlanApproved, + PlanDeployed, + PlanSuperseded, + PlanCancelled, + } { + batch.Plans[0].Status = status + message := roundTripMessage(t, batch).(CommitBatchRequest) + if message.Plans[0].Status != status { + t.Fatalf("plan status %d changed", status) + } + } +} + +func roundTripMessage(t *testing.T, message Message) Message { + t.Helper() + frame, err := Encode(message) + if err != nil { + t.Fatal(err) + } + decoded, err := Decode(frame) + if err != nil { + t.Fatal(err) + } + return decoded +} + +func fixtureFrame(t *testing.T, name string) []byte { + t.Helper() + text, err := os.ReadFile(filepath.Join(fixtureDirectory, name)) + if err != nil { + t.Fatal(err) + } + frame, err := hex.DecodeString(strings.TrimSpace(string(text))) + if err != nil { + t.Fatal(err) + } + return frame +} + +func requireProtocolKind(t *testing.T, err error, want ProtocolErrorKind) { + t.Helper() + var protocol *ProtocolError + if !errors.As(err, &protocol) { + t.Fatalf("error %v has type %T, want ProtocolError", err, err) + } + if protocol.Kind != want { + t.Fatalf("protocol error kind %q, want %q: %v", protocol.Kind, want, err) + } +} + +func refreshChecksum(frame []byte) { + sum := crc32.ChecksumIEEE(frame[:len(frame)-checksumBytes]) + binary.BigEndian.PutUint32(frame[len(frame)-checksumBytes:], sum) +} + +type oneByteReader struct{ reader io.Reader } + +func (reader *oneByteReader) Read(value []byte) (int, error) { + if len(value) > 1 { + value = value[:1] + } + return reader.reader.Read(value) +} + +type eofOnFinalReader struct{ data []byte } + +func (reader *eofOnFinalReader) Read(value []byte) (int, error) { + count := copy(value, reader.data) + reader.data = reader.data[count:] + if len(reader.data) == 0 { + return count, io.EOF + } + return count, nil +} + +type shortWriter struct { + bytes.Buffer + maximum int +} + +func (writer *shortWriter) Write(value []byte) (int, error) { + if len(value) > writer.maximum { + value = value[:writer.maximum] + } + return writer.Buffer.Write(value) +} diff --git a/go/internal/ftwdbshadow/fixture_test.go b/go/internal/ftwdbshadow/fixture_test.go new file mode 100644 index 00000000..2f8391b1 --- /dev/null +++ b/go/internal/ftwdbshadow/fixture_test.go @@ -0,0 +1,329 @@ +package ftwdbshadow + +import ( + "bufio" + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +const fixtureDirectory = "testdata/shadow-protocol-v1" + +func TestV1GoldenFixtures(t *testing.T) { + t.Parallel() + for name, want := range fixtureMessages(t) { + name, want := name, want + t.Run(name, func(t *testing.T) { + t.Parallel() + text, err := os.ReadFile(filepath.Join(fixtureDirectory, name)) + if err != nil { + t.Fatal(err) + } + frame, err := hex.DecodeString(strings.TrimSpace(string(text))) + if err != nil { + t.Fatalf("decode fixture hex: %v", err) + } + got, err := Decode(frame) + if err != nil { + t.Fatalf("decode frame: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("decoded message mismatch\n got: %#v\nwant: %#v", got, want) + } + encoded, err := Encode(want) + if err != nil { + t.Fatalf("encode message: %v", err) + } + if !bytes.Equal(encoded, frame) { + t.Fatalf("encoded bytes differ\n got: %x\nwant: %x", encoded, frame) + } + }) + } +} + +func TestVendoredFixtureHashes(t *testing.T) { + t.Parallel() + file, err := os.Open(filepath.Join(fixtureDirectory, "SHA256SUMS")) + if err != nil { + t.Fatal(err) + } + defer file.Close() + + count := 0 + scanner := bufio.NewScanner(file) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) != 2 { + t.Fatalf("invalid SHA256SUMS line %q", scanner.Text()) + } + want, err := hex.DecodeString(fields[0]) + if err != nil || len(want) != sha256.Size { + t.Fatalf("invalid digest for %s", fields[1]) + } + value, err := os.ReadFile(filepath.Join(fixtureDirectory, fields[1])) + if err != nil { + t.Fatal(err) + } + got := sha256.Sum256(value) + if !bytes.Equal(got[:], want) { + t.Fatalf("%s digest %x, want %x", fields[1], got, want) + } + count++ + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + if count != len(fixtureMessages(t)) { + t.Fatalf("manifest contains %d frames, want %d", count, len(fixtureMessages(t))) + } +} + +func TestVendoredFixturesMatchSiblingFTWDB(t *testing.T) { + t.Parallel() + source := os.Getenv("FTWDB_SHADOW_FIXTURES") + if source == "" { + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("find fixture test source") + } + source = filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "ftwdb", "testdata", "shadow-protocol-v1")) + } + if _, err := os.Stat(source); err != nil { + t.Skipf("sibling FTWDB fixtures are not present: %v", err) + } + localEntries, err := os.ReadDir(fixtureDirectory) + if err != nil { + t.Fatal(err) + } + sourceEntries, err := os.ReadDir(source) + if err != nil { + t.Fatal(err) + } + if names(localEntries) != names(sourceEntries) { + t.Fatalf("fixture file sets differ\nlocal: %s\nsource: %s", names(localEntries), names(sourceEntries)) + } + for _, entry := range localEntries { + if entry.IsDir() { + continue + } + local, err := os.ReadFile(filepath.Join(fixtureDirectory, entry.Name())) + if err != nil { + t.Fatal(err) + } + upstream, err := os.ReadFile(filepath.Join(source, entry.Name())) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(local, upstream) { + t.Fatalf("vendored fixture %s differs from %s", entry.Name(), source) + } + } +} + +func names(entries []os.DirEntry) string { + values := make([]string, 0, len(entries)) + for _, entry := range entries { + values = append(values, entry.Name()) + } + return strings.Join(values, "\n") +} + +func fixtureMessages(t *testing.T) map[string]Message { + t.Helper() + sourceID := mustID(t, "00112233445566778899aabbccddeeff") + commitID := mustID(t, "ffeeddccbbaa99887766554433221100") + entityID := mustID(t, "102030405060708090a0b0c0d0e0f001") + targetID := mustID(t, "102030405060708090a0b0c0d0e0f002") + relationID := mustID(t, "2030405060708090a0b0c0d0e0f00102") + runID := mustID(t, "30405060708090a0b0c0d0e0f0010203") + sequence := uint64(0x0102030405060708) + validFrom := int64(1_754_382_400_123_456) + validTo := int64(1_754_468_800_123_456) + maximumGap := int64(5_000_000) + rawRetention := int64(1_209_600_000_000) + tierRetention := int64(31_536_000_000_000) + parentRun := mustID(t, "00000000000000000000000000000001") + inputSnapshot := mustID(t, "00000000000000000000000000000002") + supersedes := mustID(t, "00000000000000000000000000000003") + objective := 12.25 + accepted := sequence + durable := sequence + + batch := CommitBatchRequest{ + SourceID: sourceID, + Sequence: sequence, + CommitID: commitID, + Entities: []Entity{{ + ID: entityID, + Kind: "site", + Name: "FTW test box", + ValidFrom: validFrom, + ValidTo: &validTo, + Properties: map[string]PropertyValue{ + "bool": BoolProperty(true), + "float": FloatProperty(-12.5), + "int": IntegerProperty(-42), + "null": NullProperty(), + "text": TextProperty("grid import"), + }, + }}, + Relations: []Relation{{ + ID: relationID, + Kind: "feeds", + Source: entityID, + Target: targetID, + ValidFrom: validFrom, + Properties: map[string]PropertyValue{ + "phase": TextProperty("L1"), + }, + }}, + Series: []SeriesDefinition{{ + ID: 0x1122334455667788, + OwnerEntity: &entityID, + Name: "grid_power", + PhysicalQuantity: "power", + CanonicalUnit: "W", + Semantics: SeriesGauge, + MaximumGapMicros: &maximumGap, + RollupPolicy: RollupPolicy{ + RawRetainForMicros: &rawRetention, + Tiers: []RollupTier{ + { + Resolution: RollupResolution{ + Kind: RollupFixedMicros, + FixedMicros: 300_000_000, + }, + RetainForMicros: &tierRetention, + }, + { + Resolution: RollupResolution{ + Kind: RollupCalendar, + CalendarUnit: CalendarDay, + IANATimezone: "Europe/Stockholm", + }, + }, + }, + }, + }}, + Runs: []Run{{ + ID: runID, + Kind: RunOptimization, + Status: RunSucceeded, + CreatedAt: 1_754_382_300_000_000, + KnowledgeTime: 1_754_382_350_000_000, + Workflow: "day-ahead", + Model: "ftw-plan", + ModelVersion: "2026.08", + ParentRun: &parentRun, + InputSnapshot: &inputSnapshot, + Attributes: map[string]PropertyValue{ + "tariff": TextProperty("SE4"), + }, + }}, + Plans: []Plan{{ + ID: mustID(t, "405060708090a0b0c0d0e0f001020304"), + RunID: runID, + Status: PlanDeployed, + HorizonStart: 1_754_382_400_000_000, + HorizonEnd: 1_754_468_800_000_000, + ResolutionMicros: 300_000_000, + Scenario: "base", + ObjectiveTerms: map[string]float64{ + "cost_sek": 12.25, + "peak_w": 4_500, + }, + ObjectiveValue: &objective, + Supersedes: &supersedes, + Attributes: map[string]PropertyValue{ + "mode": TextProperty("auto"), + }, + }}, + Points: []Point{{ + SeriesID: 0x1122334455667788, + ValidTime: validFrom, + ValidTimeEnd: 1_754_382_700_123_456, + KnowledgeTime: 1_754_382_350_000_000, + ChangeTime: 1_754_382_351_000_000, + RunID: runID, + Value: -1_234.5, + Quality: 0x10203040, + Flags: 0x50607080, + }}, + } + + return map[string]Message{ + "hello-request.hex": HelloRequest{ + SourceID: sourceID, + NodeID: "ftw-box-01", + ClientVersion: "go-ftw/0.1.0", + Capabilities: sequence, + }, + "commit-batch-request.hex": batch, + "flush-request.hex": FlushRequest{ + SourceID: sourceID, + ThroughSequence: sequence, + }, + "health-request.hex": HealthRequest{Nonce: 0x1122334455667788}, + "hello-response.hex": HelloResponse{ + SelectedVersion: ProtocolVersion, + SessionID: sourceID, + ServerTimeMicros: validFrom, + }, + "commit-ack-response.hex": Ack{ + Kind: AckCommitBatch, + SourceID: sourceID, + Sequence: sequence, + CommitID: commitID, + AcceptedThroughSequence: &accepted, + DurableThroughSequence: &durable, + Durable: true, + FrameOffset: 0x1112131415161718, + Records: 6, + Points: 1, + BytesWritten: 0x2122232425262728, + }, + "flush-ack-response.hex": Ack{ + Kind: AckFlush, + SourceID: sourceID, + Sequence: sequence, + AcceptedThroughSequence: &accepted, + DurableThroughSequence: &durable, + Durable: true, + }, + "health-response.hex": HealthResponse{ + Nonce: 0x1122334455667788, + SourceID: sourceID, + Status: HealthDegraded, + QueueEntries: 3, + AcceptedThroughSequence: &accepted, + Ops: &HealthOps{OverloadCount: 5, ProtocolErrorCount: 7, DatabaseBytes: 0x3132333435363738, DatabasePoints: 9, DatabaseCommits: 4, SyncPolicy: 1}, + }, + "error-response.hex": ErrorResponse{ + Code: ErrorIdempotencyConflict, + Retryable: false, + Message: "idempotency-conflict", + }, + } +} + +func mustID(t *testing.T, value string) ID128 { + t.Helper() + id, err := ParseID128(value) + if err != nil { + t.Fatalf("parse id %s: %v", value, err) + } + return id +} + +func ExampleParseID128() { + id, _ := ParseID128("00112233445566778899aabbccddeeff") + fmt.Println(id) + // Output: 00112233445566778899aabbccddeeff +} diff --git a/go/internal/ftwdbshadow/health.go b/go/internal/ftwdbshadow/health.go new file mode 100644 index 00000000..66af1745 --- /dev/null +++ b/go/internal/ftwdbshadow/health.go @@ -0,0 +1,46 @@ +package ftwdbshadow + +func (h HealthOps) validate() error { + if h.SyncPolicy < 1 || h.SyncPolicy > 3 { + return invalidEnum("sync policy", h.SyncPolicy) + } + if (h.SyncPolicy == 3) != (h.SyncEveryBytes > 0) { + return invalidField("sync every-bytes") + } + return nil +} + +func encodeHealthOps(out *[]byte, h HealthOps) error { + if err := h.validate(); err != nil { + return err + } + for _, value := range []uint64{h.OverloadCount, h.ProtocolErrorCount, h.DatabaseBytes, h.DatabasePoints, h.DatabaseCommits, h.RecoveredTailBytes} { + putUint64(out, value) + } + *out = append(*out, h.SyncPolicy) + putUint64(out, h.SyncEveryBytes) + putBool(out, h.LastAckDurable) + return nil +} + +func decodeHealthOps(in *input) (*HealthOps, error) { + h := &HealthOps{} + for _, value := range []*uint64{&h.OverloadCount, &h.ProtocolErrorCount, &h.DatabaseBytes, &h.DatabasePoints, &h.DatabaseCommits, &h.RecoveredTailBytes} { + var err error + *value, err = in.uint64() + if err != nil { + return nil, err + } + } + var err error + if h.SyncPolicy, err = in.byte(); err != nil { + return nil, err + } + if h.SyncEveryBytes, err = in.uint64(); err != nil { + return nil, err + } + if h.LastAckDurable, err = in.boolean("last_ack_durable"); err != nil { + return nil, err + } + return h, h.validate() +} diff --git a/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/README.md b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/README.md new file mode 100644 index 00000000..af853d1b --- /dev/null +++ b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/README.md @@ -0,0 +1,41 @@ +# FTW shadow protocol v1 fixtures + +These files freeze every v1 request and response frame. The acknowledgement +kind has separate commit and flush examples. Each file contains lowercase hex, +one frame, and one trailing newline. Decode the hex before passing it to a wire +codec. + +`SHA256SUMS` hashes the `.hex` files as stored, including their final newline. +The Rust test checks the manifest with a small test-only SHA-256 function. Go +can use its standard `crypto/sha256` package for the same check. + +Rust tests build the named message, require an exact byte match, decode the +fixture, and require the same typed value. A Go client must run the same four +checks against these files before it can claim v1 support: + +1. hex decoding succeeds; +2. the frame checksum and all limits pass; +3. decoding yields the fields built in `tests/shadow_protocol_v1.rs`; +4. encoding that value yields the exact fixture bytes. + +Do not replace a fixture after release. A byte change needs a new protocol +version and a new directory. The commit fixture covers all catalog record +kinds, every property tag, both rollup resolution forms, every point field, +optional values, negative power, UTC microseconds, a run, a plan, and a +hardware-style telemetry value. Existing unit tests freeze every enum tag and +cover the other enum values. + +`health-response.hex` includes trailing ops fields (overload and protocol-error +counts, database bytes/points/commits, recovered tail, sync policy, and +last-ack durable). Decoders must still accept the shorter v1 prefix that omits +those fields and treat missing counts as zero with sync policy `always`. + +The corpus uses big-endian wire values. Its shared IDs include: + +- source: `00112233445566778899aabbccddeeff`; +- sequence: `0102030405060708`; +- commit: `ffeeddccbbaa99887766554433221100`; +- series: `1122334455667788`. + +The source sequence is an opaque, strictly increasing cursor. It does not need +to rise by one. Exact retries must reuse source, sequence, commit ID, and bytes. diff --git a/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/SHA256SUMS b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/SHA256SUMS new file mode 100644 index 00000000..e58fb345 --- /dev/null +++ b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/SHA256SUMS @@ -0,0 +1,9 @@ +3d17a2173006920c7a55f378149174e246279b4d38cbacb732f87a1f4dbb0c93 commit-ack-response.hex +2af383146ce0f510dd46ab3adc0b6f6fa5080f3b0f9c41f9a6bf4886a6ddac32 commit-batch-request.hex +d46652d1fa8b2391fcbd4a076dd5a8b63ef182628b7d6124eeb4755767d787fa error-response.hex +90b72deea2ce423f96be0dc6dbe605ceabb998023d7cf41e95cd6ea9a6c5d98a flush-ack-response.hex +bb9d868e82cbfeb38e5327ef52647ef0f23ed577e297b07ae9a179534e5dca89 flush-request.hex +fa222f89961bf859a2e67beb9d3c868d990fd6fc4474d2c37422a117c6b2289d health-request.hex +608bb2e49cef0b73a401cc85d7c8d3a276e71b4c6ae8ca2f71e41c7c4acc4796 health-response.hex +3e0d24dc7e0758feba275bf03a85ff0b8b774d21a37b1921e21adc58f9461917 hello-request.hex +0c0e480a7865d0609a9c36ee264d789f40a0ce9dc3a2182cefbe66b4706a804a hello-response.hex diff --git a/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/commit-ack-response.hex b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/commit-ack-response.hex new file mode 100644 index 00000000..9054d88f --- /dev/null +++ b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/commit-ack-response.hex @@ -0,0 +1 @@ +4654575300018100000000550100112233445566778899aabbccddeeff0102030405060708ffeeddccbbaa99887766554433221100010102030405060708010102030405060708010011121314151617180000000600000001212223242526272825675c1c diff --git a/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/commit-batch-request.hex b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/commit-batch-request.hex new file mode 100644 index 00000000..363ead8b --- /dev/null +++ b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/commit-batch-request.hex @@ -0,0 +1 @@ +4654575300010200000002c500112233445566778899aabbccddeeff0102030405060708ffeeddccbbaa9988776655443322110000000001102030405060708090a0b0c0d0e0f001000473697465000c465457207465737420626f780000063b99fbc272400100063bae1999d240000000050004626f6f6c01010005666c6f617403c0290000000000000003696e7402ffffffffffffffd600046e756c6c0000047465787404000b6772696420696d706f7274000000012030405060708090a0b0c0d0e0f0010200056665656473102030405060708090a0b0c0d0e0f001102030405060708090a0b0c0d0e0f00200063b99fbc272400000000001000570686173650400024c3100000001112233445566778801102030405060708090a0b0c0d0e0f00100000a677269645f706f7765720005706f776572000157010100000000004c4b400100000119a1c7400000000002010000000011e1a3000100001cae8c13e000020100104575726f70652f53746f636b686f6c6d000000000130405060708090a0b0c0d0e0f0010203020300063b99f5caaf0000063b99f8c59f8000096461792d616865616400086674772d706c616e0007323032362e30380100000000000000000000000000000001010000000000000000000000000000000200000001000674617269666604000353453400000001405060708090a0b0c0d0e0f00102030430405060708090a0b0c0d0e0f00102030300063b99fbc0900000063bae1997f0000000000011e1a300000462617365000000020008636f73745f73656b402880000000000000067065616b5f7740b194000000000001402880000000000001000000000000000000000000000000030000000100046d6f64650400046175746f00000001112233445566778800063b99fbc2724000063b9a0da4154000063b99f8c59f8000063b99f8d4e1c030405060708090a0b0c0d0e0f0010203c0934a000000000010203040506070808c897c4a diff --git a/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/error-response.hex b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/error-response.hex new file mode 100644 index 00000000..b2d11c3f --- /dev/null +++ b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/error-response.hex @@ -0,0 +1 @@ +465457530001830000000018050000146964656d706f74656e63792d636f6e666c696374556bb115 diff --git a/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/flush-ack-response.hex b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/flush-ack-response.hex new file mode 100644 index 00000000..bbe9895a --- /dev/null +++ b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/flush-ack-response.hex @@ -0,0 +1 @@ +4654575300018100000000550200112233445566778899aabbccddeeff010203040506070800000000000000000000000000000000010102030405060708010102030405060708010000000000000000000000000000000000000000000000000034c91dd6 diff --git a/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/flush-request.hex b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/flush-request.hex new file mode 100644 index 00000000..61ccba7a --- /dev/null +++ b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/flush-request.hex @@ -0,0 +1 @@ +46545753000103000000001800112233445566778899aabbccddeeff01020304050607089b12f22b diff --git a/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/health-request.hex b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/health-request.hex new file mode 100644 index 00000000..8755c55a --- /dev/null +++ b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/health-request.hex @@ -0,0 +1 @@ +46545753000104000000000811223344556677888a1435df diff --git a/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/health-response.hex b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/health-response.hex new file mode 100644 index 00000000..749a0679 --- /dev/null +++ b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/health-response.hex @@ -0,0 +1 @@ +465457530001820000000061112233445566778800112233445566778899aabbccddeeff02000000030101020304050607080000000000000000050000000000000007313233343536373800000000000000090000000000000004000000000000000001000000000000000000882d5bf7 diff --git a/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/hello-request.hex b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/hello-request.hex new file mode 100644 index 00000000..02ce2a13 --- /dev/null +++ b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/hello-request.hex @@ -0,0 +1 @@ +46545753000101000000003200112233445566778899aabbccddeeff000a6674772d626f782d3031000c676f2d6674772f302e312e300102030405060708a53e34a8 diff --git a/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/hello-response.hex b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/hello-response.hex new file mode 100644 index 00000000..4450d85e --- /dev/null +++ b/go/internal/ftwdbshadow/testdata/shadow-protocol-v1/hello-response.hex @@ -0,0 +1 @@ +46545753000180000000001a000100112233445566778899aabbccddeeff00063b99fbc272405bbddd7c diff --git a/go/internal/ftwdbshadow/types.go b/go/internal/ftwdbshadow/types.go new file mode 100644 index 00000000..c39a6dd8 --- /dev/null +++ b/go/internal/ftwdbshadow/types.go @@ -0,0 +1,364 @@ +// Package ftwdbshadow implements the local FTWDB shadow protocol. +// +// The wire codec and client use only the Go standard library. The state mapper +// keeps a narrow local boundary: FTW remains authoritative while it copies data +// to a local FTWDB sidecar. +package ftwdbshadow + +import ( + "encoding/hex" + "fmt" +) + +const ( + ProtocolVersion uint16 = 1 + MaxFrameBytes = 4 * 1024 * 1024 + MaxBatchPoints = 16_384 + MaxMetadataRecords = 16_384 + MaxQueueEntries uint32 = 65_536 + MaxProperties = 1_024 + MaxRollupTiers = 64 + MaxTextBytes = 4_096 +) + +var frameMagic = [4]byte{'F', 'T', 'W', 'S'} + +// ID128 is a protocol ID in wire order. +type ID128 [16]byte + +func ParseID128(value string) (ID128, error) { + var id ID128 + if len(value) != hex.EncodedLen(len(id)) { + return id, fmt.Errorf("ftwdb shadow id must contain 32 hex digits") + } + if _, err := hex.Decode(id[:], []byte(value)); err != nil { + return ID128{}, fmt.Errorf("decode ftwdb shadow id: %w", err) + } + return id, nil +} + +func (id ID128) String() string { + return hex.EncodeToString(id[:]) +} + +func (id ID128) IsZero() bool { + return id == ID128{} +} + +type messageKind byte + +const ( + kindHelloRequest messageKind = 1 + kindCommitBatchRequest messageKind = 2 + kindFlushRequest messageKind = 3 + kindHealthRequest messageKind = 4 + kindHelloResponse messageKind = 128 + kindAckResponse messageKind = 129 + kindHealthResponse messageKind = 130 + kindErrorResponse messageKind = 131 +) + +// Message is one complete v1 request or response. +type Message interface { + messageKind() messageKind +} + +type HelloRequest struct { + SourceID ID128 + NodeID string + ClientVersion string + Capabilities uint64 +} + +func (HelloRequest) messageKind() messageKind { return kindHelloRequest } + +type HelloResponse struct { + SelectedVersion uint16 + SessionID ID128 + ServerTimeMicros int64 +} + +func (HelloResponse) messageKind() messageKind { return kindHelloResponse } + +type CommitBatchRequest struct { + SourceID ID128 + Sequence uint64 + CommitID ID128 + Entities []Entity + Relations []Relation + Series []SeriesDefinition + Runs []Run + Plans []Plan + Points []Point +} + +func (CommitBatchRequest) messageKind() messageKind { return kindCommitBatchRequest } + +type FlushRequest struct { + SourceID ID128 + ThroughSequence uint64 +} + +func (FlushRequest) messageKind() messageKind { return kindFlushRequest } + +type HealthRequest struct { + Nonce uint64 +} + +func (HealthRequest) messageKind() messageKind { return kindHealthRequest } + +type AckKind byte + +const ( + AckCommitBatch AckKind = 1 + AckFlush AckKind = 2 +) + +type Ack struct { + Kind AckKind + SourceID ID128 + Sequence uint64 + CommitID ID128 + AcceptedThroughSequence *uint64 + DurableThroughSequence *uint64 + Durable bool + Deduplicated bool + FrameOffset uint64 + Records uint32 + Points uint32 + BytesWritten uint64 +} + +func (Ack) messageKind() messageKind { return kindAckResponse } + +type HealthStatus byte + +const ( + HealthHealthy HealthStatus = 1 + HealthDegraded HealthStatus = 2 + HealthUnavailable HealthStatus = 3 +) + +type HealthResponse struct { + Nonce uint64 + SourceID ID128 + Status HealthStatus + QueueEntries uint32 + AcceptedThroughSequence *uint64 + DurableThroughSequence *uint64 + Ops *HealthOps +} + +type HealthOps struct { + OverloadCount uint64 `json:"overload_count"` + ProtocolErrorCount uint64 `json:"protocol_error_count"` + DatabaseBytes uint64 `json:"database_bytes"` + DatabasePoints uint64 `json:"database_points"` + DatabaseCommits uint64 `json:"database_commits"` + RecoveredTailBytes uint64 `json:"recovered_tail_bytes"` + SyncPolicy byte `json:"sync_policy"` // 1 always, 2 manual, 3 every N bytes + SyncEveryBytes uint64 `json:"sync_every_bytes"` + LastAckDurable bool `json:"last_ack_durable"` +} + +func (HealthResponse) messageKind() messageKind { return kindHealthResponse } + +type ErrorCode byte + +const ( + ErrorInvalidRequest ErrorCode = 1 + ErrorOverloaded ErrorCode = 2 + ErrorInternal ErrorCode = 3 + ErrorUnsupported ErrorCode = 4 + ErrorIdempotencyConflict ErrorCode = 5 +) + +type ErrorResponse struct { + Code ErrorCode + Retryable bool + Message string +} + +func (ErrorResponse) messageKind() messageKind { return kindErrorResponse } + +type PropertyKind byte + +const ( + PropertyNull PropertyKind = 0 + PropertyBool PropertyKind = 1 + PropertyInteger PropertyKind = 2 + PropertyFloat PropertyKind = 3 + PropertyText PropertyKind = 4 +) + +type PropertyValue struct { + Kind PropertyKind + Bool bool + Integer int64 + Float float64 + Text string +} + +func NullProperty() PropertyValue { + return PropertyValue{Kind: PropertyNull} +} + +func BoolProperty(value bool) PropertyValue { + return PropertyValue{Kind: PropertyBool, Bool: value} +} + +func IntegerProperty(value int64) PropertyValue { + return PropertyValue{Kind: PropertyInteger, Integer: value} +} + +func FloatProperty(value float64) PropertyValue { + return PropertyValue{Kind: PropertyFloat, Float: value} +} + +func TextProperty(value string) PropertyValue { + return PropertyValue{Kind: PropertyText, Text: value} +} + +type Entity struct { + ID ID128 + Kind string + Name string + Parent *ID128 + ValidFrom int64 + ValidTo *int64 + Properties map[string]PropertyValue +} + +type Relation struct { + ID ID128 + Kind string + Source ID128 + Target ID128 + ValidFrom int64 + ValidTo *int64 + Properties map[string]PropertyValue +} + +type SeriesSemantics byte + +const ( + SeriesGauge SeriesSemantics = 1 + SeriesIntervalTotal SeriesSemantics = 2 + SeriesCounter SeriesSemantics = 3 + SeriesState SeriesSemantics = 4 + SeriesEvent SeriesSemantics = 5 +) + +type CalendarUnit byte + +const ( + CalendarDay CalendarUnit = 1 + CalendarMonth CalendarUnit = 2 + CalendarYear CalendarUnit = 3 +) + +type RollupResolutionKind byte + +const ( + RollupFixedMicros RollupResolutionKind = 1 + RollupCalendar RollupResolutionKind = 2 +) + +type RollupResolution struct { + Kind RollupResolutionKind + FixedMicros int64 + CalendarUnit CalendarUnit + IANATimezone string +} + +type RollupTier struct { + Resolution RollupResolution + RetainForMicros *int64 +} + +type RollupPolicy struct { + RawRetainForMicros *int64 + Tiers []RollupTier +} + +type SeriesDefinition struct { + ID uint64 + OwnerEntity *ID128 + OwnerRelation *ID128 + Name string + PhysicalQuantity string + CanonicalUnit string + Semantics SeriesSemantics + MaximumGapMicros *int64 + RollupPolicy RollupPolicy +} + +type RunKind byte + +const ( + RunForecast RunKind = 1 + RunOptimization RunKind = 2 + RunImport RunKind = 3 + RunControl RunKind = 4 + RunReconciliation RunKind = 5 +) + +type RunStatus byte + +const ( + RunPending RunStatus = 1 + RunRunning RunStatus = 2 + RunSucceeded RunStatus = 3 + RunFailed RunStatus = 4 + RunCancelled RunStatus = 5 +) + +type Run struct { + ID ID128 + Kind RunKind + Status RunStatus + CreatedAt int64 + KnowledgeTime int64 + Workflow string + Model string + ModelVersion string + ParentRun *ID128 + InputSnapshot *ID128 + Attributes map[string]PropertyValue +} + +type PlanStatus byte + +const ( + PlanCandidate PlanStatus = 1 + PlanApproved PlanStatus = 2 + PlanDeployed PlanStatus = 3 + PlanSuperseded PlanStatus = 4 + PlanCancelled PlanStatus = 5 +) + +type Plan struct { + ID ID128 + RunID ID128 + Status PlanStatus + HorizonStart int64 + HorizonEnd int64 + ResolutionMicros int64 + Scenario string + ObjectiveTerms map[string]float64 + ObjectiveValue *float64 + Supersedes *ID128 + Attributes map[string]PropertyValue +} + +type Point struct { + SeriesID uint64 + ValidTime int64 + ValidTimeEnd int64 + KnowledgeTime int64 + ChangeTime int64 + RunID ID128 + Value float64 + Quality uint32 + Flags uint32 +} diff --git a/go/internal/state/history_feed.go b/go/internal/state/history_feed.go new file mode 100644 index 00000000..2fcabf76 --- /dev/null +++ b/go/internal/state/history_feed.go @@ -0,0 +1,75 @@ +package state + +import ( + "sync" + "time" +) + +// CommittedHistory contains only the numeric site history from one successful +// live tick transaction. Sequence follows delivery after commit, including +// clock rollback; it is not a persistent database change cursor. +// SQL imports, retention and other tables are outside this feed's scope. +type CommittedHistory struct { + Sequence uint64 + CommittedAtMicros int64 + Point HistoryPoint +} + +type HistoryFeed struct { + mu sync.Mutex + events chan CommittedHistory + offered uint64 + dropped uint64 +} + +type HistoryFeedStats struct { + Offered uint64 `json:"offered_ticks"` + Dropped uint64 `json:"dropped_ticks"` + Queued int `json:"queued_ticks"` +} + +// ObserveLiveHistory enables one bounded feed. Call before starting writers. +// The feed has no I/O, callback or backpressure path into a SQLite writer. +func (s *Store) ObserveLiveHistory() *HistoryFeed { + feed := &HistoryFeed{events: make(chan CommittedHistory, 256)} + s.historyFeedMu.Lock() + s.historyFeed = feed + s.historyFeedMu.Unlock() + return feed +} + +func (s *Store) offerCommittedHistory(p *HistoryPoint) { + if p == nil { + return + } + s.historyFeedMu.RLock() + feed := s.historyFeed + s.historyFeedMu.RUnlock() + if feed == nil { + return + } + point := *p + point.JSON = "" // The beta copies numeric site history only. + feed.mu.Lock() + defer feed.mu.Unlock() + feed.offered++ + select { + case feed.events <- CommittedHistory{Sequence: feed.offered, CommittedAtMicros: time.Now().UnixMicro(), Point: point}: + default: + feed.dropped++ + } +} + +func (f *HistoryFeed) Events() <-chan CommittedHistory { return f.events } + +func (f *HistoryFeed) Stats() HistoryFeedStats { + f.mu.Lock() + defer f.mu.Unlock() + return HistoryFeedStats{Offered: f.offered, Dropped: f.dropped, Queued: len(f.events)} +} + +func (f *HistoryFeed) MarkDropped(count uint64) { + f.mu.Lock() + f.dropped += count + f.mu.Unlock() +} diff --git a/go/internal/state/history_feed_test.go b/go/internal/state/history_feed_test.go new file mode 100644 index 00000000..0e34a203 --- /dev/null +++ b/go/internal/state/history_feed_test.go @@ -0,0 +1,57 @@ +package state + +import "testing" + +func TestLiveHistoryFeedOnlyOffersCommittedRows(t *testing.T) { + s := freshStore(t) + feed := s.ObserveLiveHistory() + if _, err := s.db.Exec(`CREATE TRIGGER fail_tick BEFORE INSERT ON history_hot BEGIN SELECT RAISE(ABORT, 'disk failure'); END`); err != nil { + t.Fatal(err) + } + if err := s.RecordTick(HistoryPoint{TsMs: 200, GridW: 42}, nil); err == nil { + t.Fatal("write should fail") + } + if got := feed.Stats(); got.Offered != 0 { + t.Fatalf("uncommitted row offered: %+v", got) + } + if _, err := s.db.Exec(`DROP TRIGGER fail_tick`); err != nil { + t.Fatal(err) + } + // Late and same-time writes have their own source sequence, independent of + // wall-clock time. SQL retention and imports remain outside the live feed. + for _, ts := range []int64{200, 100, 100} { + if err := s.RecordTick(HistoryPoint{TsMs: ts, GridW: float64(ts), JSON: "private detail"}, nil); err != nil { + t.Fatal(err) + } + } + for i, ts := range []int64{200, 100, 100} { + tick := <-feed.Events() + if tick.Sequence != uint64(i+1) || tick.Point.TsMs != ts || tick.Point.JSON != "" || tick.CommittedAtMicros == 0 { + t.Fatalf("wrong committed point: %+v", tick) + } + } + if err := s.RecordTickWithOptionalHistory(nil, nil, nil); err != nil { + t.Fatal(err) + } + if got := feed.Stats(); got.Offered != 3 { + t.Fatal("missing history became a zero observation") + } +} + +func TestLiveHistoryFeedHasBoundedMemoryAndNoBackpressure(t *testing.T) { + s := freshStore(t) + feed := s.ObserveLiveHistory() + for i := 0; i < 400; i++ { + if err := s.RecordTick(HistoryPoint{TsMs: int64(i + 1)}, nil); err != nil { + t.Fatal(err) + } + } + got := feed.Stats() + if got.Queued != 256 || got.Dropped != 144 || got.Offered != 400 { + t.Fatalf("unbounded or unreported gap: %+v", got) + } + rows, err := s.LoadHistory(0, 500, 0) + if err != nil || len(rows) != 400 { + t.Fatalf("shadow overload lost SQLite data: %d %v", len(rows), err) + } +} diff --git a/go/internal/state/store.go b/go/internal/state/store.go index ef67150c..cf02255a 100644 --- a/go/internal/state/store.go +++ b/go/internal/state/store.go @@ -46,6 +46,9 @@ const ( // // See heal.go for the boot-time integrity gate that populates healEvents. type Store struct { + historyFeedMu sync.RWMutex + historyFeed *HistoryFeed + db *sql.DB cache *sql.DB ts *internCache diff --git a/go/internal/state/store_ts.go b/go/internal/state/store_ts.go index 0efc5f12..4d953170 100644 --- a/go/internal/state/store_ts.go +++ b/go/internal/state/store_ts.go @@ -343,7 +343,11 @@ func (s *Store) RecordTickWithOptionalHistory(p *HistoryPoint, samples []Sample, if err := recordEnergyObservationsTx(tx, observations); err != nil { return err } - return tx.Commit() + if err := tx.Commit(); err != nil { + return err + } + s.offerCommittedHistory(p) + return nil } // LoadSeries returns one metric's history for one driver in [sinceMs, untilMs]. From 6af6e294996d25ec7289822a69216aff2be8e0b1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 7 Sep 2026 15:42:34 +0200 Subject: [PATCH 2/4] fix(storage): pin the final FTWDB beta package --- .changeset/ftwdb-live-history-candidate.md | 2 +- .github/workflows/ftwdb-shadow-contract.yml | 2 +- docker-compose.ftwdb-shadow.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/ftwdb-live-history-candidate.md b/.changeset/ftwdb-live-history-candidate.md index 332aae0a..d5e45780 100644 --- a/.changeset/ftwdb-live-history-candidate.md +++ b/.changeset/ftwdb-live-history-candidate.md @@ -1,4 +1,4 @@ --- -"forty-two-watts": minor +"ftw": minor --- Add an optional FTWDB beta sidecar that copies committed live site history through a bounded memory queue. Keep SQLite and Parquet authoritative and report candidate gaps, errors and durable acknowledgements in health. diff --git a/.github/workflows/ftwdb-shadow-contract.yml b/.github/workflows/ftwdb-shadow-contract.yml index 2ce859be..dd2005b3 100644 --- a/.github/workflows/ftwdb-shadow-contract.yml +++ b/.github/workflows/ftwdb-shadow-contract.yml @@ -25,7 +25,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: srcfl/ftwdb - ref: 4927c1cda891941e77f22122dd225a2dde1d43e3 + ref: 1a74d62667ffe5d30d0ee58a1bcfdab8d7eabfb6 path: .ftwdb-contract - uses: actions/setup-go@v7 with: diff --git a/docker-compose.ftwdb-shadow.yml b/docker-compose.ftwdb-shadow.yml index fe900682..96bc7329 100644 --- a/docker-compose.ftwdb-shadow.yml +++ b/docker-compose.ftwdb-shadow.yml @@ -10,8 +10,8 @@ services: ftwdb-shadow: profiles: [ftwdb-shadow] build: - context: https://github.com/srcfl/ftwdb.git#4927c1cda891941e77f22122dd225a2dde1d43e3 - image: ftwdb-shadow:4927c1cda891941e77f22122dd225a2dde1d43e3 + context: https://github.com/srcfl/ftwdb.git#1a74d62667ffe5d30d0ee58a1bcfdab8d7eabfb6 + image: ftwdb-shadow:1a74d62667ffe5d30d0ee58a1bcfdab8d7eabfb6 user: "100:101" network_mode: none read_only: true From 3c3bab44e226fd8fd6c3ddc7002545850418cbd8 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 7 Sep 2026 15:47:49 +0200 Subject: [PATCH 3/4] chore(storage): pin the verified FTWDB fault reports --- .github/workflows/ftwdb-shadow-contract.yml | 2 +- docker-compose.ftwdb-shadow.yml | 4 ++-- docs/ftwdb-shadow.md | 9 ++++++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ftwdb-shadow-contract.yml b/.github/workflows/ftwdb-shadow-contract.yml index dd2005b3..7bdc6988 100644 --- a/.github/workflows/ftwdb-shadow-contract.yml +++ b/.github/workflows/ftwdb-shadow-contract.yml @@ -25,7 +25,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: srcfl/ftwdb - ref: 1a74d62667ffe5d30d0ee58a1bcfdab8d7eabfb6 + ref: 6a2f6c88cb89200307487e864e79d622e803e956 path: .ftwdb-contract - uses: actions/setup-go@v7 with: diff --git a/docker-compose.ftwdb-shadow.yml b/docker-compose.ftwdb-shadow.yml index 96bc7329..98aa6627 100644 --- a/docker-compose.ftwdb-shadow.yml +++ b/docker-compose.ftwdb-shadow.yml @@ -10,8 +10,8 @@ services: ftwdb-shadow: profiles: [ftwdb-shadow] build: - context: https://github.com/srcfl/ftwdb.git#1a74d62667ffe5d30d0ee58a1bcfdab8d7eabfb6 - image: ftwdb-shadow:1a74d62667ffe5d30d0ee58a1bcfdab8d7eabfb6 + context: https://github.com/srcfl/ftwdb.git#6a2f6c88cb89200307487e864e79d622e803e956 + image: ftwdb-shadow:6a2f6c88cb89200307487e864e79d622e803e956 user: "100:101" network_mode: none read_only: true diff --git a/docs/ftwdb-shadow.md b/docs/ftwdb-shadow.md index 061c11c5..8674dd48 100644 --- a/docs/ftwdb-shadow.md +++ b/docs/ftwdb-shadow.md @@ -27,9 +27,12 @@ volume, no network, a 256 MiB memory limit and a quarter CPU. Only the private Unix socket volume is shared with Core. Both processes use UID 100, GID 101. There is no startup or health dependency from Core to FTWDB. -For a native install, use the FTWDB service example with the same user as Core. -Pass `-ftwdb-shadow-socket /run/ftwdb-shadow/ftwdb-shadow.sock` to Core or set -`FTWDB_SHADOW_SOCKET` to that path. An empty value disables the candidate. +For a native Linux install, use the pinned +[systemd service example](https://github.com/srcfl/ftwdb/blob/6a2f6c88cb89200307487e864e79d622e803e956/packaging/systemd/ftwdb-shadow.service) +with the same user as Core. That service listens on +`/run/ftwdb-shadow/ftwdb-shadow.sock`. Pass that path with +`-ftwdb-shadow-socket` or `FTWDB_SHADOW_SOCKET` to Core. +An empty value disables the candidate. This is an install option; household Settings do not expose an experimental storage switch. From f305fce493f346c39c7a212676517c9c7be87adf Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 7 Sep 2026 16:07:56 +0200 Subject: [PATCH 4/4] chore(storage): pin FTWDB segment and scan hardening --- .github/workflows/ftwdb-shadow-contract.yml | 2 +- docker-compose.ftwdb-shadow.yml | 4 ++-- docs/ftwdb-shadow.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ftwdb-shadow-contract.yml b/.github/workflows/ftwdb-shadow-contract.yml index 7bdc6988..1261c390 100644 --- a/.github/workflows/ftwdb-shadow-contract.yml +++ b/.github/workflows/ftwdb-shadow-contract.yml @@ -25,7 +25,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: srcfl/ftwdb - ref: 6a2f6c88cb89200307487e864e79d622e803e956 + ref: 7bbae63532f695b10aca548bf4ee58c6d7ebb3a8 path: .ftwdb-contract - uses: actions/setup-go@v7 with: diff --git a/docker-compose.ftwdb-shadow.yml b/docker-compose.ftwdb-shadow.yml index 98aa6627..c0bb2f21 100644 --- a/docker-compose.ftwdb-shadow.yml +++ b/docker-compose.ftwdb-shadow.yml @@ -10,8 +10,8 @@ services: ftwdb-shadow: profiles: [ftwdb-shadow] build: - context: https://github.com/srcfl/ftwdb.git#6a2f6c88cb89200307487e864e79d622e803e956 - image: ftwdb-shadow:6a2f6c88cb89200307487e864e79d622e803e956 + context: https://github.com/srcfl/ftwdb.git#7bbae63532f695b10aca548bf4ee58c6d7ebb3a8 + image: ftwdb-shadow:7bbae63532f695b10aca548bf4ee58c6d7ebb3a8 user: "100:101" network_mode: none read_only: true diff --git a/docs/ftwdb-shadow.md b/docs/ftwdb-shadow.md index 8674dd48..60c4448f 100644 --- a/docs/ftwdb-shadow.md +++ b/docs/ftwdb-shadow.md @@ -28,7 +28,7 @@ Unix socket volume is shared with Core. Both processes use UID 100, GID 101. There is no startup or health dependency from Core to FTWDB. For a native Linux install, use the pinned -[systemd service example](https://github.com/srcfl/ftwdb/blob/6a2f6c88cb89200307487e864e79d622e803e956/packaging/systemd/ftwdb-shadow.service) +[systemd service example](https://github.com/srcfl/ftwdb/blob/7bbae63532f695b10aca548bf4ee58c6d7ebb3a8/packaging/systemd/ftwdb-shadow.service) with the same user as Core. That service listens on `/run/ftwdb-shadow/ftwdb-shadow.sock`. Pass that path with `-ftwdb-shadow-socket` or `FTWDB_SHADOW_SOCKET` to Core.