t= mw=
` |
+| 2 | `CONFIG MISMATCH ` — the arms did not run the same workload (or base cannot host the harness) |
+| 3 | `INCONCLUSIVE paired-CV=%` / `INCONCLUSIVE load-marked=/` / `INCONCLUSIVE probe …` |
+| 4 | `UNDERPOWERED N'=` |
+
+Precedence when several apply: config mismatch, load-marked, hard rows,
+paired CV, statistical regression, underpowered, pass.
+
+Artifacts (rounds, logs, `schedule.txt`, `verdict-table.txt`, `verdict.txt`)
+land in `.omc/artifacts/perf-ab//` unless `PERF_AB_OUT_DIR` is
+set. `.github/workflows/perf-ab.yaml` runs the same driver on
+`workflow_dispatch`, nightly against the previous day's `main`, and on PRs
+labelled `perf`; runners are noisy, so INCONCLUSIVE is expected often there
+and is reported in the check summary, never hidden — the local run on a quiet
+machine is the authoritative one. Only a REGRESSION fails the check.
+
+Two harness isolation choices are pinned per round and echoed in `effective`:
+the SQLite busy timeout is 5 s (production: 60 s), and `collapseSettingsTTL`
+is one hour (production: 10 s). The latter because a consolidation save that
+has already written refreshes the CollapseConfiguration cache on a second
+connection, and with no CR present `get()`'s `DeleteMetadata` on that
+connection waits on the write lock the same goroutine holds — a self-deadlock
+resolved only by the busy timeout, during which every shard commit waits too.
+Whether a round crosses a TTL boundary is wall-clock phase, not the change
+under test; the stall itself shows up in the `over-one-sec` row of an
+unpinned run.
+
+`TestContainerProfileLoad` (`LOAD_TEST=1`) remains as a time-boxed
+diagnostic with env tunables; its absolute numbers are not evidence.
+
+## The rule this adds
+
+For any change under
+`pkg/registry/file/{storage,singlewriter,containerprofile_*,sqlite}.go`:
+
+```
+go build ./...
+go test ./... # includes TestWorkBudget
+go test -race -count=20 ./pkg/registry/file/ -run 'Consolidate|SingleWriter|Delete'
+make perf-ab BASE=$(git merge-base origin/main HEAD) # quote the verdict line
+```
+
+A golden change is stated per row in the commit message; the verdict line is
+quoted in the PR description; SHIP is not written against INCONCLUSIVE or
+UNDERPOWERED without a second run on a quieter machine or at `N'`.
diff --git a/docs/features/write-gate-sharing.md b/docs/features/write-gate-sharing.md
new file mode 100644
index 000000000..c8f9fa114
--- /dev/null
+++ b/docs/features/write-gate-sharing.md
@@ -0,0 +1,109 @@
+# Write-gate sharing: one SQLite writer for every kind
+
+**Status:** implemented behind `containerProfileSqliteBackend` (R0 + R1 of
+`.omc/plans/write-gate-sharing.md`); the flag flip (R2) and the shard removal
+(R3) are separate, later steps.
+
+## The bug class
+
+SQLite has one write lock per database. Under the ContainerProfile SQLite
+backend every ObjectStore write goes through a *write gate*: a FIFO ticket
+queue in Go owning one dedicated connection, `BEGIN IMMEDIATE … COMMIT`. Every
+write that does **not** go through the gate acquires the same lock through
+SQLite's busy handler, which polls every 1…100 ms, is not `ctx`-bounded, and
+loses systematically against a gate that commits continuously. Such a writer
+stalls for the whole busy timeout (60 s in production) and the gate's own
+histograms never see it — the loser is on a pool connection the gate does not
+instrument.
+
+Two instances were found and point-fixed before this change (the collapse
+settings self-stall, #401; the SBOM self-repair stall, `df50b1e3`). Both were
+the same `DELETE FROM metadata` reached two ways. Nine more sites remained
+(`write-gate-sharing.md` §1, W1–W9b): the shard commit, `saveObject`,
+`deleteLocked`, `get()`'s four self-repair deletes, the three gob-migration
+rewrites and the cleanup tick's row delete and sidecar migration.
+
+## What changed
+
+- **One gate per process.** `main.go` builds the gate beside the pool when the
+ flag is on (`file.NewWriteGate`) and hands it to the ObjectStore, the legacy
+ `StorageImpl` (`SetWriteGate`) and the cleanup handler. The apiserver's
+ pre-shutdown hook closes the store, then the gate, before `Pool.Close`.
+ With the flag off no gate exists and every site runs today's code, byte for
+ byte (Tier A goldens unchanged).
+- **One helper at nine sites.** `gatedWrite` (`storage.go`) is the only place
+ the flag-off/flag-on branch is chosen: bare statements or today's savepoint
+ on the caller's connection without a gate; `gate.run` with one.
+ - W1 `singleWriter.commitGated`: the shard holds **no pool connection**
+ while queued; the CAS read joins the gated transaction.
+ - W2 `saveObject(ctx, conn, …, priority, path)`: the row + rename.
+ - W3 `deleteLockedGated`: `DELETE … RETURNING` captured raw, decoded after
+ release; `Delete` takes `Lock(key)` only.
+ - W4–W7a `repairDelete`; W6b/W7b/W8 through W2 (`migrate` label).
+ - W9a/W9b `ResourcesCleanupHandler.write` on the tick's `ctx`.
+- **The gate is not re-entrant, and says so.** `fn` receives a ctx marked as
+ gate-held; a nested acquire through it is refused at O(1)
+ (`storage_write_gate_reentrant_total`; a panic under the test binary). A
+ watchdog logs a hold that outlives `gateWatchdogThreshold` with every
+ goroutine's stack (`storage_write_gate_hold_age_seconds`).
+- **One gate per pool, enforced.** `newWriteGate` refuses a pool that already
+ has a live gate (`writegate_registry.go`).
+- **Refused configuration.** `containerProfileSqliteBackend` without
+ `singleWriterEnabled` is fatal at startup and `ErrGateRequiresSingleWriter`
+ at runtime: with the single writer off every REST write would queue on the
+ gate holding a pool connection.
+
+### The connection rule and where it is relaxed
+
+Hot paths hold no pool connection while queued (the shard commit, `Delete`).
+Cold paths — a repair from `Get`, a migration rewrite, the cleanup walk — keep
+the connection they already hold; the bound is measured, not assumed
+(`TestTG5_…`, `storage_pool_wait_duration_seconds{outcome="timeout"}` = 0).
+
+## The invariant, not the enumeration
+
+The proof is not that W1–W9b are fixed; it is that the next site fails CI:
+
+- **AC-G1** (`main_test.go`, `writegate_registry.go`): every pool connection
+ carries an authorizer that reports each INSERT/UPDATE/DELETE prepared on it.
+ In production it counts `storage_sqlite_ungated_write_total{op,table}` when
+ the pool has a gate that never owned the connection — the R2 canary, alert
+ at > 0. Under the test binary every such statement is recorded with its
+ stack and judged at each gated environment's cleanup (and once more at
+ exit): a write on a connection no gate of the pool ever owned fails the
+ test naming the site. Fixtures seed state through a non-pool handle
+ (`openFixtureConn`); the recorder is never windowed off (a cached statement
+ re-executes without the authorizer).
+- **AC-G2** (`writegate_acg2_test.go`, `writegate_acg2_on_test.go`): read
+ entry point × key state, per topology. Ordinary reads complete while the
+ writer is held. Flag-off repairs attempt a write but leave metadata unchanged
+ under the held SQLite lock; flag-on repairs queue behind the gate and complete
+ after explicit release. Generous deadlines detect deadlocks; elapsed times
+ are diagnostic measurements, not scheduler-sensitive pass/fail thresholds.
+- **T-G1** (`writegate_tg1_test.go`): one env per site, W1–W9b.
+
+## Metrics
+
+| Series | Meaning |
+|---|---|
+| `storage_sqlite_write_hold_seconds{path,kind}` | gate hold per transaction; legacy paths `legacy_commit`, `legacy_delete`, `repair`, `migrate`, `cleanup`, `cleanup_migrate` |
+| `storage_sqlite_write_hold_step_seconds{path,step}` | the legacy commit's payload rename |
+| `storage_sqlite_ungated_write_total{op,table}` | must stay 0 (AC-G1) |
+| `storage_sqlite_busy_wait_seconds` | must stay ~0 with the flag on (AC-G3) |
+| `storage_write_gate_reentrant_total{path}` | must stay 0 |
+| `storage_write_gate_hold_age_seconds` | alert at > 5 s |
+
+## Measuring
+
+Tier B carries legacy traffic (`containerprofile_load_test.go`, harness
+version 3): two writers cycling sbomsyft objects at `LOAD_LEGACY_KB` and small
+vulnerability manifests through create → update → delete, and one cleanup tick
+reclaiming `LOAD_CLEANUP_ROWS` rows, concurrent with the CP scenario.
+
+```
+PERF_AB_BACKEND=objectstore PERF_AB_OUT=round.json go test -run TestPerfABRound ./pkg/registry/file/
+```
+
+reports `hold-p99-ms/`, `busy-wait-max-ms`, `ungated-writes`,
+`pool-wait-timeouts` and the legacy classes' latencies next to the CP ones;
+`hack/perf-ab.sh` compares two arms.
diff --git a/go.mod b/go.mod
index 6d21212e3..7ae7df5d8 100644
--- a/go.mod
+++ b/go.mod
@@ -21,6 +21,7 @@ require (
github.com/kubescape/k8s-interface v0.0.214
github.com/ncw/directio v1.0.5
github.com/olvrng/ujson v1.1.0
+ github.com/prometheus/client_model v0.6.2
github.com/puzpuzpuz/xsync/v2 v2.4.1
github.com/spf13/afero v1.15.0
github.com/spf13/cobra v1.10.2
@@ -43,6 +44,7 @@ require (
k8s.io/klog/v2 v2.130.1
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5
+ pgregory.net/rapid v1.2.0
sigs.k8s.io/structured-merge-diff/v6 v6.3.0
zombiezen.com/go/sqlite v1.4.0
)
@@ -141,7 +143,6 @@ require (
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
- github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
diff --git a/go.sum b/go.sum
index bf06587cd..c55f4283e 100644
--- a/go.sum
+++ b/go.sum
@@ -1387,6 +1387,8 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
+pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
diff --git a/hack/perf-ab.sh b/hack/perf-ab.sh
new file mode 100755
index 000000000..9d0c33283
--- /dev/null
+++ b/hack/perf-ab.sh
@@ -0,0 +1,186 @@
+#!/usr/bin/env bash
+# Tier B of the storage measurement harness: paired A/B of HEAD against its
+# merge-base, rebuilt in the same run window on the same machine.
+# Design: .omc/plans/raw-write-bypass-elimination.md, A.13.4.
+#
+# make perf-ab # BASE = git merge-base origin/main HEAD
+# BASE= PAIRS=10 hack/perf-ab.sh
+#
+# Inputs (env):
+# BASE commit to compare against (default: merge-base with origin/main)
+# PAIRS interleaved (base, head) rounds (default 10)
+# PROBE_ROUNDS base-only early-abort rounds (default 3)
+# PERF_AB_OUT_DIR where rounds, logs, schedule.txt and verdict.txt go
+# PERF_AB_ALLOW_NOISY 1 to run despite a high 1-minute load average (verdict stamped "(noisy)")
+# PERF_AB_KEEP 1 to keep the base worktree and build artifacts
+# PERF_AB_BASE_ENV extra KEY=VALUE pairs (space-separated) for the base arm's rounds
+# PERF_AB_HEAD_ENV ... for the head arm's rounds. With BASE=HEAD this turns the A/B
+# into a same-commit comparison of two configurations, e.g.
+# PERF_AB_BASE_ENV="PERF_AB_BACKEND=legacy"
+# PERF_AB_HEAD_ENV="PERF_AB_BACKEND=objectstore"
+# LOAD_HOT_KEYS=1 in BOTH arms selects the same-key
+# contention shape (every updater on base key 0); it is
+# part of the effective config, so one arm alone is a
+# CONFIG MISMATCH.
+#
+# Exit codes: 0 PASS, 1 REGRESSION, 2 CONFIG MISMATCH (or base cannot host
+# HEAD's harness), 3 INCONCLUSIVE, 4 UNDERPOWERED.
+set -euo pipefail
+
+ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+PKG=pkg/registry/file
+PAIRS=${PAIRS:-10}
+PROBE_ROUNDS=${PROBE_ROUNDS:-3}
+GOMAXPROCS_PIN=${GOMAXPROCS_PIN:-8}
+THRESHOLDS=$PKG/testdata/perfab.thresholds.json
+# Files overlaid onto the base worktree so both arms run HEAD's instrument.
+HARNESS_FILES=("$PKG/containerprofile_load_test.go" "$THRESHOLDS")
+
+OUT=${PERF_AB_OUT_DIR:-$ROOT/.omc/artifacts/perf-ab/$(date +%Y%m%d-%H%M%S)}
+mkdir -p "$OUT"
+BASE_WT=$OUT/base-worktree
+
+cd "$ROOT"
+HEAD_SHA=$(git rev-parse HEAD)
+if [ -z "${BASE:-}" ]; then
+ BASE=$(git merge-base origin/main HEAD)
+fi
+BASE_SHA=$(git rev-parse --verify "$BASE^{commit}")
+
+log() { printf '%s %s\n' "$(date +%H:%M:%S)" "$*"; }
+die() { echo "perf-ab: $*" >&2; exit "${2:-2}"; }
+
+cleanup() {
+ if [ "${PERF_AB_KEEP:-0}" != "1" ] && [ -d "$BASE_WT" ]; then
+ git -C "$ROOT" worktree remove --force "$BASE_WT" >/dev/null 2>&1 || true
+ fi
+}
+trap cleanup EXIT
+
+# 1. Start-of-run load check: an early abort, not the control (see step 5).
+NPROC=$(nproc)
+LOAD_BOUND=$(( NPROC / 2 ))
+load1() { cut -d' ' -f1 /proc/loadavg; }
+NOISY_FLAG=""
+L1=$(load1)
+if awk -v l="$L1" -v b="$LOAD_BOUND" 'BEGIN{exit !(l > b)}'; then
+ if [ "${PERF_AB_ALLOW_NOISY:-0}" = "1" ]; then
+ log "WARNING: 1-minute load $L1 exceeds nproc/2=$LOAD_BOUND; continuing (PERF_AB_ALLOW_NOISY=1), verdict will be stamped (noisy)"
+ NOISY_FLAG="-noisy"
+ else
+ echo "INCONCLUSIVE load=$L1 exceeds nproc/2=$LOAD_BOUND at start; refusing to run (PERF_AB_ALLOW_NOISY=1 overrides)" | tee "$OUT/verdict.txt"
+ exit 3
+ fi
+fi
+log "load check: 1-minute load $L1, bound nproc/2=$LOAD_BOUND"
+
+# 2. CPU set from nproc: the first floor(nproc/2) distinct physical cores
+# when lscpu can tell them apart, else the first floor(nproc/2) CPUs.
+NCPU=$(( NPROC / 2 ))
+[ "$NCPU" -ge 1 ] || NCPU=1
+if command -v lscpu >/dev/null 2>&1 && lscpu -p=CPU,CORE >/dev/null 2>&1; then
+ CPUSET=$(lscpu -p=CPU,CORE | grep -v '^#' | awk -F, '!seen[$2]++ {print $1}' | head -n "$NCPU" | paste -sd, -)
+else
+ CPUSET="0-$(( NCPU - 1 ))"
+fi
+PIN=(env "GOMAXPROCS=$GOMAXPROCS_PIN")
+if command -v taskset >/dev/null 2>&1; then
+ PIN=(taskset -c "$CPUSET" env "GOMAXPROCS=$GOMAXPROCS_PIN")
+fi
+BASE_ENV=${PERF_AB_BASE_ENV:-}
+HEAD_ENV=${PERF_AB_HEAD_ENV:-}
+log "head=$HEAD_SHA base=$BASE_SHA pairs=$PAIRS probe=$PROBE_ROUNDS cpuset=$CPUSET GOMAXPROCS=$GOMAXPROCS_PIN out=$OUT base_env='$BASE_ENV' head_env='$HEAD_ENV'"
+
+# 3. Build both binaries with the same harness: HEAD's harness files are
+# overlaid onto the base worktree before `go test -c`.
+git worktree add --detach "$BASE_WT" "$BASE_SHA" >/dev/null 2>&1 || die "git worktree add $BASE_SHA failed"
+for f in "${HARNESS_FILES[@]}"; do
+ mkdir -p "$BASE_WT/$(dirname "$f")"
+ cp "$ROOT/$f" "$BASE_WT/$f"
+done
+log "building base.test (base $BASE_SHA + HEAD harness overlay)"
+if ! (cd "$BASE_WT" && go test -c -o "$OUT/base.test" "./$PKG" >"$OUT/build-base.log" 2>&1); then
+ {
+ echo "CONFIG MISMATCH base $BASE_SHA cannot host HEAD's harness (${HARNESS_FILES[*]}):"
+ grep -v '^#' "$OUT/build-base.log" | head -n 5
+ } | tee "$OUT/verdict.txt"
+ exit 2
+fi
+log "building head.test"
+(cd "$ROOT" && go test -c -o "$OUT/head.test" "./$PKG" >"$OUT/build-head.log" 2>&1) || { cat "$OUT/build-head.log"; die "head build failed"; }
+(cd "$ROOT" && go build -o "$OUT/perfab" ./hack/perfab) || die "perfab tool build failed"
+PERFAB=$OUT/perfab
+
+SCHEDULE=$OUT/schedule.txt
+: >"$SCHEDULE"
+: >"$OUT/base.txt"
+: >"$OUT/head.txt"
+
+# run_round : one round of the arm's binary, pinned, on a
+# fresh temp DB (t.TempDir). Appends the bench lines and a schedule record.
+run_round() {
+ local arm=$1 label=$2 pair=$3
+ local bin="$OUT/$arm.test" dir json l marked armenv
+ case $arm in
+ base) dir="$BASE_WT/$PKG"; armenv=$BASE_ENV ;;
+ head) dir="$ROOT/$PKG"; armenv=$HEAD_ENV ;;
+ esac
+ json="$OUT/$label.json"
+ # Settle before sampling load1: rounds run back-to-back with no idle gap,
+ # so the 1-minute average never gets a chance to decay between them and
+ # climbs monotonically over a long run regardless of which arm is running
+ # -- not evidence of external contamination, just the harness's own
+ # workload never idling. A short settle restores load1 to something that
+ # actually reflects ambient load rather than a perpetually rising floor.
+ sleep "${PERF_AB_SETTLE_SECONDS:-3}"
+ l=$(load1)
+ marked=0
+ if awk -v l="$l" -v b="$LOAD_BOUND" 'BEGIN{exit !(l > b)}'; then marked=1; fi
+ log "round $label (arm=$arm pair=$pair load1=$l marked=$marked)"
+ # shellcheck disable=SC2086 # armenv is a deliberate word-split list of KEY=VALUE
+ (cd "$dir" && PERF_AB_OUT="$json" "${PIN[@]}" $armenv "$bin" -test.run '^TestPerfABRound$' -test.v -test.timeout 30m >"$OUT/$label.log" 2>&1) \
+ || { tail -n 30 "$OUT/$label.log"; die "round $label failed; see $OUT/$label.log" 2; }
+ grep '^BenchmarkPerfAB/' "$OUT/$label.log" >>"$OUT/$arm.txt" || true
+ printf 'pair=%s arm=%s label=%s start=%s load1=%s marked=%s env=%s\n' "$pair" "$arm" "$label" "$(date +%FT%T)" "$l" "$marked" "${armenv:-}" >>"$SCHEDULE"
+}
+
+# 4. Early-abort noise probe: A A A on base. Decides nothing else.
+PROBE_FILES=()
+for i in $(seq 1 "$PROBE_ROUNDS"); do
+ run_round base "probe-$i" 0
+ PROBE_FILES+=("$OUT/probe-$i.json")
+done
+PROBE_LIST=$(IFS=,; echo "${PROBE_FILES[*]}")
+if ! "$PERFAB" probe -thresholds "$ROOT/$THRESHOLDS" -rounds "$PROBE_LIST" | tee "$OUT/probe.txt"; then
+ grep -h 'PROBE ABORT' "$OUT/probe.txt" | sed 's/^PROBE ABORT/INCONCLUSIVE probe/' | tee "$OUT/verdict.txt"
+ exit 3
+fi
+
+# 5. Interleave PAIRS rounds of A B, same pinning, fresh DB each; the
+# per-round load sample marks the pair (verdict: > ceil(PAIRS/3) marked
+# pairs is INCONCLUSIVE regardless of the statistics).
+BASE_FILES=()
+HEAD_FILES=()
+for i in $(seq 1 "$PAIRS"); do
+ run_round base "base-$i" "$i"
+ run_round head "head-$i" "$i"
+ BASE_FILES+=("$OUT/base-$i.json")
+ HEAD_FILES+=("$OUT/head-$i.json")
+done
+BASE_LIST=$(IFS=,; echo "${BASE_FILES[*]}")
+HEAD_LIST=$(IFS=,; echo "${HEAD_FILES[*]}")
+
+# 6. Verdict: paired t on per-round log-ratios, Mann-Whitney as the second
+# opinion, post-hoc paired CV and MDE; benchstat if it is on PATH.
+if command -v benchstat >/dev/null 2>&1; then
+ benchstat -alpha 0.05 "$OUT/base.txt" "$OUT/head.txt" >"$OUT/benchstat.txt" 2>&1 || true
+ log "benchstat table in $OUT/benchstat.txt"
+fi
+set +e
+"$PERFAB" verdict -thresholds "$ROOT/$THRESHOLDS" -pairs "$PAIRS" -schedule "$SCHEDULE" \
+ -base "$BASE_LIST" -head "$HEAD_LIST" $NOISY_FLAG | tee "$OUT/verdict-table.txt"
+rc=${PIPESTATUS[0]}
+set -e
+tail -n 1 "$OUT/verdict-table.txt" >"$OUT/verdict.txt"
+log "verdict: $(cat "$OUT/verdict.txt") (exit $rc; artifacts in $OUT)"
+exit "$rc"
diff --git a/hack/perfab/main.go b/hack/perfab/main.go
new file mode 100644
index 000000000..7687d2be3
--- /dev/null
+++ b/hack/perfab/main.go
@@ -0,0 +1,740 @@
+// Command perfab is the statistics half of the Tier B paired A/B driver
+// (hack/perf-ab.sh; design: .omc/plans/raw-write-bypass-elimination.md A.13.4).
+//
+// perfab probe -thresholds F -rounds a.json,b.json,c.json
+// perfab verdict -thresholds F -pairs N -schedule S -base b1.json,... -head h1.json,... [-noisy]
+//
+// probe checks the A A A early-abort: the CV of every headline metric over the
+// probe rounds must stay under probe_cv_max_pct.
+//
+// verdict decides from PAIRS interleaved (base, head) rounds. Per ratio
+// metric the primary statistic is a paired t-test on the per-round log-ratios
+// d_i = ln(head_i / base_i); Mann-Whitney U on the two samples is the second
+// opinion; REGRESSION needs the mean ratio past the threshold AND either test
+// significant. Noise is measured from the pairs themselves, post hoc: the
+// paired CV is the SD of d_i, and the MDE uses t_{N-1} quantiles. Exit codes:
+// 0 PASS, 1 REGRESSION, 2 CONFIG MISMATCH, 3 INCONCLUSIVE, 4 UNDERPOWERED.
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "math"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+const (
+ exitPass = 0
+ exitRegression = 1
+ exitConfigMismatch = 2
+ exitInconclusive = 3
+ exitUnderpowered = 4
+)
+
+type thresholds struct {
+ Alpha float64 `json:"alpha"`
+ Power float64 `json:"power"`
+ PairedCVMaxPct float64 `json:"paired_cv_max_pct"`
+ ProbeCVMaxPct float64 `json:"probe_cv_max_pct"`
+ Metrics []metricSpec
+ Hard []string `json:"hard"`
+ MetricsRaw []metricSpec `json:"metrics"`
+}
+
+type metricSpec struct {
+ Name string `json:"name"`
+ Kind string `json:"kind"` // ratio | count | points | info
+ Direction string `json:"direction"`
+ ThresholdPct float64 `json:"threshold_pct"`
+ ThresholdPoints float64 `json:"threshold_points"`
+ Headline bool `json:"headline"`
+}
+
+type round struct {
+ file string
+ Effective map[string]any `json:"effective"`
+ Series map[string]float64 `json:"series"`
+}
+
+func main() {
+ if len(os.Args) < 2 {
+ usage()
+ }
+ switch os.Args[1] {
+ case "probe":
+ os.Exit(runProbe(os.Args[2:]))
+ case "verdict":
+ os.Exit(runVerdict(os.Args[2:]))
+ default:
+ usage()
+ }
+}
+
+func usage() {
+ fmt.Fprintln(os.Stderr, "usage: perfab probe|verdict [flags]")
+ os.Exit(64)
+}
+
+func loadThresholds(path string) thresholds {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ fatal("read thresholds: %v", err)
+ }
+ var t thresholds
+ if err := json.Unmarshal(data, &t); err != nil {
+ fatal("parse thresholds: %v", err)
+ }
+ t.Metrics = t.MetricsRaw
+ if t.Alpha == 0 {
+ t.Alpha = 0.05
+ }
+ if t.Power == 0 {
+ t.Power = 0.8
+ }
+ return t
+}
+
+func loadRounds(list string) []round {
+ var out []round
+ for _, f := range strings.Split(list, ",") {
+ f = strings.TrimSpace(f)
+ if f == "" {
+ continue
+ }
+ data, err := os.ReadFile(f)
+ if err != nil {
+ fatal("read round %s: %v", f, err)
+ }
+ var r round
+ if err := json.Unmarshal(data, &r); err != nil {
+ fatal("parse round %s: %v", f, err)
+ }
+ r.file = f
+ out = append(out, r)
+ }
+ return out
+}
+
+func fatal(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, "perfab: "+format+"\n", args...)
+ os.Exit(64)
+}
+
+// ---- probe ----
+
+func runProbe(args []string) int {
+ fs := flag.NewFlagSet("probe", flag.ExitOnError)
+ thrPath := fs.String("thresholds", "", "thresholds JSON")
+ roundsList := fs.String("rounds", "", "comma-separated round JSON files")
+ _ = fs.Parse(args)
+ thr := loadThresholds(*thrPath)
+ rounds := loadRounds(*roundsList)
+ if len(rounds) < 2 {
+ fatal("probe needs at least 2 rounds")
+ }
+ worst := 0.0
+ worstName := ""
+ fmt.Printf("%-26s %10s %10s %8s\n", "probe metric", "mean", "sd", "CV%")
+ for _, m := range thr.Metrics {
+ if !m.Headline {
+ continue
+ }
+ xs := series(rounds, m.Name)
+ cv := 100 * sd(xs) / mean(xs)
+ if math.IsNaN(cv) {
+ cv = 0
+ }
+ fmt.Printf("%-26s %10.3f %10.3f %7.1f%%\n", m.Name, mean(xs), sd(xs), cv)
+ if cv > worst {
+ worst, worstName = cv, m.Name
+ }
+ }
+ if worst > thr.ProbeCVMaxPct {
+ fmt.Printf("PROBE ABORT: %s CV %.1f%% over %d probe rounds exceeds %.0f%% (machine too noisy to spend the pairs)\n",
+ worstName, worst, len(rounds), thr.ProbeCVMaxPct)
+ return exitInconclusive
+ }
+ fmt.Printf("PROBE OK: worst headline CV %.1f%% (%s) over %d rounds, under %.0f%%\n", worst, worstName, len(rounds), thr.ProbeCVMaxPct)
+ return exitPass
+}
+
+// ---- verdict ----
+
+type row struct {
+ name string
+ kind string
+ base float64 // mean of base samples
+ head float64
+ effect string // ratio % or difference
+ pT float64
+ pMW float64
+ split bool
+ sdPct float64 // paired CV (SD of log-ratios), %
+ baseCVPct float64
+ n int
+ mdePct float64
+ nPrime int
+ thr string
+ verdict string // PASS | REGRESSION | UNDERPOWERED | HARD | n/a
+ breach bool
+ sig bool
+ underpow bool
+}
+
+func runVerdict(args []string) int {
+ fs := flag.NewFlagSet("verdict", flag.ExitOnError)
+ thrPath := fs.String("thresholds", "", "thresholds JSON")
+ pairs := fs.Int("pairs", 0, "number of interleaved pairs")
+ schedulePath := fs.String("schedule", "", "schedule.txt written by hack/perf-ab.sh")
+ baseList := fs.String("base", "", "comma-separated base round JSON files, in round order")
+ headList := fs.String("head", "", "comma-separated head round JSON files, in round order")
+ noisy := fs.Bool("noisy", false, "the start-of-run load check was overridden (PERF_AB_ALLOW_NOISY=1)")
+ _ = fs.Parse(args)
+
+ thr := loadThresholds(*thrPath)
+ base := loadRounds(*baseList)
+ head := loadRounds(*headList)
+ if len(base) == 0 || len(base) != len(head) {
+ fatal("need the same non-zero number of base and head rounds (got %d and %d)", len(base), len(head))
+ }
+ n := len(base)
+ if *pairs == 0 {
+ *pairs = n
+ }
+ suffix := ""
+ if *noisy {
+ suffix = " (noisy)"
+ }
+
+ // 1. Effective-config echo: every round must have run the same workload.
+ if field, a, b, file := effectiveMismatch(base, head); field != "" {
+ fmt.Printf("CONFIG MISMATCH %s: %v (base %s) vs %v (%s)%s\n", field, a, base[0].file, b, file, suffix)
+ return exitConfigMismatch
+ }
+
+ // 2. Load marks: more than ceil(PAIRS/3) marked pairs -> inconclusive.
+ marked, markedList := markedPairs(*schedulePath)
+ maxMarked := int(math.Ceil(float64(*pairs) / 3))
+
+ // 3. Per-metric rows.
+ var rows []row
+ for _, m := range thr.Metrics {
+ rows = append(rows, evalMetric(m, base, head, thr))
+ }
+ var hardRows []row
+ for _, h := range thr.Hard {
+ hardRows = append(hardRows, evalHard(h, base, head))
+ }
+
+ printTable(rows, hardRows, n)
+
+ // 4. Verdict, in precedence order (see the package comment).
+ if marked > maxMarked {
+ fmt.Printf("INCONCLUSIVE load-marked=%d/%d (pairs %s; more than ceil(%d/3)=%d)%s\n", marked, *pairs, markedList, *pairs, maxMarked, suffix)
+ return exitInconclusive
+ }
+ for _, h := range hardRows {
+ if h.breach {
+ fmt.Printf("REGRESSION %s head=%g base=%g (hard row: any on HEAD when BASE has none)%s\n", h.name, h.head, h.base, suffix)
+ return exitRegression
+ }
+ }
+ for _, r := range rows {
+ if r.kind == "ratio" && isHeadline(thr, r.name) && r.sdPct > thr.PairedCVMaxPct {
+ fmt.Printf("INCONCLUSIVE paired-CV=%.1f%% on %s (limit %.0f%%)%s\n", r.sdPct, r.name, thr.PairedCVMaxPct, suffix)
+ return exitInconclusive
+ }
+ }
+ for _, r := range rows {
+ if r.breach && r.sig {
+ fmt.Printf("REGRESSION %s %s t=%.3f mw=%.3f%s\n", r.name, r.effect, r.pT, r.pMW, suffix)
+ return exitRegression
+ }
+ }
+ maxNPrime := 0
+ worst := ""
+ for _, r := range rows {
+ if r.underpow && r.nPrime > maxNPrime {
+ maxNPrime, worst = r.nPrime, r.name
+ }
+ }
+ if maxNPrime > n {
+ fmt.Printf("UNDERPOWERED N'=%d (%s: MDE exceeds its threshold at N=%d)%s\n", maxNPrime, worst, n, suffix)
+ return exitUnderpowered
+ }
+ fmt.Printf("PASS%s\n", suffix)
+ return exitPass
+}
+
+func isHeadline(thr thresholds, name string) bool {
+ for _, m := range thr.Metrics {
+ if m.Name == name {
+ return m.Headline
+ }
+ }
+ return false
+}
+
+func series(rounds []round, name string) []float64 {
+ out := make([]float64, 0, len(rounds))
+ for _, r := range rounds {
+ v, ok := r.Series[name]
+ if !ok {
+ fatal("round %s has no series %q", r.file, name)
+ }
+ out = append(out, v)
+ }
+ return out
+}
+
+func evalMetric(m metricSpec, base, head []round, thr thresholds) row {
+ b := series(base, m.Name)
+ h := series(head, m.Name)
+ n := len(b)
+ r := row{name: m.Name, kind: m.Kind, base: mean(b), head: mean(h), n: n, baseCVPct: 100 * sd(b) / mean(b)}
+ if math.IsNaN(r.baseCVPct) {
+ r.baseCVPct = 0
+ }
+ worseIfHigher := m.Direction != "higher_is_better"
+ r.pMW = mannWhitneyP(b, h)
+
+ switch m.Kind {
+ case "ratio":
+ d := make([]float64, n)
+ positive := true
+ for i := range b {
+ if b[i] <= 0 || h[i] <= 0 {
+ positive = false
+ break
+ }
+ d[i] = math.Log(h[i] / b[i])
+ }
+ if !positive {
+ // A zero sample makes the log-ratio undefined; fall back to the
+ // paired difference and report it as such.
+ for i := range b {
+ d[i] = h[i] - b[i]
+ }
+ r.effect = fmt.Sprintf("diff=%+.3f", mean(d))
+ r.pT = pairedTP(d)
+ r.sig = r.pT < thr.Alpha || r.pMW < thr.Alpha
+ r.split = (r.pT < thr.Alpha) != (r.pMW < thr.Alpha)
+ r.thr = fmt.Sprintf("%.0f%%", m.ThresholdPct)
+ r.verdict = "n/a(zero)"
+ return r
+ }
+ md := mean(d)
+ s := sd(d)
+ r.sdPct = 100 * s
+ r.pT = pairedTP(d)
+ ratioPct := 100 * (math.Exp(md) - 1)
+ r.effect = fmt.Sprintf("%+.1f%%", ratioPct)
+ thrLog := math.Log1p(m.ThresholdPct / 100)
+ if worseIfHigher {
+ r.breach = md > thrLog
+ r.thr = fmt.Sprintf(">+%.0f%%", m.ThresholdPct)
+ } else {
+ r.breach = md < -thrLog
+ r.thr = fmt.Sprintf("<-%.0f%%", m.ThresholdPct)
+ }
+ r.sig = r.pT < thr.Alpha || r.pMW < thr.Alpha
+ r.split = (r.pT < thr.Alpha) != (r.pMW < thr.Alpha)
+ // MDE = (t_{N-1,1-alpha/2} + t_{N-1,power}) * s_d / sqrt(N), in log space.
+ df := float64(n - 1)
+ tsum := tQuantile(1-thr.Alpha/2, df) + tQuantile(thr.Power, df)
+ mdeLog := tsum * s / math.Sqrt(float64(n))
+ r.mdePct = 100 * (math.Exp(mdeLog) - 1)
+ if mdeLog > thrLog && s > 0 {
+ r.underpow = true
+ // N' = ceil(((t+t) * s_d / thr)^2), iterated once so the quantiles
+ // are taken at N'-1 rather than at the current N-1.
+ ratio := tsum * s / thrLog
+ nPrime := int(math.Ceil(ratio * ratio))
+ if nPrime > 1 {
+ tsum2 := tQuantile(1-thr.Alpha/2, float64(nPrime-1)) + tQuantile(thr.Power, float64(nPrime-1))
+ ratio2 := tsum2 * s / thrLog
+ nPrime = int(math.Ceil(ratio2 * ratio2))
+ }
+ r.nPrime = max(nPrime, n+1)
+ }
+ case "count":
+ d := make([]float64, n)
+ for i := range b {
+ d[i] = h[i] - b[i]
+ }
+ r.pT = pairedTP(d)
+ r.effect = fmt.Sprintf("sum %g->%g", sum(b), sum(h))
+ r.breach = sum(h) > sum(b)
+ r.thr = "head>base"
+ r.sig = r.pT < thr.Alpha || r.pMW < thr.Alpha
+ r.split = (r.pT < thr.Alpha) != (r.pMW < thr.Alpha)
+ case "info":
+ d := make([]float64, n)
+ for i := range b {
+ if b[i] > 0 && h[i] > 0 {
+ d[i] = math.Log(h[i] / b[i])
+ }
+ }
+ r.pT = pairedTP(d)
+ r.sdPct = 100 * sd(d)
+ r.effect = fmt.Sprintf("%+.1f%%", 100*(math.Exp(mean(d))-1))
+ r.thr = "none"
+ r.verdict = "info"
+ return r
+ case "points":
+ d := make([]float64, n)
+ for i := range b {
+ d[i] = h[i] - b[i]
+ }
+ r.pT = pairedTP(d)
+ r.effect = fmt.Sprintf("%+.2fpp", mean(d))
+ r.breach = mean(d) > m.ThresholdPoints
+ r.thr = fmt.Sprintf(">+%.0fpp", m.ThresholdPoints)
+ r.sig = r.pT < thr.Alpha || r.pMW < thr.Alpha
+ r.split = (r.pT < thr.Alpha) != (r.pMW < thr.Alpha)
+ default:
+ fatal("metric %s: unknown kind %q", m.Name, m.Kind)
+ }
+ switch {
+ case r.breach && r.sig:
+ r.verdict = "REGRESSION"
+ case r.underpow:
+ r.verdict = "UNDERPOWERED"
+ case r.breach:
+ r.verdict = "breach,n.s."
+ default:
+ r.verdict = "PASS"
+ }
+ return r
+}
+
+func evalHard(name string, base, head []round) row {
+ b := series(base, name)
+ h := series(head, name)
+ r := row{name: name, kind: "hard", base: sum(b), head: sum(h), n: len(b), thr: "any"}
+ r.breach = sum(b) == 0 && sum(h) > 0
+ r.effect = fmt.Sprintf("sum %g->%g", sum(b), sum(h))
+ if r.breach {
+ r.verdict = "REGRESSION"
+ } else {
+ r.verdict = "PASS"
+ }
+ return r
+}
+
+func printTable(rows, hard []row, n int) {
+ fmt.Printf("%-26s %10s %10s %10s %7s %7s %5s %7s %8s %3s %7s %8s %s\n",
+ "metric", "base", "head", "effect", "t-p", "mw-p", "SPLIT", "s_d%", "baseCV%", "N", "MDE%", "thr", "verdict")
+ for _, r := range rows {
+ split := ""
+ if r.split {
+ split = "SPLIT"
+ }
+ mde := "-"
+ sdp := "-"
+ if r.kind == "ratio" && r.verdict != "n/a(zero)" {
+ mde = fmt.Sprintf("%.1f", r.mdePct)
+ sdp = fmt.Sprintf("%.1f", r.sdPct)
+ }
+ verdict := r.verdict
+ if r.underpow {
+ verdict += fmt.Sprintf(" N'=%d", r.nPrime)
+ }
+ fmt.Printf("%-26s %10.3f %10.3f %10s %7.3f %7.3f %5s %7s %8.1f %3d %7s %8s %s\n",
+ r.name, r.base, r.head, r.effect, r.pT, r.pMW, split, sdp, r.baseCVPct, r.n, mde, r.thr, verdict)
+ }
+ for _, r := range hard {
+ fmt.Printf("%-26s %10.0f %10.0f %10s %7s %7s %5s %7s %8s %3d %7s %8s %s\n",
+ r.name, r.base, r.head, r.effect, "-", "-", "", "-", "-", r.n, "-", r.thr, r.verdict+" (hard)")
+ }
+}
+
+// effectiveMismatch compares every round's effective block against base[0]'s.
+func effectiveMismatch(base, head []round) (field string, a, b any, file string) {
+ ref := base[0].Effective
+ check := func(r round) bool {
+ keys := map[string]struct{}{}
+ for k := range ref {
+ keys[k] = struct{}{}
+ }
+ for k := range r.Effective {
+ keys[k] = struct{}{}
+ }
+ sorted := make([]string, 0, len(keys))
+ for k := range keys {
+ sorted = append(sorted, k)
+ }
+ sort.Strings(sorted)
+ for _, k := range sorted {
+ av, aok := ref[k]
+ bv, bok := r.Effective[k]
+ if !aok || !bok || fmt.Sprint(av) != fmt.Sprint(bv) {
+ field, a, b, file = k, av, bv, r.file
+ return true
+ }
+ }
+ return false
+ }
+ for _, r := range base[1:] {
+ if check(r) {
+ return
+ }
+ }
+ for _, r := range head {
+ if check(r) {
+ return
+ }
+ }
+ return "", nil, nil, ""
+}
+
+// markedPairs reads schedule.txt ("pair= arm= ... marked=<0|1>")
+// and counts pairs with at least one marked arm.
+func markedPairs(path string) (int, string) {
+ if path == "" {
+ return 0, "none"
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return 0, "none"
+ }
+ fatal("read schedule: %v", err)
+ }
+ marked := map[int]bool{}
+ for _, line := range strings.Split(string(data), "\n") {
+ fields := map[string]string{}
+ for _, kv := range strings.Fields(line) {
+ if i := strings.IndexByte(kv, '='); i > 0 {
+ fields[kv[:i]] = kv[i+1:]
+ }
+ }
+ if fields["marked"] == "1" {
+ if p, err := strconv.Atoi(fields["pair"]); err == nil && p > 0 {
+ marked[p] = true
+ }
+ }
+ }
+ list := make([]string, 0, len(marked))
+ for p := range marked {
+ list = append(list, strconv.Itoa(p))
+ }
+ sort.Slice(list, func(i, j int) bool { a, _ := strconv.Atoi(list[i]); b, _ := strconv.Atoi(list[j]); return a < b })
+ if len(list) == 0 {
+ return 0, "none"
+ }
+ return len(marked), strings.Join(list, ",")
+}
+
+// ---- statistics ----
+
+func sum(x []float64) float64 {
+ s := 0.0
+ for _, v := range x {
+ s += v
+ }
+ return s
+}
+
+func mean(x []float64) float64 {
+ if len(x) == 0 {
+ return math.NaN()
+ }
+ return sum(x) / float64(len(x))
+}
+
+// sd is the sample standard deviation (n-1).
+func sd(x []float64) float64 {
+ if len(x) < 2 {
+ return 0
+ }
+ m := mean(x)
+ ss := 0.0
+ for _, v := range x {
+ ss += (v - m) * (v - m)
+ }
+ return math.Sqrt(ss / float64(len(x)-1))
+}
+
+// pairedTP is the two-sided p of a one-sample t-test on the differences d.
+func pairedTP(d []float64) float64 {
+ n := len(d)
+ if n < 2 {
+ return 1
+ }
+ s := sd(d)
+ if s == 0 {
+ if mean(d) == 0 {
+ return 1
+ }
+ return 0
+ }
+ t := mean(d) / (s / math.Sqrt(float64(n)))
+ return 2 * tUpperTail(math.Abs(t), float64(n-1))
+}
+
+// tUpperTail is P(T > t) for Student's t with df degrees of freedom.
+func tUpperTail(t, df float64) float64 {
+ if t <= 0 {
+ return 0.5
+ }
+ x := df / (df + t*t)
+ return 0.5 * regIncBeta(df/2, 0.5, x)
+}
+
+// tQuantile inverts the t CDF by bisection.
+func tQuantile(p, df float64) float64 {
+ if p <= 0.5 {
+ return 0
+ }
+ lo, hi := 0.0, 1000.0
+ for i := 0; i < 200; i++ {
+ mid := (lo + hi) / 2
+ if 1-tUpperTail(mid, df) < p {
+ lo = mid
+ } else {
+ hi = mid
+ }
+ }
+ return (lo + hi) / 2
+}
+
+// regIncBeta is the regularized incomplete beta function I_x(a,b) via the
+// continued fraction (Numerical Recipes betacf, Lentz's method).
+func regIncBeta(a, b, x float64) float64 {
+ if x <= 0 {
+ return 0
+ }
+ if x >= 1 {
+ return 1
+ }
+ lbeta := lgamma(a+b) - lgamma(a) - lgamma(b) + a*math.Log(x) + b*math.Log(1-x)
+ front := math.Exp(lbeta)
+ if x < (a+1)/(a+b+2) {
+ return front * betacf(a, b, x) / a
+ }
+ return 1 - front*betacf(b, a, 1-x)/b
+}
+
+func lgamma(x float64) float64 {
+ v, _ := math.Lgamma(x)
+ return v
+}
+
+func betacf(a, b, x float64) float64 {
+ const (
+ maxIter = 300
+ eps = 3e-14
+ fpmin = 1e-300
+ )
+ qab := a + b
+ qap := a + 1
+ qam := a - 1
+ c := 1.0
+ d := 1 - qab*x/qap
+ if math.Abs(d) < fpmin {
+ d = fpmin
+ }
+ d = 1 / d
+ h := d
+ for m := 1; m <= maxIter; m++ {
+ fm := float64(m)
+ m2 := 2 * fm
+ aa := fm * (b - fm) * x / ((qam + m2) * (a + m2))
+ d = 1 + aa*d
+ if math.Abs(d) < fpmin {
+ d = fpmin
+ }
+ c = 1 + aa/c
+ if math.Abs(c) < fpmin {
+ c = fpmin
+ }
+ d = 1 / d
+ h *= d * c
+ aa = -(a + fm) * (qab + fm) * x / ((a + m2) * (qap + m2))
+ d = 1 + aa*d
+ if math.Abs(d) < fpmin {
+ d = fpmin
+ }
+ c = 1 + aa/c
+ if math.Abs(c) < fpmin {
+ c = fpmin
+ }
+ d = 1 / d
+ del := d * c
+ h *= del
+ if math.Abs(del-1) < eps {
+ break
+ }
+ }
+ return h
+}
+
+// mannWhitneyP is the two-sided Mann-Whitney U test with average ranks for
+// ties, tie-corrected variance and a continuity correction (the normal
+// approximation benchstat also uses for these sample sizes).
+func mannWhitneyP(a, b []float64) float64 {
+ n1, n2 := len(a), len(b)
+ if n1 == 0 || n2 == 0 {
+ return 1
+ }
+ type obs struct {
+ v float64
+ from int
+ }
+ all := make([]obs, 0, n1+n2)
+ for _, v := range a {
+ all = append(all, obs{v, 0})
+ }
+ for _, v := range b {
+ all = append(all, obs{v, 1})
+ }
+ sort.Slice(all, func(i, j int) bool { return all[i].v < all[j].v })
+ ranks := make([]float64, len(all))
+ tieTerm := 0.0
+ for i := 0; i < len(all); {
+ j := i
+ for j+1 < len(all) && all[j+1].v == all[i].v {
+ j++
+ }
+ r := float64(i+j+2) / 2 // average of 1-based ranks i+1..j+1
+ for k := i; k <= j; k++ {
+ ranks[k] = r
+ }
+ t := float64(j - i + 1)
+ if t > 1 {
+ tieTerm += t*t*t - t
+ }
+ i = j + 1
+ }
+ r1 := 0.0
+ for k, o := range all {
+ if o.from == 0 {
+ r1 += ranks[k]
+ }
+ }
+ fn1, fn2 := float64(n1), float64(n2)
+ u1 := r1 - fn1*(fn1+1)/2
+ u2 := fn1*fn2 - u1
+ u := math.Min(u1, u2)
+ mu := fn1 * fn2 / 2
+ nt := fn1 + fn2
+ variance := fn1 * fn2 / 12 * ((nt + 1) - tieTerm/(nt*(nt-1)))
+ if variance <= 0 {
+ return 1
+ }
+ z := (u - mu + 0.5) / math.Sqrt(variance)
+ if u == mu {
+ return 1
+ }
+ return 2 * normalUpperTail(math.Abs(z))
+}
+
+func normalUpperTail(z float64) float64 {
+ return 0.5 * math.Erfc(z/math.Sqrt2)
+}
diff --git a/main.go b/main.go
index 160233bf6..983de8fa6 100644
--- a/main.go
+++ b/main.go
@@ -17,6 +17,7 @@ limitations under the License.
package main
import (
+ "context"
"flag"
"net/url"
"os"
@@ -28,6 +29,7 @@ import (
"github.com/grafana/pyroscope-go"
"github.com/kubescape/go-logger"
"github.com/kubescape/go-logger/helpers"
+ "github.com/kubescape/storage/pkg/apiserver"
"github.com/kubescape/storage/pkg/cmd/server"
"github.com/kubescape/storage/pkg/config"
"github.com/kubescape/storage/pkg/registry/file"
@@ -68,6 +70,17 @@ func main() {
logger.L().Ctx(ctx).Fatal("load config error", helpers.Error(err))
}
cfg.DefaultNamespace = clusterData.Namespace
+ // Under the ContainerProfile SQLite backend every legacy write goes
+ // through the shared write gate from the single-writer shards, which hold
+ // no pool connection while queued; with the single writer off, every REST
+ // write would instead queue on the gate holding a pool connection and ten
+ // queued writers would starve every reader (write-gate-sharing §3.3, §4).
+ if cfg.ContainerProfileSqliteBackend && !cfg.SingleWriterEnabled {
+ logger.L().Ctx(ctx).Fatal("invalid config: containerProfileSqliteBackend requires singleWriterEnabled")
+ }
+ if cfg.ContainerProfileSqliteBackend && cfg.ContainerProfileMigrationDryRun {
+ logger.L().Ctx(ctx).Fatal("invalid config: containerProfileMigrationDryRun is a census taken before containerProfileSqliteBackend is turned on; the backend cannot serve unmigrated rows")
+ }
// to enable otel, set OTEL_COLLECTOR_SVC=otel-collector:4317
if otelHost, present := os.LookupEnv("OTEL_COLLECTOR_SVC"); present {
ctx = logger.InitOtel("storage",
@@ -99,10 +112,62 @@ func main() {
// setup storage components
osFs := afero.NewOsFs()
- pool := file.NewPool(filepath.Join(file.DefaultStorageRoot, "metadata.sq3"), cfg.SqlitePoolSize, cfg.SqliteBusyTimeout)
+ sqlitePath := filepath.Join(file.DefaultStorageRoot, "metadata.sq3")
+ pool := file.NewPoolWithOptions(sqlitePath, file.PoolOptions{
+ Size: cfg.SqlitePoolSize,
+ BusyTimeout: cfg.SqliteBusyTimeout,
+ // K-3: with the ContainerProfile SQLite backend on, no connection
+ // checkpoints inside its own COMMIT; the backend's background PASSIVE
+ // checkpointer does. Flag-off leaves SQLite's default untouched.
+ DisableAutoCheckpoint: cfg.ContainerProfileSqliteBackend,
+ })
file.SetPoolTimeout(cfg.PoolTimeout)
file.SetSingleWriterEnabled(cfg.SingleWriterEnabled)
+ // The process's one write gate (.omc/plans/write-gate-sharing.md §3.1):
+ // with the ContainerProfile SQLite backend on, every SQLite write of every
+ // kind — the ObjectStore's, the legacy StorageImpl's and the cleanup
+ // handler's — goes through it. Built beside the pool, before any writer
+ // exists; closed below, after the server has drained.
+ var writeGate *file.WriteGate
+ if cfg.ContainerProfileSqliteBackend {
+ gateCtx, gateCancel := context.WithTimeout(ctx, cfg.PoolTimeout)
+ writeGate, err = file.NewWriteGate(gateCtx, pool)
+ gateCancel()
+ if err != nil {
+ logger.L().Ctx(ctx).Fatal("write gate error", helpers.Error(err))
+ }
+ }
+
+ // The ContainerProfile data migration (full-acid-storage-architecture.md
+ // §8.2): synchronously, after the pool and the gate exist and BEFORE the
+ // cleanup goroutine and the API server — nothing else writes the
+ // database while it runs, and its batches are gated writes like every
+ // other (AC-G1). The reconcile of legacy-written rows runs on every
+ // start; the file sweeps once. A failure is fatal: the backend must not
+ // serve a half-reconciled store, and the flag can be turned off (§8.4).
+ // Precondition: one storage pod at a time (the chart's replicas: 1 +
+ // strategy: Recreate) — nothing in the database fences an older binary.
+ if cfg.ContainerProfileSqliteBackend || cfg.ContainerProfileMigrationDryRun {
+ report, err := file.MigrateContainerProfiles(ctx, pool, writeGate, osFs, file.DefaultStorageRoot, apiserver.Scheme,
+ file.ContainerProfileMigrationOptions{DryRun: cfg.ContainerProfileMigrationDryRun})
+ if err != nil {
+ logger.L().Ctx(ctx).Fatal("containerprofile migration error", helpers.Error(err))
+ }
+ logger.L().Info("containerprofile migration finished",
+ helpers.Interface("counts", report.Counts), helpers.Int("batches", report.Batches),
+ helpers.Interface("sweepsRun", report.SweepsRun), helpers.Interface("dryRun", report.DryRun),
+ helpers.String("elapsed", report.Elapsed.String()))
+ } else {
+ // Advisory startup check (D) (.omc/plans/rollback-safety-guard.md):
+ // flag-off only -- with the backend on, keys are served from
+ // ObjectStore directly and the rollback read fallback never fires, so
+ // there is nothing to report. Purely informational: never Fatal, and
+ // bounded with its own timeout rather than ctx's (an untimed signal
+ // context).
+ file.LogFallbackEligibleContainerProfilesCensus(ctx, pool, cfg.PoolTimeout)
+ }
+
// setup watcher
watchDispatcher := file.NewWatchDispatcher()
@@ -116,12 +181,23 @@ func main() {
relevancyEnabled := clusterData.RelevantImageVulnerabilitiesEnabled != nil && *clusterData.RelevantImageVulnerabilitiesEnabled
cleanupHandler := file.NewResourcesCleanupHandler(osFs, file.DefaultStorageRoot, pool, watchDispatcher, cfg.CleanupInterval, cfg.DefaultNamespace, kubernetesAPI, relevancyEnabled)
+ cleanupHandler.SetWriteGate(writeGate)
go cleanupHandler.RunCleanupTask(ctx)
// start the server
options := server.NewWardleServerOptions(os.Stdout, os.Stderr, osFs, pool, cfg, watchDispatcher, cleanupHandler)
+ options.SqlitePath = sqlitePath
+ options.WriteGate = writeGate
cmd := server.NewCommandStartWardleServer(ctx, options, false)
logger.L().Info("APIServer starting")
code := cli.Run(cmd)
+ // The server has drained: no request can arrive, every queued writer is
+ // gone. Closing the gate earlier (in a pre-shutdown hook) would fail the
+ // in-flight writes of every gated kind with errGateClosed.
+ if writeGate != nil {
+ if err := writeGate.Close(); err != nil {
+ logger.L().Error("write gate close error", helpers.Error(err))
+ }
+ }
os.Exit(code)
}
diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go
index 9956939ab..f4f0bffd2 100644
--- a/pkg/apiserver/apiserver.go
+++ b/pkg/apiserver/apiserver.go
@@ -83,11 +83,19 @@ func init() {
// ExtraConfig holds custom apiserver config
type ExtraConfig struct {
- CleanupHandler *file.ResourcesCleanupHandler
- OsFs afero.Fs
- Pool *sqlitemigration.Pool
+ CleanupHandler *file.ResourcesCleanupHandler
+ OsFs afero.Fs
+ Pool *sqlitemigration.Pool
+ // SqlitePath is the database file behind Pool; the ContainerProfile
+ // SQLite backend's checkpointer watches its -wal sibling.
+ SqlitePath string
StorageConfig config.Config
WatchDispatcher *file.WatchDispatcher
+ // WriteGate is the process's one write gate, built by main.go beside the
+ // pool when StorageConfig.ContainerProfileSqliteBackend is on and shared
+ // by the ObjectStore, the legacy StorageImpl and the cleanup handler; nil
+ // with the flag off.
+ WriteGate *file.WriteGate
}
// Config defines the config for the apiserver
@@ -146,13 +154,59 @@ func (c completedConfig) New() (*WardleServer, error) {
// read the CR, processors are baked into the storage backend.
containerProfileProcessor := file.NewContainerProfileProcessor(c.ExtraConfig.StorageConfig, c.ExtraConfig.CleanupHandler)
- var (
- storageImpl = file.NewStorageImpl(c.ExtraConfig.OsFs, file.DefaultStorageRoot, c.ExtraConfig.Pool, c.ExtraConfig.WatchDispatcher, Scheme)
+ storageImpl := file.NewStorageImpl(c.ExtraConfig.OsFs, file.DefaultStorageRoot, c.ExtraConfig.Pool, c.ExtraConfig.WatchDispatcher, Scheme)
+
+ // ContainerProfileSqliteBackend (see
+ // .omc/plans/full-acid-storage-architecture.md §3): the containerprofiles
+ // resource is served by the SQLite-native ObjectStore instead of the
+ // row+gob-file StorageImpl, and the legacy default instance carries the
+ // kind-ownership guard so that any path still handing it a containerprofile
+ // key fails loudly instead of touching a row the ObjectStore owns. Every
+ // CP consumer is wired to containerProfileStorageImpl below
+ // (GeneratedNetworkPolicyStorage's full-spec list, the cleanup handler's
+ // CP arm); main.go ran the data migration before this point.
+ var containerProfileStorageImpl storage.Interface
+ if c.ExtraConfig.StorageConfig.ContainerProfileSqliteBackend {
+ storageImpl.(*file.StorageImpl).SetForeignKinds(file.IsContainerProfileKind)
+ gate := c.ExtraConfig.WriteGate
+ if gate == nil {
+ return nil, fmt.Errorf("unable to create the ContainerProfile SQLite backend: no write gate (main.go builds it beside the pool when the flag is on)")
+ }
+ storageImpl.(*file.StorageImpl).SetWriteGate(gate)
+ objectStore, err := file.NewObjectStore(c.ExtraConfig.Pool, c.ExtraConfig.SqlitePath, c.ExtraConfig.WatchDispatcher, Scheme, containerProfileProcessor, storageImpl, gate, file.ObjectStoreOptions{})
+ if err != nil {
+ return nil, fmt.Errorf("unable to create the ContainerProfile SQLite backend: %w", err)
+ }
+ // The pre-shutdown hook stops the store's checkpointer only. It must
+ // NOT close the shared gate: pre-shutdown hooks run before in-flight
+ // requests drain, and a closed gate fails every in-flight write of
+ // every gated kind with errGateClosed. main.go closes the gate once
+ // cli.Run has returned and no request can arrive.
+ if err := s.GenericAPIServer.AddPreShutdownHook("containerprofile-sqlite-backend", objectStore.Close); err != nil {
+ return nil, err
+ }
+ // The cleanup handler's CP arm enumerates rows and deletes through the
+ // ObjectStore under the flag; it never walks CP files (§5.6 row 10).
+ if c.ExtraConfig.CleanupHandler != nil {
+ c.ExtraConfig.CleanupHandler.SetContainerProfileStore(objectStore)
+ }
+ containerProfileStorageImpl = objectStore
+ } else {
+ containerProfileStorageImpl = file.NewStorageImplWithCollector(c.ExtraConfig.OsFs, file.DefaultStorageRoot, c.ExtraConfig.Pool, c.ExtraConfig.WatchDispatcher, Scheme, containerProfileProcessor)
+ }
+ // Only after every branch above has finished wiring the processor's
+ // storage (including, under the SQLite backend, the cleanup handler's
+ // SetContainerProfileStore call): starting maintenance any earlier lets
+ // its first cleanup tick run cleanup's ContainerProfile arm with cpStore
+ // still nil, taking the legacy file-walk path against rows the
+ // ObjectStore now owns, and races unsynchronized on cpStore with the Set
+ // call above.
+ containerProfileProcessor.StartMaintenance()
- containerProfileStorageImpl = file.NewStorageImplWithCollector(c.ExtraConfig.OsFs, file.DefaultStorageRoot, c.ExtraConfig.Pool, c.ExtraConfig.WatchDispatcher, Scheme, containerProfileProcessor)
+ var (
configScanStorageImpl = file.NewConfigurationScanSummaryStorage(storageImpl)
vulnerabilitySummaryStorage = file.NewVulnerabilitySummaryStorage(storageImpl)
- generatedNetworkPolicyStorage = file.NewGeneratedNetworkPolicyStorage(storageImpl)
+ generatedNetworkPolicyStorage = file.NewGeneratedNetworkPolicyStorage(storageImpl, containerProfileStorageImpl)
// REST endpoint registration, defaults to storageImpl.
ep = func(f func(*runtime.Scheme, storage.Interface, generic.RESTOptionsGetter) (*registry.REST, error), s ...storage.Interface) *registry.REST {
diff --git a/pkg/cmd/server/start.go b/pkg/cmd/server/start.go
index f2c09c5c6..5504e6787 100644
--- a/pkg/cmd/server/start.go
+++ b/pkg/cmd/server/start.go
@@ -72,11 +72,16 @@ type WardleServerOptions struct {
AlternateDNS []string
- CleanupHandler *file.ResourcesCleanupHandler
- OsFs afero.Fs
- Pool *sqlitemigration.Pool
+ CleanupHandler *file.ResourcesCleanupHandler
+ OsFs afero.Fs
+ Pool *sqlitemigration.Pool
+ // SqlitePath is the database file behind Pool (see apiserver.ExtraConfig).
+ SqlitePath string
StorageConfig config.Config
WatchDispatcher *file.WatchDispatcher
+ // WriteGate is the process's write gate (see apiserver.ExtraConfig); nil
+ // with ContainerProfileSqliteBackend off.
+ WriteGate *file.WriteGate
}
func WardleVersionToKubeVersion(ver *version.Version) *version.Version {
@@ -313,8 +318,10 @@ func (o *WardleServerOptions) Config() (*apiserver.Config, error) {
CleanupHandler: o.CleanupHandler,
OsFs: o.OsFs,
Pool: o.Pool,
+ SqlitePath: o.SqlitePath,
StorageConfig: o.StorageConfig,
WatchDispatcher: o.WatchDispatcher,
+ WriteGate: o.WriteGate,
},
}
return c, nil
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 85a411bfc..30fbf8813 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -109,6 +109,28 @@ type Config struct {
// differential testing regardless of this flag's value.
CustomContainerProfileRestEnabled bool `mapstructure:"customContainerProfileRestEnabled"`
+ // ContainerProfileSqliteBackend selects the SQLite-native, fully-ACID
+ // ContainerProfile backend (pkg/registry/file/sqliteobject_*.go: metadata
+ // row + payload BLOB + time_series row in one transaction, one write gate,
+ // background PASSIVE checkpointer) for the containerprofiles resource in
+ // place of the legacy row+gob-file StorageImpl. Defaults to false. When
+ // on, main.go reconciles the existing rows and gob files into the new
+ // schema at every start before serving (file.MigrateContainerProfiles;
+ // legacy files are left in place for rollback), and the legacy
+ // StorageImpl refuses every full-object operation on a containerprofile
+ // key (the kind-ownership guard). See
+ // .omc/plans/full-acid-storage-architecture.md and
+ // docs/features/containerprofile-sqlite-backend.md.
+ ContainerProfileSqliteBackend bool `mapstructure:"containerProfileSqliteBackend"`
+
+ // ContainerProfileMigrationDryRun runs the ContainerProfile data
+ // migration's reconcile at startup in count-only mode — nothing is
+ // written — and logs what a real run would do (§8.3: required before the
+ // backend flag is turned on anywhere). Refused together with
+ // ContainerProfileSqliteBackend: the backend cannot serve unmigrated
+ // rows. Defaults to false.
+ ContainerProfileMigrationDryRun bool `mapstructure:"containerProfileMigrationDryRun"`
+
// The following gate the remaining Phase 4 per-resource rest.Storage
// migrations off genericregistry.Store (see
// docs/features/generic-rest-storage-phase4.md), following the same pattern as
@@ -178,6 +200,9 @@ func LoadConfig(path string) (Config, error) {
v.SetDefault("customVulnerabilityManifestSummaryRestEnabled", false)
v.SetDefault("customWorkloadConfigurationScanRestEnabled", false)
v.SetDefault("customWorkloadConfigurationScanSummaryRestEnabled", false)
+ // Prototype backend; off until the migration and the soak say otherwise.
+ v.SetDefault("containerProfileSqliteBackend", false)
+ v.SetDefault("containerProfileMigrationDryRun", false)
v.SetDefault("defaultQueueLength", 100)
v.SetDefault("defaultWorkerCount", 2)
v.SetDefault("defaultMaxObjectSize", 400000)
diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go
index 2d1ed66eb..a2028af8f 100644
--- a/pkg/metrics/metrics.go
+++ b/pkg/metrics/metrics.go
@@ -42,6 +42,14 @@ const (
CommitOutcomePanic = "panic"
)
+// Outcome label values for SqliteCheckpointTotal.
+const (
+ CheckpointOutcomeOK = "ok"
+ CheckpointOutcomeBusy = "busy"
+ CheckpointOutcomeError = "error"
+ CheckpointOutcomePanic = "panic"
+)
+
// Label values for the consolidation counters below.
const (
// FrozenReclaimedRow / FrozenReclaimedObject: what the frozen gate reclaimed.
@@ -62,6 +70,15 @@ const (
HealFailedRead = "read"
HealFailedSave = "save"
HealFailedCommit = "commit"
+
+ // KeyReserve* : how a consolidation pass's reserved retry ended.
+ KeyReserveCommitted = "committed"
+ KeyReserveConflict = "conflict"
+ KeyReserveError = "error"
+
+ // KeyYield* : how a same-series writer's wait on a reservation ended.
+ KeyYieldReleased = "released"
+ KeyYieldTimeout = "timeout"
)
// waitBuckets covers sub-millisecond acquisitions up through the ~5s
@@ -186,6 +203,140 @@ var (
[]string{"priority"},
)
+ // SqliteWriteHoldDuration observes how long the write gate was held for
+ // one transaction (BEGIN IMMEDIATE through COMMIT/ROLLBACK), by write
+ // "path" (the ObjectStore's create/update/delete/consolidate/time_series
+ // and the legacy kinds' legacy_commit/legacy_delete/repair/migrate/
+ // cleanup/cleanup_migrate) and "kind". The design's PM-3/PM-G1 detector:
+ // with the checkpoint off the commit path this is the fsync, the page
+ // writes and, for legacy_commit, one same-directory rename.
+ SqliteWriteHoldDuration = metrics.NewHistogramVec(
+ &metrics.HistogramOpts{
+ Subsystem: "storage",
+ Name: "sqlite_write_hold_seconds",
+ Help: "Time the write gate was held for one transaction, by write path and kind.",
+ Buckets: waitBuckets,
+ StabilityLevel: metrics.ALPHA,
+ },
+ []string{"path", "kind"},
+ )
+
+ // SqliteWriteHoldStepDuration observes one named step inside a gated
+ // hold, by "path" and "step" — today the legacy commit's payload rename,
+ // so a PV whose rename is not a directory-entry update is attributable
+ // without a profiler (PM-G1).
+ SqliteWriteHoldStepDuration = metrics.NewHistogramVec(
+ &metrics.HistogramOpts{
+ Subsystem: "storage",
+ Name: "sqlite_write_hold_step_seconds",
+ Help: "Time one named step inside a gated write hold took, by path and step.",
+ Buckets: waitBuckets,
+ StabilityLevel: metrics.ALPHA,
+ },
+ []string{"path", "step"},
+ )
+
+ // WriteGateHoldAge gauges how long the current gate holder has held the
+ // gate, sampled by the gate's watchdog; 0 when idle. Alert at > 5 s: a
+ // leaked ticket or a wedged holder stops every writer of every kind.
+ WriteGateHoldAge = metrics.NewGauge(
+ &metrics.GaugeOpts{
+ Subsystem: "storage",
+ Name: "write_gate_hold_age_seconds",
+ Help: "Age of the write gate's current hold as sampled by its watchdog; 0 when idle.",
+ StabilityLevel: metrics.ALPHA,
+ },
+ )
+
+ // WriteGateReentrantTotal counts acquires refused because the caller was
+ // already inside a gated transaction (a nested StorageImpl/ObjectStore
+ // call from a gated fn), by the nested "path". Must stay zero.
+ WriteGateReentrantTotal = metrics.NewCounterVec(
+ &metrics.CounterOpts{
+ Subsystem: "storage",
+ Name: "write_gate_reentrant_total",
+ Help: "Count of write gate acquires refused as re-entrant, by the nested write path. Must stay zero.",
+ StabilityLevel: metrics.ALPHA,
+ },
+ []string{"path"},
+ )
+
+ // WriteGateWaitDuration observes how long a caller queued for the
+ // ObjectStore's write gate ticket, by "priority" (high/low).
+ WriteGateWaitDuration = metrics.NewHistogramVec(
+ &metrics.HistogramOpts{
+ Subsystem: "storage",
+ Name: "write_gate_wait_seconds",
+ Help: "Time a writer queued for the ContainerProfile write gate before its ticket was granted, by priority.",
+ Buckets: waitBuckets,
+ StabilityLevel: metrics.ALPHA,
+ },
+ []string{"priority"},
+ )
+
+ // SqliteBusyWaitDuration observes how long BEGIN IMMEDIATE on the gate's
+ // dedicated connection spent in SQLite's busy handler because an ungated
+ // writer (a legacy kind's commit, cleanup.go) held the database lock.
+ SqliteBusyWaitDuration = metrics.NewHistogram(
+ &metrics.HistogramOpts{
+ Subsystem: "storage",
+ Name: "sqlite_busy_wait_seconds",
+ Help: "Time BEGIN IMMEDIATE on the ContainerProfile write gate's connection waited for SQLite's database lock.",
+ Buckets: waitBuckets,
+ StabilityLevel: metrics.ALPHA,
+ },
+ )
+
+ // CPCASConflictTotal counts compare-and-swap conflicts on the ObjectStore
+ // (an UPDATE/DELETE whose rv/uid predicate matched no row), by "op".
+ CPCASConflictTotal = metrics.NewCounterVec(
+ &metrics.CounterOpts{
+ Subsystem: "storage",
+ Name: "cp_cas_conflict_total",
+ Help: "Count of ContainerProfile compare-and-swap conflicts, by operation.",
+ StabilityLevel: metrics.ALPHA,
+ },
+ []string{"op"},
+ )
+
+ // CPOwnershipRefusalTotal counts operations on a ContainerProfile key the
+ // legacy StorageImpl refused because the kind is owned by the ObjectStore
+ // (the kind-ownership guard). Any non-zero value is a mis-wiring.
+ CPOwnershipRefusalTotal = metrics.NewCounterVec(
+ &metrics.CounterOpts{
+ Subsystem: "storage",
+ Name: "cp_ownership_refusal_total",
+ Help: "Count of legacy StorageImpl operations refused on a kind owned by the ContainerProfile SQLite backend, by operation.",
+ StabilityLevel: metrics.ALPHA,
+ },
+ []string{"op"},
+ )
+
+ // CPMigrationTotal counts every reconcile outcome of the startup
+ // ContainerProfile data migration by shape (and, for legacy_rewrite, by
+ // the source the body was rebuilt from). A non-zero legacy_rewrite or
+ // orphan_payload count means a legacy writer touched a CP row (PM-5).
+ CPMigrationTotal = metrics.NewCounterVec(
+ &metrics.CounterOpts{
+ Subsystem: "storage",
+ Name: "cp_migration_total",
+ Help: "Count of ContainerProfile rows, payloads and files reconciled by the startup migration, by shape and source.",
+ StabilityLevel: metrics.ALPHA,
+ },
+ []string{"shape", "source"},
+ )
+
+ // SqliteWalPages gauges the WAL size in pages as last observed by the
+ // background checkpointer.
+ SqliteWalPages = metrics.NewGauge(
+ &metrics.GaugeOpts{
+ Subsystem: "storage",
+ Name: "sqlite_wal_pages",
+ Help: "WAL size in pages as last observed by the background PASSIVE checkpointer.",
+ StabilityLevel: metrics.ALPHA,
+ },
+ )
+
// ConsolidationFrozenReclaimedTotal counts the time_series rows and TS
// objects consolidation's frozen gate reclaimed unmerged because the base
// ContainerProfile was already Completed/Full when the pass read it,
@@ -218,6 +369,30 @@ var (
},
)
+ // SqliteFreelistCount gauges PRAGMA freelist_count as last observed by the
+ // background checkpointer (TS profiles are create-then-delete objects; their
+ // pages cycle through the freelist).
+ SqliteFreelistCount = metrics.NewGauge(
+ &metrics.GaugeOpts{
+ Subsystem: "storage",
+ Name: "sqlite_freelist_count",
+ Help: "PRAGMA freelist_count as last observed by the background checkpointer.",
+ StabilityLevel: metrics.ALPHA,
+ },
+ )
+
+ // SqliteCheckpointTotal counts background checkpoint runs by "outcome"
+ // (ok/busy/error/panic).
+ SqliteCheckpointTotal = metrics.NewCounterVec(
+ &metrics.CounterOpts{
+ Subsystem: "storage",
+ Name: "sqlite_checkpoint_total",
+ Help: "Count of background PASSIVE checkpoint runs, by outcome.",
+ StabilityLevel: metrics.ALPHA,
+ },
+ []string{"outcome"},
+ )
+
// ConsolidationDivergenceTotal counts payload/metadata divergences
// consolidation observed on a base ContainerProfile, by "shape"
// (payload_ahead: healed; metadata_ahead: observed only). Expected zero in
@@ -232,6 +407,21 @@ var (
[]string{"shape"},
)
+ // SqliteUngatedWriteTotal counts INSERT/UPDATE/DELETE statements prepared
+ // on a pool connection the pool's write gate has never owned, by "op" and
+ // "table". With the write gate on, the gate is the only writer; any other
+ // writer busy-waits against it for the whole busy timeout, invisible to
+ // the gate's own histograms. Must stay zero; the R2 canary.
+ SqliteUngatedWriteTotal = metrics.NewCounterVec(
+ &metrics.CounterOpts{
+ Subsystem: "storage",
+ Name: "sqlite_ungated_write_total",
+ Help: "Count of write statements prepared on a pool connection the write gate does not own, by op and table. Must stay zero.",
+ StabilityLevel: metrics.ALPHA,
+ },
+ []string{"op", "table"},
+ )
+
// ConsolidationHealFailedTotal counts failed divergence heals by the step
// that failed (lock_timeout/begin/read/save/commit). A failing heal errors the
// tick before the frozen gate runs, so ConsolidationFrozenReclaimedTotal
@@ -245,6 +435,34 @@ var (
},
[]string{"reason"},
)
+
+ // ConsolidationKeyReservedTotal counts consolidation retries that ran with
+ // the series reserved (after a first CAS conflict on the ObjectStore), by
+ // how the retry ended. A "conflict" here means a same-series write landed
+ // despite the reservation: a writer's wait or the pass's drain timed out.
+ ConsolidationKeyReservedTotal = metrics.NewCounterVec(
+ &metrics.CounterOpts{
+ Subsystem: "storage",
+ Name: "consolidation_key_reserved_total",
+ Help: "Count of consolidation retries run with the series reserved against same-series writers, by outcome (committed/conflict/error).",
+ StabilityLevel: metrics.ALPHA,
+ },
+ []string{"outcome"},
+ )
+
+ // CPKeyYieldTotal counts ObjectStore writes that waited for a consolidation
+ // reservation on their series, by how the wait ended. "timeout" means the
+ // writer proceeded anyway after keyReserveWaitMax and the progress bound
+ // was not honoured for that retry.
+ CPKeyYieldTotal = metrics.NewCounterVec(
+ &metrics.CounterOpts{
+ Subsystem: "storage",
+ Name: "cp_key_yield_total",
+ Help: "Count of ContainerProfile writes that waited on a consolidation reservation of their series, by outcome (released/timeout).",
+ StabilityLevel: metrics.ALPHA,
+ },
+ []string{"outcome"},
+ )
)
func init() {
@@ -256,10 +474,92 @@ func init() {
legacyregistry.MustRegister(SingleWriterDirtyConnectionTotal)
legacyregistry.MustRegister(SingleWriterDroppedConnectionTotal)
legacyregistry.MustRegister(SingleWriterQueueDepth)
+ legacyregistry.MustRegister(SqliteWriteHoldDuration)
+ legacyregistry.MustRegister(WriteGateWaitDuration)
+ legacyregistry.MustRegister(SqliteBusyWaitDuration)
+ legacyregistry.MustRegister(CPCASConflictTotal)
+ legacyregistry.MustRegister(CPOwnershipRefusalTotal)
+ legacyregistry.MustRegister(CPMigrationTotal)
+ legacyregistry.MustRegister(SqliteWalPages)
+ legacyregistry.MustRegister(SqliteFreelistCount)
+ legacyregistry.MustRegister(SqliteCheckpointTotal)
legacyregistry.MustRegister(ConsolidationFrozenReclaimedTotal)
legacyregistry.MustRegister(ConsolidationFrozenRefusalsTotal)
legacyregistry.MustRegister(ConsolidationDivergenceTotal)
legacyregistry.MustRegister(ConsolidationHealFailedTotal)
+ legacyregistry.MustRegister(ConsolidationKeyReservedTotal)
+ legacyregistry.MustRegister(CPKeyYieldTotal)
+ legacyregistry.MustRegister(SqliteUngatedWriteTotal)
+ legacyregistry.MustRegister(SqliteWriteHoldStepDuration)
+ legacyregistry.MustRegister(WriteGateHoldAge)
+ legacyregistry.MustRegister(WriteGateReentrantTotal)
+}
+
+// IncSqliteUngatedWrite records one write statement prepared on a pool
+// connection outside the write gate.
+func IncSqliteUngatedWrite(op, table string) {
+ SqliteUngatedWriteTotal.WithLabelValues(op, table).Inc()
+}
+
+// ObserveSqliteWriteHoldStep records one named step inside a gated hold.
+func ObserveSqliteWriteHoldStep(path, step string, d time.Duration) {
+ SqliteWriteHoldStepDuration.WithLabelValues(path, step).Observe(d.Seconds())
+}
+
+// SetWriteGateHoldAge sets the current hold's age (0 when idle).
+func SetWriteGateHoldAge(d time.Duration) {
+ WriteGateHoldAge.Set(d.Seconds())
+}
+
+// IncWriteGateReentrant records one acquire refused as re-entrant.
+func IncWriteGateReentrant(path string) {
+ WriteGateReentrantTotal.WithLabelValues(path).Inc()
+}
+
+// ObserveSqliteWriteHold records one gated transaction's hold time by path
+// and kind.
+func ObserveSqliteWriteHold(path, kind string, d time.Duration) {
+ SqliteWriteHoldDuration.WithLabelValues(path, kind).Observe(d.Seconds())
+}
+
+// ObserveWriteGateWait records one caller's queue time for a gate ticket.
+func ObserveWriteGateWait(priority string, d time.Duration) {
+ WriteGateWaitDuration.WithLabelValues(priority).Observe(d.Seconds())
+}
+
+// ObserveSqliteBusyWait records how long BEGIN IMMEDIATE waited for the lock.
+func ObserveSqliteBusyWait(d time.Duration) {
+ SqliteBusyWaitDuration.Observe(d.Seconds())
+}
+
+// IncCPCASConflict records one compare-and-swap conflict for op.
+func IncCPCASConflict(op string) {
+ CPCASConflictTotal.WithLabelValues(op).Inc()
+}
+
+// IncCPOwnershipRefusal records one refused legacy operation for op.
+func IncCPOwnershipRefusal(op string) {
+ CPOwnershipRefusalTotal.WithLabelValues(op).Inc()
+}
+
+// IncCPMigration records one startup-migration reconcile outcome.
+func IncCPMigration(shape, source string) {
+ CPMigrationTotal.WithLabelValues(shape, source).Inc()
+}
+
+// SetSqliteWalPages sets the last observed WAL size in pages.
+func SetSqliteWalPages(pages int64) {
+ SqliteWalPages.Set(float64(pages))
+}
+
+// SetSqliteFreelistCount sets the last observed freelist_count.
+func SetSqliteFreelistCount(pages int64) {
+ SqliteFreelistCount.Set(float64(pages))
+}
+
+// IncSqliteCheckpoint records one checkpointer run with the given outcome.
+func IncSqliteCheckpoint(outcome string) {
+ SqliteCheckpointTotal.WithLabelValues(outcome).Inc()
}
// ObserveLockWait records a lock-hold-wait observation for the given
@@ -337,3 +637,15 @@ func IncConsolidationDivergence(shape string) {
func IncConsolidationHealFailed(reason string) {
ConsolidationHealFailedTotal.WithLabelValues(reason).Inc()
}
+
+// IncConsolidationKeyReserved records one reserved consolidation retry with
+// the given outcome (KeyReserveCommitted / KeyReserveConflict / KeyReserveError).
+func IncConsolidationKeyReserved(outcome string) {
+ ConsolidationKeyReservedTotal.WithLabelValues(outcome).Inc()
+}
+
+// IncCPKeyYield records one writer wait on a consolidation reservation with
+// the given outcome (KeyYieldReleased / KeyYieldTimeout).
+func IncCPKeyYield(outcome string) {
+ CPKeyYieldTotal.WithLabelValues(outcome).Inc()
+}
diff --git a/pkg/registry/file/cleanup.go b/pkg/registry/file/cleanup.go
index 56f23c57c..27956f896 100644
--- a/pkg/registry/file/cleanup.go
+++ b/pkg/registry/file/cleanup.go
@@ -17,8 +17,10 @@ import (
"github.com/kubescape/storage/pkg/apis/softwarecomposition"
"github.com/spf13/afero"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apiserver/pkg/storage"
"zombiezen.com/go/sqlite"
"zombiezen.com/go/sqlite/sqlitemigration"
+ "zombiezen.com/go/sqlite/sqlitex"
)
const (
@@ -39,12 +41,56 @@ type ResourcesCleanupHandler struct {
deleteFunc TypeDeleteFunc
resourceToKindHandler map[string][]TypeCleanupHandlerFunc
watchDispatcher *WatchDispatcher
+ // relevancyEnabled adds the missing-annotation handlers to the
+ // ContainerProfile arm (ContainerProfileHandlers).
+ relevancyEnabled bool
+ // gate is the process's shared write gate (write-gate-sharing §3.2, W9):
+ // when set, the tick's row deletes and sidecar migrations run on the
+ // gate's connection; nil = today's code on the walk's connection.
+ gate *writeGate
+ // cpStore, when set, replaces the file walk for the ContainerProfile
+ // kind: rows are enumerated from the metadata table and reclaimed through
+ // the ObjectStore's Delete (one gated transaction over metadata, payloads
+ // and time_series; Deleted dispatched after). Under the flag CP objects
+ // have no payload file, and a legacy file-then-row delete would leave the
+ // payloads row behind.
+ cpStore *ObjectStore
}
-func initResourceToKindHandler(relevancyEnabled bool) map[string][]TypeCleanupHandlerFunc {
- resourceKindToHandler := map[string][]TypeCleanupHandlerFunc{
+// SetWriteGate hands the cleanup handler the shared write gate (nil = no
+// gate). Called once at wiring time, before the first tick.
+func (h *ResourcesCleanupHandler) SetWriteGate(gate *WriteGate) {
+ h.gate = gate
+}
+
+// SetContainerProfileStore switches the ContainerProfile arm to row
+// enumeration through store. Called once at wiring time, before the first
+// processor cleanup.
+func (h *ResourcesCleanupHandler) SetContainerProfileStore(store *ObjectStore) {
+ h.cpStore = store
+}
+
+// ContainerProfileHandlers is the ContainerProfile arm's handler list, run
+// from ContainerProfileProcessor.cleanup() only: the workload-liveness
+// handler, plus the missing-annotation handlers when relevancy is on. The
+// generic walk (RunCleanupTask) never carries the kind.
+func (h *ResourcesCleanupHandler) ContainerProfileHandlers() []TypeCleanupHandlerFunc {
+ handlers := []TypeCleanupHandlerFunc{deleteByTemplateHashOrWlid}
+ if h.relevancyEnabled {
+ handlers = append(handlers, deleteMissingInstanceIdAnnotation, deleteMissingWlidAnnotation)
+ }
+ return handlers
+}
+
+func (h *ResourcesCleanupHandler) write(ctx context.Context, conn *sqlite.Conn, path, kind string, fn func(ctx context.Context, conn *sqlite.Conn) error) error {
+ return gatedWrite(h.gate, ctx, conn, priorityLow, path, kind, false, fn)
+}
+
+func initResourceToKindHandler() map[string][]TypeCleanupHandlerFunc {
+ return map[string][]TypeCleanupHandlerFunc{
// configurationscansummaries are virtual
// containerprofiles are handled by containerprofile_processor
+ // (ContainerProfileHandlers), never by this walk
// vulnerabilitysummaries are virtual
// DEPRECATED resources
// applicationprofiles and networkneighborhoods were replaced by
@@ -71,18 +117,12 @@ func initResourceToKindHandler(relevancyEnabled bool) map[string][]TypeCleanupHa
"workloadconfigurationscans": {deleteByWlid},
"workloadconfigurationscansummaries": {deleteByWlid},
}
-
- // only if relevancy is enabled, delete container profiles with missing
- // instanceId or wlid annotations.
- if relevancyEnabled {
- logger.L().Debug("relevancy is enabled, adding additional cleanup handlers")
- resourceKindToHandler[ContainerProfileKind] = append(resourceKindToHandler[ContainerProfileKind], deleteMissingInstanceIdAnnotation, deleteMissingWlidAnnotation)
- }
- return resourceKindToHandler
}
func NewResourcesCleanupHandler(appFs afero.Fs, root string, pool *sqlitemigration.Pool, watchDispatcher *WatchDispatcher, interval time.Duration, defaultNamespace string, fetcher ResourcesFetcher, relevancyEnabled bool) *ResourcesCleanupHandler {
-
+ if relevancyEnabled {
+ logger.L().Debug("relevancy is enabled, adding additional container profile cleanup handlers")
+ }
return &ResourcesCleanupHandler{
appFs: appFs,
root: root,
@@ -91,8 +131,9 @@ func NewResourcesCleanupHandler(appFs afero.Fs, root string, pool *sqlitemigrati
defaultNamespace: defaultNamespace,
fetcher: fetcher,
deleteFunc: deleteFile,
- resourceToKindHandler: initResourceToKindHandler(relevancyEnabled),
+ resourceToKindHandler: initResourceToKindHandler(),
watchDispatcher: watchDispatcher,
+ relevancyEnabled: relevancyEnabled,
}
}
@@ -159,6 +200,12 @@ func (h *ResourcesCleanupHandler) CleanupTask(ctx context.Context, resourceToKin
func (h *ResourcesCleanupHandler) cleanupNamespace(ctx context.Context, ns string, resourceToKindHandler map[string][]TypeCleanupHandlerFunc, conn *sqlite.Conn, resources ResourceMaps) error {
for resourceKind, handlers := range resourceToKindHandler {
+ if h.cpStore != nil && IsContainerProfileKind(resourceKind) {
+ if err := h.cleanupContainerProfileRows(ctx, ns, resourceKind, handlers, conn, resources); err != nil {
+ return err
+ }
+ continue
+ }
v1beta1ApiVersionPath := filepath.Join(h.root, softwarecomposition.GroupName, resourceKind, ns)
exists, _ := afero.DirExists(h.appFs, v1beta1ApiVersionPath)
if !exists {
@@ -183,7 +230,7 @@ func (h *ResourcesCleanupHandler) cleanupNamespace(ctx context.Context, ns strin
return nil
}
- metadata, err := h.readMetadata(conn, path)
+ metadata, err := h.readMetadata(ctx, conn, path)
if err != nil {
logger.L().Error("load metadata error", helpers.Error(err))
return nil
@@ -206,7 +253,7 @@ func (h *ResourcesCleanupHandler) cleanupNamespace(ctx context.Context, ns strin
logger.L().Debug("deleting", helpers.String("kind", resourceKind), helpers.String("namespace", metadata.Namespace), helpers.String("name", metadata.Name))
h.deleteFunc(h.appFs, path)
- metaOut, err := h.deleteMetadata(conn, path)
+ metaOut, err := h.deleteMetadata(ctx, conn, path)
if err != nil {
return fmt.Errorf("failed to delete metadata: %w", err)
}
@@ -224,6 +271,51 @@ func (h *ResourcesCleanupHandler) cleanupNamespace(ctx context.Context, ns strin
return nil
}
+// cleanupContainerProfileRows is the ContainerProfile arm under the flag:
+// the namespace's rows are enumerated from the metadata table on the tick's
+// connection and the reclaimed ones deleted through the ObjectStore, which
+// removes metadata, payloads and time_series rows in one gated transaction
+// and dispatches Deleted after it. No file is read, written or removed.
+func (h *ResourcesCleanupHandler) cleanupContainerProfileRows(ctx context.Context, ns, resourceKind string, handlers []TypeCleanupHandlerFunc, conn *sqlite.Conn, resources ResourceMaps) error {
+ type row struct {
+ name string
+ metadataJSON []byte
+ }
+ var rows []row
+ err := sqlitex.Execute(conn,
+ `SELECT name, metadata FROM metadata WHERE kind = :kind AND namespace = :namespace`,
+ &sqlitex.ExecOptions{
+ Named: map[string]any{":kind": resourceKind, ":namespace": ns},
+ ResultFunc: func(stmt *sqlite.Stmt) error {
+ rows = append(rows, row{name: stmt.ColumnText(0), metadataJSON: []byte(stmt.ColumnText(1))})
+ return nil
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("failed to list %s rows in %s: %w", resourceKind, ns, err)
+ }
+ for _, r := range rows {
+ key := K8sKeysToPath("", softwarecomposition.GroupName, resourceKind, "", ns, r.name)
+ metadata, err := loadMetadata(r.metadataJSON)
+ if err != nil {
+ logger.L().Error("load metadata error", helpers.Error(err), helpers.String("key", key))
+ continue
+ }
+ if isUserManaged(metadata) {
+ continue
+ }
+ if !or(handlers, resourceKind, key, metadata, resources) {
+ continue
+ }
+ logger.L().Debug("deleting", helpers.String("kind", resourceKind), helpers.String("namespace", metadata.Namespace), helpers.String("name", metadata.Name))
+ err = h.cpStore.Delete(ctx, key, &PartialObjectMetadata{}, nil, nil, nil, storage.DeleteOptions{})
+ if err != nil && !storage.IsNotFound(err) {
+ return fmt.Errorf("failed to delete %s: %w", key, err)
+ }
+ }
+ return nil
+}
+
// isUserManaged reports whether the given resource metadata carries the
// "user-managed" marker. The marker lives on Annotations by codebase
// convention (see pkg/apis/softwarecomposition/networkpolicy/v2/
diff --git a/pkg/registry/file/cleanup_test.go b/pkg/registry/file/cleanup_test.go
index 20ac74de3..6a8def53e 100644
--- a/pkg/registry/file/cleanup_test.go
+++ b/pkg/registry/file/cleanup_test.go
@@ -64,7 +64,7 @@ func TestCleanupTask(t *testing.T) {
root: DefaultStorageRoot,
fetcher: &ResourcesFetchMock{},
deleteFunc: deleteFunc,
- resourceToKindHandler: initResourceToKindHandler(false),
+ resourceToKindHandler: initResourceToKindHandler(),
}
handler.CleanupTask(context.TODO(), handler.resourceToKindHandler)
diff --git a/pkg/registry/file/containerprofile_advisory.go b/pkg/registry/file/containerprofile_advisory.go
new file mode 100644
index 000000000..971c25a26
--- /dev/null
+++ b/pkg/registry/file/containerprofile_advisory.go
@@ -0,0 +1,111 @@
+package file
+
+// Advisory startup check (D) (.omc/plans/rollback-safety-guard.md): a
+// purely informational census, run once at startup when
+// cfg.ContainerProfileSqliteBackend is false, of how many ContainerProfile
+// keys satisfy the rollback read fallback's database predicate. This counts
+// candidates only: it does not check legacy .g file absence or decode payloads,
+// so it does not count objects actually served through the fallback.
+// It never fails startup: a query error is logged distinctly from a
+// genuine zero count, and either way the caller proceeds.
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/kubescape/go-logger"
+ "github.com/kubescape/go-logger/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitemigration"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+// advisoryFallbackExampleCap bounds how many example keys the census logs
+// alongside the count, so a large count does not spam the log.
+const advisoryFallbackExampleCap = 10
+
+// FallbackCensusReport is the result of counting, at startup, how many
+// ContainerProfile keys currently satisfy the rollback read fallback's
+// 4-condition database predicate, without file or payload-decode checks.
+type FallbackCensusReport struct {
+ // Count is the total number of database candidates found.
+ Count int
+ // ExampleKeys holds up to advisoryFallbackExampleCap of those keys'
+ // full storage paths.
+ ExampleKeys []string
+}
+
+// CensusFallbackEligibleContainerProfiles counts ContainerProfile keys
+// currently satisfying the same 4-condition database predicate as the rollback
+// read fallback (inspectPayloadsFallback, storage.go / readFallbackCandidate,
+// sqlite.go): a metadata row exists, rv IS NOT NULL, is_time_series = 0,
+// and a payloads row exists (the two row-existence conditions are enforced
+// here by the JOIN itself). It does not check whether a legacy .g file exists
+// or whether the payload can be decoded; the count can exceed the number of
+// objects that a missing-file read could actually serve through the fallback.
+//
+// Bounded with its own timeout derived from ctx, rather than inheriting
+// ctx's own deadline (or lack of one) directly -- the caller may pass an
+// untimed signal context, and this census must never block startup
+// indefinitely.
+func CensusFallbackEligibleContainerProfiles(ctx context.Context, pool *sqlitemigration.Pool, timeout time.Duration) (FallbackCensusReport, error) {
+ censusCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ conn, err := pool.Take(censusCtx)
+ if err != nil {
+ return FallbackCensusReport{}, fmt.Errorf("fallback census: take connection: %w", err)
+ }
+ defer pool.Put(conn)
+
+ var report FallbackCensusReport
+ err = sqlitex.Execute(conn,
+ `SELECT m.namespace, m.name
+ FROM metadata m JOIN payloads p USING (kind, namespace, name)
+ WHERE m.kind = :kind
+ AND m.rv IS NOT NULL
+ AND m.is_time_series = 0`,
+ &sqlitex.ExecOptions{
+ Named: map[string]any{":kind": ContainerProfileKind},
+ ResultFunc: func(stmt *sqlite.Stmt) error {
+ if cerr := censusCtx.Err(); cerr != nil {
+ return cerr
+ }
+ report.Count++
+ if len(report.ExampleKeys) < advisoryFallbackExampleCap {
+ key := K8sKeysToPath("", softwarecomposition.GroupName, ContainerProfileKind, "", stmt.ColumnText(0), stmt.ColumnText(1))
+ report.ExampleKeys = append(report.ExampleKeys, key)
+ }
+ return nil
+ },
+ })
+ if err != nil {
+ return FallbackCensusReport{}, fmt.Errorf("fallback census: query: %w", err)
+ }
+ return report, nil
+}
+
+// LogFallbackEligibleContainerProfilesCensus runs the startup advisory
+// census and logs its result -- intended to be called from main.go only
+// when cfg.ContainerProfileSqliteBackend is false. It never calls Fatal and
+// never returns an error to the caller: this check is purely informational
+// and must never affect whether the server starts. A query failure is
+// logged distinctly (Warning, carrying the error) from a genuine
+// zero-count result (Info) so an operator never mistakes "the census
+// itself failed" for "nothing to report".
+func LogFallbackEligibleContainerProfilesCensus(ctx context.Context, pool *sqlitemigration.Pool, timeout time.Duration) {
+ report, err := CensusFallbackEligibleContainerProfiles(ctx, pool, timeout)
+ if err != nil {
+ logger.L().Ctx(ctx).Warning("containerprofile rollback advisory census failed, skipping",
+ helpers.Error(err))
+ return
+ }
+ if report.Count == 0 {
+ logger.L().Ctx(ctx).Info("containerprofile rollback advisory census: no fallback-eligible keys found")
+ return
+ }
+ logger.L().Ctx(ctx).Warning("containerprofile rollback advisory census: fallback-eligible keys found",
+ helpers.Int("count", report.Count), helpers.Interface("exampleKeys", report.ExampleKeys))
+}
diff --git a/pkg/registry/file/containerprofile_lane0_test.go b/pkg/registry/file/containerprofile_lane0_test.go
index 32f467163..1697020c7 100644
--- a/pkg/registry/file/containerprofile_lane0_test.go
+++ b/pkg/registry/file/containerprofile_lane0_test.go
@@ -198,7 +198,7 @@ func (h *lane0Harness) writeTsObject(t *testing.T, baseKey, suffix, tag string,
conn, err := h.pool.Take(context.Background())
require.NoError(t, err)
defer h.pool.Put(conn)
- _, err = h.s.saveObject(conn, tsKey, obj, &softwarecomposition.ContainerProfile{}, "")
+ _, err = h.s.saveObject(context.Background(), conn, tsKey, obj, &softwarecomposition.ContainerProfile{}, "", priorityLow, holdPathLegacyCommit)
require.NoError(t, err)
return tsKey
}
@@ -425,7 +425,7 @@ func TestUpdateProfile_MissingInstanceID_ProcessedIsNil(t *testing.T) {
profile, id, prefix, root, err := h.proc.loadOrInitializeProfile(ctx, key)
require.NoError(t, err)
- processed, err := h.proc.processTimeSeriesInTransaction(ctx, rows, key, profile, prefix, root, id, false)
+ processed, _, err := h.proc.processTimeSeriesInTransaction(ctx, rows, key, profile, prefix, root, id, false)
require.NoError(t, err)
require.Nil(t, processed, "nothing was persisted, so nothing may be scheduled for deletion")
diff --git a/pkg/registry/file/containerprofile_load_test.go b/pkg/registry/file/containerprofile_load_test.go
index f9e630adb..33a0c7c8c 100644
--- a/pkg/registry/file/containerprofile_load_test.go
+++ b/pkg/registry/file/containerprofile_load_test.go
@@ -1,41 +1,40 @@
package file
-// AC4 load/stress benchmark for the ContainerProfile locking/connection-pool
-// hardening (PRs 1f395bc5, e4149ee5, 8983b7f7). It reproduces the incident's
-// concurrency shape — node-agent continuously writing ContainerProfile time
-// series while background consolidation runs and REST clients (CVE scan /
-// network-policy check) read the same keys — against a deliberately small
-// SQLite connection pool so REST↔consolidator contention is reproducible in a
-// few seconds.
+// Mixed-load harness for the ContainerProfile write/read/consolidate paths.
+// It reproduces the incident's concurrency shape — node-agent continuously
+// writing ContainerProfile time series while background consolidation runs
+// and REST clients (CVE scan / network-policy check) read the same keys.
//
-// This file holds two tests with DIFFERENT statuses:
+// This file holds three tests with DIFFERENT statuses:
//
-// - TestContainerProfileLockFailFast — a committed REGRESSION TEST for PR1
-// (fail-fast lock backstop). Deterministic, runs in ~1s, asserts every
-// contended GET returns a ServerTimeout within a fail-fast bound instead of
-// hanging to the request deadline. Runs in the normal `make test` suite
-// (NOT gated). This is the AC4 "latency under a defined bound" assertion.
+// - TestContainerProfileLockFailFast — a committed REGRESSION TEST for the
+// fail-fast lock backstop. Deterministic, ~1s, runs in `go test ./...`.
//
-// - TestContainerProfileLoad — the full mixed-load reproduction. High
-// variance (a single consolidation pass can dominate the window), so it is
-// a manual DIAGNOSTIC only: gated behind LOAD_TEST=1 (t.Skip otherwise) and
-// never a CI pass/fail gate. Its value is the before/after delta between
-// this tree and edd2fb80, not any absolute latency. Run explicitly:
+// - TestPerfABRound — Tier B of the measurement harness (design: A.13.4 of
+// .omc/plans/raw-write-bypass-elimination.md). One round of FIXED WORK
+// with closed-loop clients on the production shape (pool 10, 8 shards,
+// Workers = pool/4, GOMAXPROCS=8), reported as JSON plus benchstat-format
+// lines. It never has a pass/fail threshold of its own: hack/perf-ab.sh
+// runs it interleaved for a merge-base build and a HEAD build on the same
+// machine in the same window and decides RELATIVELY (`make perf-ab`).
+// Gated on PERF_AB_OUT=; skipped otherwise.
//
-// LOAD_TEST=1 go test ./pkg/registry/file/ -run TestContainerProfileLoad -v -timeout 300s
+// - TestContainerProfileLoad — the original time-boxed diagnostic
+// (LOAD_TEST=1), kept for exploration with the env tunables below. Its
+// absolute numbers are machine-dependent and never a gate; use Tier B
+// for any before/after claim.
//
-// Tunables via env: LOAD_POOL, LOAD_WRITERS, LOAD_READERS, LOAD_UPDATERS,
-// LOAD_CONSOLIDATORS, LOAD_SECONDS, LOAD_{WRITER,READER,UPDATER}_SLEEP_MS,
-// LOAD_CONSOLIDATOR_SLEEP_MS.
-//
-// See the AC4 discussion in .omc/plans/ralplan-improve-the-locking-mechanism-to.md.
+// Both load tests share runLoadScenario.
import (
"context"
"encoding/json"
+ "errors"
"fmt"
+ "math"
"os"
"path/filepath"
+ "runtime"
"sort"
"strconv"
"strings"
@@ -44,25 +43,31 @@ import (
"testing"
"time"
+ mapset "github.com/deckarep/golang-set/v2"
+ "github.com/goradd/maps"
helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
"github.com/kubescape/storage/pkg/apis/softwarecomposition"
"github.com/kubescape/storage/pkg/generated/clientset/versioned/scheme"
"github.com/kubescape/storage/pkg/utils"
+ dto "github.com/prometheus/client_model/go"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
- "k8s.io/apimachinery/pkg/runtime"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ k8sruntime "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/storage"
+ "k8s.io/component-base/metrics/legacyregistry"
"zombiezen.com/go/sqlite"
"zombiezen.com/go/sqlite/sqlitemigration"
)
+// perfABHarnessVersion is echoed in every round's JSON. hack/perf-ab.sh
+// overlays this file onto the base worktree, so both arms must report the
+// same value; the driver refuses to compare rounds that do not.
+const perfABHarnessVersion = "4"
+
// ---- tunables (documented defaults; overridable via env for exploration) ----
-//
-// Defaults reproduce the incident shape: a small pool (production is
-// DefaultPoolSize=10), modest background node-agent write load, one background
-// consolidation loop, and read-dominated REST traffic (the GETs that 504'd).
func envInt(name string, def int) int {
if v := os.Getenv(name); v != "" {
@@ -73,25 +78,33 @@ func envInt(name string, def int) int {
return def
}
+// loadBackend selects the storage backend a round exercises: "legacy" (the
+// row+gob-file StorageImpl, default) or "objectstore" (the SQLite-native
+// ContainerProfile backend, config.ContainerProfileSqliteBackend). It is NOT
+// part of the effective-config echo, so hack/perf-ab.sh can run the two arms of
+// an A/B from the same commit with PERF_AB_BASE_ENV / PERF_AB_HEAD_ENV.
+func loadBackend() string {
+ if b := os.Getenv("PERF_AB_BACKEND"); b != "" {
+ return b
+ }
+ return "legacy"
+}
+
func loadPoolSize() int { return envInt("LOAD_POOL", 6) }
func loadWriters() int { return envInt("LOAD_WRITERS", 6) }
func loadReaders() int { return envInt("LOAD_READERS", 25) }
func loadUpdaters() int { return envInt("LOAD_UPDATERS", 3) }
func loadConsolidators() int { return envInt("LOAD_CONSOLIDATORS", 1) }
+func loadLegacyWriters() int { return envInt("LOAD_LEGACY_WRITERS", 2) }
+func loadLegacySizeKB() int { return envInt("LOAD_LEGACY_KB", 1024) }
+func loadCleanupRows() int { return envInt("LOAD_CLEANUP_ROWS", 300) }
-// Per-op client think-times. Real REST clients (CVE scan, netpol check,
-// node-agent) do not hammer the apiserver in a zero-gap loop; a small think-time
-// keeps the workload from degenerating into a synthetic livelock on the shared
-// per-key locks and lets background consolidation passes actually complete so
-// steady-state REST latency is what gets measured.
-var (
- loadWriterSleep = time.Duration(envInt("LOAD_WRITER_SLEEP_MS", 10)) * time.Millisecond
- readerSleep = time.Duration(envInt("LOAD_READER_SLEEP_MS", 3)) * time.Millisecond
- updaterSleep = time.Duration(envInt("LOAD_UPDATER_SLEEP_MS", 20)) * time.Millisecond
-)
+// loadHotKeys selects the same-key contention shape: every updater and one
+// writer target base key 0 instead of being spread across the 12 templates.
+func loadHotKeys() bool { return os.Getenv("LOAD_HOT_KEYS") == "1" }
// loadProcessorWorkers returns the worker bound the processor uses in the
-// benchmark. Kept a helper so the pre-fix baseline (no Workers field) can be
+// benchmark. Kept a helper so a baseline without the Workers field can be
// adapted with a single edit.
func loadProcessorWorkers() int {
return max(1, loadPoolSize()/4)
@@ -101,6 +114,172 @@ func loadDuration() time.Duration {
return time.Duration(envInt("LOAD_SECONDS", 6)) * time.Second
}
+// loadConfig is one load scenario. Ops fields > 0 select fixed-work mode
+// (each client performs exactly that many operations, then the consolidator
+// runs ExtraTicks more passes and the run ends); otherwise the run is
+// time-boxed by Duration.
+type loadConfig struct {
+ PoolSize int
+ Workers int
+ Writers int
+ Readers int
+ Updaters int
+ Listers int
+ Consolidators int
+
+ WriterOps int
+ ReaderOps int
+ UpdaterOps int
+ ListerOps int
+ ExtraTicks int
+ Duration time.Duration
+
+ // Legacy-kind traffic through the same StorageImpl the CP scenario's
+ // GetSbom reads (T-G3 of write-gate-sharing): LegacyWriters clients each
+ // run LegacyOps create → update → delete cycles, alternating a sbomsyft
+ // object of LegacySizeKB (gob-encoded before the commit; the hold is a
+ // row plus a rename regardless of size) and a small vulnerability
+ // manifest. CleanupRows unreferenced sbomsyft rows are seeded in their own
+ // namespace and one cleanup tick reclaims them concurrently with the run.
+ LegacyWriters int
+ LegacyOps int
+ LegacySizeKB int
+ CleanupRows int
+
+ WriterSleep time.Duration
+ ReaderSleep time.Duration
+ UpdaterSleep time.Duration
+ ListerSleep time.Duration
+ LegacySleep time.Duration
+ // TickInterval is the gap between consolidation passes; 0 is a zero-gap
+ // loop (the harshest diagnostic setting, and a livelock generator).
+ TickInterval time.Duration
+ BusyTimeout time.Duration
+ // RequestTimeout is each REST-facing call's context deadline.
+ RequestTimeout time.Duration
+ // HotKeys concentrates every updater and writer 0 on base key 0 (the
+ // consolidator's write to that base key then races all the updaters):
+ // the deliberate same-key shape for update-p99 and conflict rate. It is
+ // part of the effective-config echo, so a hot-keys round never pairs with
+ // a spread one.
+ HotKeys bool
+ // CollapseTTL, when > 0, pins collapseSettingsTTL for the round. The
+ // 10 s default makes a consolidation save that has already written refresh
+ // the CollapseConfiguration cache on a second connection; the absent CR's
+ // DeleteMetadata then waits on the write lock the same goroutine holds
+ // until the busy timeout, freezing every shard commit with it. Whether a
+ // round crosses a TTL boundary is wall-clock phase, not the change under
+ // test, so perf-ab rounds pin it; the stall itself is a bug in its own
+ // right, visible in the over-one-sec row of an unpinned run.
+ CollapseTTL time.Duration
+}
+
+// perfABConfig is Tier B's pinned production shape.
+func perfABConfig() loadConfig {
+ return loadConfig{
+ PoolSize: DefaultPoolSize,
+ Workers: max(1, DefaultPoolSize/4),
+ Writers: 6,
+ Readers: 25,
+ Updaters: 3,
+ Listers: 2,
+ Consolidators: 1,
+ WriterOps: 2400,
+ ReaderOps: 12000,
+ UpdaterOps: 1200,
+ ListerOps: 300,
+ ExtraTicks: 3,
+ LegacyWriters: 2,
+ LegacyOps: 60,
+ LegacySizeKB: 1024,
+ CleanupRows: 300,
+ TickInterval: 250 * time.Millisecond,
+ BusyTimeout: 5 * time.Second,
+ RequestTimeout: 15 * time.Second,
+ CollapseTTL: time.Hour,
+ HotKeys: loadHotKeys(),
+ }
+}
+
+// diagnosticConfig is TestContainerProfileLoad's env-tunable time-boxed shape.
+func diagnosticConfig() loadConfig {
+ return loadConfig{
+ PoolSize: loadPoolSize(),
+ Workers: loadProcessorWorkers(),
+ Writers: loadWriters(),
+ Readers: loadReaders(),
+ Updaters: loadUpdaters(),
+ Consolidators: loadConsolidators(),
+ LegacyWriters: loadLegacyWriters(),
+ LegacySizeKB: loadLegacySizeKB(),
+ CleanupRows: loadCleanupRows(),
+ Duration: loadDuration(),
+ WriterSleep: time.Duration(envInt("LOAD_WRITER_SLEEP_MS", 10)) * time.Millisecond,
+ ReaderSleep: time.Duration(envInt("LOAD_READER_SLEEP_MS", 3)) * time.Millisecond,
+ UpdaterSleep: time.Duration(envInt("LOAD_UPDATER_SLEEP_MS", 20)) * time.Millisecond,
+ LegacySleep: time.Duration(envInt("LOAD_LEGACY_SLEEP_MS", 50)) * time.Millisecond,
+ TickInterval: time.Duration(envInt("LOAD_CONSOLIDATOR_SLEEP_MS", 0)) * time.Millisecond,
+ BusyTimeout: 5 * time.Second,
+ RequestTimeout: 15 * time.Second,
+ HotKeys: loadHotKeys(),
+ }
+}
+
+func (c loadConfig) fixedWork() bool {
+ return c.WriterOps > 0 || c.ReaderOps > 0 || c.UpdaterOps > 0 || c.ListerOps > 0 || c.LegacyOps > 0
+}
+
+// Legacy-kind fixtures for T-G3.
+const (
+ loadLegacyNS = "load-legacy" // the legacy writers' namespace
+ loadCleanupNS = "load-cleanup" // the seeded rows one cleanup tick reclaims
+)
+
+func loadLegacyKey(kind, ns, name string) string {
+ return K8sKeysToPath("", "spdx.softwarecomposition.kubescape.io", kind, "", ns, name)
+}
+
+// loadSizedSBOM builds a sbomsyft object whose gob encoding is roughly sizeKB
+// (each catalogued file is ~80 bytes encoded).
+func loadSizedSBOM(name, ns string, sizeKB int) *softwarecomposition.SBOMSyft {
+ n := max(1, sizeKB*1024/80)
+ files := make([]softwarecomposition.SyftFile, n)
+ for i := range files {
+ files[i] = softwarecomposition.SyftFile{ID: strconv.Itoa(i), Location: softwarecomposition.Coordinates{RealPath: fmt.Sprintf("/usr/lib/x86_64-linux-gnu/lib-%08d.so.1", i), FileSystemID: "sha256:layer"}}
+ }
+ return &softwarecomposition.SBOMSyft{
+ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, Annotations: map[string]string{helpersv1.ImageIDMetadataKey: "sha256:" + name}},
+ Spec: softwarecomposition.SBOMSyftSpec{Syft: softwarecomposition.SyftDocument{Files: files}},
+ }
+}
+
+// loadCleanupFetcher lists the cleanup namespace with nothing running, so
+// every seeded row is reclaimed by the tick.
+type loadCleanupFetcher struct{}
+
+func (loadCleanupFetcher) ListNamespaces(*sqlite.Conn) ([]string, error) {
+ return []string{loadCleanupNS}, nil
+}
+func (loadCleanupFetcher) FetchResources(string) (ResourceMaps, error) {
+ return ResourceMaps{
+ RunningContainerImageIds: mapset.NewSet[string](),
+ RunningInstanceIds: mapset.NewSet[string](),
+ RunningTemplateHash: mapset.NewSet[string](),
+ RunningWlidsToContainerNames: new(maps.SafeMap[string, mapset.Set[string]]),
+ }, nil
+}
+
+func loadLabelUpdate(input k8sruntime.Object, _ storage.ResponseMeta) (k8sruntime.Object, *uint64, error) {
+ m := input.(metav1.Object)
+ labels := m.GetLabels()
+ if labels == nil {
+ labels = map[string]string{}
+ }
+ labels["load"] = strconv.FormatInt(time.Now().UnixNano(), 36)
+ m.SetLabels(labels)
+ return input, nil, nil
+}
+
// cpTemplate is one testdata TS ContainerProfile plus the derived base
// (consolidated) key that REST readers GET and the consolidator writes.
type cpTemplate struct {
@@ -129,13 +308,13 @@ func loadTemplates(t *testing.T) []cpTemplate {
return out
}
-// latencyRec accumulates per-op latencies and error classes lock-free-ish
-// (mutex only on append; cheap relative to the storage ops themselves).
+// latencyRec accumulates per-op latencies and error classes (mutex only on
+// append; cheap relative to the storage ops themselves).
type latencyRec struct {
mu sync.Mutex
name string
samples []time.Duration
- errServerTO int64 // fail-fast ServerTimeout (post-fix clean error)
+ errServerTO int64 // fail-fast ServerTimeout, or the request context's own deadline
errTakeConn int64 // "take connection" pool exhaustion
errOther int64
okCount int64
@@ -165,34 +344,74 @@ func (r *latencyRec) record(d time.Duration, err error) {
}
}
-func (r *latencyRec) report(t *testing.T) {
+// latencyStats is one client class's row in a loadReport.
+type latencyStats struct {
+ Ops int64 `json:"ops"`
+ OK int64 `json:"ok"`
+ ServerTimeout int64 `json:"server_timeout"`
+ TakeConn int64 `json:"take_conn"`
+ Other int64 `json:"other"`
+ OverOneSec int64 `json:"over_one_sec"`
+ OverFiveSec int64 `json:"over_five_sec"`
+ P50Ms float64 `json:"p50_ms"`
+ P95Ms float64 `json:"p95_ms"`
+ P99Ms float64 `json:"p99_ms"`
+ MaxMs float64 `json:"max_ms"`
+ TotalS float64 `json:"total_s"`
+}
+
+func (r *latencyRec) stats() latencyStats {
r.mu.Lock()
s := append([]time.Duration(nil), r.samples...)
r.mu.Unlock()
sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
- pct := func(p float64) time.Duration {
+ pct := func(p float64) float64 {
if len(s) == 0 {
return 0
}
idx := int(p / 100 * float64(len(s)-1))
- return s[idx]
+ return float64(s[idx]) / float64(time.Millisecond)
+ }
+ out := latencyStats{
+ Ops: int64(len(s)),
+ OK: atomic.LoadInt64(&r.okCount),
+ ServerTimeout: atomic.LoadInt64(&r.errServerTO),
+ TakeConn: atomic.LoadInt64(&r.errTakeConn),
+ Other: atomic.LoadInt64(&r.errOther),
+ OverOneSec: atomic.LoadInt64(&r.overOneSec),
+ OverFiveSec: atomic.LoadInt64(&r.overFiveSec),
+ P50Ms: pct(50),
+ P95Ms: pct(95),
+ P99Ms: pct(99),
}
- total := len(s)
+ var total time.Duration
+ for _, d := range s {
+ total += d
+ }
+ out.TotalS = total.Seconds()
+ if len(s) > 0 {
+ out.MaxMs = float64(s[len(s)-1]) / float64(time.Millisecond)
+ }
+ return out
+}
+
+func (r *latencyRec) report(t *testing.T) {
+ s := r.stats()
t.Logf("== %s ==", r.name)
t.Logf(" ops=%d ok=%d | errs: serverTimeout=%d takeConn=%d other=%d",
- total, atomic.LoadInt64(&r.okCount), atomic.LoadInt64(&r.errServerTO),
- atomic.LoadInt64(&r.errTakeConn), atomic.LoadInt64(&r.errOther))
- if total == 0 {
+ s.Ops, s.OK, s.ServerTimeout, s.TakeConn, s.Other)
+ if s.Ops == 0 {
return
}
- t.Logf(" p50=%s p95=%s p99=%s max=%s", pct(50), pct(95), pct(99), s[total-1])
- t.Logf(" >1s=%d >5s=%d", atomic.LoadInt64(&r.overOneSec), atomic.LoadInt64(&r.overFiveSec))
+ t.Logf(" p50=%.2fms p95=%.2fms p99=%.2fms max=%.2fms", s.P50Ms, s.P95Ms, s.P99Ms, s.MaxMs)
+ t.Logf(" >1s=%d >5s=%d", s.OverOneSec, s.OverFiveSec)
}
+// isServerTimeoutErr covers the fail-fast contention error and the harness's
+// own request-context deadline: a transient stall against reqCtx is a
+// timeout, not an "other" error, so it cannot trip the hard rows on its own.
func isServerTimeoutErr(err error) bool {
- // Post-fix fail-fast lock error. On the pre-fix baseline this is always
- // false (no ServerTimeout), so those failures fall into errOther.
- return apierrors.IsServerTimeout(err)
+ return apierrors.IsServerTimeout(err) || errors.Is(err, context.DeadlineExceeded)
}
func isTakeConnErr(err error) bool {
@@ -200,48 +419,57 @@ func isTakeConnErr(err error) bool {
}
// loadPool builds a temp-dir SQLite pool identical to production NewPool EXCEPT
-// it installs a bounded busy timeout on every connection. Production's NewPool
-// leaves connections in SetBlockOnBusy mode (infinite block on a held write
-// lock); under a deliberately tiny pool that turns ordinary SQLite write
-// contention into an unbounded stall that pins every connection to the 60s
-// poolContext and drowns the Go-level pool/lock behaviour this benchmark exists
-// to measure. A 5s busy timeout keeps SQLite write contention bounded so the
-// connection-pool-pinning (PR3) and MapMutex fail-fast (PR1) effects — which
-// live ABOVE the SQLite layer — are what the latency numbers reflect. This is a
-// harness isolation choice, not a claim about production; see the report note.
-func loadPool(t *testing.T, path string, size int) *sqlitemigration.Pool {
+// for a bounded busy timeout on every connection. Production's NewPool leaves
+// connections blocking indefinitely on a held write lock; under a small pool
+// that turns ordinary SQLite write contention into a stall that pins every
+// connection to the 60s poolContext and drowns the Go-level pool/lock
+// behaviour this harness measures. This is a harness isolation choice, not a
+// claim about production.
+func loadPool(t *testing.T, path string, size int, busyTimeout time.Duration) *sqlitemigration.Pool {
t.Helper()
- return sqlitemigration.NewPool(path,
- sqlitemigration.Schema{
- Migrations: []string{
- `CREATE TABLE IF NOT EXISTS metadata (
- kind TEXT, namespace TEXT, name TEXT, metadata JSON,
- PRIMARY KEY (kind, namespace, name)
- );`,
- `CREATE TABLE IF NOT EXISTS time_series (
- kind TEXT, namespace TEXT, name TEXT, seriesID TEXT,
- reportTimestamp TEXT, status TEXT, tsSuffix TEXT, completion TEXT,
- previousReportTimestamp TEXT, hasData INTEGER DEFAULT 0,
- PRIMARY KEY (kind, namespace, name, seriesID, tsSuffix)
- );`,
- },
- },
- sqlitemigration.Options{
- PoolSize: size,
- PrepareConn: func(conn *sqlite.Conn) error {
- conn.SetBusyTimeout(5 * time.Second)
- return nil
- },
- })
+ // The production schema (SchemaMigrations, including the ObjectStore's
+ // migrations 3-4); wal_autocheckpoint=0 on every connection exactly as
+ // main.go does when config.ContainerProfileSqliteBackend is on (K-3).
+ return NewPoolWithOptions(path, PoolOptions{
+ Size: size,
+ BusyTimeout: busyTimeout,
+ DisableAutoCheckpoint: loadBackend() == "objectstore",
+ })
}
// newLoadStorage builds a real StorageImpl + ContainerProfileProcessor over a
// temp-dir SQLite pool of the given size. Returns storage, processor, pool.
func newLoadStorage(t *testing.T, poolSize int) (*StorageImpl, *ContainerProfileProcessor, *sqlitemigration.Pool) {
+ t.Helper()
+ s, _, processor, pool, _, _ := newLoadStorageWith(t, poolSize, loadProcessorWorkers(), 5*time.Second)
+ return s, processor, pool
+}
+
+// loadLegacyKinds is the default StorageImpl of apiserver.go — DefaultProcessor,
+// the instance that serves every non-ContainerProfile kind — over the same
+// pool, filesystem and (under the flag) gate as the CP instance. The legacy
+// writers and the cleanup seed go through it, as REST traffic does.
+type loadLegacyKinds struct {
+ s *StorageImpl
+ cleanup *ResourcesCleanupHandler
+}
+
+// newLoadStorageWith builds the legacy StorageImpl (always: it serves GetSbom
+// and is the flag-default reference) and, when PERF_AB_BACKEND=objectstore, the
+// ObjectStore over the same pool with the legacy instance carrying the
+// kind-ownership guard — the production wiring under
+// config.ContainerProfileSqliteBackend. The returned storage.Interface is the
+// one the load clients drive; closeStore must run before pool.Close (K-5).
+// storeOwnedConns is the number of pool connections the selected backend keeps
+// for its own lifetime (the ObjectStore's write gate owns one); probePoolSize
+// cannot see them, so the effective pool size adds them back.
+var storeOwnedConns int
+
+func newLoadStorageWith(t *testing.T, poolSize, workers int, busyTimeout time.Duration) (*StorageImpl, storage.Interface, *ContainerProfileProcessor, *sqlitemigration.Pool, loadLegacyKinds, func()) {
t.Helper()
path := filepath.Join(t.TempDir(), "load.sq3")
_ = os.Remove(path)
- pool := loadPool(t, path, poolSize)
+ pool := loadPool(t, path, poolSize, busyTimeout)
require.NotNil(t, pool)
sch := scheme.Scheme
@@ -249,8 +477,9 @@ func newLoadStorage(t *testing.T, poolSize int) (*StorageImpl, *ContainerProfile
processor := &ContainerProfileProcessor{
DeleteThreshold: 0, // never expire during the run
MaxContainerProfileSize: 40000,
- Workers: loadProcessorWorkers(),
+ Workers: workers,
}
+ wd := NewWatchDispatcher()
s := &StorageImpl{
appFs: afero.NewMemMapFs(),
pool: pool,
@@ -259,66 +488,233 @@ func newLoadStorage(t *testing.T, poolSize int) (*StorageImpl, *ContainerProfile
root: DefaultStorageRoot,
scheme: sch,
versioner: storage.APIObjectVersioner{},
- watchDispatcher: NewWatchDispatcher(),
+ watchDispatcher: wd,
}
- // Exercise the real CollapseConfig provider so PreSave's (post-fix cached)
- // settings lookup is on the hot path, matching AC2/AC4 intent.
+ // Exercise the real CollapseConfig provider so PreSave's cached settings
+ // lookup is on the hot path.
processor.CollapseSettings = NewCRDCollapseSettingsProvider(s)
- // Interval 0 => SetStorage does not spawn the maintenance goroutine; we
- // drive ConsolidateTimeSeries explicitly from the load goroutines.
+ // The default (every-other-kind) instance and the cleanup handler over the
+ // same pool and filesystem (T-G3's legacy traffic and tick).
+ kinds := loadLegacyKinds{
+ s: NewStorageImpl(s.appFs, DefaultStorageRoot, pool, wd, sch).(*StorageImpl),
+ cleanup: NewResourcesCleanupHandler(s.appFs, DefaultStorageRoot, pool, wd, 0, "kubescape", loadCleanupFetcher{}, false),
+ }
+ if loadBackend() == "objectstore" {
+ s.SetForeignKinds(IsContainerProfileKind)
+ kinds.s.SetForeignKinds(IsContainerProfileKind)
+ // The process's one write gate, shared by the ObjectStore, both legacy
+ // instances and the cleanup handler (main.go's wiring under the flag).
+ gateCtx, gateCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer gateCancel()
+ gate, err := NewWriteGate(gateCtx, pool)
+ require.NoError(t, err)
+ if os.Getenv("PERF_AB_GATE_BUSY0") == "1" {
+ // AC-G3 in its strong form: with every writer gated nothing else
+ // holds the write lock, so the gate's BEGIN IMMEDIATE never needs
+ // the busy handler. With the handler off, any hidden contention
+ // fails the transaction ("database is locked", counted under
+ // err-other) instead of waiting silently — the busy-wait histogram
+ // alone cannot tell a lock wait from scheduling jitter.
+ gate.conn.SetBusyTimeout(0)
+ }
+ s.SetWriteGate(gate)
+ kinds.s.SetWriteGate(gate)
+ kinds.cleanup.SetWriteGate(gate)
+ // NewObjectStore hands the processor its ContainerProfileStorage.
+ store, err := NewObjectStore(pool, path, wd, sch, processor, s, gate, ObjectStoreOptions{})
+ require.NoError(t, err)
+ storeOwnedConns = 1
+ return s, store, processor, pool, kinds, func() { _ = store.Close(); _ = gate.Close() }
+ }
+ // Interval 0 => SetStorage does not spawn the maintenance goroutine; the
+ // load goroutines drive ConsolidateTimeSeries explicitly.
processor.SetStorage(NewContainerProfileStorageImpl(s, pool))
- return s, processor, pool
+ storeOwnedConns = 0
+ return s, s, processor, pool, kinds, func() {}
}
-func TestContainerProfileLoad(t *testing.T) {
- if os.Getenv("LOAD_TEST") != "1" {
- t.Skip("set LOAD_TEST=1 to run the ContainerProfile load/stress benchmark")
+// effectiveConfig is read back from the constructed runtime objects, not from
+// the harness's inputs: hack/perf-ab.sh compares base's block against head's
+// and refuses to call a comparison between two different workloads a verdict.
+type effectiveConfig struct {
+ HarnessVersion string `json:"harness_version"`
+ Mode string `json:"mode"`
+ PoolSize int `json:"pool_size"`
+ Shards int `json:"shards"`
+ Workers int `json:"workers"`
+ GOMAXPROCS int `json:"gomaxprocs"`
+ SingleWriterEnabled bool `json:"single_writer_enabled"`
+ Writers int `json:"writers"`
+ Readers int `json:"readers"`
+ Updaters int `json:"updaters"`
+ Listers int `json:"listers"`
+ Consolidators int `json:"consolidators"`
+ WriterOps int `json:"writer_ops"`
+ ReaderOps int `json:"reader_ops"`
+ UpdaterOps int `json:"updater_ops"`
+ ListerOps int `json:"lister_ops"`
+ ExtraTicks int `json:"extra_ticks"`
+ LegacyWriters int `json:"legacy_writers"`
+ LegacyOps int `json:"legacy_ops"`
+ LegacySizeKB int `json:"legacy_size_kb"`
+ CleanupRows int `json:"cleanup_rows"`
+ WriterSleepMs int64 `json:"writer_sleep_ms"`
+ ReaderSleepMs int64 `json:"reader_sleep_ms"`
+ UpdaterSleepMs int64 `json:"updater_sleep_ms"`
+ ListerSleepMs int64 `json:"lister_sleep_ms"`
+ TickIntervalMs int64 `json:"tick_interval_ms"`
+ BusyTimeoutMs int64 `json:"busy_timeout_ms"`
+ RequestTimeoutMs int64 `json:"request_timeout_ms"`
+ CollapseTTLMs int64 `json:"collapse_ttl_ms"`
+ HotKeys bool `json:"hot_keys"`
+ BaseKeys int `json:"base_keys"`
+ TotalClientOps int64 `json:"total_client_ops"`
+}
+
+type histStat struct {
+ Count uint64 `json:"count"`
+ Sum float64 `json:"sum"`
+ P99 float64 `json:"p99"`
+ // MaxBucket is the upper bound of the highest non-empty bucket: an upper
+ // bound on the largest observation (AC-G3 reads the busy wait's).
+ MaxBucket float64 `json:"max_bucket"`
+}
+
+// metricsSnapshot is the per-round delta of the six process-registry series
+// R1.6 named (the same vocabulary Tier C scrapes from the pod), keyed by
+// label set ("kind=containerprofiles,outcome=acquired").
+type metricsSnapshot struct {
+ LockWait map[string]histStat `json:"lock_wait"`
+ PoolWait map[string]histStat `json:"pool_wait"`
+ QueueWait map[string]histStat `json:"queue_wait"`
+ CommitTotal map[string]float64 `json:"commit_total"`
+ ConflictRetryTotal map[string]float64 `json:"conflict_retry_total"`
+ QueueDepthMax map[string]float64 `json:"queue_depth_max"`
+ // The ObjectStore's own series (zero on the legacy arm): where a write
+ // spent its time — queued for the gate ticket, waiting for SQLite's lock
+ // at BEGIN IMMEDIATE, or holding the gate through its statements + COMMIT.
+ GateWait map[string]histStat `json:"gate_wait"`
+ BusyWait map[string]histStat `json:"busy_wait"`
+ WriteHold map[string]histStat `json:"write_hold"`
+ CheckpointTotal map[string]float64 `json:"checkpoint_total"`
+ CASConflict map[string]float64 `json:"cas_conflict_total"`
+ // UngatedWrite is storage_sqlite_ungated_write_total (AC-G1's production
+ // counter): non-zero on the objectstore arm is the bug class.
+ UngatedWrite map[string]float64 `json:"ungated_write_total"`
+}
+
+// loadReport is one round. Series is the flat view the verdict reads:
+// headline latencies/throughput, contention counts and the hard rows.
+type loadReport struct {
+ // Backend is provenance only (legacy | objectstore); it is deliberately
+ // not in Effective so an A/B of the two backends is not a CONFIG MISMATCH.
+ Backend string `json:"backend"`
+ WriteBytes int64 `json:"write_bytes"`
+ Effective effectiveConfig `json:"effective"`
+ WallSeconds float64 `json:"wall_seconds"`
+ OpsPerSec float64 `json:"ops_per_s"`
+ Classes map[string]latencyStats `json:"classes"`
+ Metrics metricsSnapshot `json:"metrics"`
+ Series map[string]float64 `json:"series"`
+}
+
+// runLoadScenario runs one load round and returns its report.
+func runLoadScenario(t *testing.T, cfg loadConfig) loadReport {
+ t.Helper()
+ if cfg.CollapseTTL > 0 {
+ oldTTL := collapseSettingsTTL
+ collapseSettingsTTL = cfg.CollapseTTL
+ defer func() { collapseSettingsTTL = oldTTL }()
}
- s, processor, pool := newLoadStorage(t, loadPoolSize())
- defer func() { _ = pool.Close() }()
+ legacy, s, processor, pool, kinds, closeStore := newLoadStorageWith(t, cfg.PoolSize, cfg.Workers, cfg.BusyTimeout)
+ defer func() { closeStore(); _ = pool.Close() }()
+ legacyKinds, cleanup := kinds.s, kinds.cleanup
templates := loadTemplates(t)
- dur := loadDuration()
+ nsListKey := "/spdx.softwarecomposition.kubescape.io/containerprofile/" + templates[0].ns
- // Preseed: create the base testdata TS profiles so consolidation and reads
- // have real keys immediately.
- seedCtx, seedCancel := context.WithTimeout(context.Background(), 15*time.Second)
+ // Preseed: create the base testdata TS profiles and consolidate once so
+ // the base keys readers GET and updaters update exist from the start. The
+ // seed tick runs single-worker: concurrent deferred transactions can lose
+ // SQLite's read-to-write upgrade ("database is locked"), which is a
+ // measured outcome during the run but must not make the seed partial.
+ // Every profile is pinned Learning/Partial: a template that consolidates
+ // into a Completed/Full base would make PreSave reject every later Create
+ // for that base, and the write load would never reach the commit path.
+ seedCtx, seedCancel := context.WithTimeout(context.Background(), 30*time.Second)
for _, tpl := range templates {
p := tpl.profile.DeepCopy()
+ p.Annotations[helpersv1.StatusMetadataKey] = helpersv1.Learning
+ p.Annotations[helpersv1.CompletionMetadataKey] = helpersv1.Partial
key := "/spdx.softwarecomposition.kubescape.io/containerprofile/" + p.Namespace + "/" + p.Name
- _ = s.Create(seedCtx, key, p, nil, 0)
+ require.NoError(t, s.Create(seedCtx, key, p, nil, 0))
+ }
+ seedWorkers := processor.Workers
+ processor.Workers = 1
+ require.NoError(t, processor.ConsolidateTimeSeries(seedCtx))
+ processor.Workers = seedWorkers
+ // The rows one cleanup tick reclaims during the run: unreferenced sbomsyft
+ // objects (small: the tick's hold is one row delete per file).
+ for i := 0; i < cfg.CleanupRows; i++ {
+ name := fmt.Sprintf("dead-%d", i)
+ obj := &softwarecomposition.SBOMSyft{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: loadCleanupNS, Annotations: map[string]string{helpersv1.ImageIDMetadataKey: "sha256:" + name}}}
+ require.NoError(t, legacyKinds.Create(seedCtx, loadLegacyKey("sbomsyft", loadCleanupNS, name), obj, nil, 0))
}
seedCancel()
writes := &latencyRec{name: "REST Create (node-agent writers)"}
reads := &latencyRec{name: "REST Get (CVE/netpol readers)"}
updates := &latencyRec{name: "REST GuaranteedUpdate"}
+ lists := &latencyRec{name: "REST List (metadata / fullSpec)"}
+ listsMeta := &latencyRec{name: "REST List metadata"}
+ listsFull := &latencyRec{name: "REST List fullSpec page"}
+ ticks := &latencyRec{name: "ConsolidateTimeSeries pass"}
+ legacyCreates := &latencyRec{name: "legacy Create (sbomsyft / vulnerabilitymanifest)"}
+ legacyUpdates := &latencyRec{name: "legacy GuaranteedUpdate"}
+ legacyDeletes := &latencyRec{name: "legacy Delete"}
+ cleanupTicks := &latencyRec{name: "cleanup tick (one namespace)"}
+
+ before := gatherStorageMetrics(t)
+ writeBytesBefore := procWriteBytes()
+ depthMax := newGaugeMaxSampler(t, "storage_single_writer_queue_depth", 100*time.Millisecond)
stop := make(chan struct{})
- var wg sync.WaitGroup
+ var clients sync.WaitGroup
var suffixCounter int64
- // Each REST-facing call gets a request context whose deadline models the
- // apiserver's request timeout. Pre-fix, a contended lock blocks up to this
- // deadline; post-fix the 5s lockTimeout fails fast well under it. We keep it
- // generous (30s) so we measure the *actual* wait, not an artificial cap.
reqCtx := func() (context.Context, context.CancelFunc) {
- return context.WithTimeout(context.Background(), 15*time.Second)
+ return context.WithTimeout(context.Background(), cfg.RequestTimeout)
+ }
+ // keepGoing reports whether a client should run its i-th op.
+ keepGoing := func(i, ops int) bool {
+ if ops > 0 {
+ return i < ops
+ }
+ select {
+ case <-stop:
+ return false
+ default:
+ return true
+ }
}
- // node-agent writers: clone a template, give it a fresh ts suffix + series
- // timestamp, Create it. Funnels many TS rows into each workload's base key,
- // mirroring multi-container-per-workload streaming.
- for i := 0; i < loadWriters(); i++ {
- wg.Add(1)
+ // updaterTemplate spreads the updaters over the 12 base keys; in HotKeys
+ // mode they all share base key 0, which writer 0 feeds (id%12 == 0) so the
+ // consolidator keeps rewriting that base key under the updaters.
+ updaterTemplate := func(id int) cpTemplate {
+ if cfg.HotKeys {
+ return templates[0]
+ }
+ return templates[id%len(templates)]
+ }
+
+ start := time.Now()
+
+ for i := 0; i < cfg.Writers; i++ {
+ clients.Add(1)
go func(id int) {
- defer wg.Done()
- for {
- select {
- case <-stop:
- return
- default:
- }
+ defer clients.Done()
+ for i := 0; keepGoing(i, cfg.WriterOps); i++ {
tpl := templates[id%len(templates)]
p := tpl.profile.DeepCopy()
n := atomic.AddInt64(&suffixCounter, 1)
@@ -328,6 +724,8 @@ func TestContainerProfileLoad(t *testing.T) {
p.Annotations = map[string]string{}
}
p.Annotations[helpersv1.ReportTimestampMetadataKey] = time.Now().Format(time.RFC3339Nano)
+ p.Annotations[helpersv1.StatusMetadataKey] = helpersv1.Learning
+ p.Annotations[helpersv1.CompletionMetadataKey] = helpersv1.Partial
p.ResourceVersion = ""
key := "/spdx.softwarecomposition.kubescape.io/containerprofile/" + p.Namespace + "/" + p.Name
ctx, cancel := reqCtx()
@@ -335,22 +733,18 @@ func TestContainerProfileLoad(t *testing.T) {
err := s.Create(ctx, key, p, nil, 0)
writes.record(time.Since(t0), err)
cancel()
- time.Sleep(loadWriterSleep)
+ if cfg.WriterSleep > 0 {
+ time.Sleep(cfg.WriterSleep)
+ }
}
}(i)
}
- // REST readers: GET the consolidated base key (what CVE-scan/netpol read).
- for i := 0; i < loadReaders(); i++ {
- wg.Add(1)
+ for i := 0; i < cfg.Readers; i++ {
+ clients.Add(1)
go func(id int) {
- defer wg.Done()
- for {
- select {
- case <-stop:
- return
- default:
- }
+ defer clients.Done()
+ for i := 0; keepGoing(i, cfg.ReaderOps); i++ {
tpl := templates[id%len(templates)]
ctx, cancel := reqCtx()
t0 := time.Now()
@@ -358,87 +752,680 @@ func TestContainerProfileLoad(t *testing.T) {
err := s.Get(ctx, tpl.baseKey, storage.GetOptions{IgnoreNotFound: true}, out)
reads.record(time.Since(t0), err)
cancel()
- time.Sleep(readerSleep)
+ if cfg.ReaderSleep > 0 {
+ time.Sleep(cfg.ReaderSleep)
+ }
}
}(i)
}
- // A slice of readers instead do GuaranteedUpdate on the base key to exercise
- // the write-lock REST path too (a handful, so most traffic stays read-heavy).
- for i := 0; i < loadUpdaters(); i++ {
- wg.Add(1)
+ for i := 0; i < cfg.Updaters; i++ {
+ clients.Add(1)
go func(id int) {
- defer wg.Done()
- for {
- select {
- case <-stop:
- return
- default:
- }
- tpl := templates[id%len(templates)]
+ defer clients.Done()
+ for i := 0; keepGoing(i, cfg.UpdaterOps); i++ {
+ tpl := updaterTemplate(id)
ctx, cancel := reqCtx()
t0 := time.Now()
+ // A real mutation: an identity tryUpdate is absorbed by the #315
+ // DeepEqual short-circuit on both backends and never reaches
+ // the CAS/commit path.
err := s.GuaranteedUpdate(ctx, tpl.baseKey, &softwarecomposition.ContainerProfile{}, true,
- nil, func(input runtime.Object, _ storage.ResponseMeta) (runtime.Object, *uint64, error) {
- return input, nil, nil
- }, nil)
+ nil, loadLabelUpdate, nil)
updates.record(time.Since(t0), err)
cancel()
- time.Sleep(updaterSleep)
+ if cfg.UpdaterSleep > 0 {
+ time.Sleep(cfg.UpdaterSleep)
+ }
}
}(i)
}
- // Consolidator loop: contends for pool connections + per-key locks. An
- // optional inter-pass sleep (LOAD_CONSOLIDATOR_SLEEP_MS) models the periodic
- // nature of the real 30s maintenance loop; the default 0 is the harshest
- // "always consolidating" stress.
- consolidations := &latencyRec{name: "ConsolidateTimeSeries pass"}
- consolSleep := time.Duration(envInt("LOAD_CONSOLIDATOR_SLEEP_MS", 0)) * time.Millisecond
- for i := 0; i < loadConsolidators(); i++ {
- wg.Add(1)
+ // Listers alternate a metadata LIST of the namespace (kubectl get) with a
+ // fullSpec LIST page (the network-policy generator's read): the design's
+ // PM-1 detector.
+ for i := 0; i < cfg.Listers; i++ {
+ clients.Add(1)
+ go func(id int) {
+ defer clients.Done()
+ for i := 0; keepGoing(i, cfg.ListerOps); i++ {
+ opts := storage.ListOptions{ResourceVersion: softwarecomposition.ResourceVersionMetadata, Recursive: true}
+ variant := listsMeta
+ if i%2 == 1 {
+ opts = storage.ListOptions{ResourceVersion: softwarecomposition.ResourceVersionFullSpec, Recursive: true, Predicate: storage.SelectionPredicate{Limit: 50}}
+ variant = listsFull
+ }
+ ctx, cancel := reqCtx()
+ t0 := time.Now()
+ err := s.GetList(ctx, nsListKey, opts, &softwarecomposition.ContainerProfileList{})
+ d := time.Since(t0)
+ lists.record(d, err)
+ variant.record(d, err)
+ cancel()
+ if cfg.ListerSleep > 0 {
+ time.Sleep(cfg.ListerSleep)
+ }
+ }
+ }(i)
+ }
+
+ // Legacy writers: create → update → delete cycles of a sized sbomsyft
+ // and a small vulnerability manifest, alternating (T-G3).
+ var legacyCounter int64
+ for i := 0; i < cfg.LegacyWriters; i++ {
+ clients.Add(1)
+ go func(id int) {
+ defer clients.Done()
+ for i := 0; keepGoing(i, cfg.LegacyOps); i++ {
+ n := atomic.AddInt64(&legacyCounter, 1)
+ var key string
+ var obj k8sruntime.Object
+ var fresh func() k8sruntime.Object
+ if i%2 == 0 {
+ name := "sbom-" + strconv.FormatInt(n, 36)
+ key = loadLegacyKey("sbomsyft", loadLegacyNS, name)
+ obj = loadSizedSBOM(name, loadLegacyNS, cfg.LegacySizeKB)
+ fresh = func() k8sruntime.Object { return &softwarecomposition.SBOMSyft{} }
+ } else {
+ name := "vm-" + strconv.FormatInt(n, 36)
+ key = loadLegacyKey("vulnerabilitymanifest", loadLegacyNS, name)
+ obj = &softwarecomposition.VulnerabilityManifest{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: loadLegacyNS}}
+ fresh = func() k8sruntime.Object { return &softwarecomposition.VulnerabilityManifest{} }
+ }
+ ctx, cancel := reqCtx()
+ t0 := time.Now()
+ err := legacyKinds.Create(ctx, key, obj, nil, 0)
+ legacyCreates.record(time.Since(t0), err)
+ cancel()
+ ctx, cancel = reqCtx()
+ t0 = time.Now()
+ err = legacyKinds.GuaranteedUpdate(ctx, key, fresh(), false, nil, loadLabelUpdate, nil)
+ legacyUpdates.record(time.Since(t0), err)
+ cancel()
+ ctx, cancel = reqCtx()
+ t0 = time.Now()
+ err = legacyKinds.Delete(ctx, key, fresh(), nil, nil, nil, storage.DeleteOptions{})
+ legacyDeletes.record(time.Since(t0), err)
+ cancel()
+ if cfg.LegacySleep > 0 {
+ time.Sleep(cfg.LegacySleep)
+ }
+ }
+ }(i)
+ }
+
+ // One cleanup tick over the seeded namespace, concurrent with everything.
+ if cfg.CleanupRows > 0 {
+ clients.Add(1)
go func() {
- defer wg.Done()
+ defer clients.Done()
+ t0 := time.Now()
+ err := cleanup.CleanupTask(context.Background(), map[string][]TypeCleanupHandlerFunc{"sbomsyft": {deleteByImageId}})
+ cleanupTicks.record(time.Since(t0), err)
+ }()
+ }
+
+ // Consolidators tick on TickInterval until told to stop; in fixed-work
+ // mode they are told to stop after the clients finish plus ExtraTicks.
+ clientsDone := make(chan struct{})
+ tickStop := make(chan struct{})
+ var consolidators sync.WaitGroup
+ for i := 0; i < cfg.Consolidators; i++ {
+ consolidators.Add(1)
+ go func() {
+ defer consolidators.Done()
+ extra := 0
for {
+ t0 := time.Now()
+ err := processor.ConsolidateTimeSeries(context.Background())
+ ticks.record(time.Since(t0), err)
select {
- case <-stop:
+ case <-tickStop:
return
+ case <-clientsDone:
+ if cfg.fixedWork() {
+ extra++
+ if extra >= cfg.ExtraTicks {
+ return
+ }
+ }
default:
}
- t0 := time.Now()
- err := processor.ConsolidateTimeSeries(context.Background())
- consolidations.record(time.Since(t0), err)
- if consolSleep > 0 {
- time.Sleep(consolSleep)
+ if cfg.TickInterval > 0 {
+ select {
+ case <-time.After(cfg.TickInterval):
+ case <-tickStop:
+ return
+ }
}
}
}()
}
- time.Sleep(dur)
- close(stop)
- wg.Wait()
+ if cfg.fixedWork() {
+ clients.Wait()
+ close(clientsDone)
+ consolidators.Wait()
+ } else {
+ time.Sleep(cfg.Duration)
+ close(stop)
+ clients.Wait()
+ close(tickStop)
+ consolidators.Wait()
+ }
+ wall := time.Since(start)
+ depthMax.stop()
+ after := gatherStorageMetrics(t)
+
+ classes := map[string]latencyStats{
+ "create": writes.stats(),
+ "get": reads.stats(),
+ "update": updates.stats(),
+ "list": lists.stats(),
+ "list-meta": listsMeta.stats(),
+ "list-full": listsFull.stats(),
+ "tick": ticks.stats(),
+ "legacy-create": legacyCreates.stats(),
+ "legacy-update": legacyUpdates.stats(),
+ "legacy-delete": legacyDeletes.stats(),
+ "cleanup": cleanupTicks.stats(),
+ }
+ totalOps := classes["create"].Ops + classes["get"].Ops + classes["update"].Ops + classes["list"].Ops +
+ classes["legacy-create"].Ops + classes["legacy-update"].Ops + classes["legacy-delete"].Ops
+ writeBytes := procWriteBytes() - writeBytesBefore
+ metricsDelta := diffStorageMetrics(before, after, depthMax.max())
+
+ mode := "time"
+ if cfg.fixedWork() {
+ mode = "work"
+ }
+ eff := effectiveConfig{
+ HarnessVersion: perfABHarnessVersion,
+ Mode: mode,
+ PoolSize: probePoolSize(pool) + storeOwnedConns,
+ Shards: len(legacy.ensureWriter().shards),
+ Workers: processor.Workers,
+ GOMAXPROCS: runtime.GOMAXPROCS(0),
+ SingleWriterEnabled: singleWriterEnabled,
+ Writers: cfg.Writers,
+ Readers: cfg.Readers,
+ Updaters: cfg.Updaters,
+ Listers: cfg.Listers,
+ Consolidators: cfg.Consolidators,
+ WriterOps: cfg.WriterOps,
+ ReaderOps: cfg.ReaderOps,
+ UpdaterOps: cfg.UpdaterOps,
+ ListerOps: cfg.ListerOps,
+ ExtraTicks: cfg.ExtraTicks,
+ LegacyWriters: cfg.LegacyWriters,
+ LegacyOps: cfg.LegacyOps,
+ LegacySizeKB: cfg.LegacySizeKB,
+ CleanupRows: cfg.CleanupRows,
+ WriterSleepMs: cfg.WriterSleep.Milliseconds(),
+ ReaderSleepMs: cfg.ReaderSleep.Milliseconds(),
+ UpdaterSleepMs: cfg.UpdaterSleep.Milliseconds(),
+ ListerSleepMs: cfg.ListerSleep.Milliseconds(),
+ TickIntervalMs: cfg.TickInterval.Milliseconds(),
+ BusyTimeoutMs: cfg.BusyTimeout.Milliseconds(),
+ RequestTimeoutMs: cfg.RequestTimeout.Milliseconds(),
+ CollapseTTLMs: collapseSettingsTTL.Milliseconds(),
+ HotKeys: cfg.HotKeys,
+ BaseKeys: len(templates),
+ TotalClientOps: totalOps,
+ }
+
+ rep := loadReport{
+ Backend: loadBackend(),
+ WriteBytes: writeBytes,
+ Effective: eff,
+ WallSeconds: wall.Seconds(),
+ OpsPerSec: float64(totalOps) / wall.Seconds(),
+ Classes: classes,
+ Metrics: metricsDelta,
+ }
+ rep.Series = map[string]float64{
+ "get-p99-ms": classes["get"].P99Ms,
+ "create-p99-ms": classes["create"].P99Ms,
+ "update-p99-ms": classes["update"].P99Ms,
+ "update-p95-ms": classes["update"].P95Ms,
+ "list-p99-ms": classes["list"].P99Ms,
+ "list-p95-ms": classes["list"].P95Ms,
+ "list-p50-ms": classes["list"].P50Ms,
+ "list-meta-p95-ms": classes["list-meta"].P95Ms,
+ "list-full-p95-ms": classes["list-full"].P95Ms,
+ "write-bytes": float64(writeBytes),
+ "gate-wait-p99-ms": 1000 * maxHistP99(metricsDelta.GateWait),
+ "busy-wait-p99-ms": 1000 * maxHistP99(metricsDelta.BusyWait),
+ "write-hold-p99-ms": 1000 * maxHistP99(metricsDelta.WriteHold),
+ "tick-p50-ms": classes["tick"].P50Ms,
+ "tick-p99-ms": classes["tick"].P99Ms,
+ "tick-total-s": classes["tick"].TotalS,
+ "ops-per-s": rep.OpsPerSec,
+ "wall-s": rep.WallSeconds,
+ "lock-wait-timeouts": sumHistCount(metricsDelta.LockWait, "outcome=timeout"),
+ "pool-wait-timeouts": sumHistCount(metricsDelta.PoolWait, "outcome=timeout"),
+ "commit-conflict-rate-pct": conflictRatePct(metricsDelta.CommitTotal),
+ "err-other": float64(classes["create"].Other + classes["get"].Other + classes["update"].Other + classes["list"].Other + classes["tick"].Other),
+ "over-five-sec": float64(classes["create"].OverFiveSec + classes["get"].OverFiveSec + classes["update"].OverFiveSec + classes["list"].OverFiveSec + classes["tick"].OverFiveSec),
+ "over-one-sec": float64(classes["create"].OverOneSec + classes["get"].OverOneSec + classes["update"].OverOneSec + classes["list"].OverOneSec + classes["tick"].OverOneSec),
+ "commit-panic": sumByLabel(metricsDelta.CommitTotal, "outcome=panic"),
+ // T-G3: the legacy kinds through the shared gate.
+ "legacy-create-p99-ms": classes["legacy-create"].P99Ms,
+ "legacy-update-p99-ms": classes["legacy-update"].P99Ms,
+ "legacy-delete-p99-ms": classes["legacy-delete"].P99Ms,
+ "cleanup-tick-ms": classes["cleanup"].MaxMs,
+ "legacy-err-other": float64(classes["legacy-create"].Other + classes["legacy-update"].Other + classes["legacy-delete"].Other + classes["cleanup"].Other),
+ "legacy-over-one-sec": float64(classes["legacy-create"].OverOneSec + classes["legacy-update"].OverOneSec + classes["legacy-delete"].OverOneSec + classes["cleanup"].OverOneSec),
+ // AC-G1's production counter and AC-G3's busy wait: both must be zero
+ // on the objectstore arm (the legacy arm has no gate: 0 by construction).
+ "ungated-writes": sumByLabel(metricsDelta.UngatedWrite, ""),
+ "busy-wait-max-ms": 1000 * maxHistBucket(metricsDelta.BusyWait),
+ }
+ for path, p99 := range holdP99ByPath(metricsDelta.WriteHold) {
+ rep.Series["hold-p99-ms/"+path] = 1000 * p99
+ }
+ return rep
+}
- t.Logf("=== ContainerProfile load benchmark: pool=%d writers=%d readers=%d updaters=%d consolidators=%d workers=%d consolSleep=%s dur=%s ===",
- loadPoolSize(), loadWriters(), loadReaders(), loadUpdaters(), loadConsolidators(), loadProcessorWorkers(), consolSleep, dur)
- writes.report(t)
- reads.report(t)
- updates.report(t)
- consolidations.report(t)
+// holdP99ByPath is the largest per-kind p99 of storage_sqlite_write_hold_seconds
+// for each path label (PM-G1's per-path bound).
+func holdP99ByPath(series map[string]histStat) map[string]float64 {
+ out := map[string]float64{}
+ for labels, h := range series {
+ if h.Count == 0 {
+ continue
+ }
+ path := ""
+ for _, l := range strings.Split(labels, ",") {
+ if strings.HasPrefix(l, "path=") {
+ path = strings.TrimPrefix(l, "path=")
+ }
+ }
+ if h.P99 > out[path] {
+ out[path] = h.P99
+ }
+ }
+ return out
+}
+
+// maxHistBucket is the largest MaxBucket of a histogram family (seconds).
+func maxHistBucket(series map[string]histStat) float64 {
+ var m float64
+ for _, h := range series {
+ if h.Count > 0 && h.MaxBucket > m {
+ m = h.MaxBucket
+ }
+ }
+ return m
+}
+
+// procWriteBytes reads write_bytes from /proc/self/io (bytes the process caused
+// to be sent to the storage layer); 0 when unavailable. Under WAL every payload
+// byte is written twice (WAL, then checkpoint) where the legacy store writes it
+// once plus a row, so the A/B records the cost as a number (design C.14).
+func procWriteBytes() int64 {
+ data, err := os.ReadFile("/proc/self/io")
+ if err != nil {
+ return 0
+ }
+ for _, line := range strings.Split(string(data), "\n") {
+ if strings.HasPrefix(line, "write_bytes:") {
+ n, _ := strconv.ParseInt(strings.TrimSpace(strings.TrimPrefix(line, "write_bytes:")), 10, 64)
+ return n
+ }
+ }
+ return 0
+}
+
+// probePoolSize reads the pool's capacity back from the pool itself: it takes
+// connections until Take times out, then returns them all.
+func probePoolSize(pool *sqlitemigration.Pool) int {
+ var conns []*sqlite.Conn
+ defer func() {
+ for _, c := range conns {
+ pool.Put(c)
+ }
+ }()
+ for len(conns) < 256 {
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ c, err := pool.Take(ctx)
+ cancel()
+ if err != nil {
+ break
+ }
+ conns = append(conns, c)
+ }
+ return len(conns)
+}
+
+// ---- process-registry metrics ----
+
+type rawHist struct {
+ count uint64
+ sum float64
+ buckets map[float64]uint64 // upper bound -> cumulative count
+}
+
+type rawMetrics struct {
+ hists map[string]map[string]rawHist // family -> labels -> hist
+ counters map[string]map[string]float64
+}
+
+var storageHistFamilies = map[string]string{
+ "storage_lock_wait_duration_seconds": "lock_wait",
+ "storage_pool_wait_duration_seconds": "pool_wait",
+ "storage_single_writer_queue_wait_duration_seconds": "queue_wait",
+ "storage_write_gate_wait_seconds": "gate_wait",
+ "storage_sqlite_busy_wait_seconds": "busy_wait",
+ "storage_sqlite_write_hold_seconds": "write_hold",
+}
+
+var storageCounterFamilies = map[string]string{
+ "storage_single_writer_commit_total": "commit_total",
+ "storage_single_writer_conflict_retry_total": "conflict_retry_total",
+ "storage_sqlite_checkpoint_total": "checkpoint_total",
+ "storage_cp_cas_conflict_total": "cas_conflict_total",
+ "storage_sqlite_ungated_write_total": "ungated_write",
+}
+
+func labelKey(m *dto.Metric) string {
+ parts := make([]string, 0, len(m.GetLabel()))
+ for _, l := range m.GetLabel() {
+ parts = append(parts, l.GetName()+"="+l.GetValue())
+ }
+ sort.Strings(parts)
+ return strings.Join(parts, ",")
+}
+
+func gatherStorageMetrics(t *testing.T) rawMetrics {
+ t.Helper()
+ families, err := legacyregistry.DefaultGatherer.Gather()
+ require.NoError(t, err)
+ out := rawMetrics{hists: map[string]map[string]rawHist{}, counters: map[string]map[string]float64{}}
+ for _, mf := range families {
+ if short, ok := storageHistFamilies[mf.GetName()]; ok {
+ out.hists[short] = map[string]rawHist{}
+ for _, m := range mf.GetMetric() {
+ h := m.GetHistogram()
+ rh := rawHist{count: h.GetSampleCount(), sum: h.GetSampleSum(), buckets: map[float64]uint64{}}
+ for _, b := range h.GetBucket() {
+ rh.buckets[b.GetUpperBound()] = b.GetCumulativeCount()
+ }
+ out.hists[short][labelKey(m)] = rh
+ }
+ }
+ if short, ok := storageCounterFamilies[mf.GetName()]; ok {
+ out.counters[short] = map[string]float64{}
+ for _, m := range mf.GetMetric() {
+ out.counters[short][labelKey(m)] = m.GetCounter().GetValue()
+ }
+ }
+ }
+ return out
+}
+
+// histQuantile is Prometheus's histogram_quantile over cumulative bucket
+// deltas (linear interpolation within the bucket, +Inf clamps to the last
+// finite bound).
+func histQuantile(q float64, buckets map[float64]uint64) float64 {
+ bounds := make([]float64, 0, len(buckets))
+ for b := range buckets {
+ bounds = append(bounds, b)
+ }
+ sort.Float64s(bounds)
+ if len(bounds) == 0 {
+ return 0
+ }
+ total := buckets[bounds[len(bounds)-1]]
+ if total == 0 {
+ return 0
+ }
+ rank := q * float64(total)
+ lower := 0.0
+ prevCount := uint64(0)
+ for i, ub := range bounds {
+ c := buckets[ub]
+ if float64(c) >= rank {
+ if math.IsInf(ub, 1) {
+ if i == 0 {
+ return 0
+ }
+ return bounds[i-1]
+ }
+ if c == prevCount {
+ return ub
+ }
+ return lower + (ub-lower)*(rank-float64(prevCount))/float64(c-prevCount)
+ }
+ lower = ub
+ prevCount = c
+ }
+ return bounds[len(bounds)-1]
+}
+
+func diffStorageMetrics(before, after rawMetrics, depthMax map[string]float64) metricsSnapshot {
+ out := metricsSnapshot{
+ LockWait: map[string]histStat{},
+ PoolWait: map[string]histStat{},
+ QueueWait: map[string]histStat{},
+ CommitTotal: map[string]float64{},
+ ConflictRetryTotal: map[string]float64{},
+ QueueDepthMax: depthMax,
+ GateWait: map[string]histStat{},
+ BusyWait: map[string]histStat{},
+ WriteHold: map[string]histStat{},
+ CheckpointTotal: map[string]float64{},
+ CASConflict: map[string]float64{},
+ UngatedWrite: map[string]float64{},
+ }
+ histOut := map[string]map[string]histStat{"lock_wait": out.LockWait, "pool_wait": out.PoolWait, "queue_wait": out.QueueWait,
+ "gate_wait": out.GateWait, "busy_wait": out.BusyWait, "write_hold": out.WriteHold}
+ for fam, series := range after.hists {
+ for labels, a := range series {
+ b := before.hists[fam][labels]
+ delta := map[float64]uint64{}
+ for ub, c := range a.buckets {
+ delta[ub] = c - b.buckets[ub]
+ }
+ histOut[fam][labels] = histStat{Count: a.count - b.count, Sum: a.sum - b.sum, P99: histQuantile(0.99, delta), MaxBucket: histMaxBucket(delta)}
+ }
+ }
+ counterOut := map[string]map[string]float64{"commit_total": out.CommitTotal, "conflict_retry_total": out.ConflictRetryTotal,
+ "checkpoint_total": out.CheckpointTotal, "cas_conflict_total": out.CASConflict, "ungated_write": out.UngatedWrite}
+ for fam, series := range after.counters {
+ for labels, a := range series {
+ counterOut[fam][labels] = a - before.counters[fam][labels]
+ }
+ }
+ return out
+}
+
+// histMaxBucket is the upper bound of the lowest bucket that already holds
+// every observation of the delta: an upper bound on the largest one. 0 when
+// nothing was observed.
+func histMaxBucket(buckets map[float64]uint64) float64 {
+ bounds := make([]float64, 0, len(buckets))
+ for b := range buckets {
+ bounds = append(bounds, b)
+ }
+ sort.Float64s(bounds)
+ if len(bounds) == 0 {
+ return 0
+ }
+ total := buckets[bounds[len(bounds)-1]]
+ if total == 0 {
+ return 0
+ }
+ for _, ub := range bounds {
+ if buckets[ub] == total {
+ return ub
+ }
+ }
+ return bounds[len(bounds)-1]
+}
+
+// maxHistP99 is the largest per-label p99 of a histogram family (seconds).
+func maxHistP99(series map[string]histStat) float64 {
+ var m float64
+ for _, h := range series {
+ if h.Count > 0 && h.P99 > m {
+ m = h.P99
+ }
+ }
+ return m
+}
+
+func sumHistCount(series map[string]histStat, labelContains string) float64 {
+ var n float64
+ for labels, h := range series {
+ if strings.Contains(labels, labelContains) {
+ n += float64(h.Count)
+ }
+ }
+ return n
+}
+
+func sumByLabel(series map[string]float64, labelContains string) float64 {
+ var n float64
+ for labels, v := range series {
+ if strings.Contains(labels, labelContains) {
+ n += v
+ }
+ }
+ return n
+}
+
+// conflictRatePct is commit_total{conflict} / commit_total{committed}, in %.
+func conflictRatePct(commitTotal map[string]float64) float64 {
+ committed := sumByLabel(commitTotal, "outcome=committed")
+ if committed == 0 {
+ return 0
+ }
+ return 100 * sumByLabel(commitTotal, "outcome=conflict") / committed
+}
+
+// gaugeMaxSampler samples a gauge family every interval and keeps the max per
+// label set (queue depth is a gauge; its peak is the backlog signal).
+type gaugeMaxSampler struct {
+ mu sync.Mutex
+ maxV map[string]float64
+ done chan struct{}
+ wg sync.WaitGroup
+}
+
+func newGaugeMaxSampler(t *testing.T, family string, interval time.Duration) *gaugeMaxSampler {
+ t.Helper()
+ g := &gaugeMaxSampler{maxV: map[string]float64{}, done: make(chan struct{})}
+ g.wg.Add(1)
+ go func() {
+ defer g.wg.Done()
+ tk := time.NewTicker(interval)
+ defer tk.Stop()
+ for {
+ select {
+ case <-g.done:
+ return
+ case <-tk.C:
+ families, err := legacyregistry.DefaultGatherer.Gather()
+ if err != nil {
+ continue
+ }
+ g.mu.Lock()
+ for _, mf := range families {
+ if mf.GetName() != family {
+ continue
+ }
+ for _, m := range mf.GetMetric() {
+ k := labelKey(m)
+ if v := m.GetGauge().GetValue(); v > g.maxV[k] {
+ g.maxV[k] = v
+ }
+ }
+ }
+ g.mu.Unlock()
+ }
+ }
+ }()
+ return g
+}
+
+func (g *gaugeMaxSampler) stop() {
+ close(g.done)
+ g.wg.Wait()
+}
+
+func (g *gaugeMaxSampler) max() map[string]float64 {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ out := make(map[string]float64, len(g.maxV))
+ for k, v := range g.maxV {
+ out[k] = v
+ }
+ return out
+}
+
+// TestPerfABRound is one Tier B round; see the file comment. It writes the
+// round's JSON to $PERF_AB_OUT and prints benchstat-format lines.
+func TestPerfABRound(t *testing.T) {
+ out := os.Getenv("PERF_AB_OUT")
+ if out == "" {
+ t.Skip("set PERF_AB_OUT= to run one perf-ab round (normally via hack/perf-ab.sh)")
+ }
+ rep := runLoadScenario(t, perfABConfig())
+ data, err := json.MarshalIndent(rep, "", " ")
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(out, append(data, '\n'), 0o644))
+
+ names := make([]string, 0, len(rep.Series))
+ for n := range rep.Series {
+ names = append(names, n)
+ }
+ sort.Strings(names)
+ for _, n := range names {
+ unit := "ms"
+ switch {
+ case n == "ops-per-s":
+ unit = "ops/s"
+ case n == "wall-s", n == "tick-total-s":
+ unit = "s"
+ case n == "write-bytes":
+ unit = "B"
+ case strings.HasSuffix(n, "-pct"):
+ unit = "pct"
+ case !strings.HasSuffix(n, "-ms"):
+ unit = "count"
+ }
+ fmt.Printf("BenchmarkPerfAB/%s 1 %.4f %s\n", n, rep.Series[n], unit)
+ }
+ t.Logf("perf-ab round: backend=%s wall=%.2fs ops/s=%.0f write_bytes=%d effective=%+v", rep.Backend, rep.WallSeconds, rep.OpsPerSec, rep.WriteBytes, rep.Effective)
+}
+
+// TestContainerProfileLoad is the time-boxed diagnostic; see the file comment.
+func TestContainerProfileLoad(t *testing.T) {
+ if os.Getenv("LOAD_TEST") != "1" {
+ t.Skip("set LOAD_TEST=1 to run the ContainerProfile load/stress diagnostic")
+ }
+ cfg := diagnosticConfig()
+ rep := runLoadScenario(t, cfg)
+ t.Logf("=== ContainerProfile load diagnostic: pool=%d writers=%d readers=%d updaters=%d consolidators=%d workers=%d tickInterval=%s dur=%s wall=%.2fs ===",
+ cfg.PoolSize, cfg.Writers, cfg.Readers, cfg.Updaters, cfg.Consolidators, cfg.Workers, cfg.TickInterval, cfg.Duration, rep.WallSeconds)
+ for _, name := range []string{"create", "get", "update", "list", "tick"} {
+ c := rep.Classes[name]
+ t.Logf("== %s == ops=%d ok=%d serverTimeout=%d takeConn=%d other=%d p50=%.2fms p95=%.2fms p99=%.2fms max=%.2fms >1s=%d >5s=%d",
+ name, c.Ops, c.OK, c.ServerTimeout, c.TakeConn, c.Other, c.P50Ms, c.P95Ms, c.P99Ms, c.MaxMs, c.OverOneSec, c.OverFiveSec)
+ }
}
-// TestContainerProfileLockFailFast is the committed regression test for PR1
-// (fail-fast lock backstop). It holds a key's write lock (simulating a long
+// TestContainerProfileLockFailFast is the committed regression test for the
+// fail-fast lock backstop. It holds a key's write lock (simulating a long
// consolidation critical section) and fires many concurrent REST GETs at that
// key, then asserts every contended GET fails fast rather than hanging to the
// request deadline.
//
-// This is the AC4 "request latency stays under a defined bound" assertion: each
-// contended GET must return within failFastBound as an apierrors.IsServerTimeout
-// (HTTP 500 + Retry-After), NOT block to the (much larger) request-context
-// deadline the way the pre-fix code did (which produced the incident's ~60s
-// 504 hangs). It is deterministic and runs in ~1s, so unlike TestContainerProfileLoad
-// it is NOT gated behind LOAD_TEST — it runs in the normal suite.
+// Each contended GET must return within failFastBound as an
+// apierrors.IsServerTimeout (HTTP 500 + Retry-After), NOT block to the (much
+// larger) request-context deadline the way the pre-fix code did (which
+// produced the incident's ~60s 504 hangs). Deterministic, ~1s, ungated.
func TestContainerProfileLockFailFast(t *testing.T) {
// Shrink the backstop so the fail-fast path resolves quickly; this exercises
// the real child-context timeout -> newLockTimeoutError code path, just with
diff --git a/pkg/registry/file/containerprofile_processor.go b/pkg/registry/file/containerprofile_processor.go
index 9d26a766c..c5ce199d0 100644
--- a/pkg/registry/file/containerprofile_processor.go
+++ b/pkg/registry/file/containerprofile_processor.go
@@ -62,10 +62,21 @@ type ContainerProfileProcessor struct {
// ConsolidateTimeSeries. nil means use consolidateKeyTimeSeries; tests
// override it to count invocations or inject per-key failures.
consolidateKey func(ctx context.Context, key string, expired bool) error
+ // Hooks are test seams; see ConsolidationHooks.
+ Hooks ConsolidationHooks
// seriesOrder returns the order updateProfile processes a key's series in.
// nil means map iteration order (random); tests override it to force the
// order, which decides which series a terminal branch leaves unreached.
seriesOrder func(timeSeries map[string][]softwarecomposition.TimeSeriesContainers) []string
+ // stopMaintenance, closed by StopMaintenance, ends runMaintenanceTasks's
+ // loop between iterations (it does not interrupt a cleanup/consolidation
+ // pass already in flight). nil until StartMaintenance runs; a process
+ // never calls StopMaintenance and exits instead, so production never
+ // needs this -- it exists so a test that starts the loop can also stop
+ // it, instead of leaking a goroutine that keeps ticking (and calling
+ // into a pool a later test may reuse or have already closed) for the
+ // rest of the test binary's life.
+ stopMaintenance chan struct{}
}
func NewContainerProfileProcessor(cfg config.Config, cleanupHandler *ResourcesCleanupHandler) *ContainerProfileProcessor {
@@ -88,39 +99,84 @@ func NewContainerProfileProcessor(cfg config.Config, cleanupHandler *ResourcesCl
var _ Processor = (*ContainerProfileProcessor)(nil)
-// AfterCreate is called after a TS ContainerProfile is created to store metadata.
-func (a *ContainerProfileProcessor) AfterCreate(ctx context.Context, object runtime.Object) error {
+var _ TimeSeriesRowProvider = (*ContainerProfileProcessor)(nil)
+
+// ConsolidationHooks are test seams on the consolidation pass; nil in
+// production.
+type ConsolidationHooks struct {
+ // BeforeProcessedDeletes runs, per key, after the pass has merged the
+ // time-series objects and immediately before it deletes them: for a store
+ // that stages the deletes (ProcessedDeleteStager) this is inside the
+ // tick's transaction window, for the legacy store it is after the commit.
+ BeforeProcessedDeletes func(key string)
+}
+
+// TimeSeriesRowFor returns the time_series row a TS ContainerProfile create
+// records and the base key whose Completed/Full or TooLarge state must refuse
+// it; ok=false for a non-TS profile.
+func (a *ContainerProfileProcessor) TimeSeriesRowFor(object runtime.Object) (TimeSeriesRow, string, bool) {
profile, ok := object.(*softwarecomposition.ContainerProfile)
if !ok {
- return fmt.Errorf("given object is not an ContainerProfile")
+ return TimeSeriesRow{}, "", false
}
seriesID, ok := profile.Annotations[helpers.ReportSeriesIdMetadataKey]
if !ok {
- // if the container ID annotation is not set, it's not a TS ContainerProfile and we skip it
- return nil
+ return TimeSeriesRow{}, "", false
}
- // parse name and namespace
// remove the suffix from the name after the last hyphen
name, tsSuffix := SplitProfileName(profile.Name)
- namespace := profile.Namespace
- // parse annotations
- completion := profile.Annotations[helpers.CompletionMetadataKey]
- previousReportTimestamp := profile.Annotations[helpers.PreviousReportTimestampMetadataKey]
- reportTimestamp := profile.Annotations[helpers.ReportTimestampMetadataKey]
- status := profile.Annotations[helpers.StatusMetadataKey]
+ id := armotypes.ProfileIdentifier{
+ ProfileScope: armotypes.ProfileScope{
+ HostType: a.HostType,
+ Cluster: profile.Annotations[helpers.ClusterMetadataKey],
+ Namespace: profile.Namespace,
+ CloudAccountIdentifier: profile.Annotations[helpers.CloudAccountIdentifierMetadataKey],
+ Region: profile.Annotations[helpers.RegionMetadataKey],
+ HostID: profile.Annotations[helpers.HostIDMetadataKey],
+ },
+ Name: name,
+ }
+ return TimeSeriesRow{
+ Kind: ContainerProfileKind,
+ Namespace: profile.Namespace,
+ Name: name,
+ SeriesID: seriesID,
+ TsSuffix: tsSuffix,
+ ReportTimestamp: profile.Annotations[helpers.ReportTimestampMetadataKey],
+ Status: profile.Annotations[helpers.StatusMetadataKey],
+ Completion: profile.Annotations[helpers.CompletionMetadataKey],
+ PreviousReportTimestamp: profile.Annotations[helpers.PreviousReportTimestampMetadataKey],
+ HasData: true,
+ }, BuildContainerProfileKey(id, ContainerProfileKind), true
+}
+
+// AfterCreate is called after a TS ContainerProfile is created to store metadata.
+func (a *ContainerProfileProcessor) AfterCreate(ctx context.Context, object runtime.Object) error {
+ if _, ok := object.(*softwarecomposition.ContainerProfile); !ok {
+ return fmt.Errorf("given object is not an ContainerProfile")
+ }
+ row, _, ok := a.TimeSeriesRowFor(object)
+ if !ok {
+ // if the container ID annotation is not set, it's not a TS ContainerProfile and we skip it
+ return nil
+ }
+ writer, ok := a.ContainerProfileStorage.(TimeSeriesEntryWriter)
+ if !ok {
+ return fmt.Errorf("container profile storage %T cannot write time series entries", a.ContainerProfileStorage)
+ }
// add sequence info via storage interface
- err := a.ContainerProfileStorage.(*ContainerProfileStorageImpl).WriteTimeSeriesEntry(ctx, "containerprofile", namespace, name, seriesID, tsSuffix, reportTimestamp, status, completion, previousReportTimestamp, true)
+ err := writer.WriteTimeSeriesEntry(ctx, row.Kind, row.Namespace, row.Name, row.SeriesID, row.TsSuffix, row.ReportTimestamp, row.Status, row.Completion, row.PreviousReportTimestamp, row.HasData)
if err != nil {
logger.L().Ctx(ctx).Error("ContainerProfileProcessor.AfterCreate - failed to write time series data for container profile",
loggerhelpers.Error(err),
- loggerhelpers.String("name", profile.Name),
- loggerhelpers.String("namespace", namespace),
- loggerhelpers.String("completion", completion),
- loggerhelpers.String("seriesID", seriesID),
- loggerhelpers.String("tsSuffix", tsSuffix),
- loggerhelpers.Interface("previousReportTimestamp", previousReportTimestamp),
- loggerhelpers.Interface("reportTimestamp", reportTimestamp),
- loggerhelpers.String("status", status))
+ loggerhelpers.String("name", row.Name+"-"+row.TsSuffix),
+ loggerhelpers.String("namespace", row.Namespace),
+ loggerhelpers.String("completion", row.Completion),
+ loggerhelpers.String("seriesID", row.SeriesID),
+ loggerhelpers.String("tsSuffix", row.TsSuffix),
+ loggerhelpers.Interface("previousReportTimestamp", row.PreviousReportTimestamp),
+ loggerhelpers.Interface("reportTimestamp", row.ReportTimestamp),
+ loggerhelpers.String("status", row.Status))
return fmt.Errorf("write time series data: %w", err)
}
return nil
@@ -274,15 +330,50 @@ func (a *ContainerProfileProcessor) PreSave(ctx context.Context, object runtime.
return nil
}
+// SetStorage hands the processor its ContainerProfileStorage. It does NOT
+// start the maintenance loop: under the ObjectStore backend, NewObjectStore
+// calls this before its caller (apiserver.go) finishes wiring
+// CleanupHandler.SetContainerProfileStore(objectStore) -- a maintenance tick
+// starting in that window would run cleanup's ContainerProfile arm with
+// cpStore still nil, taking the legacy file-walk path against a database
+// whose CP rows now live in the ObjectStore schema (deleting migrated
+// metadata without its payload row), and races unsynchronized on cpStore
+// with that same Set call. Call StartMaintenance explicitly once all such
+// wiring is complete.
func (a *ContainerProfileProcessor) SetStorage(containerProfileStorage ContainerProfileStorage) {
a.ContainerProfileStorage = containerProfileStorage
+}
+
+// StartMaintenance starts the periodic cleanup/consolidation loop. The
+// caller must have finished all storage wiring first (see SetStorage). A
+// process calls this once and never StopMaintenance, letting the loop run
+// until process exit; StopMaintenance exists for callers (tests) that need
+// the loop to actually end, not just go out of scope.
+func (a *ContainerProfileProcessor) StartMaintenance() {
if a.Interval > 0 {
- go a.runMaintenanceTasks()
+ a.stopMaintenance = make(chan struct{})
+ go a.runMaintenanceTasks(a.stopMaintenance)
+ }
+}
+
+// StopMaintenance ends the loop StartMaintenance started, once its current
+// iteration (if any) finishes -- it does not cancel an in-flight cleanup or
+// consolidation pass. A no-op if StartMaintenance was never called or the
+// loop already stopped. Not safe to call concurrently with StartMaintenance.
+func (a *ContainerProfileProcessor) StopMaintenance() {
+ if a.stopMaintenance != nil {
+ close(a.stopMaintenance)
+ a.stopMaintenance = nil
}
}
-func (a *ContainerProfileProcessor) runMaintenanceTasks() {
+func (a *ContainerProfileProcessor) runMaintenanceTasks(stop <-chan struct{}) {
for {
+ select {
+ case <-stop:
+ return
+ default:
+ }
// cleanup
logger.L().Debug("ContainerProfileProcessor.runMaintenanceTasks - starting cleanup task")
err := a.cleanup()
@@ -300,7 +391,11 @@ func (a *ContainerProfileProcessor) runMaintenanceTasks() {
logger.L().Debug("ContainerProfileProcessor.runMaintenanceTasks - consolidation task completed successfully")
}
// sleep
- time.Sleep(a.Interval)
+ select {
+ case <-stop:
+ return
+ case <-time.After(a.Interval):
+ }
}
}
@@ -317,7 +412,7 @@ func (a *ContainerProfileProcessor) cleanup() error {
resourceToKindHandler := map[string][]TypeCleanupHandlerFunc{
// keyed by the storage kind segment, not the REST resource name:
// container profiles live under the singular "containerprofile"
- ContainerProfileKind: {deleteByTemplateHashOrWlid},
+ ContainerProfileKind: a.CleanupHandler.ContainerProfileHandlers(),
}
return a.CleanupHandler.CleanupTask(context.TODO(), resourceToKindHandler)
}
@@ -410,6 +505,38 @@ func (a *ContainerProfileProcessor) ConsolidateTimeSeries(ctx context.Context) e
// The expired parameter indicates whether this time series has exceeded the deleteThreshold.
// When expired=true, the resulting profile will be marked as Completed/Partial (unless already Completed/Full).
func (a *ContainerProfileProcessor) consolidateKeyTimeSeries(ctx context.Context, key string, expired bool) error {
+ err := a.consolidateKeyTimeSeriesOnce(ctx, key, expired)
+ if errors.Is(err, ErrWriteConflict) {
+ // A store whose tick commits atomically (ObjectStore) reports a
+ // compare-and-swap conflict when the base or a processed TS object
+ // changed between the pass's reads and its commit: re-read and retry
+ // once (design §3.7 Phase 3, N=2); a second conflict is next tick's.
+ // The retry runs with the series reserved against same-series writers
+ // when the store supports it, so a writer committing back-to-back
+ // cannot beat it again (ConsolidationKeyReserver).
+ logger.L().Debug("ContainerProfileProcessor.consolidateKeyTimeSeries - write conflict, retrying once", loggerhelpers.String("key", key))
+ reserver, reserved := a.ContainerProfileStorage.(ConsolidationKeyReserver)
+ retryCtx, release := ctx, func() {}
+ if reserved {
+ retryCtx, release = reserver.ReserveConsolidationKey(ctx, key)
+ }
+ err = a.consolidateKeyTimeSeriesOnce(retryCtx, key, expired)
+ release()
+ if reserved {
+ switch {
+ case err == nil:
+ metrics.IncConsolidationKeyReserved(metrics.KeyReserveCommitted)
+ case errors.Is(err, ErrWriteConflict):
+ metrics.IncConsolidationKeyReserved(metrics.KeyReserveConflict)
+ default:
+ metrics.IncConsolidationKeyReserved(metrics.KeyReserveError)
+ }
+ }
+ }
+ return err
+}
+
+func (a *ContainerProfileProcessor) consolidateKeyTimeSeriesOnce(ctx context.Context, key string, expired bool) error {
logger.L().Debug("ContainerProfileProcessor.consolidateKeyTimeSeries - consolidating data for key", loggerhelpers.String("key", key), loggerhelpers.Interface("expired", expired))
// Each unit of work owns its own pool connection so keys can be consolidated
@@ -473,7 +600,7 @@ func (a *ContainerProfileProcessor) consolidateKeyTimeSeries(ctx context.Context
// the predicate after the pass would turn the slug guard into "never send".
frozen := softwarecomposition.IsCompletedFull(profile.Annotations)
- processed, err := a.processTimeSeriesInTransaction(ctx, timeSeries, key, profile, prefix, root, id, expired)
+ processed, deletesStaged, err := a.processTimeSeriesInTransaction(ctx, timeSeries, key, profile, prefix, root, id, expired)
if err != nil {
return err
}
@@ -492,8 +619,13 @@ func (a *ContainerProfileProcessor) consolidateKeyTimeSeries(ctx context.Context
}
}
- if err := a.deleteProcessedTimeSeries(ctx, processed); err != nil {
- return err
+ if !deletesStaged {
+ if a.Hooks.BeforeProcessedDeletes != nil {
+ a.Hooks.BeforeProcessedDeletes(key)
+ }
+ if err := a.deleteProcessedTimeSeries(ctx, processed); err != nil {
+ return err
+ }
}
logger.L().Debug("ContainerProfileProcessor.consolidateKeyTimeSeries - finished consolidating data for key", loggerhelpers.String("key", key))
@@ -585,20 +717,29 @@ func (a *ContainerProfileProcessor) loadOrInitializeProfile(ctx context.Context,
}
// processTimeSeriesInTransaction processes time series data within a database transaction
+//
+// deletesStaged reports that the processed time-series deletes were handed to
+// the store inside the transaction (ProcessedDeleteStager) and must not be
+// issued again by the caller.
func (a *ContainerProfileProcessor) processTimeSeriesInTransaction(ctx context.Context,
timeSeries map[string][]softwarecomposition.TimeSeriesContainers, key string,
- profile softwarecomposition.ContainerProfile, prefix, root string, id armotypes.ProfileIdentifier, expired bool) (processed []string, err error) {
+ profile softwarecomposition.ContainerProfile, prefix, root string, id armotypes.ProfileIdentifier, expired bool) (processed []string, deletesStaged bool, err error) {
endFn, err := a.ContainerProfileStorage.BeginTransaction(ctx)
if err != nil {
- return nil, fmt.Errorf("failed to begin nested transaction: %w", err)
+ return nil, false, fmt.Errorf("failed to begin nested transaction: %w", err)
}
// Registered before endFn so it runs after it (LIFO) and wraps a failed
- // COMMIT the same way as a failed updateProfile.
+ // COMMIT the same way as a failed updateProfile. A CAS conflict discovered
+ // by endFn's COMMIT (not just one raised by updateProfile) passes through
+ // unwrapped so the caller's errors.Is(err, ErrWriteConflict) retry still
+ // fires.
defer func() {
if err != nil {
processed = nil
- err = fmt.Errorf("failed to process time series data for key %s (transaction rolled back): %w", key, err)
+ if !errors.Is(err, ErrWriteConflict) {
+ err = fmt.Errorf("failed to process time series data for key %s (transaction rolled back): %w", key, err)
+ }
}
}()
// endFn must be deferred DIRECTLY: it recovers a panic raised inside
@@ -607,8 +748,18 @@ func (a *ContainerProfileProcessor) processTimeSeriesInTransaction(ctx context.C
// transaction open, leaving SQLite's write lock held by a connection that
// went back to the pool with nobody left to end it.
defer endFn(&err)
+
processed, err = a.updateProfile(ctx, timeSeries, key, profile, prefix, root, id, expired)
- return processed, err
+ if err == nil {
+ if st, ok := a.ContainerProfileStorage.(ProcessedDeleteStager); ok && st.StagesProcessedDeletes() {
+ if a.Hooks.BeforeProcessedDeletes != nil {
+ a.Hooks.BeforeProcessedDeletes(key)
+ }
+ err = a.deleteProcessedTimeSeries(ctx, processed)
+ deletesStaged = true
+ }
+ }
+ return processed, deletesStaged, err
}
// deleteProcessedTimeSeries removes processed time series profiles from storage.
diff --git a/pkg/registry/file/containerprofile_storage.go b/pkg/registry/file/containerprofile_storage.go
index c0bed3a29..5701d83b3 100644
--- a/pkg/registry/file/containerprofile_storage.go
+++ b/pkg/registry/file/containerprofile_storage.go
@@ -44,10 +44,13 @@ var _ ContainerProfileStorage = (*ContainerProfileStorageImpl)(nil)
// WithConnection acquires a connection from the pool and returns a new context
// with the connection embedded, plus a cleanup function to return the connection to the pool.
func (c *ContainerProfileStorageImpl) WithConnection(ctx context.Context) (context.Context, func(), error) {
+ beforePool := time.Now()
conn, err := c.pool.Take(ctx)
if err != nil {
+ metrics.ObservePoolWait(ContainerProfileKindPlural, metrics.OutcomeTimeout, time.Since(beforePool))
return nil, nil, fmt.Errorf("failed to take connection from pool: %w", err)
}
+ metrics.ObservePoolWait(ContainerProfileKindPlural, metrics.OutcomeAcquired, time.Since(beforePool))
var cleaned bool
cleanup := func() {
if !cleaned {
@@ -61,10 +64,33 @@ func (c *ContainerProfileStorageImpl) WithConnection(ctx context.Context) (conte
// BeginTransaction starts a SQLite transaction (savepoint) and returns a function
// to commit or rollback based on the error state.
func (c *ContainerProfileStorageImpl) BeginTransaction(ctx context.Context) (func(*error), error) {
+ if err := c.refuseGated("BeginTransaction"); err != nil {
+ return nil, err
+ }
conn := ctx.Value(connKey).(*sqlite.Conn)
+ observeStmt("Transaction")
return sqlitex.Transaction(conn), nil
}
+// errCPStorageGated: the legacy ContainerProfile storage opens long
+// transactions on a pool connection (the consolidation's BEGIN DEFERRED, the
+// heal's BEGIN IMMEDIATE) and calls saveObject inside them. Over a StorageImpl
+// that shares the write gate, saveObject would queue on the gate while this
+// connection already holds SQLite's write lock — the gate busy-waiting behind
+// its own caller, the class write-gate sharing exists to close. Under the
+// flag the ContainerProfile kind is served by the ObjectStore and this type
+// is never constructed; the refusal makes that structural (W11/W12 of
+// write-gate-sharing §3.2: never routed through a gate).
+var errCPStorageGated = errors.New("legacy ContainerProfile storage cannot run over a StorageImpl that shares the write gate")
+
+func (c *ContainerProfileStorageImpl) refuseGated(op string) error {
+ if c.storageImpl.gate == nil {
+ return nil
+ }
+ logger.L().Error("ContainerProfileStorageImpl refused: the wrapped StorageImpl shares the write gate", loggerhelpers.String("op", op))
+ return fmt.Errorf("%s: %w", op, errCPStorageGated)
+}
+
func (c *ContainerProfileStorageImpl) DeleteContainerProfile(ctx context.Context, key string) error {
conn := ctx.Value(connKey).(*sqlite.Conn)
return c.storageImpl.delete(ctx, conn, key, &softwarecomposition.ContainerProfile{}, nil, nil, nil, storage.DeleteOptions{})
@@ -221,6 +247,9 @@ func healFailureReason(err error) string {
//
// Must run in autocommit, before the pass's transaction (it opens its own).
func (c *ContainerProfileStorageImpl) HealDivergence(ctx context.Context, key string) error {
+ if err := c.refuseGated("HealDivergence"); err != nil {
+ return err
+ }
conn := ctx.Value(connKey).(*sqlite.Conn)
s := c.storageImpl
lockCtx, cancel := context.WithTimeout(ctx, lockTimeout)
@@ -273,7 +302,7 @@ func (c *ContainerProfileStorageImpl) HealDivergence(ctx context.Context, key st
// The payload changed under us (a migration re-save, a REST reset): not our case.
return nil
}
- metaEvent, err = s.saveObject(conn, key, &cur, &softwarecomposition.ContainerProfile{}, "")
+ metaEvent, err = s.saveObject(ctx, conn, key, &cur, &softwarecomposition.ContainerProfile{}, "", priorityLow, holdPathLegacyCommit)
if err != nil {
return &healFailure{reason: metrics.HealFailedSave, err: err}
}
diff --git a/pkg/registry/file/generatednetworkpolicy.go b/pkg/registry/file/generatednetworkpolicy.go
index 23fd9cebf..cf5871abb 100644
--- a/pkg/registry/file/generatednetworkpolicy.go
+++ b/pkg/registry/file/generatednetworkpolicy.go
@@ -37,6 +37,13 @@ const (
type GeneratedNetworkPolicyStorage struct {
immutableStorage
realStore StorageQuerier
+ // containerProfileStore serves the full-spec ContainerProfile list. It is
+ // the instance wired for the containerprofiles resource (the ObjectStore
+ // under config.ContainerProfileSqliteBackend), never the default
+ // StorageImpl: the default instance's get() deletes the shared metadata
+ // row of any CP key whose payload file is absent, which under the flag is
+ // every row the ObjectStore owns (§5.6 row 9).
+ containerProfileStore storage.Interface
}
func (s *GeneratedNetworkPolicyStorage) EnableResourceSizeEstimation(keysFunc storage.KeysFunc) error {
@@ -55,9 +62,13 @@ func (s *GeneratedNetworkPolicyStorage) CompactRevision() int64 {
var _ storage.Interface = (*GeneratedNetworkPolicyStorage)(nil)
-func NewGeneratedNetworkPolicyStorage(realStore StorageQuerier) storage.Interface {
+// NewGeneratedNetworkPolicyStorage builds the aggregate over realStore (the
+// default instance, for knownservers) and containerProfileStore (the
+// containerprofiles resource's own storage.Interface, for the CP list).
+func NewGeneratedNetworkPolicyStorage(realStore StorageQuerier, containerProfileStore storage.Interface) storage.Interface {
return &GeneratedNetworkPolicyStorage{
- realStore: realStore,
+ realStore: realStore,
+ containerProfileStore: containerProfileStore,
}
}
@@ -157,7 +168,7 @@ func (s *GeneratedNetworkPolicyStorage) listNamespaceContainerProfiles(ctx conte
var items []softwarecomposition.ContainerProfile
for {
cpList := &softwarecomposition.ContainerProfileList{}
- if err := s.realStore.GetList(ctx, listKey, opts, cpList); err != nil {
+ if err := s.containerProfileStore.GetList(ctx, listKey, opts, cpList); err != nil {
return nil, err
}
items = append(items, cpList.Items...)
diff --git a/pkg/registry/file/generatednetworkpolicy_multicontainer_test.go b/pkg/registry/file/generatednetworkpolicy_multicontainer_test.go
index 0b3d0eb37..55bb30250 100644
--- a/pkg/registry/file/generatednetworkpolicy_multicontainer_test.go
+++ b/pkg/registry/file/generatednetworkpolicy_multicontainer_test.go
@@ -91,7 +91,7 @@ func newGNPTestStorage(t *testing.T) (StorageQuerier, storage.Interface, *sqlite
sch := scheme.Scheme
require.NoError(t, softwarecomposition.AddToScheme(sch))
realStorage := NewStorageImpl(afero.NewMemMapFs(), "/", pool, nil, sch)
- return realStorage, NewGeneratedNetworkPolicyStorage(realStorage), pool
+ return realStorage, NewGeneratedNetworkPolicyStorage(realStorage, realStorage), pool
}
// TestGeneratedNetworkPolicyStorage_Get_MultiContainerWorkload pins contract (1):
diff --git a/pkg/registry/file/generatednetworkpolicy_test.go b/pkg/registry/file/generatednetworkpolicy_test.go
index 09bb6b52b..39fd80d74 100644
--- a/pkg/registry/file/generatednetworkpolicy_test.go
+++ b/pkg/registry/file/generatednetworkpolicy_test.go
@@ -153,7 +153,7 @@ func TestGeneratedNetworkPolicyStorage_Get(t *testing.T) {
sch := scheme.Scheme
require.NoError(t, softwarecomposition.AddToScheme(sch))
realStorage := NewStorageImpl(afero.NewMemMapFs(), "/", pool, nil, sch)
- generatedNetworkPolicyStorage := NewGeneratedNetworkPolicyStorage(realStorage)
+ generatedNetworkPolicyStorage := NewGeneratedNetworkPolicyStorage(realStorage, realStorage)
ctx, cancel := context.WithTimeout(context.TODO(), 5*time.Second)
defer cancel()
if tt.create {
@@ -201,7 +201,7 @@ func TestGeneratedNetworkPolicyStorage_Get(t *testing.T) {
func TestGeneratedNetworkPolicyStorage_Create(t *testing.T) {
storageImpl := NewStorageImpl(afero.NewMemMapFs(), "", nil, nil, nil)
- generatedNetworkPolicyStorage := NewGeneratedNetworkPolicyStorage(storageImpl)
+ generatedNetworkPolicyStorage := NewGeneratedNetworkPolicyStorage(storageImpl, storageImpl)
err := generatedNetworkPolicyStorage.Create(context.TODO(), "", nil, nil, 0)
@@ -212,7 +212,7 @@ func TestGeneratedNetworkPolicyStorage_Create(t *testing.T) {
func TestGeneratedNetworkPolicyStorage_Delete(t *testing.T) {
storageImpl := NewStorageImpl(afero.NewMemMapFs(), "", nil, nil, nil)
- generatedNetworkPolicyStorage := NewGeneratedNetworkPolicyStorage(storageImpl)
+ generatedNetworkPolicyStorage := NewGeneratedNetworkPolicyStorage(storageImpl, storageImpl)
err := generatedNetworkPolicyStorage.Delete(context.TODO(), "", nil, nil, nil, nil, storage.DeleteOptions{})
@@ -223,7 +223,7 @@ func TestGeneratedNetworkPolicyStorage_Delete(t *testing.T) {
func TestGeneratedNetworkPolicyStorage_Watch(t *testing.T) {
storageImpl := NewStorageImpl(afero.NewMemMapFs(), "", nil, nil, nil)
- generatedNetworkPolicyStorage := NewGeneratedNetworkPolicyStorage(storageImpl)
+ generatedNetworkPolicyStorage := NewGeneratedNetworkPolicyStorage(storageImpl, storageImpl)
_, err := generatedNetworkPolicyStorage.Watch(context.TODO(), "", storage.ListOptions{})
assert.NoError(t, err)
@@ -231,7 +231,7 @@ func TestGeneratedNetworkPolicyStorage_Watch(t *testing.T) {
func TestGeneratedNetworkPolicyStorage_GuaranteedUpdate(t *testing.T) {
storageImpl := NewStorageImpl(afero.NewMemMapFs(), "", nil, nil, nil)
- generatedNetworkPolicyStorage := NewGeneratedNetworkPolicyStorage(storageImpl)
+ generatedNetworkPolicyStorage := NewGeneratedNetworkPolicyStorage(storageImpl, storageImpl)
err := generatedNetworkPolicyStorage.GuaranteedUpdate(context.TODO(), "", nil, false, nil, nil, nil)
@@ -268,7 +268,7 @@ func TestGeneratedNetworkPolicyStorage_GetList_SelectorAppliedAfterGeneration(t
require.NoError(t, realStorage.Create(ctx, "/spdx.softwarecomposition.kubescape.io/containerprofile/default/"+workloadName, source, nil, 0))
}
- s := NewGeneratedNetworkPolicyStorage(realStorage)
+ s := NewGeneratedNetworkPolicyStorage(realStorage, realStorage)
list := &softwarecomposition.GeneratedNetworkPolicyList{}
opts := storage.ListOptions{
Predicate: generatednetworkpolicy.MatchGeneratedNetworkPolicy(
diff --git a/pkg/registry/file/main_test.go b/pkg/registry/file/main_test.go
new file mode 100644
index 000000000..a69e1c557
--- /dev/null
+++ b/pkg/registry/file/main_test.go
@@ -0,0 +1,140 @@
+package file
+
+// AC-G1 of .omc/plans/write-gate-sharing.md: no write statement is ever
+// prepared on a connection the pool's write gate does not own. The package's
+// write authorizer (sqlite.go) reports every INSERT/UPDATE/DELETE prepared on
+// any pool connection; this ledger keeps each report with the stack of the
+// site that prepared it and judges it against every gate ever built on that
+// pool (gate.owns: a connection the gate has EVER held, and a record older
+// than the gate's Close). Pools that never had a gate — every flag-off test —
+// are vacuous.
+//
+// Record-and-fail, not deny: both found bugs swallow the statement's error
+// (`_ = DeleteMetadata(...)`), so a denied statement would have passed the
+// test; recording sees the attempt regardless of what the caller does with
+// the result. Every gated environment arms a per-test check
+// (armUngatedWriteCheck); TestMain sweeps what escaped every per-test check.
+
+import (
+ "fmt"
+ "os"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+
+ "zombiezen.com/go/sqlite/sqlitemigration"
+)
+
+type ungatedRecord struct {
+ writeStatement
+ pcs []uintptr
+ judged bool
+}
+
+func (r *ungatedRecord) String() string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "%s on %s (seq %d) prepared on a pool connection the write gate never owned; prepared at:\n", writeOpLabel(r.op), r.table, r.seq)
+ frames := runtime.CallersFrames(r.pcs)
+ for {
+ f, more := frames.Next()
+ if strings.Contains(f.File, "/pkg/registry/file/") || strings.Contains(f.Function, "kubescape/storage") {
+ fmt.Fprintf(&b, "\t%s\n\t\t%s:%d\n", f.Function, f.File, f.Line)
+ }
+ if !more {
+ break
+ }
+ }
+ return b.String()
+}
+
+type ungatedLedger struct {
+ mu sync.Mutex
+ records []*ungatedRecord
+ // gates is every gate ever constructed, per pool; a pool with an entry is
+ // armed for the whole test binary's lifetime.
+ gates map[*sqlitemigration.Pool][]*writeGate
+}
+
+var acg1Ledger = &ungatedLedger{gates: map[*sqlitemigration.Pool][]*writeGate{}}
+
+func (l *ungatedLedger) note(rec writeStatement) {
+ var pcs [48]uintptr
+ n := runtime.Callers(3, pcs[:])
+ r := &ungatedRecord{writeStatement: rec, pcs: pcs[:n]}
+ l.mu.Lock()
+ l.records = append(l.records, r)
+ l.mu.Unlock()
+}
+
+func (l *ungatedLedger) noteGate(g *writeGate) {
+ l.mu.Lock()
+ l.gates[g.pool] = append(l.gates[g.pool], g)
+ l.mu.Unlock()
+}
+
+// violations judges every unjudged record of pool (every pool when nil) and
+// returns those no gate of the pool owns. Records on unarmed pools stay
+// unjudged: a gate may still be built on that pool later in the test.
+func (l *ungatedLedger) violations(pool *sqlitemigration.Pool) []*ungatedRecord {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ var out []*ungatedRecord
+ for _, r := range l.records {
+ if r.judged || (pool != nil && r.pool != pool) {
+ continue
+ }
+ gates := l.gates[r.pool]
+ if len(gates) == 0 {
+ continue
+ }
+ r.judged = true
+ owned := false
+ for _, g := range gates {
+ if g.owns(r.conn, r.seq) {
+ owned = true
+ break
+ }
+ }
+ if !owned {
+ out = append(out, r)
+ }
+ }
+ return out
+}
+
+// armUngatedWriteCheck registers AC-G1 for pool on t: at cleanup, every write
+// statement recorded on a pool connection that no gate of the pool has ever
+// owned fails the test with the stack of the site that prepared it. Register
+// it before the pool's own cleanup so it runs after the stores closed.
+func armUngatedWriteCheck(t *testing.T, pool *sqlitemigration.Pool) {
+ t.Helper()
+ t.Cleanup(func() {
+ for _, v := range acg1Ledger.violations(pool) {
+ t.Errorf("AC-G1 ungated write: %s", v)
+ }
+ })
+}
+
+func TestMain(m *testing.M) {
+ note := acg1Ledger.note
+ writeStmtObserver.Store(¬e)
+ noteGate := acg1Ledger.noteGate
+ writeGateObserver.Store(¬eGate)
+ // A re-entrant gate acquire panics under the test binary: a site that
+ // swallows the production error (`_ = DeleteMetadata(...)`) still fails.
+ gateReentrantPanics.Store(true)
+
+ code := m.Run()
+
+ if vs := acg1Ledger.violations(nil); len(vs) > 0 {
+ fmt.Fprintf(os.Stderr, "AC-G1: %d ungated write(s) escaped every per-test check:\n", len(vs))
+ for _, v := range vs {
+ fmt.Fprintf(os.Stderr, " %s\n", v)
+ }
+ if code == 0 {
+ code = 1
+ }
+ }
+ os.Exit(code)
+}
diff --git a/pkg/registry/file/processor.go b/pkg/registry/file/processor.go
index b6e5d9262..15acfd1f9 100644
--- a/pkg/registry/file/processor.go
+++ b/pkg/registry/file/processor.go
@@ -15,6 +15,53 @@ type Processor interface {
SetStorage(storageImpl ContainerProfileStorage)
}
+// TimeSeriesRow is one time_series table row.
+type TimeSeriesRow struct {
+ Kind, Namespace, Name, SeriesID, TsSuffix string
+ ReportTimestamp, Status, Completion string
+ PreviousReportTimestamp string
+ HasData bool
+}
+
+// TimeSeriesRowProvider is implemented by a Processor whose AfterCreate side
+// effect is a time_series row. A backend that can write that row inside the
+// object's own transaction (ObjectStore) asks for the row here instead of
+// calling AfterCreate on a second connection after the commit.
+type TimeSeriesRowProvider interface {
+ // TimeSeriesRowFor returns the row a Create of object must record, the
+ // base (consolidated) key whose admission the write must re-check, and
+ // ok=false when object is not a time-series profile.
+ TimeSeriesRowFor(object runtime.Object) (row TimeSeriesRow, baseKey string, ok bool)
+}
+
+// TimeSeriesEntryWriter is the storage-side counterpart AfterCreate uses when
+// the backend does not fold the row into Create's transaction.
+type TimeSeriesEntryWriter interface {
+ WriteTimeSeriesEntry(ctx context.Context, kind, namespace, name, seriesID, tsSuffix, reportTimestamp, status, completion, previousReportTimestamp string, hasData bool) error
+}
+
+// ProcessedDeleteStager is implemented by a ContainerProfileStorage whose
+// BeginTransaction stages writes for one atomic commit: the consolidation pass
+// then hands it the processed time-series deletes BEFORE the end function
+// runs, so they commit with the base write and the time_series rewrite
+// (design §3.7). The legacy StorageImpl path does not implement it and keeps
+// its commit-then-delete order unchanged.
+type ProcessedDeleteStager interface {
+ StagesProcessedDeletes() bool
+}
+
+// ConsolidationKeyReserver is implemented by a ContainerProfileStorage whose
+// consolidation commit can lose a compare-and-swap to a concurrent writer of
+// the same series (the ObjectStore). After a first conflict on key, the pass
+// runs its one retry with the series reserved: writes to it that have not
+// started yet wait for the retry, and the retry waits for those already in
+// flight (both bounded), so the retry's window is free of same-series commits
+// (sqliteobject_keyreserve.go). The pass MUST use the returned ctx for the
+// retry and call release when it ends.
+type ConsolidationKeyReserver interface {
+ ReserveConsolidationKey(ctx context.Context, key string) (reservedCtx context.Context, release func())
+}
+
type DefaultProcessor struct {
}
diff --git a/pkg/registry/file/race_off_test.go b/pkg/registry/file/race_off_test.go
new file mode 100644
index 000000000..8c39b4e34
--- /dev/null
+++ b/pkg/registry/file/race_off_test.go
@@ -0,0 +1,7 @@
+//go:build !race
+
+package file
+
+// raceDetectorEnabled reports whether the test binary runs under -race, for
+// tests whose time bounds are measured on the plain build.
+const raceDetectorEnabled = false
diff --git a/pkg/registry/file/race_on_test.go b/pkg/registry/file/race_on_test.go
new file mode 100644
index 000000000..ef3c9a3b7
--- /dev/null
+++ b/pkg/registry/file/race_on_test.go
@@ -0,0 +1,7 @@
+//go:build race
+
+package file
+
+// raceDetectorEnabled reports whether the test binary runs under -race, for
+// tests whose time bounds are measured on the plain build.
+const raceDetectorEnabled = true
diff --git a/pkg/registry/file/rollback_safety_review5_test.go b/pkg/registry/file/rollback_safety_review5_test.go
new file mode 100644
index 000000000..1867736a0
--- /dev/null
+++ b/pkg/registry/file/rollback_safety_review5_test.go
@@ -0,0 +1,354 @@
+package file
+
+// Tests for the fifth review round on .omc/plans/rollback-safety-guard.md:
+//
+// - Part 3 (previously deferred on the incorrect assumption that flag-off +
+// singleWriterEnabled=false is unreachable -- main.go only Fatals on
+// flag-ON + singleWriterEnabled=false, so it is a supported
+// configuration): Create's existence check must also see an object that
+// is visible only through the rollback read fallback, not just the legacy
+// .g file on disk.
+// - Part 1's error handling: a payloads-row delete failure inside the real
+// StorageImpl.Delete must reach the caller instead of being logged and
+// swallowed; the remaining object must survive for a retry.
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/kubescape/storage/pkg/generated/clientset/versioned/scheme"
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/storage"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitemigration"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+// denyPayloadsDeleteAuthorizer fails exactly one statement shape -- a DELETE
+// against the payloads table -- and authorizes everything else, so a test can
+// drive the real Delete path with DeletePayloads (and only DeletePayloads)
+// failing.
+type denyPayloadsDeleteAuthorizer struct{}
+
+func (denyPayloadsDeleteAuthorizer) Authorize(action sqlite.Action) sqlite.AuthResult {
+ if action.Type() == sqlite.OpDelete && action.Table() == "payloads" {
+ return sqlite.AuthResultDeny
+ }
+ return sqlite.AuthResultOK
+}
+
+// newRollbackSafetyTestStorageWithPool is newRollbackSafetyTestStorage with a
+// caller-supplied pool, for the fault-injection test below.
+func newRollbackSafetyTestStorageWithPool(t *testing.T, opts PoolOptions) (*StorageImpl, *sqlitemigration.Pool) {
+ t.Helper()
+ fs := afero.NewMemMapFs()
+ pool := NewPoolWithOptions(t.TempDir()+"/test.sq3", opts)
+ require.NotNil(t, pool)
+ t.Cleanup(func() { _ = pool.Close() })
+ sch := scheme.Scheme
+ require.NoError(t, softwarecomposition.AddToScheme(sch))
+ s := NewStorageImpl(fs, DefaultStorageRoot, pool, nil, sch).(*StorageImpl)
+ return s, pool
+}
+
+// TestDelete_PayloadsDeleteFailureSurfacesToCaller drives the REAL
+// StorageImpl.Delete (not DeletePayloads in isolation) with the payloads
+// DELETE rejected at the SQLite authorizer. The error must reach the caller
+// and leave the object available for a successful retry.
+func TestDelete_PayloadsDeleteFailureSurfacesToCaller(t *testing.T) {
+ s, pool := newRollbackSafetyTestStorageWithPool(t, PoolOptions{
+ Size: 1,
+ Authorizer: func(*sqlite.Conn) sqlite.Authorizer { return denyPayloadsDeleteAuthorizer{} },
+ })
+ ctx := context.Background()
+ key := cpTestKey("delete-payloads-fails")
+ obj := cpTestObject("delete-payloads-fails")
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, obj, testFallbackRV, false, testFallbackUID, false)
+ seedDecodablePayloadsRow(t, conn, s, key, obj)
+ pool.Put(conn)
+
+ delErr := s.Delete(ctx, key, &softwarecomposition.ContainerProfile{}, nil, nil, nil, storage.DeleteOptions{})
+ require.Error(t, delErr, "a Delete that could not remove the payloads row must not report success")
+ assert.Contains(t, delErr.Error(), "delete payloads")
+
+ // Failed cleanup preserves the object; removing the injected failure
+ // allows the same Delete to succeed on retry.
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.True(t, payloadsRowExistsForTest(t, conn, key))
+ assert.True(t, metadataRowExistsForTest(t, conn, key))
+ require.NoError(t, conn.SetAuthorizer(nil))
+ pool.Put(conn)
+ require.NoError(t, s.Delete(ctx, key, &softwarecomposition.ContainerProfile{}, nil, nil, nil, storage.DeleteOptions{}))
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.False(t, payloadsRowExistsForTest(t, conn, key))
+ assert.False(t, metadataRowExistsForTest(t, conn, key))
+ pool.Put(conn)
+}
+
+// TestDelete_SucceedsWhenPayloadsDeleteWorks is the control for the test
+// above: with no fault injected, the same Delete reports success and removes
+// both rows. Without it, the assertion above could pass for the wrong reason.
+func TestDelete_SucceedsWhenPayloadsDeleteWorks(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := cpTestKey("delete-payloads-ok")
+ obj := cpTestObject("delete-payloads-ok")
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, obj, testFallbackRV, false, testFallbackUID, false)
+ seedDecodablePayloadsRow(t, conn, s, key, obj)
+ pool.Put(conn)
+
+ require.NoError(t, s.Delete(ctx, key, &softwarecomposition.ContainerProfile{}, nil, nil, nil, storage.DeleteOptions{}))
+
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.False(t, payloadsRowExistsForTest(t, conn, key))
+ assert.False(t, metadataRowExistsForTest(t, conn, key))
+ pool.Put(conn)
+}
+
+// TestCreate_RefusesAnObjectVisibleOnlyThroughTheFallback is Part 3. A key
+// with a metadata row (rv non-NULL, is_time_series = 0) and a payloads row
+// but NO legacy .g file is a live object: a GET serves it from the payloads
+// body. Create must therefore report AlreadyExists for it, in BOTH supported
+// configurations -- and must not have replaced the row.
+func TestCreate_RefusesAnObjectVisibleOnlyThroughTheFallback(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ singleWriter bool
+ }{
+ {"singleWriterEnabled=false (CreateWithConn's own existence check)", false},
+ {"singleWriterEnabled=true (the commit-time recheck)", true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ old := singleWriterEnabled
+ singleWriterEnabled = tc.singleWriter
+ t.Cleanup(func() { singleWriterEnabled = old })
+
+ s, pool, fs := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := cpTestKey("create-vs-fallback")
+ existing := cpTestObject("create-vs-fallback")
+ existing.Spec.Execs = []softwarecomposition.ExecCalls{{Path: "/bin/original"}}
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, existing, testFallbackRV, false, testFallbackUID, false)
+ seedDecodablePayloadsRow(t, conn, s, key, existing)
+ pool.Put(conn)
+ // Deliberately no .g file: the only thing CreateWithConn's Stat
+ // check can see is absent, which is the whole point.
+
+ // Not vacuous: the key really is served by the fallback today.
+ before := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, s.Get(ctx, key, storage.GetOptions{}, before))
+ require.Equal(t, "/bin/original", before.Spec.Execs[0].Path)
+
+ incoming := cpTestObject("create-vs-fallback")
+ incoming.Spec.Execs = []softwarecomposition.ExecCalls{{Path: "/bin/overwriter"}}
+ createErr := s.Create(ctx, key, incoming, &softwarecomposition.ContainerProfile{}, 0)
+ require.Error(t, createErr, "Create must not silently replace an object the read fallback is serving")
+ assert.True(t, storage.IsExist(createErr), "expected AlreadyExists, got %v", createErr)
+
+ // And the refusal was real: the stored object is untouched, and
+ // no .g file was left behind by a half-done create.
+ after := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, s.Get(ctx, key, storage.GetOptions{}, after))
+ assert.Equal(t, "/bin/original", after.Spec.Execs[0].Path, "the fallback-visible object must be unchanged")
+ _, statErr := fs.Stat(getStoredPayloadFilepath(DefaultStorageRoot, key))
+ assert.Error(t, statErr, "the refused Create must not have written a .g file")
+ })
+ }
+}
+
+// TestCreate_StillSucceedsForKeysTheFallbackDoesNotServe is the
+// over-refusal control for Part 3: the new existence check must refuse
+// exactly what the read fallback serves and nothing more. Each shape below
+// fails one of the predicate's conditions, so a GET self-repairs it rather
+// than serving it -- and Create must go through.
+func TestCreate_StillSucceedsForKeysTheFallbackDoesNotServe(t *testing.T) {
+ seed := func(t *testing.T, s *StorageImpl, pool *sqlitemigration.Pool, key string, rvNull, isTimeSeries, withPayloads bool) {
+ t.Helper()
+ obj := cpTestObject("noop")
+ conn, err := pool.Take(context.Background())
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, obj, testFallbackRV, rvNull, testFallbackUID, isTimeSeries)
+ if withPayloads {
+ seedDecodablePayloadsRow(t, conn, s, key, obj)
+ }
+ pool.Put(conn)
+ }
+
+ for _, tc := range []struct {
+ name string
+ rvNull, isTimeSeries, hasPayload bool
+ }{
+ {"rv IS NULL (a legacy-owned row)", true, false, true},
+ {"a time-series row", false, true, true},
+ {"no payloads row", false, false, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ old := singleWriterEnabled
+ singleWriterEnabled = false
+ t.Cleanup(func() { singleWriterEnabled = old })
+
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := cpTestKey("create-allowed")
+ seed(t, s, pool, key, tc.rvNull, tc.isTimeSeries, tc.hasPayload)
+
+ incoming := cpTestObject("create-allowed")
+ incoming.Spec.Execs = []softwarecomposition.ExecCalls{{Path: "/bin/new"}}
+ require.NoError(t, s.Create(ctx, key, incoming, &softwarecomposition.ContainerProfile{}, 0),
+ "the new existence check must not refuse a key the fallback does not serve")
+
+ out := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, s.Get(ctx, key, storage.GetOptions{}, out))
+ assert.Equal(t, "/bin/new", out.Spec.Execs[0].Path)
+ })
+ }
+}
+
+// Query failures must preserve live rows and must never look like absence,
+// including when Get is called with IgnoreNotFound by an update path.
+func TestRollbackSafety_InspectionFailuresPreserveLiveObject(t *testing.T) {
+ for _, table := range []string{"payloads", "metadata"} {
+ for _, operation := range []string{"get", "get-ignore-not-found", "create"} {
+ t.Run(table+"/"+operation, func(t *testing.T) {
+ old := singleWriterEnabled
+ singleWriterEnabled = false
+ t.Cleanup(func() { singleWriterEnabled = old })
+ s, pool := newRollbackSafetyTestStorageWithPool(t, PoolOptions{Size: 1})
+ ctx := context.Background()
+ key := cpTestKey("inspection-failure")
+ obj := cpTestObject("inspection-failure")
+ obj.Spec.Execs = []softwarecomposition.ExecCalls{{Path: "/bin/original"}}
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, obj, testFallbackRV, false, testFallbackUID, false)
+ seedDecodablePayloadsRow(t, conn, s, key, obj)
+ beforeMetadata, err := ReadMetadata(conn, key)
+ require.NoError(t, err)
+ before, err := readFallbackCandidate(conn, key)
+ require.NoError(t, err)
+ // Installing an authorizer expires cached statements. Reject reads
+ // without dropping tables or destroying the evidence of preservation.
+ require.NoError(t, conn.SetAuthorizer(sqlite.AuthorizeFunc(func(a sqlite.Action) sqlite.AuthResult {
+ if a.Type() == sqlite.OpRead && a.Table() == table {
+ return sqlite.AuthResultDeny
+ }
+ return sqlite.AuthResultOK
+ })))
+ pool.Put(conn)
+
+ var operationErr error
+ if operation == "create" {
+ operationErr = s.Create(ctx, key, cpTestObject("inspection-failure"), &softwarecomposition.ContainerProfile{}, 0)
+ } else {
+ operationErr = s.Get(ctx, key, storage.GetOptions{IgnoreNotFound: operation == "get-ignore-not-found"}, &softwarecomposition.ContainerProfile{})
+ }
+ require.Error(t, operationErr)
+ assert.False(t, storage.IsNotFound(operationErr))
+ assert.False(t, storage.IsExist(operationErr))
+
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ require.NoError(t, conn.SetAuthorizer(nil))
+ afterMetadata, err := ReadMetadata(conn, key)
+ require.NoError(t, err)
+ after, err := readFallbackCandidate(conn, key)
+ require.NoError(t, err)
+ assert.Equal(t, beforeMetadata, afterMetadata)
+ assert.Equal(t, before, after, "both rows, including payload bytes, must survive")
+ pool.Put(conn)
+ exists, err := afero.Exists(s.appFs, getStoredPayloadFilepath(s.root, key))
+ require.NoError(t, err)
+ assert.False(t, exists)
+ out := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, s.Get(ctx, key, storage.GetOptions{}, out))
+ assert.Equal(t, "/bin/original", out.Spec.Execs[0].Path, "read recovers after the transient failure")
+ })
+ }
+ }
+}
+
+func TestRollbackSafety_TimeSeriesPayloadSurvivesWithEitherRVState(t *testing.T) {
+ for _, rvNull := range []bool{false, true} {
+ t.Run(fmt.Sprintf("rvNull=%t", rvNull), func(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := cpTestKey("time-series")
+ obj := cpTestObject("time-series")
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, obj, testFallbackRV, rvNull, testFallbackUID, true)
+ seedDecodablePayloadsRow(t, conn, s, key, obj)
+ before, err := readFallbackCandidate(conn, key)
+ require.NoError(t, err)
+ pool.Put(conn)
+ require.True(t, storage.IsNotFound(s.Get(ctx, key, storage.GetOptions{}, &softwarecomposition.ContainerProfile{})))
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.False(t, metadataRowExistsForTest(t, conn, key))
+ var body []byte
+ require.NoError(t, sqlitex.Execute(conn, "SELECT body FROM payloads", &sqlitex.ExecOptions{
+ ResultFunc: func(stmt *sqlite.Stmt) error {
+ body = make([]byte, stmt.ColumnLen(0))
+ stmt.ColumnBytes(0, body)
+ return nil
+ },
+ }))
+ assert.Equal(t, before.body, body)
+ pool.Put(conn)
+ })
+ }
+}
+
+// An unreadable SQL object must never become an empty update candidate.
+func TestRollbackSafety_UpdateDoesNotReplaceUndecodablePayload(t *testing.T) {
+ for _, singleWriter := range []bool{false, true} {
+ t.Run(fmt.Sprintf("singleWriter=%t", singleWriter), func(t *testing.T) {
+ old := singleWriterEnabled
+ singleWriterEnabled = singleWriter
+ t.Cleanup(func() { singleWriterEnabled = old })
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := cpTestKey("undecodable-update")
+ obj := cpTestObject("undecodable-update")
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, obj, testFallbackRV, false, testFallbackUID, false)
+ seedRawPayloadsRow(t, conn, key, "future-encoding", []byte("recoverable bytes"))
+ before, err := readFallbackCandidate(conn, key)
+ require.NoError(t, err)
+ pool.Put(conn)
+ called := false
+ err = s.GuaranteedUpdate(ctx, key, &softwarecomposition.ContainerProfile{}, true, nil,
+ func(runtime.Object, storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ called = true
+ return cpTestObject("replacement"), nil, nil
+ }, nil)
+ require.Error(t, err)
+ assert.False(t, storage.IsNotFound(err))
+ assert.False(t, called, "inspection failure must prevent the update callback")
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ after, err := readFallbackCandidate(conn, key)
+ require.NoError(t, err)
+ assert.Equal(t, before, after)
+ pool.Put(conn)
+ })
+ }
+}
diff --git a/pkg/registry/file/rollback_safety_us001_test.go b/pkg/registry/file/rollback_safety_us001_test.go
new file mode 100644
index 000000000..1691e8e8e
--- /dev/null
+++ b/pkg/registry/file/rollback_safety_us001_test.go
@@ -0,0 +1,248 @@
+package file
+
+// Tests for US-001 of .omc/plans/rollback-safety-guard.md, Part 1: the
+// shared DeletePayloads helper wired into the delete paths that may safely
+// use it: deleteLocked's non-gated arm (crash-safety ordering: payloads
+// before metadata, and a failure surfaced to the caller rather than
+// swallowed) and get()'s missing-payload-file self-repair -- and NOT the
+// shared repairDelete, which the other three self-repair sites use and which
+// must never touch a payloads row.
+
+import (
+ "context"
+ "testing"
+
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1"
+ "github.com/kubescape/storage/pkg/generated/clientset/versioned/scheme"
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/storage"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitemigration"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+// insertPayloadsRowForTest seeds a payloads row for key directly, mirroring
+// what an ObjectStore-era write (or a pre-existing orphan) would have left
+// behind. The encoding/body content is irrelevant to these tests -- only the
+// row's presence/absence is asserted.
+func insertPayloadsRowForTest(t *testing.T, conn *sqlite.Conn, key string) {
+ t.Helper()
+ _, _, kind, _, namespace, name := K8sPathToKeys(key)
+ require.NoError(t, sqlitex.Execute(conn,
+ `INSERT INTO payloads (kind, namespace, name, encoding, body) VALUES (?, ?, ?, ?, ?)`,
+ &sqlitex.ExecOptions{Args: []any{kind, namespace, name, "json", []byte("{}")}}))
+}
+
+func payloadsRowExistsForTest(t *testing.T, conn *sqlite.Conn, key string) bool {
+ t.Helper()
+ _, _, kind, _, namespace, name := K8sPathToKeys(key)
+ exists := false
+ require.NoError(t, sqlitex.Execute(conn,
+ `SELECT 1 FROM payloads WHERE kind = ? AND namespace = ? AND name = ?`,
+ &sqlitex.ExecOptions{
+ Args: []any{kind, namespace, name},
+ ResultFunc: func(*sqlite.Stmt) error { exists = true; return nil },
+ }))
+ return exists
+}
+
+func newRollbackSafetyTestStorage(t *testing.T) (*StorageImpl, *sqlitemigration.Pool, afero.Fs) {
+ t.Helper()
+ fs := afero.NewMemMapFs()
+ pool := NewTestPool(t.TempDir())
+ require.NotNil(t, pool)
+ t.Cleanup(func() { _ = pool.Close() })
+ sch := scheme.Scheme
+ require.NoError(t, softwarecomposition.AddToScheme(sch))
+ s := NewStorageImpl(fs, DefaultStorageRoot, pool, nil, sch).(*StorageImpl)
+ return s, pool, fs
+}
+
+// TestDelete_RemovesPayloadsRowBeforeMetadataRow is Part 1's ordering
+// guarantee, exercised directly: a crash-simulated partial delete (the
+// payloads row removed, the metadata row not yet -- deleteLocked's fixed
+// ordering, storage.go's non-gated arm) must leave the key in today's
+// existing safe self-repair shape. A subsequent GET on that key must behave
+// exactly like a legacy row with no file and no payloads: it self-repairs
+// (NotFound), and is never resurrected or corrupted.
+func TestDelete_RemovesPayloadsRowBeforeMetadataRow(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/toto"
+ obj := &v1beta1.SBOMSyft{ObjectMeta: v1.ObjectMeta{Name: "toto"}}
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ // Seed a metadata row and a payloads row with no payload file on disk --
+ // the shape of an ObjectStore-era row under flag-off, the one live path
+ // deleteLocked's non-gated arm actually deletes.
+ require.NoError(t, writeMetadata(conn, key, obj))
+ insertPayloadsRowForTest(t, conn, key)
+
+ // Simulate the crash: only the first of deleteLocked's two SQLite
+ // deletes (payloads, in the fixed order) has committed; the process
+ // dies before the metadata delete runs.
+ require.NoError(t, DeletePayloads(conn, key))
+ pool.Put(conn)
+
+ // Surviving state: metadata row present, no payload file, no payloads
+ // row -- exactly today's existing, already-correct self-repair case.
+ out := &v1beta1.SBOMSyft{}
+ getErr := s.Get(ctx, key, storage.GetOptions{}, out)
+ assert.True(t, storage.IsNotFound(getErr), "expected NotFound after crash-simulated partial delete, got %v", getErr)
+
+ // Confirm self-repair actually ran and did not resurrect or corrupt the
+ // key: the orphaned metadata row must be gone too, and no payloads row
+ // left behind.
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ _, mErr := ReadMetadata(conn, key)
+ assert.ErrorIs(t, mErr, ErrMetadataNotFound, "metadata row must have been cleaned up by self-repair")
+ assert.False(t, payloadsRowExistsForTest(t, conn, key), "no payloads row must remain")
+ pool.Put(conn)
+
+ // A second GET must behave identically: still NotFound, not resurrected.
+ getErr2 := s.Get(ctx, key, storage.GetOptions{}, &v1beta1.SBOMSyft{})
+ assert.True(t, storage.IsNotFound(getErr2))
+}
+
+// TestRepairDelete_PrunesPayloadsOnlyAtTheMissingFileSite is table-driven
+// across the 4 sites in storage.go's get() that call repairDelete
+// (~1017/1092/1153/1315 in the plan's line numbering): the missing-payload-
+// file branch, the gob-EOF ("irrecoverable" decode error) branch, and the
+// two migration-tool-failure branches (migrateObject for the hasWriteLock
+// caller state, migrateObjectUnlocked for the noLock/hasReadLock states).
+//
+// Only the FIRST site prunes the payloads row, and there only for a row a
+// legacy write owns (rv IS NULL) whose payloads body therefore predates it --
+// avoiding a superseded payload that could conflict with ObjectStore insertion.
+//
+// The other three sites fire on an undecodable .g FILE, which says nothing
+// about the payloads row sitting next to it: that row may be a perfectly
+// valid native payload, and these three sites are explicitly out of scope for
+// the rollback-safety guard (docs/features/containerprofile-sqlite-backend.md).
+// They must behave exactly as they did before the guard -- delete the metadata
+// row, leave a recoverable orphan payload behind -- which is what the
+// payloads-survive assertions below pin.
+func TestRepairDelete_PrunesPayloadsOnlyAtTheMissingFileSite(t *testing.T) {
+ t.Run("missing payload file (get() afero.ErrFileNotFound branch)", func(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/site1"
+ obj := &v1beta1.SBOMSyft{ObjectMeta: v1.ObjectMeta{Name: "site1"}}
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ require.NoError(t, writeMetadata(conn, key, obj))
+ insertPayloadsRowForTest(t, conn, key)
+ pool.Put(conn)
+ // No payload file written: this is the missing-file branch.
+
+ getErr := s.Get(ctx, key, storage.GetOptions{}, &v1beta1.SBOMSyft{})
+ assert.True(t, storage.IsNotFound(getErr), "got %v", getErr)
+
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ // writeMetadata leaves rv NULL (the legacy-write shape), so this is
+ // the fallbackPrunable outcome: the one case that prunes payloads.
+ assert.False(t, payloadsRowExistsForTest(t, conn, key), "payloads row must be gone after the missing-file self-repair")
+ pool.Put(conn)
+ })
+
+ t.Run("gob EOF on decode (get() irrecoverable-error branch)", func(t *testing.T) {
+ s, pool, fs := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/site2"
+ obj := &v1beta1.SBOMSyft{ObjectMeta: v1.ObjectMeta{Name: "site2"}}
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ require.NoError(t, writeMetadata(conn, key, obj))
+ insertPayloadsRowForTest(t, conn, key)
+ pool.Put(conn)
+ // An empty payload file: gob.Decode returns io.EOF immediately,
+ // matching get()'s io.ErrUnexpectedEOF/io.EOF branch.
+ require.NoError(t, afero.WriteFile(fs, getStoredPayloadFilepath(DefaultStorageRoot, key), []byte{}, 0644))
+
+ getErr := s.Get(ctx, key, storage.GetOptions{}, &v1beta1.SBOMSyft{})
+ assert.True(t, storage.IsNotFound(getErr), "got %v", getErr)
+
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.True(t, payloadsRowExistsForTest(t, conn, key),
+ "an undecodable .g file says nothing about the payloads row: this site must leave it recoverable, exactly as before the rollback-safety guard")
+ pool.Put(conn)
+ })
+
+ t.Run("migration tool failure, hasWriteLock caller (migrateObject branch)", func(t *testing.T) {
+ installFakeMigrationTool(t)
+ t.Setenv("MIGRATION_FAKE_FAIL", "1")
+
+ // This call site (storage.go's GuaranteedUpdateWithConn -> s.get
+ // with hasWriteLock) is only reachable when singleWriterEnabled is
+ // off; guaranteedUpdateSingleWriter never calls get() with
+ // hasWriteLock.
+ old := singleWriterEnabled
+ singleWriterEnabled = false
+ t.Cleanup(func() { singleWriterEnabled = old })
+
+ s, pool, fs := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/site3"
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ // An ObjectStore-owned row (rv non-NULL) whose native payload sits
+ // next to a corrupt legacy .g file -- the shape that makes the
+ // payloads-survives assertion below non-vacuous.
+ seedMetadataRow(t, conn, key, &v1beta1.SBOMSyft{ObjectMeta: v1.ObjectMeta{Name: "site3"}}, testFallbackRV, false, testFallbackUID, false)
+ insertPayloadsRowForTest(t, conn, key)
+ pool.Put(conn)
+ require.NoError(t, afero.WriteFile(fs, getStoredPayloadFilepath(DefaultStorageRoot, key), gobPayloadNeedingMigration(t), 0644))
+
+ tryUpdate := func(input runtime.Object, res storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ t.Fatal("tryUpdate must not run: getCurrentState should fail with NotFound before reaching it")
+ return nil, nil, nil
+ }
+ updErr := s.GuaranteedUpdate(ctx, key, &v1beta1.SBOMSyft{}, false, nil, tryUpdate, nil)
+ assert.True(t, storage.IsNotFound(updErr), "got %v", updErr)
+
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.False(t, metadataRowExistsForTest(t, conn, key), "self-repair must still have deleted the metadata row, exactly as before")
+ assert.True(t, payloadsRowExistsForTest(t, conn, key),
+ "an undecodable .g file says nothing about the payloads row: this site must leave it recoverable, exactly as before the rollback-safety guard")
+ pool.Put(conn)
+ })
+
+ t.Run("migration tool failure, noLock caller (migrateObjectUnlocked branch)", func(t *testing.T) {
+ installFakeMigrationTool(t)
+ t.Setenv("MIGRATION_FAKE_FAIL", "1")
+
+ s, pool, fs := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/site4"
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, &v1beta1.SBOMSyft{ObjectMeta: v1.ObjectMeta{Name: "site4"}}, testFallbackRV, false, testFallbackUID, false)
+ insertPayloadsRowForTest(t, conn, key)
+ pool.Put(conn)
+ require.NoError(t, afero.WriteFile(fs, getStoredPayloadFilepath(DefaultStorageRoot, key), gobPayloadNeedingMigration(t), 0644))
+
+ getErr := s.Get(ctx, key, storage.GetOptions{}, &v1beta1.SBOMSyft{})
+ assert.True(t, storage.IsNotFound(getErr), "got %v", getErr)
+
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.False(t, metadataRowExistsForTest(t, conn, key), "self-repair must still have deleted the metadata row, exactly as before")
+ assert.True(t, payloadsRowExistsForTest(t, conn, key),
+ "an undecodable .g file says nothing about the payloads row: this site must leave it recoverable, exactly as before the rollback-safety guard")
+ pool.Put(conn)
+ })
+}
diff --git a/pkg/registry/file/rollback_safety_us002_test.go b/pkg/registry/file/rollback_safety_us002_test.go
new file mode 100644
index 000000000..ac20f9b11
--- /dev/null
+++ b/pkg/registry/file/rollback_safety_us002_test.go
@@ -0,0 +1,499 @@
+package file
+
+// Tests for US-002 of .omc/plans/rollback-safety-guard.md, Part 2: the
+// read-time fallback at get()'s missing-payload-file branch, and its exact
+// 4-condition predicate --
+//
+// (1) a metadata row exists, (2) rv IS NOT NULL, (3) is_time_series = 0,
+// (4) a payloads row exists
+//
+// Inspection failures must return errors without mutation. Time-series
+// payloads remain outside the guard and are never pruned by read repair.
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1"
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/storage"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitemigration"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+const (
+ testFallbackRV = int64(4242)
+ testFallbackUID = "11111111-2222-3333-4444-555555555555"
+)
+
+// seedMetadataRow inserts a metadata row for key carrying the exact rv / uid /
+// is_time_series column values the predicate reads. rvNull seeds the shape
+// every legacy write leaves behind (WriteJSON's INSERT OR REPLACE omits the
+// rv column, so the row's rv is NULL).
+func seedMetadataRow(t *testing.T, conn *sqlite.Conn, key string, obj runtime.Object, rv int64, rvNull bool, uid string, isTimeSeries bool) {
+ t.Helper()
+ _, _, kind, _, namespace, name := K8sPathToKeys(key)
+ metadataJSON, err := json.Marshal(obj)
+ require.NoError(t, err)
+ var rvArg any
+ if !rvNull {
+ rvArg = rv
+ }
+ require.NoError(t, sqlitex.Execute(conn,
+ `INSERT OR REPLACE INTO metadata (kind, namespace, name, metadata, rv, uid, is_time_series)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ &sqlitex.ExecOptions{Args: []any{kind, namespace, name, string(metadataJSON), rvArg, uid, isTimeSeries}}))
+}
+
+// seedDecodablePayloadsRow inserts a payloads row whose body is exactly what
+// an ObjectStore write would have stored for obj (encodePayloadBody, at the
+// json/v1beta1 encoding decodePayloadBody requires).
+func seedDecodablePayloadsRow(t *testing.T, conn *sqlite.Conn, s *StorageImpl, key string, obj runtime.Object) {
+ t.Helper()
+ body, err := encodePayloadBody(s.scheme, obj)
+ require.NoError(t, err)
+ seedRawPayloadsRow(t, conn, key, PayloadEncodingJSONV1Beta1, body)
+}
+
+func seedRawPayloadsRow(t *testing.T, conn *sqlite.Conn, key, encoding string, body []byte) {
+ t.Helper()
+ _, _, kind, _, namespace, name := K8sPathToKeys(key)
+ require.NoError(t, sqlitex.Execute(conn,
+ `INSERT OR REPLACE INTO payloads (kind, namespace, name, encoding, body) VALUES (?, ?, ?, ?, ?)`,
+ &sqlitex.ExecOptions{Args: []any{kind, namespace, name, encoding, body}}))
+}
+
+func metadataRowExistsForTest(t *testing.T, conn *sqlite.Conn, key string) bool {
+ t.Helper()
+ _, err := ReadMetadata(conn, key)
+ if err == nil {
+ return true
+ }
+ require.ErrorIs(t, err, ErrMetadataNotFound)
+ return false
+}
+
+// fallbackTestObject is the object the fallback-eligible payloads body holds:
+// content distinctive enough that serving it is unmistakable in an assertion.
+func fallbackTestObject(name string) *v1beta1.SBOMSyft {
+ return &v1beta1.SBOMSyft{
+ ObjectMeta: v1.ObjectMeta{
+ Name: name,
+ Namespace: "kubescape",
+ Annotations: map[string]string{"kubescape.io/status": "completed", "kubescape.io/completion": "full"},
+ },
+ Spec: v1beta1.SBOMSyftSpec{
+ Metadata: v1beta1.SPDXMeta{Tool: v1beta1.ToolMeta{Name: "payloads-body", Version: "v1"}},
+ },
+ }
+}
+
+// TestGet_FallsBackToPayloadsWithCorrectResourceVersion is Part 2's positive
+// case: all four conditions hold, so GET succeeds from the payloads body,
+// stamped with the metadata row's rv and uid, and with no side effects --
+// neither row is deleted.
+func TestGet_FallsBackToPayloadsWithCorrectResourceVersion(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/fallback-ok"
+ obj := fallbackTestObject("fallback-ok")
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, obj, testFallbackRV, false, testFallbackUID, false)
+ seedDecodablePayloadsRow(t, conn, s, key, obj)
+ pool.Put(conn)
+ // No .g file on disk: this is the missing-file branch.
+
+ out := &v1beta1.SBOMSyft{}
+ require.NoError(t, s.Get(ctx, key, storage.GetOptions{}, out))
+
+ assert.Equal(t, "4242", out.ResourceVersion, "ResourceVersion must be stamped from the metadata row's rv column")
+ assert.Equal(t, testFallbackUID, string(out.UID), "UID must be stamped from the metadata row's uid column")
+ assert.Equal(t, "fallback-ok", out.Name)
+ assert.Equal(t, "payloads-body", out.Spec.Metadata.Tool.Name, "the served content must be the payloads body")
+
+ // No side effects: no repairDelete, so both rows survive.
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.True(t, metadataRowExistsForTest(t, conn, key), "the metadata row must not be deleted by a successful fallback read")
+ assert.True(t, payloadsRowExistsForTest(t, conn, key), "the payloads row must not be deleted by a successful fallback read")
+ pool.Put(conn)
+
+ // Repeatable: a second GET serves the same object, still not NotFound.
+ out2 := &v1beta1.SBOMSyft{}
+ require.NoError(t, s.Get(ctx, key, storage.GetOptions{}, out2))
+ assert.Equal(t, "4242", out2.ResourceVersion)
+}
+
+// TestGet_DeletedKeyStaysNotFoundAfterDelete is the C1 regression: a key
+// deleted through the real delete path (which removes the payloads row too,
+// US-001) must stay NotFound. The fallback must never resurrect it.
+func TestGet_DeletedKeyStaysNotFoundAfterDelete(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/deleted"
+ obj := fallbackTestObject("deleted")
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, obj, testFallbackRV, false, testFallbackUID, false)
+ seedDecodablePayloadsRow(t, conn, s, key, obj)
+ pool.Put(conn)
+
+ // Before the delete the key IS fallback-eligible -- otherwise this test
+ // would pass vacuously.
+ require.NoError(t, s.Get(ctx, key, storage.GetOptions{}, &v1beta1.SBOMSyft{}))
+
+ require.NoError(t, s.Delete(ctx, key, &v1beta1.SBOMSyft{}, nil, nil, nil, storage.DeleteOptions{}))
+
+ getErr := s.Get(ctx, key, storage.GetOptions{}, &v1beta1.SBOMSyft{})
+ assert.True(t, storage.IsNotFound(getErr), "a deleted key must stay NotFound, not be resurrected from payloads; got %v", getErr)
+
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.False(t, metadataRowExistsForTest(t, conn, key))
+ assert.False(t, payloadsRowExistsForTest(t, conn, key))
+ pool.Put(conn)
+}
+
+// TestGet_PreExistingOrphanPayloadsRowNeverServed is the specific C1 gap the
+// metadata-row condition closes: a payloads row with NO metadata row (a
+// pre-existing orphan that predates this fix) must never be served.
+func TestGet_PreExistingOrphanPayloadsRowNeverServed(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/orphan"
+ obj := fallbackTestObject("orphan")
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedDecodablePayloadsRow(t, conn, s, key, obj)
+ pool.Put(conn)
+ // Deliberately NO metadata row: condition 1 fails.
+
+ out := &v1beta1.SBOMSyft{}
+ getErr := s.Get(ctx, key, storage.GetOptions{}, out)
+ assert.True(t, storage.IsNotFound(getErr), "an orphan payloads row must never be served; got %v", getErr)
+ assert.Empty(t, out.Spec.Metadata.Tool.Name, "nothing must have been decoded into the output object")
+
+ // The absent-key read must also not have issued a repair write: the row
+ // is inert, not reclaimed (the plan's named residual).
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.True(t, payloadsRowExistsForTest(t, conn, key), "the orphan payloads row is left inert, untouched")
+ pool.Put(conn)
+}
+
+// TestGet_LegacyTouchedRowWithLostRenameNeverServesStalePayload is the C2
+// regression, and the reason condition 2 exists: a legacy write's row commit
+// survived a crash that lost its .g file rename. The row has been legally
+// touched by a legacy write (which nulls rv), so the payloads body is STALE
+// ObjectStore-era content. Serving it would be a silent content rollback that
+// a downstream consolidation pass would then re-persist as authoritative.
+func TestGet_LegacyTouchedRowWithLostRenameNeverServesStalePayload(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/lost-rename"
+
+ // The row the legacy write committed: Completed/Full, rv NULL.
+ rowObj := fallbackTestObject("lost-rename")
+ // The stale ObjectStore-era body that survived in payloads.
+ staleObj := fallbackTestObject("lost-rename")
+ staleObj.Spec.Metadata.Tool.Name = "stale-objectstore-era-body"
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, rowObj, 0, true /* rv IS NULL */, testFallbackUID, false)
+ seedDecodablePayloadsRow(t, conn, s, key, staleObj)
+ pool.Put(conn)
+ // No .g file: the rename was lost by the crash.
+
+ out := &v1beta1.SBOMSyft{}
+ getErr := s.Get(ctx, key, storage.GetOptions{}, out)
+ assert.True(t, storage.IsNotFound(getErr), "an rv-NULL row must fall through to self-repair, not serve the stale payload; got %v", getErr)
+ assert.NotEqual(t, "stale-objectstore-era-body", out.Spec.Metadata.Tool.Name, "the stale payloads body must never be served")
+
+ // Today's existing self-repair ran, exactly as before this change.
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.False(t, metadataRowExistsForTest(t, conn, key), "self-repair must have removed the metadata row")
+ assert.False(t, payloadsRowExistsForTest(t, conn, key), "self-repair must have removed the payloads row (US-001)")
+ pool.Put(conn)
+}
+
+// TestGet_TimeSeriesRowNeverFallsBack is the condition-3 regression: a
+// time-series row satisfies every OTHER condition (rv non-NULL, a decodable
+// payloads row, no .g file), and must still take today's existing self-repair
+// path. Time-series rows are explicitly deferred out of the fallback.
+func TestGet_TimeSeriesRowNeverFallsBack(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/timeseries"
+ obj := fallbackTestObject("timeseries")
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, obj, testFallbackRV, false, testFallbackUID, true /* is_time_series = 1 */)
+ seedDecodablePayloadsRow(t, conn, s, key, obj)
+ pool.Put(conn)
+
+ out := &v1beta1.SBOMSyft{}
+ getErr := s.Get(ctx, key, storage.GetOptions{}, out)
+ assert.True(t, storage.IsNotFound(getErr), "a time-series row must not reach the fallback; got %v", getErr)
+ assert.Empty(t, out.Spec.Metadata.Tool.Name, "nothing must have been decoded into the output object")
+
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.False(t, metadataRowExistsForTest(t, conn, key), "self-repair must have run, as it does today")
+ assert.True(t, payloadsRowExistsForTest(t, conn, key),
+ "a time-series row's payloads body may be its only copy: self-repair must leave it exactly as before this change, not prune it")
+ pool.Put(conn)
+}
+
+// TestGet_UndecodablePayloadsNeverDeletesAnything: all four conditions hold,
+// so the payloads body IS the object's only surviving copy -- but this binary
+// cannot decode it (a corrupt body, or an encoding a newer binary wrote).
+// That is an INSPECTION FAILURE, not an ineligibility verdict: the read
+// returns an inspection error and deletes NOTHING, so a human -- or the binary that
+// understands the encoding -- can still recover the data. Deleting here would
+// be precisely the destroy-the-last-copy bug this whole mechanism exists to
+// prevent.
+func TestGet_UndecodablePayloadsNeverDeletesAnything(t *testing.T) {
+ t.Run("body is not valid JSON", func(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/undecodable-body"
+ obj := fallbackTestObject("undecodable-body")
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, obj, testFallbackRV, false, testFallbackUID, false)
+ seedRawPayloadsRow(t, conn, key, PayloadEncodingJSONV1Beta1, []byte("this is not json"))
+ pool.Put(conn)
+
+ getErr := s.Get(ctx, key, storage.GetOptions{}, &v1beta1.SBOMSyft{})
+ require.Error(t, getErr)
+ assert.False(t, storage.IsNotFound(getErr), "got %v", getErr)
+
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.True(t, metadataRowExistsForTest(t, conn, key), "an undecodable body must not cost the key its metadata row")
+ assert.True(t, payloadsRowExistsForTest(t, conn, key), "an undecodable body must never be deleted: it is the only copy left")
+ pool.Put(conn)
+
+ // Repeatable and still non-destructive: a second read behaves the
+ // same, so a hot-looping client cannot erode the data either.
+ getErr = s.Get(ctx, key, storage.GetOptions{}, &v1beta1.SBOMSyft{})
+ require.Error(t, getErr)
+ assert.False(t, storage.IsNotFound(getErr))
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.True(t, metadataRowExistsForTest(t, conn, key))
+ assert.True(t, payloadsRowExistsForTest(t, conn, key))
+ pool.Put(conn)
+ })
+
+ t.Run("unsupported encoding", func(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/bad-encoding"
+ obj := fallbackTestObject("bad-encoding")
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, obj, testFallbackRV, false, testFallbackUID, false)
+ body, err := encodePayloadBody(s.scheme, obj)
+ require.NoError(t, err)
+ seedRawPayloadsRow(t, conn, key, "gob/v0", body)
+ pool.Put(conn)
+
+ getErr := s.Get(ctx, key, storage.GetOptions{}, &v1beta1.SBOMSyft{})
+ require.Error(t, getErr)
+ assert.False(t, storage.IsNotFound(getErr), "got %v", getErr)
+
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.True(t, metadataRowExistsForTest(t, conn, key), "an encoding this binary does not know must not cost the key its metadata row")
+ assert.True(t, payloadsRowExistsForTest(t, conn, key), "an encoding this binary does not know must never be deleted -- a newer binary can still read it")
+ pool.Put(conn)
+ })
+
+ t.Run("IgnoreNotFound keeps its contract and still deletes nothing", func(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/undecodable-ignore"
+ obj := fallbackTestObject("undecodable-ignore")
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, obj, testFallbackRV, false, testFallbackUID, false)
+ seedRawPayloadsRow(t, conn, key, PayloadEncodingJSONV1Beta1, []byte("this is not json"))
+ pool.Put(conn)
+
+ out := &v1beta1.SBOMSyft{}
+ getErr := s.Get(ctx, key, storage.GetOptions{IgnoreNotFound: true}, out)
+ require.Error(t, getErr)
+ assert.False(t, storage.IsNotFound(getErr))
+
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.True(t, metadataRowExistsForTest(t, conn, key))
+ assert.True(t, payloadsRowExistsForTest(t, conn, key))
+ pool.Put(conn)
+ })
+}
+
+// TestGet_LegacyOnlyRowStillSelfRepairs is the pure regression guard: a
+// genuine legacy-only key (a metadata row, no payloads row, no .g file)
+// behaves exactly as it did before this change -- condition 4 fails.
+func TestGet_LegacyOnlyRowStillSelfRepairs(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/legacy-only"
+ obj := fallbackTestObject("legacy-only")
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ require.NoError(t, writeMetadata(conn, key, obj))
+ pool.Put(conn)
+
+ getErr := s.Get(ctx, key, storage.GetOptions{}, &v1beta1.SBOMSyft{})
+ assert.True(t, storage.IsNotFound(getErr), "got %v", getErr)
+
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.False(t, metadataRowExistsForTest(t, conn, key), "self-repair must have removed the orphaned metadata row, as today")
+ pool.Put(conn)
+
+ // IgnoreNotFound keeps its existing contract too.
+ out := &v1beta1.SBOMSyft{}
+ assert.NoError(t, s.Get(ctx, key, storage.GetOptions{IgnoreNotFound: true}, out))
+ assert.Empty(t, out.Name)
+}
+
+// TestGet_RowWithRVButNoPayloadsRowStillSelfRepairs covers condition 4 on its
+// own: conditions 1-3 all hold (a metadata row, rv non-NULL, not a
+// time-series row) and only the payloads row is missing. Without this case,
+// condition 4's false branch is never reached -- the legacy-only guard above
+// short-circuits at condition 2 first.
+func TestGet_RowWithRVButNoPayloadsRowStillSelfRepairs(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/rv-no-payloads"
+ obj := fallbackTestObject("rv-no-payloads")
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, obj, testFallbackRV, false, testFallbackUID, false)
+ pool.Put(conn)
+ // Deliberately NO payloads row: condition 4 fails.
+
+ getErr := s.Get(ctx, key, storage.GetOptions{}, &v1beta1.SBOMSyft{})
+ assert.True(t, storage.IsNotFound(getErr), "got %v", getErr)
+
+ conn, err = pool.Take(ctx)
+ require.NoError(t, err)
+ assert.False(t, metadataRowExistsForTest(t, conn, key), "self-repair must have run, as it does today")
+ pool.Put(conn)
+}
+
+// TestGet_UndecodableLegacyFileSitesStillDoNotServeFallback pins the scope of
+// Part 2: the fallback lives at the missing-file branch ONLY. The three
+// undecodable-.g-file self-repair sites (gob EOF, and the migration-tool
+// failure for both the noLock and hasWriteLock caller states) behave exactly
+// as they did before this change -- self-repair, never serve-from-payloads --
+// even with all four fallback conditions satisfied for the key.
+func TestGet_UndecodableLegacyFileSitesStillDoNotServeFallback(t *testing.T) {
+ // seedFallbackEligible seeds a key that WOULD satisfy all four
+ // conditions, so only the presence of the undecodable file separates
+ // these sites from the fallback.
+ seedFallbackEligible := func(t *testing.T, s *StorageImpl, pool *sqlitemigration.Pool, key, name string) {
+ t.Helper()
+ obj := fallbackTestObject(name)
+ obj.Spec.Metadata.Tool.Name = "payloads-body"
+ conn, err := pool.Take(context.Background())
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, obj, testFallbackRV, false, testFallbackUID, false)
+ seedDecodablePayloadsRow(t, conn, s, key, obj)
+ pool.Put(conn)
+ }
+
+ t.Run("gob EOF on decode", func(t *testing.T) {
+ s, pool, fs := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/badfile1"
+ seedFallbackEligible(t, s, pool, key, "badfile1")
+ // An empty payload file: gob.Decode returns io.EOF immediately.
+ require.NoError(t, afero.WriteFile(fs, getStoredPayloadFilepath(DefaultStorageRoot, key), []byte{}, 0644))
+
+ out := &v1beta1.SBOMSyft{}
+ getErr := s.Get(ctx, key, storage.GetOptions{}, out)
+ assert.True(t, storage.IsNotFound(getErr), "got %v", getErr)
+ assert.NotEqual(t, "payloads-body", out.Spec.Metadata.Tool.Name, "the payloads body must not be served at this site")
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ assert.False(t, metadataRowExistsForTest(t, conn, key), "self-repair must have run, exactly as before this change")
+ assert.True(t, payloadsRowExistsForTest(t, conn, key), "and the valid native payload next to the corrupt file must survive it, exactly as before this change")
+ pool.Put(conn)
+ })
+
+ t.Run("migration tool failure, noLock caller", func(t *testing.T) {
+ installFakeMigrationTool(t)
+ t.Setenv("MIGRATION_FAKE_FAIL", "1")
+
+ s, pool, fs := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/badfile2"
+ seedFallbackEligible(t, s, pool, key, "badfile2")
+ require.NoError(t, afero.WriteFile(fs, getStoredPayloadFilepath(DefaultStorageRoot, key), gobPayloadNeedingMigration(t), 0644))
+
+ out := &v1beta1.SBOMSyft{}
+ getErr := s.Get(ctx, key, storage.GetOptions{}, out)
+ assert.True(t, storage.IsNotFound(getErr), "got %v", getErr)
+ assert.NotEqual(t, "payloads-body", out.Spec.Metadata.Tool.Name, "the payloads body must not be served at this site")
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ assert.False(t, metadataRowExistsForTest(t, conn, key), "self-repair must have run, exactly as before this change")
+ assert.True(t, payloadsRowExistsForTest(t, conn, key), "and the valid native payload next to the corrupt file must survive it, exactly as before this change")
+ pool.Put(conn)
+ })
+
+ t.Run("migration tool failure, hasWriteLock caller", func(t *testing.T) {
+ installFakeMigrationTool(t)
+ t.Setenv("MIGRATION_FAKE_FAIL", "1")
+
+ // The hasWriteLock get() call site is only reachable with the single
+ // writer off (see the US-001 test's equivalent case).
+ old := singleWriterEnabled
+ singleWriterEnabled = false
+ t.Cleanup(func() { singleWriterEnabled = old })
+
+ s, pool, fs := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyfts/kubescape/badfile3"
+ seedFallbackEligible(t, s, pool, key, "badfile3")
+ require.NoError(t, afero.WriteFile(fs, getStoredPayloadFilepath(DefaultStorageRoot, key), gobPayloadNeedingMigration(t), 0644))
+
+ tryUpdate := func(_ runtime.Object, _ storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ t.Fatal("tryUpdate must not run: getCurrentState must fail with NotFound before reaching it")
+ return nil, nil, nil
+ }
+ updErr := s.GuaranteedUpdate(ctx, key, &v1beta1.SBOMSyft{}, false, nil, tryUpdate, nil)
+ assert.True(t, storage.IsNotFound(updErr), "got %v", updErr)
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ assert.False(t, metadataRowExistsForTest(t, conn, key), "self-repair must have run, exactly as before this change")
+ assert.True(t, payloadsRowExistsForTest(t, conn, key), "and the valid native payload next to the corrupt file must survive it, exactly as before this change")
+ pool.Put(conn)
+ })
+}
diff --git a/pkg/registry/file/rollback_safety_us003_test.go b/pkg/registry/file/rollback_safety_us003_test.go
new file mode 100644
index 000000000..df569a226
--- /dev/null
+++ b/pkg/registry/file/rollback_safety_us003_test.go
@@ -0,0 +1,177 @@
+package file
+
+// Tests for US-003 of .omc/plans/rollback-safety-guard.md, Part 4: the
+// rv-column/metadata-JSON invariant Part 2's read fallback depends on (M3),
+// and the positive consolidation-tick case for Part 4's INV-2 argument.
+//
+// Both tests exercise real production write/consolidation paths against a
+// fallback-eligible key (a metadata row with rv non-NULL, is_time_series=0,
+// a payloads row, and no .g file -- the exact shape US-002's fallback
+// serves), never storage.go's internals directly.
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/apiserver/pkg/storage"
+)
+
+// TestSaveContainerProfile_FirstWriteAfterFallbackSucceedsWithoutConflict is
+// the M3 regression named in the plan's Part 4: the fallback stamps
+// ResourceVersion from the metadata row's rv COLUMN
+// (serveFromPayloadsFallback, storage.go), but GuaranteedUpdate's CAS check
+// at commit time reads the base resourceVersion from the metadata JSON BLOB
+// (readCurrentResourceVersion, singlewriter.go). These two sources currently
+// always agree because every rv-writing site derives both the column and the
+// blob's resourceVersion field from one stamped object before committing --
+// but that was never proven end-to-end. If the two sources ever disagreed,
+// the symptom would be a PERMANENT write-conflict loop on that key, so this
+// test drives the real SaveContainerProfile -> guaranteedUpdateSingleWriter
+// -> commit path (not the internal helpers in isolation) and asserts the
+// first attempt succeeds, with no retry and no errWriteConflict.
+func TestSaveContainerProfile_FirstWriteAfterFallbackSucceedsWithoutConflict(t *testing.T) {
+ si, cleanup := newSingleWriterTestStorage(t)
+ t.Cleanup(cleanup)
+ cps := NewContainerProfileStorageImpl(si, si.pool)
+ ctx := context.Background()
+
+ key := testProfileKey("fallback-write")
+ const uid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
+
+ // The object an ObjectStore-era write would have stamped: rv is carried
+ // both in the metadata row's rv column AND in the metadata JSON blob's
+ // resourceVersion field, exactly as every real rv-writing site
+ // (sqliteobject_store.go, sqliteobject_migration.go) derives both from
+ // one object before committing.
+ existing := &softwarecomposition.ContainerProfile{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "fallback-write",
+ Namespace: "ns1",
+ UID: types.UID(uid),
+ Annotations: map[string]string{
+ helpersv1.StatusMetadataKey: helpersv1.Learning,
+ helpersv1.CompletionMetadataKey: helpersv1.Partial,
+ },
+ },
+ Spec: softwarecomposition.ContainerProfileSpec{
+ Execs: []softwarecomposition.ExecCalls{{Path: "/bin/original", Args: []string{"original"}}},
+ },
+ }
+ require.NoError(t, storage.APIObjectVersioner{}.UpdateObject(existing, 100))
+
+ conn, err := si.pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, existing, 100, false, uid, false)
+ seedDecodablePayloadsRow(t, conn, si, key, existing)
+ si.pool.Put(conn)
+ // Deliberately no .g file: this key is fallback-eligible (US-002's
+ // 4-condition predicate holds), not a normal legacy-write key.
+
+ // Confirm fallback-eligible before the write, so this test does not pass
+ // vacuously: a plain Get must serve the object from its payloads body,
+ // stamped with rv=100 from the metadata row's rv column.
+ before := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, si.Get(ctx, key, storage.GetOptions{}, before))
+ require.Equal(t, "100", before.ResourceVersion, "the key must be fallback-eligible before the write, or this test passes vacuously")
+
+ updated := existing.DeepCopy()
+ updated.Spec.Execs = append(updated.Spec.Execs, softwarecomposition.ExecCalls{Path: "/bin/new", Args: []string{"new"}})
+
+ // The real production call path: SaveContainerProfile ->
+ // guaranteedUpdateSingleWriter -> the single writer's prepare (reads the
+ // fallback-served object, rv=100) and commit (re-reads the current
+ // resourceVersion from the metadata JSON blob) phases.
+ err = cps.SaveContainerProfile(ctx, key, updated)
+ require.NoError(t, err, "a SaveContainerProfile on a fallback-eligible key must succeed on the first attempt")
+ assert.False(t, errors.Is(err, errWriteConflict), "must not be a write-conflict error")
+
+ // The write actually landed and the key left the fallback-eligible set
+ // (there is now a .g file backing it).
+ after := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, si.Get(ctx, key, storage.GetOptions{}, after))
+ assert.Equal(t, "101", after.ResourceVersion, "resourceVersion must have advanced by exactly one, not retried")
+ assert.True(t, specHasExec(after.Spec, "new"), "the write's content must have landed")
+ assert.Equal(t, uid, string(after.UID))
+
+ _, err = si.appFs.Stat(getStoredPayloadFilepath(si.root, key))
+ assert.NoError(t, err, "a successful write must leave a .g file behind, taking the key out of the fallback-eligible set")
+}
+
+// TestConsolidation_FallbackServedKeyMergesCorrectlyWithoutDivergence is the
+// positive case for Part 4's INV-2 argument: a payloads-only key (rv
+// non-NULL, is_time_series=0, a payloads row, no .g file -- fallback-eligible
+// per US-002's predicate) must, under a REAL consolidation tick, merge
+// correctly on top of its fallback-served content (not a synthesized-empty
+// profile, and not a new UID) and must not be observed as a divergence --
+// the specific failure mode (an earlier design iteration where the fallback
+// looked like a divergence to HealDivergence/the consolidation pass's
+// divergence-heal arms) this design closes by construction.
+func TestConsolidation_FallbackServedKeyMergesCorrectlyWithoutDivergence(t *testing.T) {
+ h := newLane0Harness(t, 0)
+ const ns, name = "ns1", "us003-fallback"
+ key := lane0Key(ns, name)
+ const uid = "11111111-2222-3333-4444-555555555555"
+
+ // The ObjectStore-era content already persisted for this key: rv=50 in
+ // both the metadata row's rv column and the metadata JSON blob (as every
+ // real rv-writing site stamps them), a payloads row carrying it, and no
+ // .g file -- the exact shape US-002's fallback serves.
+ original := &softwarecomposition.ContainerProfile{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: ns,
+ UID: types.UID(uid),
+ Annotations: map[string]string{
+ helpersv1.StatusMetadataKey: helpersv1.Learning,
+ helpersv1.CompletionMetadataKey: helpersv1.Partial,
+ helpersv1.InstanceIDMetadataKey: lane0InstanceID,
+ },
+ },
+ Spec: lane0Spec("original"),
+ }
+ require.NoError(t, storage.APIObjectVersioner{}.UpdateObject(original, 50))
+
+ conn, err := h.pool.Take(context.Background())
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, original, 50, false, uid, false)
+ seedDecodablePayloadsRow(t, conn, h.s, key, original)
+ h.pool.Put(conn)
+
+ // Confirm fallback-eligible before the tick, so the test does not pass
+ // vacuously: a plain Get must serve the original content.
+ before := h.readPayload(t, key)
+ require.Equal(t, uid, string(before.UID))
+ require.True(t, specHasExec(before.Spec, "original"))
+
+ // A time series with data, so ConsolidateTimeSeries's list queries pick
+ // up this key and actually run a tick against it.
+ h.seedTsRow(t, ns, name, "A", "1", lane0Ts(1), lane0ZeroTime, helpersv1.Learning, helpersv1.Partial, true)
+ h.writeTsObject(t, key, "1", "new", true)
+
+ c0 := snapshotCounters(t)
+
+ require.NoError(t, h.proc.ConsolidateTimeSeries(context.Background()))
+
+ // (a) The persisted profile still carries the original content and UID
+ // -- proof that loadOrInitializeProfile's GetContainerProfile served the
+ // fallback-read original object (not storage.IsNotFound, which would
+ // have synthesized a brand-new, empty-Spec profile with a fresh UID).
+ after := h.readPayload(t, key)
+ assert.Equal(t, uid, string(after.UID), "the fallback-served profile's UID must survive the tick, not a synthesized-empty one's")
+ assert.True(t, specHasExec(after.Spec, "original"), "the original fallback-served content must still be present")
+ assert.True(t, specHasExec(after.Spec, "new"), "the tick's new time-series data must have merged in")
+
+ // (b) No divergence metric fired: the fallback-served state must not
+ // look like a payload/metadata divergence to consolidateKeyTimeSeriesOnce's
+ // divergence check or trigger HealDivergence.
+ d := c0.delta(t)
+ assert.Zero(t, d.payloadAhead, "the fallback-served key must not be observed as payload-ahead")
+ assert.Zero(t, d.metadataAhead, "the fallback-served key must not be observed as metadata-ahead")
+}
diff --git a/pkg/registry/file/rollback_safety_us004_test.go b/pkg/registry/file/rollback_safety_us004_test.go
new file mode 100644
index 000000000..614290fc9
--- /dev/null
+++ b/pkg/registry/file/rollback_safety_us004_test.go
@@ -0,0 +1,206 @@
+package file
+
+// Tests for US-004 of .omc/plans/rollback-safety-guard.md, the advisory
+// startup check (D): CensusFallbackEligibleContainerProfiles must count
+// exactly the keys satisfying the same 4-condition predicate as US-002's
+// read fallback (a metadata row exists, rv IS NOT NULL, is_time_series = 0,
+// a payloads row exists), must not error or panic against an empty pool,
+// and must be bounded by its own timeout rather than the caller's context.
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// cpTestKey builds a ContainerProfile storage key directly, in the exact
+// K8s path shape BuildContainerProfileKey produces for HostTypeKubernetes.
+func cpTestKey(name string) string {
+ return K8sKeysToPath("", softwarecomposition.GroupName, ContainerProfileKind, "", "ns1", name)
+}
+
+func cpTestObject(name string) *softwarecomposition.ContainerProfile {
+ return &softwarecomposition.ContainerProfile{
+ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "ns1"},
+ }
+}
+
+// TestCensusFallbackEligibleContainerProfiles_Zero confirms an empty pool
+// (no metadata rows at all) reports a zero count and does not error or
+// panic.
+func TestCensusFallbackEligibleContainerProfiles_Zero(t *testing.T) {
+ _, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+
+ report, err := CensusFallbackEligibleContainerProfiles(ctx, pool, 5*time.Second)
+ require.NoError(t, err)
+ assert.Zero(t, report.Count)
+ assert.Empty(t, report.ExampleKeys)
+}
+
+// TestCensusFallbackEligibleContainerProfiles_One seeds exactly one
+// fallback-eligible ContainerProfile key (all four conditions hold) and
+// confirms it is counted and reported as an example.
+func TestCensusFallbackEligibleContainerProfiles_One(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+ key := cpTestKey("census-one")
+ obj := cpTestObject("census-one")
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ seedMetadataRow(t, conn, key, obj, 10, false, "uid-one", false)
+ seedDecodablePayloadsRow(t, conn, s, key, obj)
+ pool.Put(conn)
+
+ report, err := CensusFallbackEligibleContainerProfiles(ctx, pool, 5*time.Second)
+ require.NoError(t, err)
+ assert.Equal(t, 1, report.Count)
+ require.Len(t, report.ExampleKeys, 1)
+ assert.Equal(t, key, report.ExampleKeys[0])
+}
+
+// TestCensusFallbackEligibleContainerProfiles_Several seeds several
+// fallback-eligible keys, plus one of each kind of ineligible row (rv
+// NULL, is_time_series=1, no payloads row), and confirms only the eligible
+// ones are counted -- the same predicate US-002's read fallback applies at
+// read time.
+func TestCensusFallbackEligibleContainerProfiles_Several(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+
+ eligibleNames := []string{"census-a", "census-b", "census-c"}
+ for i, name := range eligibleNames {
+ key := cpTestKey(name)
+ obj := cpTestObject(name)
+ seedMetadataRow(t, conn, key, obj, int64(100+i), false, "uid-"+name, false)
+ seedDecodablePayloadsRow(t, conn, s, key, obj)
+ }
+
+ // rv IS NULL (a legacy-touched row): condition 2 fails.
+ rvNullKey := cpTestKey("census-rv-null")
+ rvNullObj := cpTestObject("census-rv-null")
+ seedMetadataRow(t, conn, rvNullKey, rvNullObj, 0, true, "uid-rv-null", false)
+ seedDecodablePayloadsRow(t, conn, s, rvNullKey, rvNullObj)
+
+ // is_time_series = 1: condition 3 fails.
+ tsKey := cpTestKey("census-ts")
+ tsObj := cpTestObject("census-ts")
+ seedMetadataRow(t, conn, tsKey, tsObj, 200, false, "uid-ts", true)
+ seedDecodablePayloadsRow(t, conn, s, tsKey, tsObj)
+
+ // No payloads row: condition 4 fails.
+ noPayloadKey := cpTestKey("census-no-payload")
+ noPayloadObj := cpTestObject("census-no-payload")
+ seedMetadataRow(t, conn, noPayloadKey, noPayloadObj, 300, false, "uid-no-payload", false)
+
+ pool.Put(conn)
+
+ report, err := CensusFallbackEligibleContainerProfiles(ctx, pool, 5*time.Second)
+ require.NoError(t, err)
+ assert.Equal(t, len(eligibleNames), report.Count, "only the three fully-eligible keys must be counted")
+ assert.Len(t, report.ExampleKeys, len(eligibleNames))
+ for _, name := range eligibleNames {
+ assert.Contains(t, report.ExampleKeys, cpTestKey(name))
+ }
+ assert.NotContains(t, report.ExampleKeys, rvNullKey)
+ assert.NotContains(t, report.ExampleKeys, tsKey)
+ assert.NotContains(t, report.ExampleKeys, noPayloadKey)
+}
+
+// TestCensusFallbackEligibleContainerProfiles_ExampleKeysCapped confirms
+// the example-key list is bounded (advisoryFallbackExampleCap) even when
+// the count exceeds it, so a large fallback-eligible population cannot
+// spam the log via the report.
+func TestCensusFallbackEligibleContainerProfiles_ExampleKeysCapped(t *testing.T) {
+ s, pool, _ := newRollbackSafetyTestStorage(t)
+ ctx := context.Background()
+
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ total := advisoryFallbackExampleCap + 5
+ for i := 0; i < total; i++ {
+ name := "census-cap-" + string(rune('a'+i))
+ key := cpTestKey(name)
+ obj := cpTestObject(name)
+ seedMetadataRow(t, conn, key, obj, int64(1000+i), false, "uid-cap", false)
+ seedDecodablePayloadsRow(t, conn, s, key, obj)
+ }
+ pool.Put(conn)
+
+ report, err := CensusFallbackEligibleContainerProfiles(ctx, pool, 5*time.Second)
+ require.NoError(t, err)
+ assert.Equal(t, total, report.Count)
+ assert.Len(t, report.ExampleKeys, advisoryFallbackExampleCap, "example keys must be capped even though the count is not")
+}
+
+// TestCensusFallbackEligibleContainerProfiles_RespectsOwnTimeout confirms
+// the census is bounded by the timeout argument passed to it, not by
+// whatever deadline (or lack of one) the caller's context carries: an
+// already-expired parent context still yields a context.DeadlineExceeded
+// error from the census itself (via its own context.WithTimeout child),
+// rather than hanging or silently succeeding.
+func TestCensusFallbackEligibleContainerProfiles_RespectsOwnTimeout(t *testing.T) {
+ _, pool, _ := newRollbackSafetyTestStorage(t)
+
+ // An untimed parent context (mirroring main.go's shutdown signal
+ // context, which has no deadline) is itself fine -- the function's own
+ // context.WithTimeout must impose the bound regardless.
+ parent := context.Background()
+
+ // A vanishingly small timeout forces pool.Take (or the query) to hit
+ // the census's own deadline rather than blocking indefinitely, proving
+ // the timeout argument is actually wired into a real context passed
+ // downstream.
+ _, err := CensusFallbackEligibleContainerProfiles(parent, pool, 1*time.Nanosecond)
+ if err != nil {
+ assert.True(t, errors.Is(err, context.DeadlineExceeded), "a timed-out census must report a deadline error, not an unrelated failure: %v", err)
+ }
+ // A near-zero timeout may occasionally still win the race against
+ // pool.Take/the query on a fast, otherwise-idle test pool; what this
+ // test guards against is a hang, which the surrounding test timeout
+ // would catch, and a non-deadline error when it does time out, checked
+ // above.
+}
+
+// TestCensusFallbackEligibleContainerProfiles_TimeoutContextIsBoundedNotParent
+// is a lighter, code-level confirmation that the census derives its
+// deadline from the timeout argument via its own context.WithTimeout, by
+// asserting a parent context that is ALREADY cancelled does not prevent a
+// normal-sized timeout from being honored independently: the function must
+// still return promptly (its own bounded context, not an indefinite wait
+// on a cancelled parent that never resolves the query).
+func TestCensusFallbackEligibleContainerProfiles_TimeoutContextIsBoundedNotParent(t *testing.T) {
+ _, pool, _ := newRollbackSafetyTestStorage(t)
+
+ parent, cancel := context.WithCancel(context.Background())
+ cancel() // already cancelled before the call
+
+ done := make(chan struct{})
+ var err error
+ go func() {
+ _, err = CensusFallbackEligibleContainerProfiles(parent, pool, 5*time.Second)
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ // context.WithTimeout(parent, timeout) on an already-cancelled
+ // parent yields an already-cancelled child, so the census must
+ // return promptly with a context error -- not hang.
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, context.Canceled), "expected a cancellation error propagated from the cancelled parent: %v", err)
+ case <-time.After(5 * time.Second):
+ t.Fatal("census did not return promptly against an already-cancelled parent context")
+ }
+}
diff --git a/pkg/registry/file/singlewriter.go b/pkg/registry/file/singlewriter.go
index 800e79ab0..722d4329b 100644
--- a/pkg/registry/file/singlewriter.go
+++ b/pkg/registry/file/singlewriter.go
@@ -465,6 +465,9 @@ func commitOpName(job *commitJob) string {
// callers like WithConnection that hold a connection and request an RLock.
func (w *singleWriter) commit(job *commitJob) commitResult {
s := w.s
+ if s.gate != nil {
+ return w.commitGated(job)
+ }
kind := resourceFromKey(job.key)
priority := job.priority.label()
@@ -562,6 +565,7 @@ func (w *singleWriter) commit(job *commitJob) commitResult {
renamePayload = s.appFs.Rename
}
+ observeStmt("Save:commit")
release := sqlitex.Save(conn)
err = func() error {
if werr := writeMeta(conn, job.key, metadata); werr != nil {
@@ -584,6 +588,88 @@ func (w *singleWriter) commit(job *commitJob) commitResult {
return commitResult{metadata: metadata}
}
+// commitGated is commit under the shared write gate (W1 of write-gate-sharing
+// §3.2): the shard takes no pool connection at all — Lock(key), then one gate
+// ticket on the submitter's ctx, then on the gate's connection the CAS read,
+// the INSERT OR REPLACE and the payload rename, inside one BEGIN IMMEDIATE …
+// COMMIT. The CAS is thereby atomic against every writer, not only the
+// Lock(key)-respecting ones (it closes the cleanup-vs-shard race of §1.2).
+// The gate's own accounting counts the commit outcome by kind and priority.
+//
+// Two shapes, deliberately: with no gate the CAS read stays an autocommit
+// SELECT before the savepoint (moving it inside a deferred SAVEPOINT would
+// turn the write into a read-to-write lock upgrade, SQLITE_BUSY_SNAPSHOT
+// territory, and change the statement golden); under BEGIN IMMEDIATE the
+// lock is already held, so the read joins the transaction.
+func (w *singleWriter) commitGated(job *commitJob) commitResult {
+ s := w.s
+ kind := resourceFromKey(job.key)
+ priority := job.priority.label()
+
+ lockCtx, lockCancel := context.WithTimeout(job.ctx, lockTimeout)
+ beforeLock := time.Now()
+ lockErr := s.locks.Lock(lockCtx, job.key)
+ lockCancel()
+ lockDuration := time.Since(beforeLock)
+ if lockErr != nil {
+ metrics.ObserveLockWait(kind, metrics.OutcomeTimeout, lockDuration)
+ _ = s.appFs.Remove(job.tmpPayloadPath)
+ metrics.IncSingleWriterCommit(kind, priority, metrics.CommitOutcomeError)
+ return commitResult{err: newContentionTimeoutError(commitOpName(job), job.key, lockErr)}
+ }
+ metrics.ObserveLockWait(kind, metrics.OutcomeAcquired, lockDuration)
+ defer s.locks.Unlock(job.key)
+
+ if job.custom != nil {
+ // Lane 0's runOnShard leaf (CP-only, unreachable under the flag): the
+ // leaf gates its own statements, so it runs with no connection.
+ if err := callGuarded("custom", nil, job.custom); err != nil {
+ metrics.IncSingleWriterCommit(kind, priority, metrics.CommitOutcomeError)
+ return commitResult{err: err}
+ }
+ metrics.IncSingleWriterCommit(kind, priority, metrics.CommitOutcomeCommitted)
+ return commitResult{}
+ }
+
+ metadata := extractFields(job.newObj, []string{"ObjectMeta", "SchemaVersion"})
+ writeMeta := s.writeMetadataFn
+ if writeMeta == nil {
+ writeMeta = writeMetadata
+ }
+ renamePayload := s.renamePayloadFn
+ if renamePayload == nil {
+ renamePayload = s.appFs.Rename
+ }
+
+ err := s.write(job.ctx, nil, job.priority, holdPathLegacyCommit, kind, true, func(_ context.Context, conn *sqlite.Conn) error {
+ currentRV, exists, err := readCurrentResourceVersion(conn, job.key, job.newObjFactory, s.versioner)
+ if err != nil {
+ return fmt.Errorf("read current resourceVersion: %w", err)
+ }
+ if job.create {
+ if exists {
+ return storage.NewKeyExistsError(job.key, 0)
+ }
+ } else if job.baseRV != 0 && (!exists || currentRV != job.baseRV) {
+ return errWriteConflict
+ }
+ if werr := writeMeta(conn, job.key, metadata); werr != nil {
+ return fmt.Errorf("write metadata: %w", werr)
+ }
+ renameStart := time.Now()
+ if rerr := renamePayload(job.tmpPayloadPath, job.finalPayloadPath); rerr != nil {
+ return fmt.Errorf("rename payload into place: %w", rerr)
+ }
+ metrics.ObserveSqliteWriteHoldStep(holdPathLegacyCommit, "rename", time.Since(renameStart))
+ return nil
+ })
+ if err != nil {
+ _ = s.appFs.Remove(job.tmpPayloadPath)
+ return commitResult{err: err}
+ }
+ return commitResult{metadata: metadata}
+}
+
// putChecked returns the shard's pool connection, on every exit from commit()
// including a panic unwind, and owns the whole decision of whether it can be
// reused: it must run inside commit()'s frame, where conn is in scope, because
@@ -837,6 +923,9 @@ func (s *StorageImpl) prepareSingleWriterPayload(key string, obj runtime.Object,
// serialized against every other write on the SAME key via that shard's
// priority queue.
func (s *StorageImpl) createSingleWriter(ctx context.Context, key string, obj, metaOut runtime.Object, priority writePriority) error {
+ if err := s.refuseForeign("create", key); err != nil {
+ return err
+ }
// Cheap existence pre-check (mirrors CreateWithConn's early Stat check).
// This is an optimization only -- the authoritative check happens at
// commit time against SQLite, inside the single writer.
@@ -913,10 +1002,13 @@ func (s *StorageImpl) createSingleWriter(ctx context.Context, key string, obj, m
// was already released before commit.
poolCtx2, poolCancel2 := poolContext()
defer poolCancel2()
+ beforePool2 := time.Now()
conn2, err := s.pool.Take(poolCtx2)
if err != nil {
+ metrics.ObservePoolWait(resourceFromKey(key), metrics.OutcomeTimeout, time.Since(beforePool2))
return newContentionTimeoutError("create", key, err)
}
+ metrics.ObservePoolWait(resourceFromKey(key), metrics.OutcomeAcquired, time.Since(beforePool2))
defer s.pool.Put(conn2)
afterCtx := context.WithValue(ctx, connKey, conn2)
if err := s.processor.AfterCreate(afterCtx, candidate); err != nil {
@@ -951,6 +1043,9 @@ func (s *StorageImpl) guaranteedUpdateSingleWriter(
preconditions *storage.Preconditions, tryUpdate storage.UpdateFunc, cachedExistingObject runtime.Object,
checksum string, priority writePriority) error {
+ if err := s.refuseForeign("update", key); err != nil {
+ return err
+ }
v, err := conversion.EnforcePtr(metaOut)
if err != nil {
logger.L().Ctx(ctx).Error("GuaranteedUpdate - unable to convert output object to pointer", helpers.Error(err), helpers.String("key", key))
diff --git a/pkg/registry/file/sqlite.go b/pkg/registry/file/sqlite.go
index 4811b18cc..5d453b1cd 100644
--- a/pkg/registry/file/sqlite.go
+++ b/pkg/registry/file/sqlite.go
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
+ "sync/atomic"
"time"
"github.com/armosec/armoapi-go/armotypes"
@@ -34,6 +35,117 @@ const DefaultPoolSize = 10
// unset.
const DefaultBusyTimeout = 60 * time.Second
+// SchemaMigrations returns the ordered SQLite migrations sqlitemigration
+// applies to the metadata database. Migrations 3 and 4 are additive: the
+// nullable rv/uid columns and the payloads table are written only by the
+// ContainerProfile SQLite-native backend (ObjectStore); the legacy
+// StorageImpl keeps writing (kind, namespace, name, metadata) and leaves them
+// NULL / empty for every other kind. Migration 5 is the data migration's
+// done-flag table (MigrateContainerProfiles). Migration 6 (is_time_series) is
+// also additive with a constant DEFAULT, so SQLite backfills every existing
+// row's logical value to 0 without a table rewrite: a base object row reads
+// correctly as 0 immediately, and a TS row already in the database from
+// before the upgrade reads as 0 too (wrongly "not a TS row") until it is next
+// consolidated away -- the same bounded, self-healing staleness the rv/uid
+// columns above already accept for pre-upgrade data, not a new kind of risk.
+func SchemaMigrations() []string {
+ return []string{
+ `CREATE TABLE IF NOT EXISTS metadata (
+ kind TEXT,
+ namespace TEXT,
+ name TEXT,
+ metadata JSON,
+ PRIMARY KEY (kind, namespace, name)
+ );`,
+ `CREATE TABLE IF NOT EXISTS time_series (
+ kind TEXT,
+ namespace TEXT,
+ name TEXT,
+ seriesID TEXT,
+ reportTimestamp TEXT,
+ status TEXT,
+ tsSuffix TEXT,
+ completion TEXT,
+ previousReportTimestamp TEXT,
+ hasData INTEGER DEFAULT 0,
+ PRIMARY KEY (kind, namespace, name, seriesID, tsSuffix)
+ );`,
+ `ALTER TABLE metadata ADD COLUMN rv INTEGER;`,
+ `ALTER TABLE metadata ADD COLUMN uid TEXT;`,
+ `CREATE TABLE IF NOT EXISTS payloads (
+ kind TEXT NOT NULL,
+ namespace TEXT NOT NULL,
+ name TEXT NOT NULL,
+ encoding TEXT NOT NULL,
+ body BLOB NOT NULL,
+ PRIMARY KEY (kind, namespace, name)
+ );`,
+ `CREATE TABLE IF NOT EXISTS migration_state (
+ name TEXT PRIMARY KEY,
+ state TEXT NOT NULL,
+ counts TEXT,
+ updated_at TEXT
+ );`,
+ `ALTER TABLE metadata ADD COLUMN is_time_series INTEGER NOT NULL DEFAULT 0;`,
+ }
+}
+
+// PoolOptions configures NewPoolWithOptions.
+type PoolOptions struct {
+ // Size is the pool capacity; non-positive falls back to DefaultPoolSize.
+ Size int
+ // BusyTimeout is the per-connection busy-timeout; non-positive falls back
+ // to DefaultBusyTimeout.
+ BusyTimeout time.Duration
+ // DisableAutoCheckpoint sets PRAGMA wal_autocheckpoint=0 on EVERY pool
+ // connection. Autocheckpoint is a per-connection sqlite3_wal_hook, so
+ // setting it on one connection leaves the others checkpointing inside
+ // their own COMMIT; the ObjectStore's background PASSIVE checkpointer
+ // takes over that job. Off by default so flag-off is byte-identical.
+ DisableAutoCheckpoint bool
+ // PrepareConn, when set, runs on every connection after the standard
+ // preparation.
+ PrepareConn func(conn *sqlite.Conn) error
+ // Authorizer, when set, returns a per-connection authorizer consulted
+ // after the package's own write-statement authorizer (tests install
+ // statement recorders through it). SetAuthorizer replaces rather than
+ // chains, so this is the one way to add a second authorizer.
+ Authorizer func(conn *sqlite.Conn) sqlite.Authorizer
+}
+
+// poolRef hands the pool pointer to authorizers created before NewPool
+// returns (the migration connection is prepared inside NewPool).
+type poolRef struct {
+ pool atomic.Pointer[sqlitemigration.Pool]
+}
+
+// writeAuthorizer is installed on every pool connection. It reports each
+// INSERT/UPDATE/DELETE prepared on the connection to noteWriteStatement —
+// the instrument behind the write-gate invariant (AC-G1 of
+// .omc/plans/write-gate-sharing.md): with a write gate on the pool, a write
+// statement on any connection the gate does not own is an ungated writer
+// that busy-waits against the gate for the whole busy timeout. Prepare-time
+// is sufficient: a statement re-executed through the connection's statement
+// cache was first prepared, and recorded, on that same connection.
+type writeAuthorizer struct {
+ ref *poolRef
+ conn *sqlite.Conn
+ next sqlite.Authorizer
+}
+
+func (a *writeAuthorizer) Authorize(action sqlite.Action) sqlite.AuthResult {
+ switch action.Type() {
+ case sqlite.OpInsert, sqlite.OpUpdate, sqlite.OpDelete:
+ if pool := a.ref.pool.Load(); pool != nil {
+ noteWriteStatement(pool, a.conn, action.Type(), action.Table())
+ }
+ }
+ if a.next != nil {
+ return a.next.Authorize(action)
+ }
+ return sqlite.AuthResultOK
+}
+
// NewPool creates a new SQLite connection pool at the given path.
// It returns an error if the connection cannot be opened or the database cannot be initialized.
// It is your responsibility to call conn.Close() when you no longer need conn.
@@ -43,37 +155,22 @@ const DefaultBusyTimeout = 60 * time.Second
// DefaultBusyTimeout respectively. Both are operator-tunable via
// config.Config (SqlitePoolSize / SqliteBusyTimeout) — see pkg/config.
func NewPool(path string, size int, busyTimeout time.Duration) *sqlitemigration.Pool {
+ return NewPoolWithOptions(path, PoolOptions{Size: size, BusyTimeout: busyTimeout})
+}
+
+// NewPoolWithOptions is NewPool with the full option set.
+func NewPoolWithOptions(path string, opts PoolOptions) *sqlitemigration.Pool {
+ size := opts.Size
if size < 1 {
size = DefaultPoolSize
}
+ busyTimeout := opts.BusyTimeout
if busyTimeout <= 0 {
busyTimeout = DefaultBusyTimeout
}
- return sqlitemigration.NewPool(path,
- sqlitemigration.Schema{
- Migrations: []string{
- `CREATE TABLE IF NOT EXISTS metadata (
- kind TEXT,
- namespace TEXT,
- name TEXT,
- metadata JSON,
- PRIMARY KEY (kind, namespace, name)
- );`,
- `CREATE TABLE IF NOT EXISTS time_series (
- kind TEXT,
- namespace TEXT,
- name TEXT,
- seriesID TEXT,
- reportTimestamp TEXT,
- status TEXT,
- tsSuffix TEXT,
- completion TEXT,
- previousReportTimestamp TEXT,
- hasData INTEGER DEFAULT 0,
- PRIMARY KEY (kind, namespace, name, seriesID, tsSuffix)
- );`,
- },
- },
+ ref := &poolRef{}
+ pool := sqlitemigration.NewPool(path,
+ sqlitemigration.Schema{Migrations: SchemaMigrations()},
sqlitemigration.Options{
PoolSize: size,
// Under write bursts (per-container profile churn plus the
@@ -82,9 +179,26 @@ func NewPool(path string, size int, busyTimeout time.Duration) *sqlitemigration.
// "database is locked" to API clients. Wait instead of failing.
PrepareConn: func(conn *sqlite.Conn) error {
conn.SetBusyTimeout(busyTimeout)
+ if opts.DisableAutoCheckpoint {
+ if err := sqlitex.ExecuteTransient(conn, `PRAGMA wal_autocheckpoint=0`, nil); err != nil {
+ return fmt.Errorf("disable wal_autocheckpoint: %w", err)
+ }
+ }
+ var next sqlite.Authorizer
+ if opts.Authorizer != nil {
+ next = opts.Authorizer(conn)
+ }
+ if err := conn.SetAuthorizer(&writeAuthorizer{ref: ref, conn: conn, next: next}); err != nil {
+ return fmt.Errorf("install write authorizer: %w", err)
+ }
+ if opts.PrepareConn != nil {
+ return opts.PrepareConn(conn)
+ }
return nil
},
})
+ ref.pool.Store(pool)
+ return pool
}
// NewTestPool creates a new temporary SQLite connection (for testing only).
@@ -206,6 +320,7 @@ func ParseContainerProfileKey(key string, hostType armotypes.HostType) (id armot
func countMetadata(conn *sqlite.Conn, path string) (int64, error) {
_, _, kind, _, namespace, _ := K8sPathToKeys(path)
var count int64
+ observeStmt("countMetadata")
err := sqlitex.Execute(conn,
`SELECT COUNT(*) FROM metadata
WHERE kind = :kind
@@ -226,6 +341,7 @@ func countMetadata(conn *sqlite.Conn, path string) (int64, error) {
// DeleteMetadata deletes metadata for the given path and unmarshals the deleted metadata into the provided runtime.Object.
func DeleteMetadata(conn *sqlite.Conn, path string, metadata runtime.Object) error {
_, _, kind, _, namespace, name := K8sPathToKeys(path)
+ observeStmt("DeleteMetadata")
err := sqlitex.Execute(conn,
`DELETE FROM metadata
WHERE kind = :kind
@@ -248,6 +364,113 @@ func DeleteMetadata(conn *sqlite.Conn, path string, metadata runtime.Object) err
return nil
}
+// DeletePayloads deletes the payloads row for the given path, if any.
+func DeletePayloads(conn *sqlite.Conn, path string) error {
+ _, _, kind, _, namespace, name := K8sPathToKeys(path)
+ observeStmt("DeletePayloads")
+ err := sqlitex.Execute(conn,
+ `DELETE FROM payloads
+ WHERE kind = :kind
+ AND namespace = :namespace
+ AND name = :name`,
+ &sqlitex.ExecOptions{
+ Named: map[string]any{":kind": kind, ":namespace": namespace, ":name": name},
+ })
+ if err != nil {
+ return fmt.Errorf("delete payloads: %w", err)
+ }
+ return nil
+}
+
+// fallbackCandidate is one key's metadata row joined with its payloads row,
+// carrying exactly what the rollback read fallback's predicate needs
+// (.omc/plans/rollback-safety-guard.md, Part 2): the rv/uid stamping source,
+// the is_time_series scoping column and the payloads body, read together in
+// a single statement.
+type fallbackCandidate struct {
+ // rvNull is the row's `rv IS NULL` — predicate condition 2 fails when true.
+ rvNull bool
+ // rv is the metadata row's rv column; meaningful only when !rvNull.
+ rv int64
+ // uid is the metadata row's uid column; "" when the column is NULL.
+ uid string
+ // isTimeSeries is the row's is_time_series column — predicate condition 3
+ // fails when true.
+ isTimeSeries bool
+ // payloadsFound reports whether a payloads row exists for the key —
+ // predicate condition 4 fails when false.
+ payloadsFound bool
+ // encoding/body are the payloads row's columns; meaningful only when
+ // payloadsFound.
+ encoding string
+ body []byte
+}
+
+// readFallbackCandidate reads the metadata row for path together with its
+// payloads row, in the same metadata-JOIN-payloads shape readExportRows uses
+// (sqliteobject_export.go), but as a LEFT JOIN so that a metadata row with no
+// payloads row is still reported (condition 4 must be observed, not inferred
+// from an empty result set). nil, nil when no metadata row exists at all.
+func readFallbackCandidate(conn *sqlite.Conn, path string) (*fallbackCandidate, error) {
+ _, _, kind, _, namespace, name := K8sPathToKeys(path)
+ var out *fallbackCandidate
+ observeStmt("readFallbackCandidate")
+ err := sqlitex.Execute(conn,
+ `SELECT m.rv IS NULL, m.rv, m.uid, m.is_time_series, p.rowid IS NOT NULL, p.encoding, p.body
+ FROM metadata m LEFT JOIN payloads p
+ ON p.kind = m.kind AND p.namespace = m.namespace AND p.name = m.name
+ WHERE m.kind = :kind
+ AND m.namespace = :namespace
+ AND m.name = :name`,
+ &sqlitex.ExecOptions{
+ Named: map[string]any{":kind": kind, ":namespace": namespace, ":name": name},
+ ResultFunc: func(stmt *sqlite.Stmt) error {
+ c := fallbackCandidate{
+ rvNull: stmt.ColumnInt64(0) == 1,
+ rv: stmt.ColumnInt64(1),
+ uid: stmt.ColumnText(2),
+ isTimeSeries: stmt.ColumnInt64(3) != 0,
+ payloadsFound: stmt.ColumnInt64(4) == 1,
+ encoding: stmt.ColumnText(5),
+ }
+ c.body = make([]byte, stmt.ColumnLen(6))
+ stmt.ColumnBytes(6, c.body)
+ out = &c
+ return nil
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("read fallback candidate: %w", err)
+ }
+ return out, nil
+}
+
+// deleteMetadataRaw is DeleteMetadata returning the deleted row's JSON
+// instead of decoding it: under the write gate the decode belongs after the
+// hold (INV-1′), not inside the RETURNING callback. nil when no row matched.
+func deleteMetadataRaw(conn *sqlite.Conn, path string) ([]byte, error) {
+ _, _, kind, _, namespace, name := K8sPathToKeys(path)
+ var raw []byte
+ observeStmt("DeleteMetadata")
+ err := sqlitex.Execute(conn,
+ `DELETE FROM metadata
+ WHERE kind = :kind
+ AND namespace = :namespace
+ AND name = :name
+ RETURNING metadata`,
+ &sqlitex.ExecOptions{
+ Named: map[string]any{":kind": kind, ":namespace": namespace, ":name": name},
+ ResultFunc: func(stmt *sqlite.Stmt) error {
+ raw = []byte(stmt.ColumnText(0))
+ return nil
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("delete metadata: %w", err)
+ }
+ return raw, nil
+}
+
func listMetadataKeys(conn *sqlite.Conn, path, cont string, limit int64) ([]string, string, error) {
prefix, root, kind, _, namespace, _ := K8sPathToKeys(path)
if cont == "" {
@@ -255,11 +478,13 @@ func listMetadataKeys(conn *sqlite.Conn, path, cont string, limit int64) ([]stri
}
var last string
var names []string
+ observeStmt("listMetadataKeys")
err := sqlitex.Execute(conn,
`SELECT rowid, namespace, name FROM metadata
WHERE kind = :kind
AND (:namespace = '' OR namespace = :namespace)
AND rowid > :cont
+ AND is_time_series = 0
ORDER BY rowid
LIMIT :limit`,
&sqlitex.ExecOptions{
@@ -285,11 +510,13 @@ func listMetadata(conn *sqlite.Conn, path, cont string, limit int64) ([]string,
}
var last string
var metadataJSONs []string
+ observeStmt("listMetadata")
err := sqlitex.Execute(conn,
`SELECT rowid, metadata FROM metadata
WHERE kind = :kind
AND (:namespace = '' OR namespace = :namespace)
AND rowid > :cont
+ AND is_time_series = 0
ORDER BY rowid
LIMIT :limit`,
&sqlitex.ExecOptions{
@@ -309,6 +536,7 @@ func listMetadata(conn *sqlite.Conn, path, cont string, limit int64) ([]string,
func listNamespaces(conn *sqlite.Conn) ([]string, error) {
var namespaces []string
+ observeStmt("listNamespaces")
err := sqlitex.Execute(conn,
`SELECT DISTINCT namespace FROM metadata
WHERE namespace != ''`,
@@ -329,6 +557,7 @@ func listNamespaces(conn *sqlite.Conn) ([]string, error) {
func DeleteTimeSeriesContainerEntries(conn *sqlite.Conn, path string) error {
_, _, kind, _, namespace, name := K8sPathToKeys(path)
kind = NormalizeContainerProfileKind(kind)
+ observeStmt("DeleteTimeSeriesContainerEntries")
err := sqlitex.Execute(conn,
`DELETE FROM time_series
WHERE kind = ?
@@ -347,6 +576,7 @@ func DeleteTimeSeriesContainerEntries(conn *sqlite.Conn, path string) error {
func ListTimeSeriesContainers(conn *sqlite.Conn, path string) (map[string][]softwarecomposition.TimeSeriesContainers, error) {
containers := make(map[string][]softwarecomposition.TimeSeriesContainers)
_, _, kind, _, namespace, name := K8sPathToKeys(path)
+ observeStmt("ListTimeSeriesContainers")
err := sqlitex.Execute(conn,
`SELECT seriesID, tsSuffix, reportTimestamp, status, completion, previousReportTimestamp, hasData
FROM time_series
@@ -393,6 +623,7 @@ func ListTimeSeriesExpired(conn *sqlite.Conn, d time.Duration) ([]string, error)
return keys, nil
}
threshold := time.Now().Add(-d).String()
+ observeStmt("ListTimeSeriesExpired")
err := sqlitex.Execute(conn,
`SELECT kind, namespace, name
FROM time_series
@@ -416,6 +647,7 @@ func ListTimeSeriesExpired(conn *sqlite.Conn, d time.Duration) ([]string, error)
// ListTimeSeriesWithData retrieves all time series keys that have data.
func ListTimeSeriesWithData(conn *sqlite.Conn) ([]string, error) {
var keys []string
+ observeStmt("ListTimeSeriesWithData")
err := sqlitex.Execute(conn,
`SELECT kind, namespace, name
FROM time_series
@@ -439,6 +671,7 @@ func ListTimeSeriesWithData(conn *sqlite.Conn) ([]string, error) {
func ReadMetadata(conn *sqlite.Conn, path string) ([]byte, error) {
_, _, kind, _, namespace, name := K8sPathToKeys(path)
var metadataJSON string
+ observeStmt("ReadMetadata")
err := sqlitex.Execute(conn,
`SELECT metadata FROM metadata
WHERE kind = :kind
@@ -471,6 +704,7 @@ func writeMetadata(conn *sqlite.Conn, path string, metadata runtime.Object) erro
// WriteJSON writes the given JSON metadata to the database for the specified path.
func WriteJSON(conn *sqlite.Conn, path string, metadataJSON []byte) error {
_, _, kind, _, namespace, name := K8sPathToKeys(path)
+ observeStmt("WriteJSON")
err := sqlitex.Execute(conn,
`INSERT OR REPLACE INTO metadata
(kind, namespace, name, metadata) VALUES (?, ?, ?, ?)`,
@@ -485,6 +719,7 @@ func WriteJSON(conn *sqlite.Conn, path string, metadataJSON []byte) error {
// WriteTimeSeriesEntry writes a time series entry to the database.
func WriteTimeSeriesEntry(conn *sqlite.Conn, kind, namespace, name, seriesID, tsSuffix, reportTimestamp, status, completion, previousReportTimestamp string, hasData bool) error {
+ observeStmt("WriteTimeSeriesEntry")
err := sqlitex.Execute(conn,
`INSERT OR REPLACE INTO time_series
(kind, namespace, name, seriesID, tsSuffix, reportTimestamp, status, completion, previousReportTimestamp, hasData)
@@ -519,6 +754,7 @@ func ReplaceTimeSeriesContainerEntries(conn *sqlite.Conn, path, seriesID string,
if err != nil {
return fmt.Errorf("failed to marshal tsSuffixes: %w", err)
}
+ observeStmt("ReplaceTimeSeriesContainerEntries")
err = sqlitex.Execute(conn,
`DELETE FROM time_series
WHERE kind = ?
diff --git a/pkg/registry/file/sqliteobject_checkpoint.go b/pkg/registry/file/sqliteobject_checkpoint.go
new file mode 100644
index 000000000..78844af70
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_checkpoint.go
@@ -0,0 +1,209 @@
+package file
+
+// Background PASSIVE WAL checkpointer for the ObjectStore (design §6.2, K-3).
+//
+// With PRAGMA wal_autocheckpoint=0 on every pool connection (PoolOptions.
+// DisableAutoCheckpoint), no COMMIT ever runs a checkpoint inside the gate
+// holder; this goroutine runs PRAGMA wal_checkpoint(PASSIVE) on its own pooled
+// connection instead. PASSIVE takes the checkpoint lock, never the writer lock,
+// and never invokes the busy handler, so it cannot stall a gated commit.
+//
+// zombiezen v1.4.0 exposes no sqlite3_wal_hook, so the trigger is a size check
+// on the -wal file after each gated commit (one os.Stat) plus a timer for the
+// ungated writers (legacy kinds, cleanup.go). The goroutine is supervised: a
+// panic is recovered, counted and the loop restarted, because with
+// autocheckpoint off everywhere a dead checkpointer means an unbounded WAL.
+
+import (
+ "context"
+ "os"
+ "runtime/debug"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/kubescape/go-logger"
+ "github.com/kubescape/go-logger/helpers"
+ "github.com/kubescape/storage/pkg/metrics"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitemigration"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+const (
+ // DefaultCheckpointThresholdBytes is the -wal size that triggers a
+ // checkpoint after a gated commit: the 1000 pages × 4 KB SQLite's own
+ // autocheckpoint would have used.
+ DefaultCheckpointThresholdBytes = 1000 * 4096
+ // DefaultCheckpointInterval is the timer fallback for WAL growth produced
+ // by writers that do not go through the gate.
+ DefaultCheckpointInterval = 5 * time.Second
+ // DefaultCheckpointMinSpacing bounds how often PASSIVE checkpoints run when
+ // commits keep kicking the checkpointer. Under concurrent readers the WAL
+ // cannot be reset, so its file size never drops below the kick threshold
+ // and every commit would re-kick; back-to-back checkpoints rewrite the
+ // wal-index header continuously and readers that see it change retry with
+ // SQLite's quadratic backoff (multi-second silent read stalls, measured in
+ // Tier B as 5-10 s ticks/updates with 7000 checkpoints per round). Kicks
+ // arriving inside the spacing are coalesced into one run at its end.
+ DefaultCheckpointMinSpacing = 250 * time.Millisecond
+ // checkpointRestartBackoff spaces supervised restarts after a panic.
+ checkpointRestartBackoff = 100 * time.Millisecond
+)
+
+type checkpointer struct {
+ pool *sqlitemigration.Pool
+ walPath string
+ thresholdBytes int64
+ interval time.Duration
+ minSpacing time.Duration
+
+ kick chan struct{}
+ stop chan struct{}
+ done chan struct{}
+ stopOnce sync.Once
+
+ runs atomic.Int64
+ restarts atomic.Int64
+ walPages atomic.Int64
+
+ // beforeCheckpoint is a test seam invoked at the top of every checkpoint
+ // run (nil in production); the supervision test panics from it.
+ beforeCheckpoint func()
+}
+
+func newCheckpointer(pool *sqlitemigration.Pool, dbPath string, thresholdBytes int64, interval time.Duration) *checkpointer {
+ if thresholdBytes <= 0 {
+ thresholdBytes = DefaultCheckpointThresholdBytes
+ }
+ if interval <= 0 {
+ interval = DefaultCheckpointInterval
+ }
+ return &checkpointer{
+ pool: pool,
+ walPath: dbPath + "-wal",
+ thresholdBytes: thresholdBytes,
+ interval: interval,
+ minSpacing: DefaultCheckpointMinSpacing,
+ kick: make(chan struct{}, 1),
+ stop: make(chan struct{}),
+ done: make(chan struct{}),
+ }
+}
+
+func (c *checkpointer) start() {
+ go c.supervise()
+}
+
+func (c *checkpointer) supervise() {
+ defer close(c.done)
+ for {
+ panicked := c.loop()
+ if !panicked {
+ return
+ }
+ c.restarts.Add(1)
+ metrics.IncSqliteCheckpoint(metrics.CheckpointOutcomePanic)
+ select {
+ case <-c.stop:
+ return
+ case <-time.After(checkpointRestartBackoff):
+ }
+ }
+}
+
+// loop runs until stop; it returns true if it exited because of a panic.
+func (c *checkpointer) loop() (panicked bool) {
+ defer func() {
+ if r := recover(); r != nil {
+ logger.L().Error("ObjectStore checkpointer panicked; restarting",
+ helpers.Interface("panic", r), helpers.String("stack", string(debug.Stack())))
+ panicked = true
+ }
+ }()
+ ticker := time.NewTicker(c.interval)
+ defer ticker.Stop()
+ var last time.Time
+ for {
+ select {
+ case <-c.stop:
+ return false
+ case <-c.kick:
+ if wait := c.minSpacing - time.Since(last); wait > 0 {
+ select {
+ case <-c.stop:
+ return false
+ case <-time.After(wait):
+ }
+ }
+ c.checkpoint()
+ last = time.Now()
+ case <-ticker.C:
+ c.checkpoint()
+ last = time.Now()
+ }
+ }
+}
+
+// afterCommit is called by the gate holder's caller after every COMMIT (never
+// inside the hold): one os.Stat, and a non-blocking kick when the WAL is over
+// the threshold.
+func (c *checkpointer) afterCommit() {
+ st, err := os.Stat(c.walPath)
+ if err != nil || st.Size() < c.thresholdBytes {
+ return
+ }
+ select {
+ case c.kick <- struct{}{}:
+ default:
+ }
+}
+
+func (c *checkpointer) checkpoint() {
+ if c.beforeCheckpoint != nil {
+ c.beforeCheckpoint()
+ }
+ c.runs.Add(1)
+ ctx, cancel := context.WithTimeout(context.Background(), poolTimeout)
+ defer cancel()
+ conn, err := c.pool.Take(ctx)
+ if err != nil {
+ metrics.IncSqliteCheckpoint(metrics.CheckpointOutcomeError)
+ logger.L().Debug("ObjectStore checkpointer: no pool connection", helpers.Error(err))
+ return
+ }
+ defer c.pool.Put(conn)
+
+ var busy, logPages int64
+ err = sqlitex.ExecuteTransient(conn, `PRAGMA wal_checkpoint(PASSIVE)`, &sqlitex.ExecOptions{
+ ResultFunc: func(stmt *sqlite.Stmt) error {
+ busy = stmt.ColumnInt64(0)
+ logPages = stmt.ColumnInt64(1)
+ return nil
+ },
+ })
+ if err != nil {
+ metrics.IncSqliteCheckpoint(metrics.CheckpointOutcomeError)
+ logger.L().Error("ObjectStore checkpointer: wal_checkpoint failed", helpers.Error(err))
+ return
+ }
+ c.walPages.Store(logPages)
+ metrics.SetSqliteWalPages(logPages)
+ if busy != 0 {
+ metrics.IncSqliteCheckpoint(metrics.CheckpointOutcomeBusy)
+ } else {
+ metrics.IncSqliteCheckpoint(metrics.CheckpointOutcomeOK)
+ }
+ _ = sqlitex.ExecuteTransient(conn, `PRAGMA freelist_count`, &sqlitex.ExecOptions{
+ ResultFunc: func(stmt *sqlite.Stmt) error {
+ metrics.SetSqliteFreelistCount(stmt.ColumnInt64(0))
+ return nil
+ },
+ })
+}
+
+// Stop ends the goroutine and waits for it. Idempotent.
+func (c *checkpointer) Stop() {
+ c.stopOnce.Do(func() { close(c.stop) })
+ <-c.done
+}
diff --git a/pkg/registry/file/sqliteobject_checkpoint_test.go b/pkg/registry/file/sqliteobject_checkpoint_test.go
new file mode 100644
index 000000000..a10e7a352
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_checkpoint_test.go
@@ -0,0 +1,86 @@
+package file
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+// TestCheckpointer_WalGrowthTriggersPassiveCheckpoint: with autocheckpoint off
+// on every connection, a write burst grows the WAL past the threshold; the
+// size check after a gated commit kicks the checkpointer, which runs PASSIVE on
+// its own pooled connection and leaves no frame un-checkpointed.
+func TestCheckpointer_WalGrowthTriggersPassiveCheckpoint(t *testing.T) {
+ e := newObjectStoreEnv(t, withCheckpoint(64*1024, time.Hour))
+ c := e.store.checkpointer
+ require.Equal(t, int64(0), c.runs.Load())
+
+ // ~40 KB of spec per object, several objects: well past 64 KB of WAL.
+ for i := 0; i < 8; i++ {
+ p := e.plain(fmt.Sprintf("wal-%d", i))
+ p.Spec.Syscalls = append(p.Spec.Syscalls, strings.Repeat("x", 5000), strings.Repeat("y", 5000))
+ e.create(p)
+ }
+ st, err := os.Stat(e.dbPath + "-wal")
+ require.NoError(t, err)
+ assert.Greater(t, st.Size(), int64(64*1024), "the WAL grew past the threshold (autocheckpoint is off)")
+
+ require.Eventually(t, func() bool { return c.runs.Load() > 0 }, 5*time.Second, 10*time.Millisecond, "the size check did not kick the checkpointer")
+ assert.Equal(t, int64(0), c.restarts.Load())
+
+ // Nothing left to checkpoint: a PASSIVE run from the test reports
+ // checkpointed == log frames.
+ require.Eventually(t, func() bool {
+ var busy, logFrames, ckpt int64
+ e.withConn(func(conn *sqlite.Conn) {
+ require.NoError(t, sqlitex.ExecuteTransient(conn, `PRAGMA wal_checkpoint(PASSIVE)`, &sqlitex.ExecOptions{
+ ResultFunc: func(stmt *sqlite.Stmt) error {
+ busy, logFrames, ckpt = stmt.ColumnInt64(0), stmt.ColumnInt64(1), stmt.ColumnInt64(2)
+ return nil
+ },
+ }))
+ })
+ return busy == 0 && logFrames == ckpt
+ }, 5*time.Second, 20*time.Millisecond)
+ assert.Greater(t, c.walPages.Load(), int64(0), "wal pages gauge observed")
+}
+
+// TestCheckpointer_TimerFallback: WAL growth from writers that never touch the
+// gate is still checkpointed, on the timer.
+func TestCheckpointer_TimerFallback(t *testing.T) {
+ e := newObjectStoreEnv(t, withCheckpoint(1<<40, 30*time.Millisecond))
+ c := e.store.checkpointer
+ // An ungated writer: WAL growth from a connection outside the gate (the
+ // fixture handle; a pool connection here would be an AC-G1 violation).
+ e.withFixture(func(conn *sqlite.Conn) {
+ require.NoError(t, sqlitex.ExecuteTransient(conn, `INSERT INTO metadata (kind,namespace,name,metadata) VALUES ('other','n','x','{}')`, nil))
+ })
+ require.Eventually(t, func() bool { return c.runs.Load() >= 2 }, 5*time.Second, 5*time.Millisecond)
+}
+
+// TestCheckpointer_SupervisedRestartAfterPanic: a panic in the checkpointer
+// goroutine is recovered, counted, and the loop restarted; the next kick runs.
+func TestCheckpointer_SupervisedRestartAfterPanic(t *testing.T) {
+ e := newObjectStoreEnv(t, withCheckpoint(1, time.Hour))
+ c := e.store.checkpointer
+ var fired bool
+ c.beforeCheckpoint = func() {
+ if !fired {
+ fired = true
+ panic("checkpointer test panic")
+ }
+ }
+ e.create(e.plain("cp-1")) // WAL > 1 byte → kick → panic → restart
+ require.Eventually(t, func() bool { return c.restarts.Load() == 1 }, 5*time.Second, 5*time.Millisecond)
+ e.create(e.plain("cp-2")) // kick again → the restarted loop serves it
+ // The panicked run never reached runs.Add; the restarted loop's run does.
+ require.Eventually(t, func() bool { return c.runs.Load() >= 1 }, 5*time.Second, 5*time.Millisecond)
+ assert.Equal(t, int64(1), c.restarts.Load())
+}
diff --git a/pkg/registry/file/sqliteobject_cleanup_test.go b/pkg/registry/file/sqliteobject_cleanup_test.go
new file mode 100644
index 000000000..479b5f068
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_cleanup_test.go
@@ -0,0 +1,331 @@
+package file
+
+// K-4 / §5.6 row 10 of .omc/plans/full-acid-storage-architecture.md: the
+// ContainerProfile cleanup handlers (deleteByTemplateHashOrWlid and, with
+// relevancy on, the two missing-annotation handlers) run ONLY from
+// ContainerProfileProcessor.cleanup(); main.go's generic relevancy walk never
+// contains ContainerProfileKind and never visits a CP directory. Under the
+// flag the CP arm enumerates rows and deletes through the ObjectStore (one
+// gated transaction over metadata + payloads + time_series, Deleted
+// dispatched after) and never walks files.
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ mapset "github.com/deckarep/golang-set/v2"
+ "github.com/goradd/maps"
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition/install"
+ "github.com/kubescape/storage/pkg/config"
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/storage"
+ "zombiezen.com/go/sqlite"
+)
+
+// pathSpyFs records every path the cleanup walk touches through the fs.
+type pathSpyFs struct {
+ afero.Fs
+ mu sync.Mutex
+ paths []string
+}
+
+func (s *pathSpyFs) note(p string) {
+ s.mu.Lock()
+ s.paths = append(s.paths, p)
+ s.mu.Unlock()
+}
+
+func (s *pathSpyFs) Open(name string) (afero.File, error) {
+ s.note(name)
+ return s.Fs.Open(name)
+}
+
+func (s *pathSpyFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
+ s.note(name)
+ return s.Fs.OpenFile(name, flag, perm)
+}
+
+func (s *pathSpyFs) Stat(name string) (os.FileInfo, error) {
+ s.note(name)
+ return s.Fs.Stat(name)
+}
+
+func (s *pathSpyFs) Remove(name string) error {
+ s.note(name)
+ return s.Fs.Remove(name)
+}
+
+// cpPathsVisited returns the recorded paths under the containerprofile kind.
+func (s *pathSpyFs) cpPathsVisited() []string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ var out []string
+ for _, p := range s.paths {
+ if strings.Contains(p, "/"+ContainerProfileKind+"/") {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+// nsFetchMock reports one namespace with one running pod wlid.
+type nsFetchMock struct {
+ ns string
+ runningWlid string
+}
+
+func (m *nsFetchMock) ListNamespaces(_ *sqlite.Conn) ([]string, error) { return []string{m.ns}, nil }
+
+func (m *nsFetchMock) FetchResources(_ string) (ResourceMaps, error) {
+ r := ResourceMaps{
+ RunningContainerImageIds: mapset.NewSet[string](),
+ RunningTemplateHash: mapset.NewSet[string](),
+ RunningInstanceIds: mapset.NewSet[string](),
+ RunningWlidsToContainerNames: new(maps.SafeMap[string, mapset.Set[string]]),
+ }
+ r.RunningWlidsToContainerNames.Set(wlidWithoutClusterName(m.runningWlid), mapset.NewSet[string]("main"))
+ return r, nil
+}
+
+const (
+ cleanupLiveWlid = "wlid://cluster-test/namespace-default/pod-running"
+ cleanupStaleWlid = "wlid://cluster-test/namespace-default/pod-deleted"
+)
+
+func cpFilePath(ns, name string) string {
+ return filepath.Join(DefaultStorageRoot, softwarecomposition.GroupName, ContainerProfileKind, ns, name+GobExt)
+}
+
+func cpMetadataJSON(ns, name string, annotations map[string]string) []byte {
+ parts := make([]string, 0, len(annotations))
+ for k, v := range annotations {
+ parts = append(parts, fmt.Sprintf("%q:%q", k, v))
+ }
+ return []byte(fmt.Sprintf(`{"name":%q,"namespace":%q,"annotations":{%s},"labels":{"kubescape.io/workload-kind":"Pod"}}`,
+ name, ns, strings.Join(parts, ",")))
+}
+
+// The generic relevancy walk (main.go's cleanup goroutine) never contains the
+// ContainerProfile kind and never visits a CP directory, even with relevancy
+// on: a CP file whose row lacks both relevancy annotations survives it.
+func TestCleanup_GenericWalkNeverVisitsContainerProfiles(t *testing.T) {
+ memFs := afero.NewMemMapFs()
+ const name = "pod-running-main-1111-2222"
+ require.NoError(t, afero.WriteFile(memFs, cpFilePath("default", name), []byte("payload"), 0644))
+ // A deprecated kind's file in the same namespace proves the walk itself ran.
+ deprecated := filepath.Join(DefaultStorageRoot, softwarecomposition.GroupName, "applicationprofiles", "default", "ap"+GobExt)
+ require.NoError(t, afero.WriteFile(memFs, deprecated, []byte("payload"), 0644))
+
+ pool := NewTestPool(t.TempDir())
+ conn, err := pool.Take(context.Background())
+ require.NoError(t, err)
+ require.NoError(t, WriteJSON(conn, payloadPathToKey(cpFilePath("default", name)), cpMetadataJSON("default", name, map[string]string{})))
+ require.NoError(t, WriteJSON(conn, payloadPathToKey(deprecated), []byte(`{"name":"ap","namespace":"default"}`)))
+ pool.Put(conn)
+
+ spy := &pathSpyFs{Fs: memFs}
+ h := NewResourcesCleanupHandler(spy, DefaultStorageRoot, pool, nil, 0, "kubescape", &nsFetchMock{ns: "default", runningWlid: cleanupLiveWlid}, true)
+ _, hasCP := h.resourceToKindHandler[ContainerProfileKind]
+ require.False(t, hasCP, "initResourceToKindHandler must never map ContainerProfileKind")
+ _, hasCP = initResourceToKindHandler()[ContainerProfileKind]
+ require.False(t, hasCP)
+
+ require.NoError(t, h.CleanupTask(context.Background(), h.resourceToKindHandler))
+
+ exists, err := afero.Exists(memFs, deprecated)
+ require.NoError(t, err)
+ require.False(t, exists, "the walk ran: the deprecated kind's file is reclaimed")
+ exists, err = afero.Exists(memFs, cpFilePath("default", name))
+ require.NoError(t, err)
+ require.True(t, exists, "the generic walk must not reclaim a ContainerProfile")
+ require.Empty(t, spy.cpPathsVisited(), "the generic walk must not visit the containerprofile directory")
+ conn, err = pool.Take(context.Background())
+ require.NoError(t, err)
+ _, err = ReadMetadata(conn, payloadPathToKey(cpFilePath("default", name)))
+ pool.Put(conn)
+ require.NoError(t, err, "the ContainerProfile row must survive the generic walk")
+}
+
+// With relevancy on, the missing-annotation handlers run from the processor's
+// cleanup: a profile of a running workload with no instance-id annotation is
+// reclaimed there (deleteByTemplateHashOrWlid alone keeps it).
+func TestCleanup_RelevancyHandlersRunFromTheProcessorCleanup(t *testing.T) {
+ memFs := afero.NewMemMapFs()
+ const (
+ complete = "pod-running-main-1111-2222"
+ noInstance = "pod-running-main-3333-4444"
+ )
+ for _, n := range []string{complete, noInstance} {
+ require.NoError(t, afero.WriteFile(memFs, cpFilePath("default", n), []byte("payload"), 0644))
+ }
+ pool := NewTestPool(t.TempDir())
+ conn, err := pool.Take(context.Background())
+ require.NoError(t, err)
+ require.NoError(t, WriteJSON(conn, payloadPathToKey(cpFilePath("default", complete)), cpMetadataJSON("default", complete, map[string]string{
+ helpersv1.WlidMetadataKey: cleanupLiveWlid, helpersv1.InstanceIDMetadataKey: "iid",
+ })))
+ require.NoError(t, WriteJSON(conn, payloadPathToKey(cpFilePath("default", noInstance)), cpMetadataJSON("default", noInstance, map[string]string{
+ helpersv1.WlidMetadataKey: cleanupLiveWlid,
+ })))
+ pool.Put(conn)
+
+ for _, relevancy := range []bool{false, true} {
+ t.Run(fmt.Sprintf("relevancy=%v", relevancy), func(t *testing.T) {
+ h := NewResourcesCleanupHandler(memFs, DefaultStorageRoot, pool, nil, 0, "kubescape", &nsFetchMock{ns: "default", runningWlid: cleanupLiveWlid}, relevancy)
+ processor := ContainerProfileProcessor{CleanupHandler: h}
+ require.NoError(t, processor.cleanup())
+
+ exists, err := afero.Exists(memFs, cpFilePath("default", complete))
+ require.NoError(t, err)
+ require.True(t, exists, "a running workload's complete profile is kept")
+ exists, err = afero.Exists(memFs, cpFilePath("default", noInstance))
+ require.NoError(t, err)
+ require.Equal(t, !relevancy, exists, "the missing-instance-id profile is reclaimed exactly when relevancy is on")
+ })
+ }
+}
+
+// Under the flag the CP arm enumerates rows and deletes through the
+// ObjectStore: the reclaimed profile leaves no row in any of the three
+// tables, the kept one is intact, and no CP file path is visited.
+func TestCleanup_ContainerProfileArmUsesRowsUnderTheFlag(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ // Without the template-hash label the liveness decision is the wlid's.
+ stale := e.plain("pod-deleted-main-1234-5678")
+ stale.Annotations[helpersv1.WlidMetadataKey] = cleanupStaleWlid
+ delete(stale.Labels, helpersv1.TemplateHashKey)
+ live := e.plain("pod-running-main-8765-4321")
+ live.Annotations[helpersv1.WlidMetadataKey] = cleanupLiveWlid
+ delete(live.Labels, helpersv1.TemplateHashKey)
+ e.create(stale)
+ e.create(live)
+ staleKey, liveKey := e.key(stale.Name), e.key(live.Name)
+
+ // A stray legacy file for the stale key: a file walk would find it and
+ // delete file-then-row on the legacy path, leaving the payloads row.
+ spy := &pathSpyFs{Fs: e.legacyFs}
+ require.NoError(t, afero.WriteFile(e.legacyFs, cpFilePath(e.ns, stale.Name), []byte("legacy"), 0644))
+
+ h := NewResourcesCleanupHandler(spy, DefaultStorageRoot, e.pool, e.wd, 0, "kubescape", &nsFetchMock{ns: e.ns, runningWlid: cleanupLiveWlid}, false)
+ h.SetWriteGate(e.gate)
+ h.SetContainerProfileStore(e.store)
+ w, err := e.store.Watch(e.ctx, testCPPrefix+e.ns, storage.ListOptions{Predicate: storage.Everything, Recursive: true})
+ require.NoError(t, err)
+ defer w.Stop()
+
+ processor := ContainerProfileProcessor{CleanupHandler: h}
+ require.NoError(t, processor.cleanup())
+
+ row := e.inspect(staleKey)
+ require.False(t, row.metaExists, "the stale profile's metadata row is reclaimed")
+ require.False(t, row.payloadExists, "the stale profile's payloads row is reclaimed with it (INV-2)")
+ require.Equal(t, 0, row.tsRows)
+ e.withConn(func(conn *sqlite.Conn) { assertINV2(t, conn, liveKey) })
+ require.True(t, e.inspect(liveKey).metaExists, "the running workload's profile is kept")
+ require.Empty(t, spy.cpPathsVisited(), "the CP arm must not walk files under the flag")
+ exists, err := afero.Exists(e.legacyFs, cpFilePath(e.ns, stale.Name))
+ require.NoError(t, err)
+ require.True(t, exists, "legacy files are left for the export tool, never reclaimed by the cleanup")
+
+ select {
+ case ev := <-w.ResultChan():
+ require.Equal(t, "DELETED", string(ev.Type))
+ case <-time.After(5 * time.Second):
+ // A non-blocking default here raced the watch dispatcher's own
+ // goroutine: the event is genuinely async (see WatchDispatcher),
+ // so nothing guarantees it is already queued the instant
+ // processor.cleanup() returns -- under load (a busy CI runner)
+ // the dispatch goroutine simply may not have run yet. A bounded
+ // wait is still a hard failure on a real bug, just not a race.
+ t.Fatal("expected a Deleted event for the reclaimed profile")
+ }
+}
+
+// TestContainerProfileProcessor_MaintenanceDoesNotStartUntilExplicit: under
+// the ObjectStore backend, apiserver.go calls NewObjectStore (which calls
+// SetStorage) BEFORE it finishes wiring CleanupHandler.SetContainerProfileStore
+// -- so SetStorage starting the maintenance goroutine by itself let a
+// cleanup tick run cleanup's ContainerProfile arm with cpStore still nil
+// (the legacy file-walk path against rows the ObjectStore owns) and raced
+// unsynchronized on cpStore with that Set call. SetStorage must not start
+// maintenance; only an explicit StartMaintenance, called once all such
+// wiring is complete, may.
+func TestContainerProfileProcessor_MaintenanceDoesNotStartUntilExplicit(t *testing.T) {
+ // A throwaway pool/store this test never closes: StartMaintenance's
+ // runMaintenanceTasks loops forever with no stop mechanism (matching
+ // production, where it runs for the process's life), so once started it
+ // outlives this test function. Reusing newObjectStoreEnv's pool would
+ // race that leaked goroutine against the env's own t.Cleanup closing it;
+ // an isolated, never-closed pool has nothing left for it to race.
+ dir := t.TempDir()
+ sch := runtime.NewScheme()
+ install.Install(sch)
+ pool := NewPoolWithOptions(filepath.Join(dir, "metadata.sq3"), PoolOptions{Size: DefaultPoolSize, BusyTimeout: 5 * time.Second})
+ wd := NewWatchDispatcher()
+ legacyFs := afero.NewMemMapFs()
+ legacy := NewStorageImpl(legacyFs, DefaultStorageRoot, pool, wd, sch).(*StorageImpl)
+ legacy.SetForeignKinds(IsContainerProfileKind)
+ gateCtx, gateCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer gateCancel()
+ gate, err := newWriteGate(gateCtx, pool)
+ require.NoError(t, err)
+ legacy.SetWriteGate(gate)
+
+ // fetcher.calls is read from require.Eventually's own polling goroutine:
+ // an atomic, not processor.LastCleanup (a plain field the maintenance
+ // goroutine writes unsynchronized -- reading it cross-goroutine would
+ // itself be a race, independent of the one this test exists to close).
+ fetcher := &countingFetcher{nsFetchMock: &nsFetchMock{ns: "kubescape", runningWlid: cleanupLiveWlid}}
+ h := NewResourcesCleanupHandler(legacyFs, DefaultStorageRoot, pool, wd, 0, "kubescape", fetcher, false)
+ h.SetWriteGate(gate)
+
+ processor := NewContainerProfileProcessor(config.Config{DefaultNamespace: "kubescape", MaxContainerProfileSize: 40000}, h)
+ processor.Interval = 5 * time.Millisecond
+ processor.Workers = 1
+
+ store, err := NewObjectStore(pool, filepath.Join(dir, "metadata.sq3"), wd, sch, processor, legacy, gate, ObjectStoreOptions{})
+ require.NoError(t, err)
+
+ // SetStorage (called inside NewObjectStore) alone: no cleanup handler
+ // wired yet (the production ordering's dangerous window), and no
+ // maintenance goroutine may exist.
+ time.Sleep(10 * processor.Interval)
+ require.Zero(t, fetcher.calls.Load(), "SetStorage must not start maintenance")
+
+ // Finish wiring, exactly as apiserver.go's SetContainerProfileStore call
+ // does, then start maintenance explicitly.
+ h.SetContainerProfileStore(store)
+ processor.StartMaintenance()
+ // Without this, the 5ms-interval loop keeps calling ConsolidateTimeSeries
+ // (and so ListTimeSeriesWithData) against this test's pool for the rest
+ // of the test binary's life, contaminating whichever later test happens
+ // to be sensitive to that call -- this is what TestWorkBudget's own
+ // leak detector was catching before StopMaintenance existed.
+ t.Cleanup(processor.StopMaintenance)
+
+ require.Eventually(t, func() bool { return fetcher.calls.Load() > 0 }, time.Second, time.Millisecond,
+ "StartMaintenance must start the maintenance loop")
+}
+
+// countingFetcher counts ListNamespaces calls: one per CleanupTask, so it
+// doubles as a race-free "did a cleanup tick run" signal.
+type countingFetcher struct {
+ *nsFetchMock
+ calls atomic.Int64
+}
+
+func (f *countingFetcher) ListNamespaces(conn *sqlite.Conn) ([]string, error) {
+ f.calls.Add(1)
+ return f.nsFetchMock.ListNamespaces(conn)
+}
diff --git a/pkg/registry/file/sqliteobject_concurrency_test.go b/pkg/registry/file/sqliteobject_concurrency_test.go
new file mode 100644
index 000000000..1b329a5d0
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_concurrency_test.go
@@ -0,0 +1,616 @@
+package file
+
+// Same-key concurrency on the ObjectStore write path (§3.4 GuaranteedUpdate's
+// CAS + singleWriterConflictBackoff retry, §3.7's consolidation write set).
+// The legacy StorageImpl has TestSingleWriter_ConcurrentUpdatesSameKey_
+// NoLostUpdates; under config.ContainerProfileSqliteBackend that path is
+// bypassed entirely, so these run the same shapes against the ObjectStore.
+//
+// Every test asserts the conflict counter moved: a run in which the CAS never
+// failed would pass the no-lost-update check without exercising the retry
+// path at all, which is the silent short-circuit these tests exist to catch.
+
+import (
+ "fmt"
+ "os"
+ "sort"
+ "strconv"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/kubescape/storage/pkg/metrics"
+ "github.com/stretchr/testify/require"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/storage"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+// casConflicts snapshots storage_cp_cas_conflict_total by op.
+type casConflicts struct{ update, delete float64 }
+
+func snapshotCASConflicts(t *testing.T) casConflicts {
+ t.Helper()
+ return casConflicts{
+ update: counterValue(t, metrics.CPCASConflictTotal.WithLabelValues("update")),
+ delete: counterValue(t, metrics.CPCASConflictTotal.WithLabelValues("delete")),
+ }
+}
+
+func (c casConflicts) delta(t *testing.T) casConflicts {
+ t.Helper()
+ n := snapshotCASConflicts(t)
+ return casConflicts{update: n.update - c.update, delete: n.delete - c.delete}
+}
+
+// incrementCounter is the tryUpdate every writer runs: read the label, add
+// one. Two writers that both prepared from the same row produce the same
+// value; the CAS must let exactly one of them through.
+func incrementCounter(input runtime.Object, _ storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ cp := input.(*softwarecomposition.ContainerProfile).DeepCopy()
+ n, _ := strconv.Atoi(cp.Labels["counter"])
+ cp.Labels["counter"] = strconv.Itoa(n + 1)
+ return cp, nil, nil
+}
+
+// isContentionTimeout reports whether err is what newContentionTimeoutError
+// returns (a ServerTimeout, or the InternalError of a cancelled ctx).
+func isContentionTimeout(err error) bool {
+ return err != nil && (apierrors.IsServerTimeout(err) || apierrors.IsInternalError(err))
+}
+
+func percentile(sorted []time.Duration, p float64) time.Duration {
+ if len(sorted) == 0 {
+ return 0
+ }
+ i := int(float64(len(sorted)-1) * p)
+ return sorted[i]
+}
+
+// runSameKeyWriters launches n GuaranteedUpdate calls on key, released
+// together by a start barrier so they all read the same row version, and
+// returns the per-call latencies (sorted) and errors.
+func runSameKeyWriters(e *objectStoreEnv, key string, n int) ([]time.Duration, []error) {
+ var wg sync.WaitGroup
+ start := make(chan struct{})
+ errs := make([]error, n)
+ lat := make([]time.Duration, n)
+ for i := 0; i < n; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ <-start
+ t0 := time.Now()
+ errs[i] = e.store.GuaranteedUpdate(e.ctx, key, &softwarecomposition.ContainerProfile{}, false, nil, incrementCounter, nil)
+ lat[i] = time.Since(t0)
+ }(i)
+ }
+ close(start)
+ wg.Wait()
+ sort.Slice(lat, func(a, b int) bool { return lat[a] < lat[b] })
+ return lat, errs
+}
+
+// TestObjectStore_ConcurrentUpdatesSameKey_NoLostUpdates is the ObjectStore
+// port of TestSingleWriter_ConcurrentUpdatesSameKey_NoLostUpdates: 40 writers
+// on one key, every one must land exactly once.
+func TestObjectStore_ConcurrentUpdatesSameKey_NoLostUpdates(t *testing.T) {
+ // A pool larger than the writer count for the same reason the legacy test
+ // gives: guaranteedUpdate holds its pool connection across the gate wait
+ // (the re-read on conflict needs it), so with the default pool of 10 most
+ // of 40 simultaneous writers would first queue on pool.Take, and the
+ // property under test is the CAS, not pool sizing.
+ e := newObjectStoreEnv(t, withPoolSize(64))
+ const n = 40
+ key := e.key("concurrent-same-key")
+ p := e.plain("concurrent-same-key")
+ p.Labels["counter"] = "0"
+ e.create(p)
+
+ before := snapshotCASConflicts(t)
+ lat, errs := runSameKeyWriters(e, key, n)
+ conflicts := before.delta(t)
+
+ for i, err := range errs {
+ require.NoError(t, err, "update %d failed", i)
+ require.False(t, isContentionTimeout(err))
+ }
+ got := e.mustGet(key)
+ require.Equal(t, strconv.Itoa(n), got.Labels["counter"], "no update lost or double-applied")
+ require.Equal(t, strconv.Itoa(n+1), got.ResourceVersion, "1 create + n updates")
+ e.withFixture(func(conn *sqlite.Conn) { assertINV2(t, conn, key) })
+ require.Greater(t, conflicts.update, 0.0, "storage_cp_cas_conflict_total{op=update} did not move: the CAS was never contended, so the retry path was not exercised")
+ require.False(t, e.gate.held(), "the gate is still held after every writer returned")
+ maxLat := lat[len(lat)-1]
+ require.Less(t, maxLat, 10*time.Second, "max latency %s is not well under the 60s ctx deadline", maxLat)
+ t.Logf("n=%d conflicts(update)=%.0f latency p50=%s p99=%s max=%s", n, conflicts.update, percentile(lat, 0.5), percentile(lat, 0.99), maxLat)
+}
+
+// TestObjectStore_ConcurrentUpdatesSameKey_Stress is the LOAD_TEST=1 variant:
+// 200 writers, reporting p99 and the conflict count.
+func TestObjectStore_ConcurrentUpdatesSameKey_Stress(t *testing.T) {
+ if os.Getenv("LOAD_TEST") != "1" {
+ t.Skip("set LOAD_TEST=1 to run the 200-writer same-key stress")
+ }
+ e := newObjectStoreEnv(t, withPoolSize(256))
+ const n = 200
+ key := e.key("stress-same-key")
+ p := e.plain("stress-same-key")
+ p.Labels["counter"] = "0"
+ e.create(p)
+
+ before := snapshotCASConflicts(t)
+ retriesBefore := counterValue(t, metrics.SingleWriterConflictRetryTotal.WithLabelValues(ContainerProfileKindPlural))
+ t0 := time.Now()
+ lat, errs := runSameKeyWriters(e, key, n)
+ wall := time.Since(t0)
+ conflicts := before.delta(t)
+ retries := counterValue(t, metrics.SingleWriterConflictRetryTotal.WithLabelValues(ContainerProfileKindPlural)) - retriesBefore
+
+ timeouts := 0
+ for i, err := range errs {
+ if isContentionTimeout(err) {
+ timeouts++
+ }
+ require.NoError(t, err, "update %d failed", i)
+ }
+ got := e.mustGet(key)
+ require.Equal(t, strconv.Itoa(n), got.Labels["counter"])
+ require.Equal(t, strconv.Itoa(n+1), got.ResourceVersion)
+ e.withFixture(func(conn *sqlite.Conn) { assertINV2(t, conn, key) })
+ require.Greater(t, conflicts.update, 0.0)
+ require.False(t, e.gate.held())
+ require.Equal(t, 0, timeouts)
+ t.Logf("n=%d wall=%s conflicts(update)=%.0f retries=%.0f timeouts=%d latency p50=%s p90=%s p99=%s max=%s",
+ n, wall, conflicts.update, retries, timeouts, percentile(lat, 0.5), percentile(lat, 0.9), percentile(lat, 0.99), lat[len(lat)-1])
+}
+
+// pendingTSRows counts key's time_series rows a tick would still process
+// (hasData=1); a consolidated report's row stays behind with hasData=0.
+func (e *objectStoreEnv) pendingTSRows(key string) int {
+ e.t.Helper()
+ _, _, kind, _, ns, name := K8sPathToKeys(key)
+ var n int
+ e.withFixture(func(conn *sqlite.Conn) {
+ require.NoError(e.t, sqlitex.Execute(conn,
+ `SELECT count(*) FROM time_series WHERE kind = ? AND namespace = ? AND name = ? AND hasData = 1`,
+ &sqlitex.ExecOptions{Args: []any{NormalizeContainerProfileKind(kind), ns, name}, ResultFunc: func(stmt *sqlite.Stmt) error { n = int(stmt.ColumnInt64(0)); return nil }}))
+ })
+ return n
+}
+
+// tickRace drives the §9 "same-key REST-Update-vs-consolidation race": ticks
+// consolidating pending reports of one series while writers mutate the same
+// key. One committed tick merges every pending row, so the race runs in
+// rounds: seed perRound reports, tick until none is pending (bounded), repeat.
+//
+// injectOnce, when set, runs on the tick goroutine from the processor's
+// BeforeProcessedDeletes hook — after the pass's Phase 1 reads and before its
+// staged commit, outside the gate — on the FIRST attempt of every tick only:
+// a deterministic write in the window the CAS exists for. The pass's retry
+// (N=2) then re-reads and must commit.
+type tickRace struct {
+ e *objectStoreEnv
+ rounds, perRound int
+ maxTicksPerRound int
+ injectOnce func(tick, newest int)
+
+ reports int // newest report number created
+ ticks int
+ worstRound int
+ tickErrs []error
+ injected int
+}
+
+func (r *tickRace) run(t *testing.T) {
+ t.Helper()
+ e := r.e
+ var firedThisTick bool
+ e.processor.Hooks.BeforeProcessedDeletes = func(string) {
+ if r.injectOnce == nil || firedThisTick {
+ return
+ }
+ firedThisTick = true
+ r.injected++
+ r.injectOnce(r.ticks, r.reports)
+ }
+ t.Cleanup(func() { e.processor.Hooks.BeforeProcessedDeletes = nil })
+
+ for round := 1; round <= r.rounds; round++ {
+ for i := 0; i < r.perRound; i++ {
+ r.reports++
+ e.createReport(r.reports)
+ }
+ roundTicks := 0
+ for e.pendingTSRows(e.baseKey) > 0 {
+ require.Less(t, roundTicks, r.maxTicksPerRound,
+ "round %d: %d pending time_series rows did not drain in %d ticks (livelock); tick errors so far: %v",
+ round, e.pendingTSRows(e.baseKey), r.maxTicksPerRound, r.tickErrs)
+ roundTicks++
+ r.ticks++
+ firedThisTick = false
+ if err := e.processor.ConsolidateTimeSeries(e.ctx); err != nil {
+ require.ErrorIs(t, err, ErrWriteConflict, "a tick failed for something other than a CAS conflict")
+ r.tickErrs = append(r.tickErrs, err)
+ }
+ }
+ r.worstRound = max(r.worstRound, roundTicks)
+ }
+}
+
+// seedLearningBase materialises the base from report 1 (consolidated once)
+// and stamps counter=0 on it.
+func (e *objectStoreEnv) seedLearningBase() {
+ e.t.Helper()
+ e.create(e.ts("r1", 1, helpersv1.Learning, helpersv1.Partial))
+ e.tick()
+ require.Equal(e.t, 0, e.pendingTSRows(e.baseKey), "a consolidated series keeps its rows with hasData=0; none may be pending")
+ require.Equal(e.t, helpersv1.Learning, e.mustGet(e.baseKey).Annotations[helpersv1.StatusMetadataKey])
+ require.NoError(e.t, e.store.GuaranteedUpdate(e.ctx, e.baseKey, &softwarecomposition.ContainerProfile{}, false, nil, setLabel("counter", "0"), nil))
+}
+
+// assertTickRaceOutcome is the common post-condition: the base carries every
+// counter increment (no writer's update was lost to a tick's commit), every
+// report was merged and deleted, INV-2 holds on every key, the gate is free.
+func assertTickRaceOutcome(t *testing.T, e *objectStoreEnv, reports int, wantCounter int64) {
+ t.Helper()
+ final := e.mustGet(e.baseKey)
+ require.Equal(t, strconv.FormatInt(wantCounter, 10), final.Labels["counter"], "the base lost a writer's update to a consolidation commit")
+ for n := 1; n <= reports; n++ {
+ _, err := e.get(e.tsKey(fmt.Sprintf("r%d", n)))
+ require.True(t, storage.IsNotFound(err), "report r%d survived consolidation", n)
+ if n > 1 {
+ require.True(t, hasExec(final, reportExec(n)), "report r%d was deleted without its observation reaching the base", n)
+ }
+ }
+ e.withFixture(func(conn *sqlite.Conn) {
+ for _, k := range allCPKeys(t, conn) {
+ assertINV2(t, conn, k)
+ }
+ })
+ require.False(t, e.gate.held())
+}
+
+func reportExec(n int) string { return fmt.Sprintf("/bin/report-%d", n) }
+
+// createReport creates report n of the series. Every report carries one
+// observation of its own, so every tick's merge changes the base and stages
+// the save-base CAS (identical reports merge to a no-op: no write, nothing
+// to race).
+func (e *objectStoreEnv) createReport(n int) {
+ e.t.Helper()
+ p := e.ts(fmt.Sprintf("r%d", n), n, helpersv1.Learning, helpersv1.Partial)
+ p.Spec.Execs = append(p.Spec.Execs, softwarecomposition.ExecCalls{Path: reportExec(n)})
+ e.create(p)
+}
+
+// measureTickWindow consolidates reports first+1..first+n one at a time and
+// returns the slowest tick: the Phase 1 read → merge → encode → gate →
+// commit window a same-key writer has to miss, on this machine and build
+// (the race detector stretches it several-fold).
+func (e *objectStoreEnv) measureTickWindow(first, n int) time.Duration {
+ e.t.Helper()
+ var worst time.Duration
+ for i := 1; i <= n; i++ {
+ e.createReport(first + i)
+ t0 := time.Now()
+ e.tick()
+ worst = max(worst, time.Since(t0))
+ }
+ require.Equal(e.t, 0, e.pendingTSRows(e.baseKey))
+ return worst
+}
+
+func hasExec(cp *softwarecomposition.ContainerProfile, path string) bool {
+ for _, x := range cp.Spec.Execs {
+ if x.Path == path {
+ return true
+ }
+ }
+ return false
+}
+
+// TestObjectStore_UpdateVsConsolidationTick_SameBaseKey is the §9 "same-key
+// REST-Update-vs-consolidation race" on the ObjectStore: one commits, the
+// other conflicts once, no update is lost, and the rows still drain.
+func TestObjectStore_UpdateVsConsolidationTick_SameBaseKey(t *testing.T) {
+ const rounds, perRound = 8, 3
+
+ // Deterministic: a base update lands between the tick's Phase 1 read and
+ // its commit, every tick. The save-base CAS fails (op=update), the retry
+ // re-reads the base — with the update — and commits: one conflict per
+ // tick, one tick per round, the update in the merged base.
+ t.Run("base update between Phase 1 and commit", func(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ e.seedLearningBase()
+ before := snapshotCASConflicts(t)
+ race := &tickRace{e: e, rounds: rounds, perRound: perRound, maxTicksPerRound: 25}
+ race.injectOnce = func(tick, _ int) {
+ err := e.store.GuaranteedUpdate(e.ctx, e.baseKey, &softwarecomposition.ContainerProfile{}, false, nil,
+ func(input runtime.Object, m storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ out, _, err := incrementCounter(input, m)
+ cp := out.(*softwarecomposition.ContainerProfile)
+ cp.Spec.Execs = append(cp.Spec.Execs, softwarecomposition.ExecCalls{Path: fmt.Sprintf("/bin/base-update-%d", tick)})
+ return cp, nil, err
+ }, nil)
+ if err != nil {
+ t.Errorf("injected base update (tick %d): %v", tick, err)
+ }
+ }
+ race.run(t)
+ conflicts := before.delta(t)
+
+ require.Equal(t, rounds, race.ticks, "every round drains in exactly one tick: the retry commits")
+ require.Equal(t, rounds, race.injected)
+ require.Equal(t, float64(rounds), conflicts.update, "exactly one save-base CAS conflict per tick")
+ require.Equal(t, 0.0, conflicts.delete)
+ require.Empty(t, race.tickErrs, "no tick failed: the once-retry absorbed every conflict")
+ assertTickRaceOutcome(t, e, race.reports, int64(rounds))
+ final := e.mustGet(e.baseKey)
+ for tick := 1; tick <= rounds; tick++ {
+ require.True(t, hasExec(final, fmt.Sprintf("/bin/base-update-%d", tick)), "the base update of tick %d survived the retried merge", tick)
+ }
+ t.Logf("rounds=%d ticks=%d conflicts(update)=%.0f conflicts(delete)=%.0f", rounds, race.ticks, conflicts.update, conflicts.delete)
+ })
+
+ // Deterministic, R4: the newest pending report is updated in the same
+ // window. The pass's staged delete carries the (rv, uid) it read the
+ // report with; it matches no row (op=delete), the tick rolls back, the
+ // retry merges the updated report.
+ t.Run("TS update between Phase 1 and commit (R4)", func(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ e.seedLearningBase()
+ before := snapshotCASConflicts(t)
+ race := &tickRace{e: e, rounds: rounds, perRound: perRound, maxTicksPerRound: 25}
+ race.injectOnce = func(tick, newest int) {
+ err := e.store.GuaranteedUpdate(e.ctx, e.tsKey(fmt.Sprintf("r%d", newest)), &softwarecomposition.ContainerProfile{}, false, nil,
+ func(input runtime.Object, _ storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ cp := input.(*softwarecomposition.ContainerProfile).DeepCopy()
+ cp.Spec.Execs = append(cp.Spec.Execs, softwarecomposition.ExecCalls{Path: fmt.Sprintf("/bin/ts-update-%d", tick)})
+ return cp, nil, nil
+ }, nil)
+ if err != nil {
+ t.Errorf("injected report update (tick %d): %v", tick, err)
+ }
+ }
+ race.run(t)
+ conflicts := before.delta(t)
+
+ require.Equal(t, rounds, race.ticks)
+ require.Equal(t, float64(rounds), conflicts.delete, "exactly one per-TS delete CAS conflict per tick")
+ require.Equal(t, 0.0, conflicts.update)
+ require.Empty(t, race.tickErrs)
+ assertTickRaceOutcome(t, e, race.reports, 0)
+ final := e.mustGet(e.baseKey)
+ for tick := 1; tick <= rounds; tick++ {
+ require.True(t, hasExec(final, fmt.Sprintf("/bin/ts-update-%d", tick)), "the report update of tick %d was merged by the retry, not deleted unmerged", tick)
+ }
+ t.Logf("rounds=%d ticks=%d conflicts(update)=%.0f conflicts(delete)=%.0f", rounds, race.ticks, conflicts.update, conflicts.delete)
+ })
+
+ // Stochastic overlay: paced background writers on the base and on the
+ // newest report, plus the deterministic base injection, so conflicts of
+ // both kinds and double conflicts (a failed tick) can happen; the rows
+ // must still drain in a bounded number of ticks and no update is lost.
+ // The writers' gap is 4× the tick window measured on this build, so most
+ // ticks commit on their first attempt and the reservation escalation
+ // stays mostly out of the picture (the unpaced test below is the one that
+ // exercises it); a fixed wall-clock gap crosses that line under the race
+ // detector. Production producers of base updates are orders of magnitude
+ // sparser than either.
+ t.Run("paced background writers", func(t *testing.T) {
+ e := newObjectStoreEnv(t, withPoolSize(16))
+ e.seedLearningBase()
+ const warmup = 3
+ window := e.measureTickWindow(1, warmup)
+ pace := max(time.Millisecond, 4*window)
+ before := snapshotCASConflicts(t)
+ stop := make(chan struct{})
+ var stopOnce sync.Once
+ var bgBaseUpdates, bgTSUpdates atomic.Int64
+ var newest atomic.Int64
+ newest.Store(1 + warmup)
+ var wg sync.WaitGroup
+ wg.Add(2)
+ t.Cleanup(func() { stopOnce.Do(func() { close(stop) }); wg.Wait() })
+ go func() {
+ defer wg.Done()
+ for {
+ select {
+ case <-stop:
+ return
+ case <-time.After(pace):
+ }
+ if err := e.store.GuaranteedUpdate(e.ctx, e.baseKey, &softwarecomposition.ContainerProfile{}, false, nil, incrementCounter, nil); err != nil {
+ t.Errorf("background base update: %v", err)
+ return
+ }
+ bgBaseUpdates.Add(1)
+ }
+ }()
+ go func() {
+ defer wg.Done()
+ for {
+ select {
+ case <-stop:
+ return
+ case <-time.After(pace):
+ }
+ // ignoreNotFound + an unchanged object for an absent key (already
+ // merged and deleted) is a logged-nothing no-op.
+ out := &softwarecomposition.ContainerProfile{}
+ err := e.store.GuaranteedUpdate(e.ctx, e.tsKey(fmt.Sprintf("r%d", newest.Load())), out, true, nil,
+ func(input runtime.Object, _ storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ cp := input.(*softwarecomposition.ContainerProfile)
+ if cp.Name == "" {
+ return cp, nil, nil
+ }
+ cp = cp.DeepCopy()
+ cp.Labels["ts-touch"] = strconv.FormatInt(time.Now().UnixNano(), 10)
+ return cp, nil, nil
+ }, nil)
+ if err != nil {
+ t.Errorf("background report update: %v", err)
+ return
+ }
+ if out.Name != "" {
+ bgTSUpdates.Add(1)
+ }
+ }
+ }()
+
+ race := &tickRace{e: e, rounds: rounds, perRound: perRound, maxTicksPerRound: 25, reports: 1 + warmup}
+ var injectedUpdates int64
+ race.injectOnce = func(tick, n int) {
+ newest.Store(int64(n))
+ if err := e.store.GuaranteedUpdate(e.ctx, e.baseKey, &softwarecomposition.ContainerProfile{}, false, nil, incrementCounter, nil); err != nil {
+ t.Errorf("injected base update (tick %d): %v", tick, err)
+ return
+ }
+ injectedUpdates++
+ }
+ race.run(t)
+ stopOnce.Do(func() { close(stop) })
+ wg.Wait()
+ conflicts := before.delta(t)
+
+ require.GreaterOrEqual(t, conflicts.update, float64(rounds), "at least the injected conflict of every round's first tick")
+ require.Greater(t, bgBaseUpdates.Load(), int64(0))
+ assertTickRaceOutcome(t, e, race.reports, injectedUpdates+bgBaseUpdates.Load())
+ t.Logf("tickWindow=%s pace=%s rounds=%d ticks=%d worstRoundTicks=%d failedTicks=%d bgBaseUpdates=%d bgTSUpdates=%d conflicts(update)=%.0f conflicts(delete)=%.0f",
+ window, pace, rounds, race.ticks, race.worstRound, len(race.tickErrs), bgBaseUpdates.Load(), bgTSUpdates.Load(), conflicts.update, conflicts.delete)
+ })
+}
+
+// keyReserveCounters snapshots the reservation counters: reserved retries by
+// outcome, writer yields by outcome.
+type keyReserveCounters struct{ committed, conflict, released, timeout float64 }
+
+func snapshotKeyReserve(t *testing.T) keyReserveCounters {
+ t.Helper()
+ return keyReserveCounters{
+ committed: counterValue(t, metrics.ConsolidationKeyReservedTotal.WithLabelValues(metrics.KeyReserveCommitted)),
+ conflict: counterValue(t, metrics.ConsolidationKeyReservedTotal.WithLabelValues(metrics.KeyReserveConflict)),
+ released: counterValue(t, metrics.CPKeyYieldTotal.WithLabelValues(metrics.KeyYieldReleased)),
+ timeout: counterValue(t, metrics.CPKeyYieldTotal.WithLabelValues(metrics.KeyYieldTimeout)),
+ }
+}
+
+func (c keyReserveCounters) delta(t *testing.T) keyReserveCounters {
+ t.Helper()
+ n := snapshotKeyReserve(t)
+ return keyReserveCounters{committed: n.committed - c.committed, conflict: n.conflict - c.conflict, released: n.released - c.released, timeout: n.timeout - c.timeout}
+}
+
+// TestObjectStore_UpdateVsConsolidationTick_UnpacedWriter is the worst case
+// the paced subtest above deliberately stays clear of: a writer committing
+// base updates back-to-back (no gap; ~0.6 ms per commit here, several per
+// tick window). Before the series reservation (sqliteobject_keyreserve.go)
+// this starved the pass — 30/30 ticks conflicting in most runs, a drain after
+// a dozen failed ticks in the rest — because the once-retry re-ran the same
+// 1–3 ms window the writer commits inside. The bound it now proves:
+//
+// Regardless of the writer's pace, a series consolidates within ONE tick —
+// the first attempt may conflict, the reserved retry commits — as long as
+// the retry runs within keyReserveWaitMax and in-flight same-series writes
+// drain within keyReserveDrainMax (1 s each; this retry takes ms).
+//
+// Asserted per round: exactly one tick, no failed tick, no writer wait timed
+// out; over the run: the reserved retry committed at least once (the
+// escalation was exercised, not bypassed), the writer yielded at least once,
+// no update lost. LOAD_TEST=1 raises the rounds.
+func TestObjectStore_UpdateVsConsolidationTick_UnpacedWriter(t *testing.T) {
+ rounds := 8
+ if os.Getenv("LOAD_TEST") == "1" {
+ rounds = 40
+ }
+ const perRound = 3
+ e := newObjectStoreEnv(t, withPoolSize(16))
+ e.seedLearningBase()
+ before := snapshotCASConflicts(t)
+ reserveBefore := snapshotKeyReserve(t)
+ stop := make(chan struct{})
+ var stopOnce sync.Once
+ var bgUpdates atomic.Int64
+ var wg sync.WaitGroup
+ wg.Add(1)
+ t.Cleanup(func() { stopOnce.Do(func() { close(stop) }); wg.Wait() })
+ go func() {
+ defer wg.Done()
+ for {
+ select {
+ case <-stop:
+ return
+ default:
+ }
+ if err := e.store.GuaranteedUpdate(e.ctx, e.baseKey, &softwarecomposition.ContainerProfile{}, false, nil, incrementCounter, nil); err != nil {
+ t.Errorf("background base update: %v", err)
+ return
+ }
+ bgUpdates.Add(1)
+ }
+ }()
+
+ race := &tickRace{e: e, rounds: rounds, perRound: perRound, maxTicksPerRound: 2}
+ race.run(t)
+ stopOnce.Do(func() { close(stop) })
+ wg.Wait()
+ conflicts := before.delta(t)
+ reserve := reserveBefore.delta(t)
+
+ require.Equal(t, rounds, race.ticks, "every round drains in exactly one tick against the unpaced writer")
+ require.Equal(t, 1, race.worstRound)
+ require.Empty(t, race.tickErrs, "no tick failed: the reserved retry commits")
+ require.Equal(t, 0.0, reserve.conflict, "a reserved retry conflicted: a same-series write committed inside its window")
+ require.Equal(t, 0.0, reserve.timeout, "a writer's wait on the reservation timed out")
+ require.Greater(t, reserve.committed, 0.0, "no reserved retry ran: the first attempt never conflicted, so the escalation was not exercised")
+ require.Greater(t, reserve.released, 0.0, "no writer ever yielded to a reservation")
+ require.Greater(t, conflicts.update, 0.0)
+ assertTickRaceOutcome(t, e, race.reports, bgUpdates.Load())
+ require.Empty(t, e.store.reservations.reservedKeys(), "a reservation outlived its retry")
+ t.Logf("rounds=%d ticks=%d bgUpdates=%d conflicts(update)=%.0f reserved(committed)=%.0f reserved(conflict)=%.0f yields(released)=%.0f yields(timeout)=%.0f",
+ rounds, race.ticks, bgUpdates.Load(), conflicts.update, reserve.committed, reserve.conflict, reserve.released, reserve.timeout)
+}
+
+// TestObjectStore_ConsolidationPanicMidStaging_DoesNotCommitPartialWork:
+// a panic between the tick's two staging phases (updateProfile's base/TS
+// merge, staged into the write set; then the processed-TS deletes,
+// BeforeProcessedDeletes fires just before those are staged) must discard
+// the whole write set, not commit whatever was staged before the panic.
+// BeginTransaction's returned finalizer used to check only *errp, which a
+// panic leaves nil; it would commit the partial merge and re-panic, leaving
+// a base materialised (or changed) with its TS report never deleted -- a
+// consolidation the tick never actually completed, persisted anyway.
+func TestObjectStore_ConsolidationPanicMidStaging_DoesNotCommitPartialWork(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ e.create(e.ts("r1", 1, helpersv1.Learning, helpersv1.Partial))
+ require.Equal(t, 1, e.pendingTSRows(e.baseKey), "one report pending consolidation")
+ _, errBefore := e.get(e.baseKey)
+ require.Error(t, errBefore, "the base does not exist before the first tick merges it")
+
+ const panicMsg = "injected: panic between staging phases"
+ e.processor.Hooks.BeforeProcessedDeletes = func(string) { panic(panicMsg) }
+ t.Cleanup(func() { e.processor.Hooks.BeforeProcessedDeletes = nil })
+
+ // consolidateKeyTimeSeries directly, not ConsolidateTimeSeries: the latter
+ // fans work out via errgroup.Group.Go, so the panic would happen in a
+ // different goroutine than this recover() and crash the test binary
+ // instead of being caught here.
+ func() {
+ defer func() {
+ r := recover()
+ require.Equal(t, panicMsg, r, "the original panic must propagate unchanged, not be swallowed")
+ }()
+ _ = e.processor.consolidateKeyTimeSeries(e.ctx, e.baseKey, false)
+ t.Fatal("expected consolidateKeyTimeSeries to panic, it returned normally")
+ }()
+
+ _, errAfter := e.get(e.baseKey)
+ require.Error(t, errAfter, "the base must still not exist: the partial staged write was discarded, not committed")
+ require.Equal(t, 1, e.pendingTSRows(e.baseKey), "the pending report is still pending: the tick never actually completed")
+}
diff --git a/pkg/registry/file/sqliteobject_cpstorage.go b/pkg/registry/file/sqliteobject_cpstorage.go
new file mode 100644
index 000000000..803bb07a5
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_cpstorage.go
@@ -0,0 +1,521 @@
+package file
+
+// objectStoreCPStorage implements ContainerProfileStorage on top of the
+// ObjectStore (design §3.6, §3.7). The connection-in-context convention of the
+// legacy implementation is replaced by a read handle: WithConnection hands the
+// consolidation pass one autocommit pool connection for its Phase 1 reads,
+// BeginTransaction opens a STAGED WRITE SET on that handle, every write called
+// while the set is open is appended to it as prepared SQL (with the CAS
+// predicates captured from the same reads that produced the objects), and the
+// end function returned by BeginTransaction executes the whole set under the
+// gate in one BEGIN IMMEDIATE … COMMIT. Any CAS that matches no row (the
+// base's, or a processed TS object's — R4) rolls the whole tick back and the
+// end function reports ErrWriteConflict; the processor retries once.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/kubescape/go-logger"
+ loggerhelpers "github.com/kubescape/go-logger/helpers"
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/kubescape/storage/pkg/metrics"
+ "k8s.io/apimachinery/pkg/conversion"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/storage"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+type readHandleKey struct{}
+
+// rowVersion is the (rv, uid) a payload was read with: a CAS expectation.
+type rowVersion struct {
+ rv int64
+ uid string
+}
+
+// readHandle is what WithConnection puts in the context: one pool connection
+// for autocommit reads, the versions of every object read through it, and the
+// write set BeginTransaction opened (nil outside a transaction).
+type readHandle struct {
+ store *ObjectStore
+ conn *sqlite.Conn
+ seen map[string]rowVersion
+ ws *writeSet
+}
+
+func withReadHandle(ctx context.Context, h *readHandle) context.Context {
+ return context.WithValue(ctx, readHandleKey{}, h)
+}
+
+func readHandleFrom(ctx context.Context) *readHandle {
+ h, _ := ctx.Value(readHandleKey{}).(*readHandle)
+ return h
+}
+
+func (h *readHandle) record(key string, rv int64, uid string) {
+ if h.seen == nil {
+ h.seen = make(map[string]rowVersion)
+ }
+ h.seen[key] = rowVersion{rv: rv, uid: uid}
+}
+
+// stagedStmt is one prepared statement group of a write set. run contains
+// only sqlitex.Execute calls on already-bound values (INV-1).
+type stagedStmt struct {
+ name string
+ run func(conn *sqlite.Conn) error
+}
+
+// writeSet is the consolidation pass's Phase 2 payload: statements executed
+// in order under the gate, and the Phase 3 events dispatched after COMMIT.
+type writeSet struct {
+ stmts []stagedStmt
+ post []func()
+}
+
+func (ws *writeSet) stage(name string, run func(conn *sqlite.Conn) error) {
+ ws.stmts = append(ws.stmts, stagedStmt{name: name, run: run})
+}
+
+type objectStoreCPStorage struct {
+ s *ObjectStore
+ sbom storage.Interface
+}
+
+var _ ContainerProfileStorage = (*objectStoreCPStorage)(nil)
+var _ TimeSeriesEntryWriter = (*objectStoreCPStorage)(nil)
+var _ ProcessedDeleteStager = (*objectStoreCPStorage)(nil)
+var _ ConsolidationKeyReserver = (*objectStoreCPStorage)(nil)
+
+func newObjectStoreCPStorage(s *ObjectStore, sbom storage.Interface) *objectStoreCPStorage {
+ return &objectStoreCPStorage{s: s, sbom: sbom}
+}
+
+// StagesProcessedDeletes tells the processor to hand the processed-TS deletes
+// to the store BEFORE the end function commits, so they join the tick's
+// transaction (§3.7) instead of running after it.
+func (c *objectStoreCPStorage) StagesProcessedDeletes() bool { return true }
+
+// ReserveConsolidationKey reserves key's series for the pass's retry (the
+// fairness escalation of sqliteobject_keyreserve.go).
+func (c *objectStoreCPStorage) ReserveConsolidationKey(ctx context.Context, key string) (context.Context, func()) {
+ reservedCtx, release, drained := c.s.reservations.reserve(ctx, key)
+ if !drained {
+ logger.L().Warning("objectStoreCPStorage.ReserveConsolidationKey - same-series writes still in flight when the reserved retry began; its CAS may conflict",
+ loggerhelpers.String("key", key))
+ }
+ return reservedCtx, release
+}
+
+// withConn runs fn on the context's read handle connection, or on a pool
+// connection taken for the call.
+func (c *objectStoreCPStorage) withConn(ctx context.Context, op, key string, fn func(conn *sqlite.Conn) error) error {
+ if h := readHandleFrom(ctx); h != nil {
+ return fn(h.conn)
+ }
+ conn, err := c.s.takeConn(ctx, op, key)
+ if err != nil {
+ return err
+ }
+ defer c.s.pool.Put(conn)
+ return fn(conn)
+}
+
+// ---- TransactionManager ----
+
+func (c *objectStoreCPStorage) WithConnection(ctx context.Context) (context.Context, func(), error) {
+ if c.s.hooks.onPoolTake != nil {
+ c.s.hooks.onPoolTake()
+ }
+ beforePool := time.Now()
+ conn, err := c.s.pool.Take(ctx)
+ if err != nil {
+ metrics.ObservePoolWait(ContainerProfileKindPlural, metrics.OutcomeTimeout, time.Since(beforePool))
+ return nil, nil, fmt.Errorf("failed to take connection from pool: %w", err)
+ }
+ metrics.ObservePoolWait(ContainerProfileKindPlural, metrics.OutcomeAcquired, time.Since(beforePool))
+ h := &readHandle{store: c.s, conn: conn}
+ var cleaned bool
+ cleanup := func() {
+ if !cleaned {
+ cleaned = true
+ c.s.pool.Put(conn)
+ }
+ }
+ return withReadHandle(ctx, h), cleanup, nil
+}
+
+// BeginTransaction opens the write set. The returned function commits it
+// under the gate when *err is nil (setting *err to ErrWriteConflict when any
+// CAS in the set matched no row), and discards it otherwise.
+func (c *objectStoreCPStorage) BeginTransaction(ctx context.Context) (func(*error), error) {
+ h := readHandleFrom(ctx)
+ if h == nil {
+ return nil, errors.New("BeginTransaction: no connection in context (call WithConnection first)")
+ }
+ if h.ws != nil {
+ return nil, errors.New("BeginTransaction: a write set is already open on this connection")
+ }
+ ws := &writeSet{}
+ h.ws = ws
+ return func(errp *error) {
+ h.ws = nil
+ // A panic between BeginTransaction and here (e.g. mid-staging, before
+ // all of the tick's writes and processed-TS deletes are appended to
+ // ws) unwinds through this deferred call with *errp still nil: *errp
+ // is only ever set by a normal return path. Checking *errp alone
+ // would then commit whatever was staged before the panic -- a
+ // partial consolidation persisted despite the operation failing.
+ // recover() here is only effective because this function IS the
+ // deferred call (defer endFn(&err)); discard the write set and
+ // re-panic unchanged so the caller's own recover/log path still
+ // sees the original panic.
+ if r := recover(); r != nil {
+ panic(r)
+ }
+ if *errp != nil || len(ws.stmts) == 0 {
+ return
+ }
+ err := c.s.gate.run(ctx, priorityLow, holdPathConsolidate, ContainerProfileKindPlural, func(_ context.Context, conn *sqlite.Conn) error {
+ hook := c.s.stmtHook(conn, holdPathConsolidate)
+ for _, st := range ws.stmts {
+ if err := hook(st.name); err != nil {
+ return err
+ }
+ if err := st.run(conn); err != nil {
+ return err
+ }
+ }
+ return hook("commit")
+ })
+ if err != nil {
+ *errp = err
+ return
+ }
+ c.s.checkpointer.afterCommit()
+ for _, p := range ws.post {
+ p()
+ }
+ }, nil
+}
+
+// ---- reads ----
+
+func (c *objectStoreCPStorage) GetContainerProfile(ctx context.Context, key string) (softwarecomposition.ContainerProfile, error) {
+ return c.getFull(ctx, key)
+}
+
+// GetTsContainerProfile is the same read as GetContainerProfile: there is no
+// per-key lock to bypass.
+func (c *objectStoreCPStorage) GetTsContainerProfile(ctx context.Context, key string) (softwarecomposition.ContainerProfile, error) {
+ return c.getFull(ctx, key)
+}
+
+func (c *objectStoreCPStorage) getFull(ctx context.Context, key string) (softwarecomposition.ContainerProfile, error) {
+ profile := softwarecomposition.ContainerProfile{}
+ err := c.withConn(ctx, "get", key, func(conn *sqlite.Conn) error {
+ row, err := c.s.getWithConn(ctx, conn, key, storage.GetOptions{}, &profile)
+ if err != nil {
+ return err
+ }
+ if h := readHandleFrom(ctx); h != nil && row != nil {
+ h.record(key, row.rv, row.uid)
+ }
+ return nil
+ })
+ return profile, err
+}
+
+func (c *objectStoreCPStorage) GetContainerProfileMetadata(ctx context.Context, key string) (softwarecomposition.ContainerProfile, error) {
+ profile := softwarecomposition.ContainerProfile{}
+ err := c.withConn(ctx, "get", key, func(conn *sqlite.Conn) error {
+ _, err := c.s.getWithConn(ctx, conn, key, storage.GetOptions{ResourceVersion: softwarecomposition.ResourceVersionMetadata}, &profile)
+ return err
+ })
+ return profile, err
+}
+
+// GetContainerProfileMetadataNoLock is GetContainerProfileMetadata: the new
+// store has no per-key lock, so there is nothing to skip.
+func (c *objectStoreCPStorage) GetContainerProfileMetadataNoLock(ctx context.Context, key string) (softwarecomposition.ContainerProfile, error) {
+ return c.GetContainerProfileMetadata(ctx, key)
+}
+
+// GetSbom reads the sbomsyft kind through the legacy StorageImpl that owns it
+// (R7): its per-key lock map is the one the SBOM writer uses. It runs in the
+// prepare phase, never under the gate. When the caller already holds a read
+// handle (every PreSave does), the read reuses that connection through
+// GetWithConn instead of taking a second one: a nested pool acquisition under
+// a full pool spins until the caller's deadline (measured in Tier B as 5 s
+// stalls on ticks and updates before this reuse).
+func (c *objectStoreCPStorage) GetSbom(ctx context.Context, key string) (softwarecomposition.SBOMSyft, error) {
+ sbom := softwarecomposition.SBOMSyft{}
+ if c.sbom == nil {
+ return sbom, storage.NewKeyNotFoundError(key, 0)
+ }
+ if h := readHandleFrom(ctx); h != nil {
+ if legacy, ok := c.sbom.(*StorageImpl); ok {
+ // Absent SBOM (the common case while an image is unscanned): answer
+ // from the metadata row. The legacy full read would open the payload
+ // file, miss, and run its self-repair DELETE FROM metadata even for
+ // zero rows - a write-lock acquisition on an ungated connection that
+ // busy-waits behind the gate's continuous commits (Tier B: every
+ // update/tick PreSave stalled for the whole busy timeout).
+ if _, err := ReadMetadata(h.conn, key); errors.Is(err, ErrMetadataNotFound) {
+ return sbom, storage.NewKeyNotFoundError(key, 0)
+ }
+ return sbom, legacy.GetWithConn(ctx, h.conn, key, storage.GetOptions{}, &sbom)
+ }
+ }
+ err := c.sbom.Get(ctx, key, storage.GetOptions{}, &sbom)
+ return sbom, err
+}
+
+// ---- writes ----
+
+// SaveContainerProfile creates or updates the base profile. Inside a write set
+// the CAS UPDATE is staged with the expectation taken from the row the profile
+// was read from (profile.ResourceVersion / profile.UID come from that read);
+// outside one it is a gated GuaranteedUpdate on the low lane.
+func (c *objectStoreCPStorage) SaveContainerProfile(ctx context.Context, key string, profile *softwarecomposition.ContainerProfile) error {
+ // X-A: input is the persisted object as of this write's own read (the
+ // staged path's Phase 1 read below, or guaranteedUpdate's read/re-read on
+ // conflict) -- fresher than the profile updateProfile's frozen gate
+ // checked at the top of the tick, so this is what actually catches a
+ // completer that raced in between: the pass's own transaction refuses
+ // instead of overwriting, and the next tick's frozen gate reclaims
+ // unmerged. Mirrors legacy's ContainerProfileStorageImpl.SaveContainerProfile.
+ tryUpdate := func(input runtime.Object, res storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ if cur, ok := input.(*softwarecomposition.ContainerProfile); ok && softwarecomposition.IsCompletedFull(cur.Annotations) {
+ metrics.IncConsolidationFrozenRefusals()
+ return nil, nil, ErrProfileFrozen
+ }
+ return profile, nil, nil
+ }
+ cpCtx, cpCancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cpCancel()
+
+ h := readHandleFrom(ctx)
+ if h == nil || h.ws == nil {
+ if err := c.s.guaranteedUpdate(cpCtx, key, &softwarecomposition.ContainerProfile{}, true, nil, tryUpdate, nil, "", priorityLow); err != nil {
+ return fmt.Errorf("failed to update container profile: %w", err)
+ }
+ return nil
+ }
+
+ // Staged: prepare against a fresh autocommit read (the #315 DeepEqual
+ // short-circuit compares against what is persisted), but the CAS
+ // expectation is the profile's own (rv, uid) from the Phase 1 read.
+ phase1RV, err := c.s.versioner.ObjectResourceVersion(profile)
+ if err != nil {
+ return fmt.Errorf("failed to read container profile resource version: %w", err)
+ }
+ var out softwarecomposition.ContainerProfile
+ v, _ := conversion.EnforcePtr(&out)
+ orig, err := c.s.readState(cpCtx, h.conn, key, true, v)
+ if err != nil {
+ return fmt.Errorf("failed to read container profile: %w", err)
+ }
+ if annotations := orig.obj.(*softwarecomposition.ContainerProfile).Annotations; annotations != nil && annotations[helpersv1.StatusMetadataKey] == helpersv1.TooLarge {
+ return nil
+ }
+ pw, err := c.s.prepareUpdate(cpCtx, h, key, true, nil, tryUpdate, orig, true, "")
+ if err != nil {
+ return fmt.Errorf("failed to update container profile: %w", err)
+ }
+ if pw == nil {
+ return nil
+ }
+ // Expect exactly the row Phase 1 read the profile from; a profile
+ // synthesised for an absent base (RV 0) is a create-or-conflict INSERT.
+ pw.insert = phase1RV == 0
+ pw.expectRV = int64(phase1RV)
+ pw.expectUID = pw.uid
+ h.ws.stage("save-base", func(conn *sqlite.Conn) error {
+ return c.s.execUpdate(conn, pw, noHook)
+ })
+ h.ws.post = append(h.ws.post, func() {
+ c.s.watchDispatcher.Modified(key, pw.metaObj, pw.candidate)
+ })
+ return nil
+}
+
+// DeleteContainerProfile deletes key. Inside a write set the DELETE carries
+// the (rv, uid) the object was read with on this handle (R4) and is staged;
+// outside one it is a gated transaction of its own.
+func (c *objectStoreCPStorage) DeleteContainerProfile(ctx context.Context, key string) error {
+ h := readHandleFrom(ctx)
+ if h == nil || h.ws == nil {
+ return c.s.deleteKey(ctx, key, &softwarecomposition.ContainerProfile{}, nil, priorityLow)
+ }
+ var expect *rowVersion
+ if rv, ok := h.seen[key]; ok {
+ expect = &rv
+ }
+ res := &deleteResult{}
+ h.ws.stage("delete-ts", func(conn *sqlite.Conn) error {
+ return c.s.execDelete(conn, key, expect, res, noHook)
+ })
+ h.ws.post = append(h.ws.post, func() {
+ metaOut := &softwarecomposition.ContainerProfile{}
+ _ = json.Unmarshal(res.metadataJSON, metaOut)
+ c.s.watchDispatcher.Deleted(key, metaOut)
+ })
+ return nil
+}
+
+// HealDivergence is a defensive verification, not a repair. The shape the
+// legacy backend's HealDivergence fixes -- a payload whose rename landed
+// while its metadata row's COMMIT did not -- requires two independently
+// observable, separately-timed write steps (a filesystem rename, then a
+// later SQL commit) with a real window between them. The ObjectStore write
+// path (execCreate/execUpdate/execDelete, sqliteobject_store.go) has no such
+// window: the metadata row and the payloads row are two columns of the same
+// stampAndEncode call, written by the same statement list inside the one
+// BEGIN IMMEDIATE ... COMMIT the write gate wraps around it (sqliteobject_
+// gate.go's writeGate.run). SQLite's WAL guarantees that transaction's
+// frames become visible to every other reader together or not at all --
+// recovery after a crash (an OS kill, or a power loss under synchronous=
+// NORMAL, WAL's default) validates the WAL up to the last frame carrying a
+// complete commit and discards anything after: a transaction interrupted
+// mid-write leaves NO row, on either table, not a half-written one. So the
+// "payload says Completed/Full, metadata row does not" shape is not just
+// unlikely here, it is unreachable by construction, on every path including
+// a crash mid-COMMIT.
+//
+// Because that claim could still have a hole, this does not simply trust it:
+// it re-reads metadata and payload from the SAME statement (readRow's join,
+// one SQLite snapshot -- unlike the caller's own two separate reads, which
+// straddle the connection's autocommit boundary and can observe an
+// in-flight commit land between them) and verifies they agree on
+// Completed/Full. A caller landing between those two reads, or racing a
+// concurrent completer, is the ordinary explanation and self-resolves: this
+// re-read is monotonic with the caller's (same connection, later in time),
+// so it can only be equal to or newer than what the caller saw, never older
+// -- see the comment above. Disagreement here is not that; it means the
+// invariant this function exists to confirm does not hold, and is worth
+// paging on, not silently re-persisting a guess. Absence (the row list is
+// empty -- key deleted between the caller's read and this one) is not
+// divergence, exactly as for the legacy backend: nothing to check.
+func (c *objectStoreCPStorage) HealDivergence(ctx context.Context, key string) error {
+ return c.withConn(ctx, "heal", key, func(conn *sqlite.Conn) error {
+ row, err := c.s.readRow(conn, key)
+ if err != nil {
+ return fmt.Errorf("HealDivergence: read row: %w", err)
+ }
+ if row == nil {
+ return nil
+ }
+ var meta softwarecomposition.ContainerProfile
+ if err := json.Unmarshal(row.metadataJSON, &meta); err != nil {
+ return fmt.Errorf("HealDivergence: decode metadata: %w", err)
+ }
+ var payload softwarecomposition.ContainerProfile
+ if err := c.s.decodeBody(row.encoding, row.body, &payload); err != nil {
+ return fmt.Errorf("HealDivergence: decode payload: %w", err)
+ }
+ if softwarecomposition.IsCompletedFull(payload.Annotations) != softwarecomposition.IsCompletedFull(meta.Annotations) {
+ metrics.IncConsolidationDivergence(metrics.DivergencePayloadAhead)
+ logger.L().Error("objectStoreCPStorage.HealDivergence - payload/metadata Completed-Full disagreement observed on the ObjectStore backend, whose single-transaction write path is supposed to make this impossible; no repair attempted",
+ loggerhelpers.String("key", key))
+ }
+ return nil
+ })
+}
+
+// ---- TimeSeriesOperations ----
+
+func (c *objectStoreCPStorage) ListTimeSeriesExpired(ctx context.Context, threshold time.Duration) (keys []string, err error) {
+ err = c.withConn(ctx, "list", "", func(conn *sqlite.Conn) error {
+ keys, err = ListTimeSeriesExpired(conn, threshold)
+ return err
+ })
+ return keys, err
+}
+
+func (c *objectStoreCPStorage) ListTimeSeriesWithData(ctx context.Context) (keys []string, err error) {
+ err = c.withConn(ctx, "list", "", func(conn *sqlite.Conn) error {
+ keys, err = ListTimeSeriesWithData(conn)
+ return err
+ })
+ return keys, err
+}
+
+func (c *objectStoreCPStorage) ListTimeSeriesContainers(ctx context.Context, key string) (out map[string][]softwarecomposition.TimeSeriesContainers, err error) {
+ err = c.withConn(ctx, "list", key, func(conn *sqlite.Conn) error {
+ out, err = ListTimeSeriesContainers(conn, key)
+ return err
+ })
+ return out, err
+}
+
+func (c *objectStoreCPStorage) DeleteTimeSeriesContainerEntries(ctx context.Context, key string) error {
+ run := func(conn *sqlite.Conn) error { return DeleteTimeSeriesContainerEntries(conn, key) }
+ if h := readHandleFrom(ctx); h != nil && h.ws != nil {
+ h.ws.stage("delete-time-series", run)
+ return nil
+ }
+ return c.s.gate.run(ctx, priorityLow, holdPathTimeSeries, ContainerProfileKindPlural, gatedStmt(run))
+}
+
+// gatedStmt adapts a staged statement (which never touches the ctx) to the
+// gate's fn signature.
+func gatedStmt(run func(conn *sqlite.Conn) error) func(context.Context, *sqlite.Conn) error {
+ return func(_ context.Context, conn *sqlite.Conn) error { return run(conn) }
+}
+
+// ReplaceTimeSeriesContainerEntries stages (or runs) the per-series DELETE +
+// INSERTs of ReplaceTimeSeriesContainerEntries with the suffix list marshalled
+// at staging time, so nothing is encoded under the gate.
+func (c *objectStoreCPStorage) ReplaceTimeSeriesContainerEntries(ctx context.Context, key, seriesID string, deleteTimeSeries []string, newTimeSeries []softwarecomposition.TimeSeriesContainers) error {
+ _, _, kind, _, namespace, name := K8sPathToKeys(key)
+ tsSuffixes, err := json.Marshal(deleteTimeSeries)
+ if err != nil {
+ return fmt.Errorf("failed to marshal tsSuffixes: %w", err)
+ }
+ rows := make([]TimeSeriesRow, 0, len(newTimeSeries))
+ for _, p := range newTimeSeries {
+ rows = append(rows, TimeSeriesRow{Kind: kind, Namespace: namespace, Name: name, SeriesID: seriesID, TsSuffix: p.TsSuffix,
+ ReportTimestamp: p.ReportTimestamp, Status: p.Status, Completion: p.Completion, PreviousReportTimestamp: p.PreviousReportTimestamp, HasData: p.HasData})
+ }
+ run := func(conn *sqlite.Conn) error {
+ if err := sqlitex.Execute(conn,
+ `DELETE FROM time_series
+ WHERE kind = ? AND namespace = ? AND name = ? AND seriesID = ?
+ AND tsSuffix IN (SELECT value FROM json_each(?))`,
+ &sqlitex.ExecOptions{Args: []any{kind, namespace, name, seriesID, string(tsSuffixes)}}); err != nil {
+ return fmt.Errorf("delete time series entries: %w", err)
+ }
+ for i := range rows {
+ if err := execTimeSeriesRow(conn, &rows[i]); err != nil {
+ return fmt.Errorf("insert profile: %w", err)
+ }
+ }
+ return nil
+ }
+ if h := readHandleFrom(ctx); h != nil && h.ws != nil {
+ h.ws.stage("replace-time-series", run)
+ return nil
+ }
+ return c.s.gate.run(ctx, priorityLow, holdPathTimeSeries, ContainerProfileKindPlural, gatedStmt(run))
+}
+
+// WriteTimeSeriesEntry is the AfterCreate fallback (a processor without
+// TimeSeriesRowProvider); the ObjectStore's own Create writes the row inside
+// the object's transaction and never calls this.
+func (c *objectStoreCPStorage) WriteTimeSeriesEntry(ctx context.Context, kind, namespace, name, seriesID, tsSuffix, reportTimestamp, status, completion, previousReportTimestamp string, hasData bool) error {
+ row := TimeSeriesRow{Kind: kind, Namespace: namespace, Name: name, SeriesID: seriesID, TsSuffix: tsSuffix,
+ ReportTimestamp: reportTimestamp, Status: status, Completion: completion, PreviousReportTimestamp: previousReportTimestamp, HasData: hasData}
+ run := func(conn *sqlite.Conn) error { return execTimeSeriesRow(conn, &row) }
+ if h := readHandleFrom(ctx); h != nil && h.ws != nil {
+ h.ws.stage("write-time-series", run)
+ return nil
+ }
+ return c.s.gate.run(ctx, priorityLow, holdPathTimeSeries, ContainerProfileKindPlural, gatedStmt(run))
+}
diff --git a/pkg/registry/file/sqliteobject_differential_test.go b/pkg/registry/file/sqliteobject_differential_test.go
new file mode 100644
index 000000000..ca660367f
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_differential_test.go
@@ -0,0 +1,734 @@
+package file
+
+// Differential suite at the storage.Interface / ContainerProfileStorage level
+// (design §9): identical operation sequences against the legacy StorageImpl
+// (singleWriterEnabled in its default position) and the ObjectStore, asserting
+// identical observable results — returned objects, RVs, errors, LIST pages and
+// continue tokens, watch events — EXCEPT the enumerated, intended divergences,
+// each of which is asserted as WHAT differs, not tolerated:
+//
+// 1. AfterCreate crash atomicity (a TS create whose time_series write fails
+// leaves an object without a series row on the old store; nothing on the
+// new one);
+// 2. TS admission after base completion (old admits a TS profile whose base
+// completed between PreSave and commit; new refuses inside the transaction);
+// 3. rowid-stable pagination (old INSERT OR REPLACE moves an updated object
+// to the end of a paginated LIST; new UPDATE keeps its place);
+// 4. GET creationTimestamp truncated to whole seconds on the new store;
+// 5. the R4 per-TS CAS (old silently deletes a TS object updated during the
+// tick; new conflicts once, retries and merges the update);
+// 6. (NOT in the design's list — found by this suite) v1beta1 spec collections
+// without omitempty decode as empty slices from JSON and as nil from gob.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "testing"
+ "time"
+
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition/install"
+ "github.com/kubescape/storage/pkg/config"
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/util/uuid"
+ "k8s.io/apimachinery/pkg/watch"
+ "k8s.io/apiserver/pkg/storage"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitemigration"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+// backend is one side of the differential: a storage.Interface plus the
+// processor and pool behind it, and a watcher on "/" collecting every event.
+type backend struct {
+ name string
+ store storage.Interface
+ processor *ContainerProfileProcessor
+ pool *sqlitemigration.Pool
+ wd *WatchDispatcher
+ watcher watch.Interface
+ // wrap lets a test interpose on the processor before the store is built.
+ env *objectStoreEnv // nil for the legacy backend
+}
+
+type processorWrap func(inner Processor) Processor
+
+func newLegacyBackend(t *testing.T, wrap processorWrap) *backend {
+ t.Helper()
+ dir := t.TempDir()
+ pool := NewPoolWithOptions(filepath.Join(dir, "legacy.sq3"), PoolOptions{BusyTimeout: 5 * time.Second})
+ t.Cleanup(func() { _ = pool.Close() })
+ sch := runtime.NewScheme()
+ install.Install(sch)
+ wd := NewWatchDispatcher()
+ processor := NewContainerProfileProcessor(config.Config{DefaultNamespace: "kubescape", MaxContainerProfileSize: 40000}, nil)
+ processor.Interval = 0
+ processor.Workers = 1
+ processor.DeleteThreshold = 24 * time.Hour
+ var p Processor = processor
+ if wrap != nil {
+ p = wrap(processor)
+ }
+ s := NewStorageImplWithCollector(afero.NewMemMapFs(), DefaultStorageRoot, pool, wd, sch, p)
+ b := &backend{name: "legacy", store: s, processor: processor, pool: pool, wd: wd}
+ b.watcher, _ = s.Watch(context.Background(), "/", storage.ListOptions{ResourceVersion: softwarecomposition.ResourceVersionFullSpec})
+ return b
+}
+
+func newObjectBackend(t *testing.T, wrap processorWrap) *backend {
+ t.Helper()
+ e := newObjectStoreEnv(t)
+ if wrap != nil {
+ // Rebuild the store's processor view through the wrapper; the
+ // ContainerProfileStorage stays wired to the real processor.
+ e.store.processor = wrap(e.processor)
+ }
+ b := &backend{name: "objectstore", store: e.store, processor: e.processor, pool: e.pool, wd: e.wd, env: e}
+ b.watcher, _ = e.store.Watch(context.Background(), "/", storage.ListOptions{ResourceVersion: softwarecomposition.ResourceVersionFullSpec})
+ return b
+}
+
+// drainEvents collects (type, name, rv) of the events delivered so far.
+func (b *backend) drainEvents(t *testing.T) []string {
+ t.Helper()
+ var out []string
+ for {
+ select {
+ case ev := <-b.watcher.ResultChan():
+ cp := ev.Object.(*softwarecomposition.ContainerProfile)
+ out = append(out, fmt.Sprintf("%s %s rv=%s", ev.Type, cp.Name, cp.ResourceVersion))
+ case <-time.After(150 * time.Millisecond):
+ return out
+ }
+ }
+}
+
+func (b *backend) tsRows(t *testing.T, key string) int {
+ t.Helper()
+ conn, err := b.pool.Take(context.Background())
+ require.NoError(t, err)
+ defer b.pool.Put(conn)
+ _, _, kind, _, ns, name := K8sPathToKeys(key)
+ var n int
+ require.NoError(t, sqlitex.Execute(conn, `SELECT count(*) FROM time_series WHERE kind=? AND namespace=? AND name=?`,
+ &sqlitex.ExecOptions{Args: []any{NormalizeContainerProfileKind(kind), ns, name}, ResultFunc: func(stmt *sqlite.Stmt) error { n = int(stmt.ColumnInt64(0)); return nil }}))
+ return n
+}
+
+func (b *backend) metaRowExists(t *testing.T, key string) bool {
+ t.Helper()
+ conn, err := b.pool.Take(context.Background())
+ require.NoError(t, err)
+ defer b.pool.Put(conn)
+ return inspectRow(t, conn, key).metaExists
+}
+
+// fixtures shared by both sides
+type diffFixtures struct {
+ tpl softwarecomposition.ContainerProfile
+ baseNm string
+ ns string
+ baseKey string
+ now time.Time
+}
+
+func loadDiffFixtures(t *testing.T) diffFixtures {
+ t.Helper()
+ content, err := os.ReadFile("testdata/p1.json")
+ require.NoError(t, err)
+ var tpl softwarecomposition.ContainerProfile
+ require.NoError(t, json.Unmarshal(content, &tpl))
+ baseNm, _ := SplitProfileName(tpl.Name)
+ // Endpoint header values come out of AnalyzeEndpoints in map-iteration
+ // order, so a re-merge of identical data can randomly look "changed" to the
+ // #315 DeepEqual short-circuit and bump the RV — on either backend. Drop
+ // them so RV sequences are deterministic.
+ tpl.Spec.Endpoints = nil
+ return diffFixtures{tpl: tpl, baseNm: baseNm, ns: tpl.Namespace, baseKey: testCPPrefix + tpl.Namespace + "/" + baseNm, now: time.Now().Round(0)}
+}
+
+func (f diffFixtures) ts(suffix string, n int, status, completion string) *softwarecomposition.ContainerProfile {
+ p := f.tpl.DeepCopy()
+ p.Name = f.baseNm + "-" + suffix
+ p.ResourceVersion = ""
+ p.UID = uuid.NewUUID()
+ prev := "0001-01-01 00:00:00 +0000 UTC"
+ if n > 1 {
+ prev = f.now.Add(time.Duration(n-11) * time.Minute).String()
+ }
+ p.Annotations[helpersv1.ReportTimestampMetadataKey] = f.now.Add(time.Duration(n-10) * time.Minute).String()
+ p.Annotations[helpersv1.PreviousReportTimestampMetadataKey] = prev
+ p.Annotations[helpersv1.StatusMetadataKey] = status
+ p.Annotations[helpersv1.CompletionMetadataKey] = completion
+ return p
+}
+
+func (f diffFixtures) plain(name string) *softwarecomposition.ContainerProfile {
+ p := f.tpl.DeepCopy()
+ p.Name = name
+ p.ResourceVersion = ""
+ p.UID = uuid.NewUUID()
+ delete(p.Annotations, helpersv1.ReportSeriesIdMetadataKey)
+ return p
+}
+
+func (f diffFixtures) key(name string) string { return testCPPrefix + f.ns + "/" + name }
+
+func (f diffFixtures) tsKey(suffix string) string { return f.baseKey + "-" + suffix }
+
+// errClass classifies an error the way the REST layer would.
+func errClass(err error) string {
+ switch {
+ case err == nil:
+ return "nil"
+ case storage.IsNotFound(err):
+ return "NotFound"
+ case storage.IsExist(err):
+ return "KeyExists"
+ case storage.IsConflict(err):
+ return "Conflict"
+ case errors.Is(err, ObjectCompletedError):
+ return "ObjectCompleted"
+ case errors.Is(err, ObjectTooLargeError):
+ return "ObjectTooLarge"
+ default:
+ return "other:" + err.Error()
+ }
+}
+
+// TestDifferential_Storage_IdenticalSequence runs the same script on both
+// backends and compares every observable, pinning divergences 4 and 6.
+func TestDifferential_Storage_IdenticalSequence(t *testing.T) {
+ f := loadDiffFixtures(t)
+ sides := []*backend{newLegacyBackend(t, nil), newObjectBackend(t, nil)}
+ ctx := context.Background()
+
+ type result struct {
+ errs []string
+ rvs []string
+ objs []*softwarecomposition.ContainerProfile // raw GET results
+ lists [][]string
+ conts []string
+ events []string
+ tsRows []int
+ metaRow []bool
+ }
+ run := func(b *backend) result {
+ var r result
+ rec := func(err error) { r.errs = append(r.errs, errClass(err)) }
+ out := &softwarecomposition.ContainerProfile{}
+ // TS creates (three chained reports)
+ for i, sfx := range []string{"r1", "r2", "r3"} {
+ rec(b.store.Create(ctx, f.tsKey(sfx), f.ts(sfx, i+1, helpersv1.Learning, helpersv1.Partial), out, 0))
+ r.rvs = append(r.rvs, out.ResourceVersion)
+ }
+ rec(b.store.Create(ctx, f.tsKey("r1"), f.ts("r1", 1, helpersv1.Learning, helpersv1.Partial), out, 0)) // KeyExists
+ r.tsRows = append(r.tsRows, b.tsRows(t, f.baseKey))
+ // consolidation
+ rec(b.processor.ConsolidateTimeSeries(ctx))
+ r.tsRows = append(r.tsRows, b.tsRows(t, f.baseKey))
+ for _, sfx := range []string{"r1", "r2", "r3"} {
+ r.metaRow = append(r.metaRow, b.metaRowExists(t, f.tsKey(sfx)))
+ }
+ got := &softwarecomposition.ContainerProfile{}
+ rec(b.store.Get(ctx, f.baseKey, storage.GetOptions{}, got))
+ r.objs = append(r.objs, got.DeepCopy())
+ r.rvs = append(r.rvs, got.ResourceVersion)
+ // second tick: no new data → no write, no RV bump
+ rec(b.processor.ConsolidateTimeSeries(ctx))
+ got2 := &softwarecomposition.ContainerProfile{}
+ rec(b.store.Get(ctx, f.baseKey, storage.GetOptions{}, got2))
+ r.rvs = append(r.rvs, got2.ResourceVersion)
+ // REST update of the base, then a no-op update
+ rec(b.store.GuaranteedUpdate(ctx, f.baseKey, out, false, nil, setLabel("diff", "1"), nil))
+ r.rvs = append(r.rvs, out.ResourceVersion)
+ rec(b.store.GuaranteedUpdate(ctx, f.baseKey, out, false, nil, identityTryUpdate, nil))
+ r.rvs = append(r.rvs, out.ResourceVersion)
+ // stale-RV precondition
+ stale := &storage.Preconditions{ResourceVersion: strPtr("1")}
+ rec(b.store.GuaranteedUpdate(ctx, f.baseKey, out, false, stale, setLabel("diff", "2"), nil))
+ // update / get / delete of an absent key
+ rec(b.store.GuaranteedUpdate(ctx, f.key("absent"), out, false, nil, setLabel("x", "y"), nil))
+ rec(b.store.Get(ctx, f.key("absent"), storage.GetOptions{}, got))
+ rec(b.store.Get(ctx, f.key("absent"), storage.GetOptions{IgnoreNotFound: true}, got))
+ // plain objects for LIST
+ for _, n := range []string{"pa", "pb"} {
+ rec(b.store.Create(ctx, f.key(n), f.plain(n), out, 0))
+ }
+ for _, rv := range []string{softwarecomposition.ResourceVersionMetadata, softwarecomposition.ResourceVersionFullSpec} {
+ l := &softwarecomposition.ContainerProfileList{}
+ rec(b.store.GetList(ctx, testCPPrefix+f.ns, storage.ListOptions{ResourceVersion: rv, Recursive: true, Predicate: storage.SelectionPredicate{Limit: 2}}, l))
+ var names []string
+ for _, it := range l.Items {
+ names = append(names, it.Name)
+ }
+ r.lists = append(r.lists, names)
+ r.conts = append(r.conts, l.Continue)
+ l2 := &softwarecomposition.ContainerProfileList{}
+ rec(b.store.GetList(ctx, testCPPrefix+f.ns, storage.ListOptions{ResourceVersion: rv, Recursive: true, Predicate: storage.SelectionPredicate{Limit: 2, Continue: l.Continue}}, l2))
+ names = nil
+ for _, it := range l2.Items {
+ names = append(names, it.Name)
+ }
+ r.lists = append(r.lists, names)
+ r.conts = append(r.conts, l2.Continue)
+ }
+ // delete
+ del := &softwarecomposition.ContainerProfile{}
+ rec(b.store.Delete(ctx, f.key("pa"), del, nil, nil, nil, storage.DeleteOptions{}))
+ r.rvs = append(r.rvs, del.ResourceVersion)
+ rec(b.store.Get(ctx, f.key("pa"), storage.GetOptions{}, got))
+ r.events = b.drainEvents(t)
+ return r
+ }
+ old, nw := run(sides[0]), run(sides[1])
+
+ assert.Equal(t, old.errs, nw.errs, "error classes")
+ assert.Equal(t, old.rvs, nw.rvs, "resource versions")
+ assert.Equal(t, old.lists, nw.lists, "LIST pages")
+ // Continue tokens are rowids. Divergence 3's side effect: the one REST
+ // update of the base re-inserted its row on the old store (INSERT OR
+ // REPLACE), so every later rowid — and token — is exactly one higher there.
+ require.Len(t, nw.conts, len(old.conts))
+ for i := range old.conts {
+ if old.conts[i] == "" || nw.conts[i] == "" {
+ assert.Equal(t, old.conts[i], nw.conts[i], "continue token emptiness")
+ continue
+ }
+ o, err := strconv.Atoi(old.conts[i])
+ require.NoError(t, err)
+ n, err := strconv.Atoi(nw.conts[i])
+ require.NoError(t, err)
+ assert.Equal(t, n+1, o, "old continue token is the new one plus the single re-insert")
+ }
+ assert.Equal(t, old.tsRows, nw.tsRows, "time_series rows")
+ assert.Equal(t, old.metaRow, nw.metaRow, "processed TS objects deleted")
+ assert.Equal(t, old.events, nw.events, "watch events (type, name, rv)")
+
+ // The consolidated base: identical modulo the two codec deltas.
+ require.Len(t, old.objs, 1)
+ o, n := old.objs[0], nw.objs[0]
+ // UIDs are generated independently per backend; compare everything else.
+ n.UID, o.UID = "", ""
+ delete(o.Annotations, helpersv1.SyncChecksumMetadataKey)
+ delete(n.Annotations, helpersv1.SyncChecksumMetadataKey)
+ // Divergence 4: sub-second creationTimestamp. Each backend stamped its
+ // own metav1.Now() during its tick, so only the precision property is
+ // comparable, not the instant (the two runs may straddle a second).
+ assert.NotEqual(t, 0, o.CreationTimestamp.Nanosecond(), "old GET keeps the nanoseconds consolidation stamped")
+ assert.Equal(t, 0, n.CreationTimestamp.Nanosecond(), "new GET truncates to whole seconds")
+ assert.WithinDuration(t, o.CreationTimestamp.Time, n.CreationTimestamp.Time, 5*time.Second)
+ o.CreationTimestamp, n.CreationTimestamp = metav1.Time{}, metav1.Time{}
+ assert.Equal(t, canonicalCP(o), canonicalCP(n), "consolidated base differs beyond the documented codec deltas")
+ // Divergence 6: nil vs empty for non-omitempty spec collections (PreSave's
+ // deflate leaves Execs as an empty, non-nil slice; gob drops it, JSON keeps it).
+ assert.Nil(t, o.Spec.Execs, "old: empty execs decode to nil (gob)")
+ assert.NotNil(t, n.Spec.Execs, "new: empty execs decode to [] (json, no omitempty)")
+ assert.Empty(t, n.Spec.Execs)
+}
+
+func strPtr(s string) *string { return &s }
+
+// failingAfterCreate makes the legacy processor's post-commit time_series
+// write fail: divergence 1's old-side injection.
+type failingAfterCreate struct{ Processor }
+
+func (f failingAfterCreate) AfterCreate(context.Context, runtime.Object) error {
+ return errors.New("injected AfterCreate failure")
+}
+
+func (f failingAfterCreate) TimeSeriesRowFor(o runtime.Object) (TimeSeriesRow, string, bool) {
+ return f.Processor.(TimeSeriesRowProvider).TimeSeriesRowFor(o)
+}
+
+// TestDifferential_1_AfterCreateCrashAtomicity: the old store commits the TS
+// object and then fails to record its time_series row on a second connection
+// (an object no consolidation tick will ever list); the new store's row joins
+// the object's transaction, so the same failure leaves nothing at all.
+func TestDifferential_1_AfterCreateCrashAtomicity(t *testing.T) {
+ f := loadDiffFixtures(t)
+ ctx := context.Background()
+
+ old := newLegacyBackend(t, func(p Processor) Processor { return failingAfterCreate{p} })
+ err := old.store.Create(ctx, f.tsKey("r1"), f.ts("r1", 1, helpersv1.Learning, helpersv1.Partial), nil, 0)
+ require.Error(t, err)
+ assert.True(t, old.metaRowExists(t, f.tsKey("r1")), "OLD: the object is committed …")
+ assert.Equal(t, 0, old.tsRows(t, f.baseKey), "OLD: … without its time_series row (the divergent state)")
+
+ nw := newObjectBackend(t, nil)
+ nw.env.store.hooks.beforeStatement = func(_ *sqlite.Conn, path string, _ int, name string) error {
+ if path == holdPathCreate && name == "insert-time-series" {
+ return errors.New("injected time_series failure")
+ }
+ return nil
+ }
+ err = nw.store.Create(ctx, f.tsKey("r1"), f.ts("r1", 1, helpersv1.Learning, helpersv1.Partial), nil, 0)
+ require.Error(t, err)
+ assert.False(t, nw.metaRowExists(t, f.tsKey("r1")), "NEW: the object is not committed either")
+ assert.Equal(t, 0, nw.tsRows(t, f.baseKey))
+}
+
+// completingPreSave lets the inner PreSave admit the TS profile, then flips
+// the base to Completed/Full — the "gap 1" race between PreSave's unlocked
+// metadata read and the create's commit.
+type completingPreSave struct {
+ Processor
+ flip func()
+}
+
+func (c *completingPreSave) TimeSeriesRowFor(o runtime.Object) (TimeSeriesRow, string, bool) {
+ return c.Processor.(TimeSeriesRowProvider).TimeSeriesRowFor(o)
+}
+
+func (c *completingPreSave) PreSave(ctx context.Context, o runtime.Object) error {
+ if err := c.Processor.PreSave(ctx, o); err != nil {
+ return err
+ }
+ if cp, ok := o.(*softwarecomposition.ContainerProfile); ok && cp.Annotations[helpersv1.ReportSeriesIdMetadataKey] != "" && c.flip != nil {
+ flip := c.flip
+ c.flip = nil
+ flip()
+ }
+ return nil
+}
+
+// TestDifferential_2_TSAdmissionAfterBaseCompletion: old admits (then reclaims
+// on a later tick), new refuses inside the transaction.
+func TestDifferential_2_TSAdmissionAfterBaseCompletion(t *testing.T) {
+ f := loadDiffFixtures(t)
+ ctx := context.Background()
+ completeBase := func(s storage.Interface) func() {
+ return func() {
+ out := &softwarecomposition.ContainerProfile{}
+ err := s.GuaranteedUpdate(ctx, f.baseKey, out, false, nil, func(in runtime.Object, _ storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ cp := in.(*softwarecomposition.ContainerProfile)
+ cp.Annotations[helpersv1.StatusMetadataKey] = helpersv1.Completed
+ cp.Annotations[helpersv1.CompletionMetadataKey] = helpersv1.Full
+ return cp, nil, nil
+ }, nil)
+ require.NoError(t, err)
+ }
+ }
+
+ oldWrap := &completingPreSave{}
+ old := newLegacyBackend(t, func(p Processor) Processor { oldWrap.Processor = p; return oldWrap })
+ require.NoError(t, old.store.Create(ctx, f.key(f.baseNm), f.plain(f.baseNm), nil, 0)) // Learning base
+ oldWrap.flip = completeBase(old.store)
+ err := old.store.Create(ctx, f.tsKey("late"), f.ts("late", 1, helpersv1.Learning, helpersv1.Partial), nil, 0)
+ assert.NoError(t, err, "OLD: admitted — PreSave saw a Learning base")
+ assert.True(t, old.metaRowExists(t, f.tsKey("late")))
+ assert.Equal(t, 1, old.tsRows(t, f.baseKey), "OLD: a time_series row for a Completed/Full base")
+
+ nwWrap := &completingPreSave{}
+ nw := newObjectBackend(t, func(p Processor) Processor { nwWrap.Processor = p; return nwWrap })
+ require.NoError(t, nw.store.Create(ctx, f.key(f.baseNm), f.plain(f.baseNm), nil, 0))
+ nwWrap.flip = completeBase(nw.store)
+ err = nw.store.Create(ctx, f.tsKey("late"), f.ts("late", 1, helpersv1.Learning, helpersv1.Partial), nil, 0)
+ assert.ErrorIs(t, err, ObjectCompletedError, "NEW: refused by the in-transaction admission check")
+ assert.False(t, nw.metaRowExists(t, f.tsKey("late")))
+ assert.Equal(t, 0, nw.tsRows(t, f.baseKey))
+}
+
+// TestDifferential_3_RowidStablePagination: page 1 = [a b]; update a; page 2
+// is [c a] on the old store (INSERT OR REPLACE re-inserts a at a new rowid) and
+// [c] on the new one (UPDATE keeps the rowid).
+func TestDifferential_3_RowidStablePagination(t *testing.T) {
+ f := loadDiffFixtures(t)
+ ctx := context.Background()
+ page := func(b *backend, cont string) ([]string, string) {
+ l := &softwarecomposition.ContainerProfileList{}
+ require.NoError(t, b.store.GetList(ctx, testCPPrefix+f.ns, storage.ListOptions{ResourceVersion: softwarecomposition.ResourceVersionMetadata, Recursive: true, Predicate: storage.SelectionPredicate{Limit: 2, Continue: cont}}, l))
+ var names []string
+ for _, it := range l.Items {
+ names = append(names, it.Name)
+ }
+ return names, l.Continue
+ }
+ run := func(b *backend) (p1, p2 []string) {
+ for _, n := range []string{"pg-a", "pg-b", "pg-c"} {
+ require.NoError(t, b.store.Create(ctx, f.key(n), f.plain(n), nil, 0))
+ }
+ p1, cont := page(b, "")
+ require.NoError(t, b.store.GuaranteedUpdate(ctx, f.key("pg-a"), &softwarecomposition.ContainerProfile{}, false, nil, setLabel("k", "v"), nil))
+ p2, _ = page(b, cont)
+ return p1, p2
+ }
+ op1, op2 := run(newLegacyBackend(t, nil))
+ np1, np2 := run(newObjectBackend(t, nil))
+ assert.Equal(t, []string{"pg-a", "pg-b"}, op1)
+ assert.Equal(t, []string{"pg-a", "pg-b"}, np1)
+ assert.Equal(t, []string{"pg-c", "pg-a"}, op2, "OLD: the updated object reappears on page 2")
+ assert.Equal(t, []string{"pg-c"}, np2, "NEW: each object exactly once")
+}
+
+// TestDifferential_5_PerTSCASConflict: a TS object updated between the tick's
+// reads and its deletes. Old: the update lands after the commit and the
+// unconditional delete discards it. New: the staged DELETE … WHERE rv=:rv
+// conflicts, the tick rolls back and retries once, merging the update.
+func TestDifferential_5_PerTSCASConflict(t *testing.T) {
+ f := loadDiffFixtures(t)
+ ctx := context.Background()
+ marker := softwarecomposition.ExecCalls{Path: "/bin/injected-during-tick", Args: []string{"/bin/injected-during-tick"}}
+ addExec := func(in runtime.Object, _ storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ cp := in.(*softwarecomposition.ContainerProfile)
+ cp.Spec.Execs = append(cp.Spec.Execs, marker)
+ return cp, nil, nil
+ }
+ hasMarker := func(cp *softwarecomposition.ContainerProfile) bool {
+ for _, e := range cp.Spec.Execs {
+ if e.Path == marker.Path {
+ return true
+ }
+ }
+ return false
+ }
+ run := func(b *backend) (baseHasUpdate bool, tsGone bool, conflicts int) {
+ require.NoError(t, b.store.Create(ctx, f.tsKey("r1"), f.ts("r1", 1, helpersv1.Learning, helpersv1.Partial), nil, 0))
+ fired := 0
+ b.processor.Hooks.BeforeProcessedDeletes = func(key string) {
+ fired++
+ if fired > 1 {
+ return // the retry must not race again
+ }
+ require.NoError(t, b.store.GuaranteedUpdate(ctx, f.tsKey("r1"), &softwarecomposition.ContainerProfile{}, false, nil, addExec, nil))
+ }
+ require.NoError(t, b.processor.ConsolidateTimeSeries(ctx))
+ base := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, b.store.Get(ctx, f.baseKey, storage.GetOptions{}, base))
+ return hasMarker(base), !b.metaRowExists(t, f.tsKey("r1")), fired - 1
+ }
+ oldUpd, oldGone, oldRetries := run(newLegacyBackend(t, nil))
+ assert.False(t, oldUpd, "OLD: the update is silently lost")
+ assert.True(t, oldGone, "OLD: the TS object is deleted by key")
+ assert.Equal(t, 0, oldRetries)
+
+ nwUpd, nwGone, nwRetries := run(newObjectBackend(t, nil))
+ assert.True(t, nwUpd, "NEW: the retried tick merged the update")
+ assert.True(t, nwGone, "NEW: the TS object is deleted after the merge")
+ assert.Equal(t, 1, nwRetries, "NEW: exactly one conflict → one retry")
+}
+
+// TestINV3_FrozenBaseParity: X-A (a Completed/Full base is never merged into)
+// is now on origin/main (Lane 0, PR #399) as the frozen gate at the top of
+// ContainerProfileProcessor.updateProfile, which reclaims a late report
+// unmerged before either backend's SaveContainerProfile is reached — shared,
+// backend-uniform code. This asserts the real guarantee, identically on both
+// backends: a late report arriving after the base is already Completed/Full
+// changes nothing (RV, spec, annotations, no Modified event) and its series
+// rows are reclaimed (deleted unmerged), not left pending.
+//
+// This exercises only the steady-state case: the base is already frozen
+// BEFORE the tick that reads it starts. It does not exercise a completer
+// racing IN BETWEEN a tick's frozen-gate read and its own save — see
+// TestX_A_SaveRefusesConcurrentlyFrozenBase for that window.
+func TestINV3_FrozenBaseParity(t *testing.T) {
+ f := loadDiffFixtures(t)
+ ctx := context.Background()
+ type outcome struct {
+ rvBefore, rvAfter string
+ status, compl string
+ execs int
+ events []string
+ tsRows int
+ }
+ run := func(b *backend) outcome {
+ require.NoError(t, b.store.Create(ctx, f.tsKey("r1"), f.ts("r1", 1, helpersv1.Completed, helpersv1.Full), nil, 0))
+ require.NoError(t, b.processor.ConsolidateTimeSeries(ctx))
+ frozen := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, b.store.Get(ctx, f.baseKey, storage.GetOptions{}, frozen))
+ require.Equal(t, helpersv1.Completed, frozen.Annotations[helpersv1.StatusMetadataKey])
+ require.Equal(t, helpersv1.Full, frozen.Annotations[helpersv1.CompletionMetadataKey])
+ b.drainEvents(t)
+
+ // A late report cannot be created through the guarded path (PreSave
+ // refuses); seed its object and time_series row directly, the state a
+ // create that raced ahead of consolidation leaves behind.
+ late := f.ts("late", 2, helpersv1.Learning, helpersv1.Partial)
+ late.Spec.Execs = append(late.Spec.Execs, softwarecomposition.ExecCalls{Path: "/bin/late"})
+ // The seed row goes through the ObjectStore side's fixture handle (its
+ // pool is gated: a pool connection would be an ungated writer under
+ // AC-G1); the legacy side has no gate and seeds on a pool connection.
+ var conn *sqlite.Conn
+ put := func() {}
+ if b.env != nil {
+ saved := b.env.store.processor
+ b.env.store.processor = DefaultProcessor{}
+ require.NoError(t, b.store.Create(ctx, f.tsKey("late"), late, nil, 0))
+ b.env.store.processor = saved
+ conn = b.env.fixture
+ } else {
+ c, err := b.pool.Take(ctx)
+ require.NoError(t, err)
+ conn, put = c, func() { b.pool.Put(c) }
+ _, err = b.store.(*StorageImpl).saveObject(context.Background(), conn, f.tsKey("late"), late, nil, "", priorityLow, holdPathLegacyCommit)
+ require.NoError(t, err)
+ }
+ require.NoError(t, WriteTimeSeriesEntry(conn, ContainerProfileKind, f.ns, f.baseNm, late.Annotations[helpersv1.ReportSeriesIdMetadataKey], "late",
+ late.Annotations[helpersv1.ReportTimestampMetadataKey], helpersv1.Learning, helpersv1.Partial, late.Annotations[helpersv1.PreviousReportTimestampMetadataKey], true))
+ put()
+ b.drainEvents(t) // the seeding itself dispatched on one backend only
+
+ require.NoError(t, b.processor.ConsolidateTimeSeries(ctx))
+ after := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, b.store.Get(ctx, f.baseKey, storage.GetOptions{}, after))
+ return outcome{
+ rvBefore: frozen.ResourceVersion, rvAfter: after.ResourceVersion,
+ status: after.Annotations[helpersv1.StatusMetadataKey], compl: after.Annotations[helpersv1.CompletionMetadataKey],
+ execs: len(after.Spec.Execs), events: b.drainEvents(t), tsRows: b.tsRows(t, f.baseKey),
+ }
+ }
+ old := run(newLegacyBackend(t, nil))
+ nw := run(newObjectBackend(t, nil))
+ assert.Equal(t, old, nw, "frozen-base handling must be identical on both backends")
+ // X-A's real guarantee, now that Lane 0 is merged: the late report changes
+ // nothing. RV is untouched, no Execs merged, no Modified event, and its
+ // series row was reclaimed (deleted unmerged) rather than left pending.
+ for _, r := range []struct {
+ name string
+ o outcome
+ }{{"legacy", old}, {"objectstore", nw}} {
+ assert.Equal(t, r.o.rvBefore, r.o.rvAfter, "%s: a frozen base's RV must not move", r.name)
+ assert.Equal(t, helpersv1.Completed, r.o.status, "%s", r.name)
+ assert.Equal(t, helpersv1.Full, r.o.compl, "%s", r.name)
+ assert.Zero(t, r.o.execs, "%s: the late report's Execs must never be merged into a frozen base", r.name)
+ for _, ev := range r.o.events {
+ assert.NotContains(t, ev, "MODIFIED "+f.baseNm, "%s: a reclaimed-unmerged tick dispatches no Modified event on the base (a DELETED event for the reclaimed TS object is expected): %v", r.name, r.o.events)
+ }
+ assert.Zero(t, r.o.tsRows, "%s: the late report's series row is reclaimed (deleted unmerged), not left pending", r.name)
+ }
+}
+
+// TestX_A_SaveRefusesConcurrentlyFrozenBase exercises the window
+// TestINV3_FrozenBaseParity does not: a completer that lands IN BETWEEN a
+// consolidation pass's own frozen-gate read (updateProfile, at the top of the
+// tick) and that same pass's SaveContainerProfile call. The pass's merge was
+// prepared against a base that was NOT yet Completed/Full; by the time it
+// saves, another replica finished it first. X-A requires the save itself to
+// refuse (ErrProfileFrozen) and leave the persisted state untouched — this is
+// enforced inside SaveContainerProfile's own tryUpdate closure (re-checking
+// the row's state at write time, not the pass's stale read), independently of
+// the frozen gate in updateProfile, which by construction cannot see this
+// race (it already ran, before the completer landed).
+func TestX_A_SaveRefusesConcurrentlyFrozenBase(t *testing.T) {
+ f := loadDiffFixtures(t)
+ ctx := context.Background()
+ type outcome struct {
+ refused bool
+ rvBeforeRace, rvAfter string
+ status, compl string
+ events []string
+ }
+ run := func(b *backend) outcome {
+ // 1. One partial report: the base lands Learning, not yet Full.
+ require.NoError(t, b.store.Create(ctx, f.tsKey("r1"), f.ts("r1", 1, helpersv1.Learning, helpersv1.Partial), nil, 0))
+ require.NoError(t, b.processor.ConsolidateTimeSeries(ctx))
+ stale := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, b.store.Get(ctx, f.baseKey, storage.GetOptions{}, stale))
+ require.Equal(t, helpersv1.Learning, stale.Annotations[helpersv1.StatusMetadataKey], "sanity: base must not already be frozen")
+ // stale is exactly what a consolidation pass's loadOrInitializeProfile
+ // would have read and merged into, at the instant before a concurrent
+ // completer lands.
+ stale = stale.DeepCopy()
+ stale.Spec.Execs = append(stale.Spec.Execs, softwarecomposition.ExecCalls{Path: "/bin/late-merge"})
+
+ // 2. A concurrent completer finishes the base first.
+ require.NoError(t, b.store.Create(ctx, f.tsKey("r2"), f.ts("r2", 2, helpersv1.Completed, helpersv1.Full), nil, 0))
+ require.NoError(t, b.processor.ConsolidateTimeSeries(ctx))
+ frozen := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, b.store.Get(ctx, f.baseKey, storage.GetOptions{}, frozen))
+ require.Equal(t, helpersv1.Completed, frozen.Annotations[helpersv1.StatusMetadataKey])
+ require.Equal(t, helpersv1.Full, frozen.Annotations[helpersv1.CompletionMetadataKey])
+ b.drainEvents(t)
+
+ // 3. The delayed pass now tries to save its stale merge directly
+ // through the storage layer, bypassing updateProfile's own (already
+ // stale) frozen-gate read — exactly the race window.
+ err := b.processor.ContainerProfileStorage.SaveContainerProfile(ctx, f.baseKey, stale)
+
+ after := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, b.store.Get(ctx, f.baseKey, storage.GetOptions{}, after))
+ return outcome{
+ refused: errors.Is(err, ErrProfileFrozen),
+ rvBeforeRace: frozen.ResourceVersion, rvAfter: after.ResourceVersion,
+ status: after.Annotations[helpersv1.StatusMetadataKey], compl: after.Annotations[helpersv1.CompletionMetadataKey],
+ events: b.drainEvents(t),
+ }
+ }
+ old := run(newLegacyBackend(t, nil))
+ nw := run(newObjectBackend(t, nil))
+ assert.Equal(t, old, nw, "the race-window refusal must be identical on both backends")
+ for _, r := range []struct {
+ name string
+ o outcome
+ }{{"legacy", old}, {"objectstore", nw}} {
+ assert.True(t, r.o.refused, "%s: SaveContainerProfile must refuse a stale merge against a concurrently-completed base", r.name)
+ assert.Equal(t, r.o.rvBeforeRace, r.o.rvAfter, "%s: the completer's commit must survive untouched", r.name)
+ assert.Equal(t, helpersv1.Completed, r.o.status, "%s", r.name)
+ assert.Equal(t, helpersv1.Full, r.o.compl, "%s", r.name)
+ assert.Empty(t, r.o.events, "%s: a refused save dispatches no Modified event", r.name)
+ }
+}
+
+// TestDifferential_ConsolidationGolden runs the consolidation golden windows
+// of the legacy store's oracle through the ObjectStore and compares the base's
+// spec and annotations after each tick with the legacy result.
+func TestDifferential_ConsolidationWindows(t *testing.T) {
+ f := loadDiffFixtures(t)
+ ctx := context.Background()
+ type snap struct {
+ rv string
+ status string
+ compl string
+ execs int
+ tsRows int
+ }
+ run := func(b *backend) []snap {
+ var out []snap
+ snapshot := func() {
+ base := &softwarecomposition.ContainerProfile{}
+ _ = b.store.Get(ctx, f.baseKey, storage.GetOptions{IgnoreNotFound: true}, base)
+ out = append(out, snap{base.ResourceVersion, base.Annotations[helpersv1.StatusMetadataKey], base.Annotations[helpersv1.CompletionMetadataKey], len(base.Spec.Execs), b.tsRows(t, f.baseKey)})
+ }
+ // window 1: two chained learning reports
+ for i, sfx := range []string{"r1", "r2"} {
+ p := f.ts(sfx, i+1, helpersv1.Learning, helpersv1.Partial)
+ p.Spec.Execs = append(p.Spec.Execs, softwarecomposition.ExecCalls{Path: "/bin/" + sfx})
+ require.NoError(t, b.store.Create(ctx, f.tsKey(sfx), p, nil, 0))
+ }
+ require.NoError(t, b.processor.ConsolidateTimeSeries(ctx))
+ snapshot()
+ // window 2: a gap (report 5 without 3,4) keeps it Learning
+ p := f.ts("r5", 5, helpersv1.Learning, helpersv1.Partial)
+ require.NoError(t, b.store.Create(ctx, f.tsKey("r5"), p, nil, 0))
+ require.NoError(t, b.processor.ConsolidateTimeSeries(ctx))
+ snapshot()
+ // window 3: the completing report closes the chain
+ for i, sfx := range []string{"r3", "r4"} {
+ require.NoError(t, b.store.Create(ctx, f.tsKey(sfx), f.ts(sfx, i+3, helpersv1.Learning, helpersv1.Partial), nil, 0))
+ }
+ require.NoError(t, b.store.Create(ctx, f.tsKey("r6"), f.ts("r6", 6, helpersv1.Completed, helpersv1.Full), nil, 0))
+ require.NoError(t, b.processor.ConsolidateTimeSeries(ctx))
+ snapshot()
+ // window 4: nothing pending
+ require.NoError(t, b.processor.ConsolidateTimeSeries(ctx))
+ snapshot()
+ return out
+ }
+ old := run(newLegacyBackend(t, nil))
+ nw := run(newObjectBackend(t, nil))
+ assert.Equal(t, old, nw)
+ assert.Equal(t, helpersv1.Completed, nw[2].status)
+ assert.Equal(t, helpersv1.Full, nw[2].compl)
+}
+
+var _ = metav1.Now
diff --git a/pkg/registry/file/sqliteobject_export.go b/pkg/registry/file/sqliteobject_export.go
new file mode 100644
index 000000000..e91abe168
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_export.go
@@ -0,0 +1,338 @@
+package file
+
+// Reverse export of ObjectStore rows into the legacy on-disk representation
+// (design §8.4: the rollback order is export-then-downgrade).
+//
+// An older storage binary opens a migrated database without error and
+// reads only the metadata row and the .g file: a key the ObjectStore created
+// or updated since the flip has no file (or a stale one), and the old
+// binary's get() DELETES the metadata row of a key whose file is missing
+// (its self-repair) — the object is destroyed, not merely invisible. Its
+// INSERT OR REPLACE also nulls rv/uid, which the every-start reconcile
+// repairs on re-enable (legacy_rewrite), but nothing repairs a deleted row.
+// So before a downgrade, every migrated row's payload is written back as
+// the gob file the old binary expects, at the row's resourceVersion and UID.
+//
+// The database is not touched: the old binary ignores rv, uid and the
+// payloads table, and a row it never rewrites stays consistent with its
+// payloads row for the re-enable; a row it rewrites or deletes is the
+// legacy_rewrite / orphan_payload shape the reconcile handles.
+//
+// This is an operator step (cmd/cpexport), run with the server stopped;
+// it never runs from the server binary.
+
+import (
+ "context"
+ "encoding/gob"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "time"
+
+ "github.com/kubescape/go-logger"
+ "github.com/kubescape/go-logger/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/spf13/afero"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitemigration"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+// ContainerProfileExportOptions tunes ExportContainerProfiles.
+type ContainerProfileExportOptions struct {
+ // BatchSize is the number of rows read per query; non-positive =
+ // DefaultMigrationBatchSize.
+ BatchSize int
+ // DryRun decodes and counts without writing any file.
+ DryRun bool
+}
+
+// ContainerProfileExportReport is what one export did.
+type ContainerProfileExportReport struct {
+ // Exported is the number of files written (or, dry-run, that would be).
+ Exported int
+ // LegacySkipped is the number of rows with rv NULL: legacy rows that were
+ // never migrated (or that a legacy writer already rewrote) and whose file
+ // is already the legacy writer's. Such a row with NO file (no known
+ // producer) is exported from its payloads body at the JSON's
+ // resourceVersion instead, and counted under Exported.
+ LegacySkipped int
+ // Undecodable is the number of payloads that could not be decoded; the
+ // row and any existing file are left as they are.
+ Undecodable int
+ // StaleFilesRemoved is the number of legacy payload files on disk with
+ // no matching metadata row: an object deleted under the ObjectStore
+ // backend (which removes only the database rows) whose pre-flip .g file
+ // survived. Left in place, an old binary reads it back after downgrade
+ // and resurrects the deleted object.
+ StaleFilesRemoved int
+ DryRun bool
+ Elapsed time.Duration
+}
+
+type exportRow struct {
+ rowid int64
+ namespace string
+ name string
+ metadataJSON []byte
+ rv int64
+ rvNull bool
+ uid string
+ encoding string
+ body []byte
+}
+
+// ExportContainerProfiles writes every migrated ContainerProfile row's
+// payload back as its legacy gob file under root, exactly as the legacy
+// writer does (staged to .g.t, then renamed into place).
+func ExportContainerProfiles(ctx context.Context, pool *sqlitemigration.Pool, fs afero.Fs, root string, scheme *runtime.Scheme, opts ContainerProfileExportOptions) (*ContainerProfileExportReport, error) {
+ // Cleaned once, here, as general hygiene for every other use of root
+ // below. reconcileStaleExportedFiles' key derivation no longer depends
+ // on root's exact textual form either way: it recovers the key via
+ // keyFromPayloadPath's filepath.Rel, not by slicing a Walk()-reported
+ // path at len(root) -- that byte-length slice silently mis-derived the
+ // key for a trailing-slash root ("/data/"), and would have for "/" and
+ // "." too (each off by a different amount, since Walk reports each
+ // root shape's paths differently). Rel is exact for all of them.
+ root = filepath.Clean(root)
+ if opts.BatchSize <= 0 {
+ opts.BatchSize = DefaultMigrationBatchSize
+ }
+ start := time.Now()
+ report := &ContainerProfileExportReport{DryRun: opts.DryRun}
+ cursor := int64(0)
+ for {
+ if err := ctx.Err(); err != nil {
+ return report, err
+ }
+ rows, err := readExportRows(ctx, pool, cursor, opts.BatchSize)
+ if err != nil {
+ return report, err
+ }
+ if len(rows) == 0 {
+ break
+ }
+ cursor = rows[len(rows)-1].rowid
+ for i := range rows {
+ r := &rows[i]
+ key := K8sKeysToPath("", softwarecomposition.GroupName, ContainerProfileKind, "", r.namespace, r.name)
+ p := filepath.Join(root, key)
+ rv, uid := r.rv, r.uid
+ if r.rvNull {
+ if exists, _ := afero.Exists(fs, makePayloadPath(p)); exists {
+ report.LegacySkipped++
+ continue
+ }
+ // No file to fall back on: the body is the only copy; the
+ // row JSON says which version it is.
+ row := &PartialObjectMetadata{}
+ if err := json.Unmarshal(r.metadataJSON, row); err != nil {
+ report.Undecodable++
+ logger.L().Warning("containerprofile export: metadata row is not JSON; skipped", helpers.Error(err), helpers.String("key", key))
+ continue
+ }
+ rv, uid = max(parseRV(row.ResourceVersion), 1), string(row.UID)
+ logger.L().Warning("containerprofile export: rv NULL row without a payload file; exported from the payloads body", helpers.String("key", key))
+ }
+ obj := &softwarecomposition.ContainerProfile{}
+ if err := decodePayloadBody(scheme, r.encoding, r.body, obj); err != nil {
+ report.Undecodable++
+ logger.L().Warning("containerprofile export: payload undecodable; skipped", helpers.Error(err), helpers.String("key", key))
+ continue
+ }
+ // The file carries what the row says (INV-2 makes these equal already).
+ obj.ResourceVersion = strconv.FormatInt(rv, 10)
+ if uid != "" {
+ obj.UID = types.UID(uid)
+ }
+ if !opts.DryRun {
+ if err := writeLegacyPayloadFile(fs, p, obj); err != nil {
+ return report, fmt.Errorf("containerprofile export: %s: %w", key, err)
+ }
+ }
+ report.Exported++
+ }
+ }
+ stale, err := reconcileStaleExportedFiles(ctx, pool, fs, root, opts.DryRun)
+ if err != nil {
+ return report, err
+ }
+ report.StaleFilesRemoved = stale
+ report.Elapsed = time.Since(start)
+ logger.L().Info("containerprofile export: done",
+ helpers.Int("exported", report.Exported), helpers.Int("legacySkipped", report.LegacySkipped),
+ helpers.Int("undecodable", report.Undecodable), helpers.Int("staleFilesRemoved", report.StaleFilesRemoved),
+ helpers.Interface("dryRun", report.DryRun), helpers.String("elapsed", report.Elapsed.String()))
+ return report, nil
+}
+
+// reconcileStaleExportedFiles removes every legacy ContainerProfile payload
+// file under root that (a) still decodes as a valid object an old binary
+// would serve, AND (b) has no matching metadata row. ObjectStore's delete
+// path removes only the database rows, so a key deleted since the pre-flip
+// migration (or since a prior export) can still have its old .g file on
+// disk; ExportContainerProfiles above only ever visits rows that still
+// exist, so it never reaches these. Left in place, an old binary's get()
+// after downgrade finds the stale file and returns the deleted object as if
+// it were live. This must run after the row export above, using the same
+// definition of "current" (the database at read time).
+//
+// A file that fails to decode is left untouched, exactly as the main export
+// loop leaves undecodable payloads: an old binary can't resurrect an object
+// from a file it can't decode either, so there is no resurrection risk to
+// close, and removing it would just be destroying data outside this tool's
+// contract (matches sweepFiles' migration-side handling of undecodable
+// content).
+func reconcileStaleExportedFiles(ctx context.Context, pool *sqlitemigration.Pool, fs afero.Fs, root string, dryRun bool) (int, error) {
+ dir := filepath.Join(root, softwarecomposition.GroupName, ContainerProfileKind)
+ // A real filesystem error here (e.g. permission denied) must not read as
+ // "the directory doesn't exist": DirExists returns exists=false on ANY
+ // stat error, not just os.ErrNotExist. Reading that as "nothing to
+ // reconcile" would let the export report success while a subtree of
+ // stale, possibly resurrection-capable files went unchecked (the same
+ // class of bug fixed in the migration sweep, sqliteobject_migration.go).
+ exists, err := afero.DirExists(fs, dir)
+ if err != nil {
+ return 0, fmt.Errorf("containerprofile export: stat %s: %w", dir, err)
+ }
+ if !exists {
+ return 0, nil
+ }
+ conn, err := pool.Take(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("containerprofile export: take connection: %w", err)
+ }
+ defer pool.Put(conn)
+ var stale []string
+ walkErr := afero.Walk(fs, dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() || !IsPayloadFile(path) {
+ return nil
+ }
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return ctxErr
+ }
+ key, kerr := keyFromPayloadPath(root, path)
+ if kerr != nil {
+ return fmt.Errorf("containerprofile export: %w", kerr)
+ }
+ if _, rerr := ReadMetadata(conn, key); rerr == nil {
+ return nil
+ } else if !errors.Is(rerr, ErrMetadataNotFound) {
+ return fmt.Errorf("read metadata %s: %w", key, rerr)
+ }
+ _, found, derr := decodeLegacyFileAt(ctx, fs, root, key)
+ if derr != nil && errors.Is(derr, errLegacyFileAccess) {
+ // A real filesystem error (permission denied, I/O failure), not
+ // the file's content being bad: we cannot tell whether this file
+ // is safe to leave, so a "clean" export must not silently do so.
+ return fmt.Errorf("decode %s: %w", key, derr)
+ }
+ if derr != nil || !found {
+ // Undecodable, or raced away between Walk and here: leave it.
+ return nil
+ }
+ stale = append(stale, path)
+ return nil
+ })
+ if walkErr != nil {
+ return 0, fmt.Errorf("containerprofile export: reconcile stale files: %w", walkErr)
+ }
+ if dryRun {
+ for _, path := range stale {
+ logger.L().Warning("containerprofile export: stale payload file with no metadata row would be removed", helpers.String("path", path))
+ }
+ return len(stale), nil
+ }
+ for _, path := range stale {
+ if rerr := fs.Remove(path); rerr != nil {
+ return len(stale), fmt.Errorf("containerprofile export: remove stale file %s: %w", path, rerr)
+ }
+ logger.L().Warning("containerprofile export: stale payload file with no metadata row removed", helpers.String("path", path))
+ }
+ return len(stale), nil
+}
+
+func readExportRows(ctx context.Context, pool *sqlitemigration.Pool, cursor int64, limit int) ([]exportRow, error) {
+ conn, err := pool.Take(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("containerprofile export: take connection: %w", err)
+ }
+ defer pool.Put(conn)
+ var out []exportRow
+ err = sqlitex.Execute(conn,
+ `SELECT m.rowid, m.namespace, m.name, m.rv, m.rv IS NULL, m.uid, p.encoding, p.body, m.metadata
+ FROM metadata m JOIN payloads p USING (kind, namespace, name)
+ WHERE m.kind = :kind AND m.rowid > :cursor
+ ORDER BY m.rowid LIMIT :limit`,
+ &sqlitex.ExecOptions{
+ Named: map[string]any{":kind": ContainerProfileKind, ":cursor": cursor, ":limit": limit},
+ ResultFunc: func(stmt *sqlite.Stmt) error {
+ r := exportRow{
+ rowid: stmt.ColumnInt64(0),
+ namespace: stmt.ColumnText(1),
+ name: stmt.ColumnText(2),
+ rv: stmt.ColumnInt64(3),
+ rvNull: stmt.ColumnInt64(4) == 1,
+ uid: stmt.ColumnText(5),
+ encoding: stmt.ColumnText(6),
+ }
+ r.body = make([]byte, stmt.ColumnLen(7))
+ stmt.ColumnBytes(7, r.body)
+ r.metadataJSON = []byte(stmt.ColumnText(8))
+ out = append(out, r)
+ return nil
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("containerprofile export: read rows: %w", err)
+ }
+ return out, nil
+}
+
+// writeLegacyPayloadFile writes obj as the legacy writer does (saveObject):
+// gob through the direct-I/O writer into .g.t, then a rename into
.g.
+func writeLegacyPayloadFile(fs afero.Fs, p string, obj runtime.Object) error {
+ if err := fs.MkdirAll(filepath.Dir(p), 0755); err != nil {
+ return fmt.Errorf("mkdir: %w", err)
+ }
+ finalPath := makePayloadPath(p)
+ tmpPath := finalPath + ".t"
+ f, err := openPayloadFileWithFallbackFs(fs, tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
+ if err != nil {
+ return fmt.Errorf("open payload file: %w", err)
+ }
+ w := NewDirectIOWriter(f)
+ if err := gob.NewEncoder(w).Encode(obj); err != nil {
+ _ = w.Close()
+ _ = f.Close()
+ _ = fs.Remove(tmpPath)
+ return fmt.Errorf("encode payload: %w", err)
+ }
+ if err := errors.Join(w.Close(), f.Close()); err != nil {
+ _ = fs.Remove(tmpPath)
+ return fmt.Errorf("close payload file: %w", err)
+ }
+ if err := fs.Rename(tmpPath, finalPath); err != nil {
+ _ = fs.Remove(tmpPath)
+ return fmt.Errorf("rename payload into place: %w", err)
+ }
+ return nil
+}
+
+// openPayloadFileWithFallbackFs is StorageImpl.openPayloadFileWithFallback
+// without the receiver.
+func openPayloadFileWithFallbackFs(fs afero.Fs, path string, flag int, perm os.FileMode) (afero.File, error) {
+ f, err := fs.OpenFile(path, openFlagDirect|flag, perm)
+ if err != nil && isDirectIOUnsupported(err) {
+ f, err = fs.OpenFile(path, flag, perm)
+ }
+ return f, err
+}
diff --git a/pkg/registry/file/sqliteobject_export_test.go b/pkg/registry/file/sqliteobject_export_test.go
new file mode 100644
index 000000000..813f6baa6
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_export_test.go
@@ -0,0 +1,476 @@
+package file
+
+// The reverse export (§8.4: export-then-downgrade) and the danger it closes.
+
+import (
+ "bytes"
+ "context"
+ "encoding/gob"
+ "fmt"
+ "path/filepath"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apiserver/pkg/storage"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+func (e *migrationEnv) export(opts ContainerProfileExportOptions) *ContainerProfileExportReport {
+ e.t.Helper()
+ report, err := ExportContainerProfiles(e.ctx, e.pool, e.fs, DefaultStorageRoot, e.scheme, opts)
+ require.NoError(e.t, err)
+ return report
+}
+
+func (e *migrationEnv) storeCreate(name string) *softwarecomposition.ContainerProfile {
+ e.t.Helper()
+ out := &softwarecomposition.ContainerProfile{}
+ require.NoError(e.t, e.store.Create(e.ctx, e.key(name), e.plain(name), out, 0))
+ return out
+}
+
+func (e *migrationEnv) readFile(name string) []byte {
+ e.t.Helper()
+ b, err := afero.ReadFile(e.fs, e.filePath(name))
+ require.NoError(e.t, err)
+ return b
+}
+
+func decodeGob(t *testing.T, b []byte) *softwarecomposition.ContainerProfile {
+ t.Helper()
+ obj := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, gob.NewDecoder(bytes.NewReader(b)).Decode(obj))
+ return obj
+}
+
+// The danger, on this branch's code, without the export: an older binary
+// opening the migrated database destroys the rows the ObjectStore wrote and
+// nulls rv/uid on the rows it rewrites.
+func TestExport_DangerWithoutExport_OldBinaryDestroysNewStoreRows(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ e.seed(2)
+ updated, created := e.key("plain-00"), e.key("created-under-the-new-store")
+ e.startNew()
+ e.mustMigrate(ContainerProfileMigrationOptions{})
+ createdObj := e.storeCreate("created-under-the-new-store")
+ require.NoError(t, e.storeUpdate(e.ctx, updated))
+ require.Equal(t, "3", e.mustStoreGet(updated).ResourceVersion)
+ e.assertINV2(updated, created)
+ exists, err := afero.Exists(e.fs, e.filePath("created-under-the-new-store"))
+ require.NoError(t, err)
+ require.False(t, exists, "a key created under the new store has no file")
+
+ // Downgrade without the export: the old binary runs.
+ e.stopNew()
+
+ // (1) A key with no file: get() used to delete the metadata row and
+ // destroy the object. Rollback-safety guard Part 2: this key satisfies
+ // all four fallback conditions (a metadata row, rv non-NULL, not a
+ // time-series row, a payloads row), so the read is now served from the
+ // payloads body at the row's rv/uid instead — no self-repair, no side
+ // effects. The remaining dangers below, (2) and (3), are unaffected.
+ servedCreated, err := e.storeGetLegacy(created)
+ require.NoError(t, err, "Part 2: the fallback serves this key instead of destroying it")
+ require.Equal(t, createdObj.ResourceVersion, servedCreated.ResourceVersion, "stamped from the row's rv column")
+ require.Equal(t, createdObj.UID, servedCreated.UID, "stamped from the row's uid column")
+ row := e.inspect(created)
+ require.True(t, row.metaExists, "Part 2: no self-repair fires on a fallback-served read")
+ require.True(t, row.payloadExists, "Part 2: the payloads row the read was served from survives")
+
+ // (2) A key the new store updated: the old binary serves the STALE file
+ // while the row says otherwise — a divergent pair.
+ stale := e.legacyGet(updated)
+ require.Equal(t, "2", stale.ResourceVersion, "the file is the pre-flip version")
+ require.Equal(t, "3", e.inspect(updated).jsonRV, "the row is the post-flip version")
+
+ // (3) The old binary's row write, exactly as its writeMetadata issues it,
+ // nulls rv/uid: the CAS can never match again.
+ other := e.key("plain-01")
+ _, _, kind, _, ns, name := K8sPathToKeys(other)
+ require.NoError(t, sqlitex.Execute(e.fixture,
+ `INSERT OR REPLACE INTO metadata (kind, namespace, name, metadata) SELECT kind, namespace, name, metadata FROM metadata WHERE kind = ? AND namespace = ? AND name = ?`,
+ &sqlitex.ExecOptions{Args: []any{kind, ns, name}}))
+ row = e.inspect(other)
+ require.Nil(t, row.rv, "INSERT OR REPLACE nulled rv")
+ require.Nil(t, row.uid, "INSERT OR REPLACE nulled uid")
+ e.startNew()
+ shortCtx, cancel := context.WithTimeout(e.ctx, 700*time.Millisecond)
+ err = e.storeUpdate(shortCtx, other)
+ cancel()
+ require.Error(t, err, "rv NULL never matches the CAS")
+}
+
+// storeGetLegacy is the old binary's Get.
+func (e *migrationEnv) storeGetLegacy(key string) (*softwarecomposition.ContainerProfile, error) {
+ out := &softwarecomposition.ContainerProfile{}
+ err := e.legacy.Get(e.ctx, key, storage.GetOptions{}, out)
+ return out, err
+}
+
+// The round trip: legacy → migrate → export → the old binary serves every
+// object exactly as it did before the migration, at the same RV and UID,
+// and can keep writing it.
+//
+// Bytes are compared as decoded objects, not raw: gob encodes maps
+// (labels, annotations) in Go's randomised map order, so two encodes of the
+// same object differ byte-wise; and the JSON codec keeps creationTimestamp
+// to the second and empty collections as empty (canonicalCP), both
+// documented codec deltas of the backend. Everything the old binary reads
+// back is asserted equal.
+func TestExport_RoundTripIsBehaviourallyIdentical(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ before := e.seed(4)
+ original := map[string][]byte{}
+ for k := range before {
+ original[k] = e.readFile(filepath.Base(k))
+ }
+
+ e.startNew()
+ report := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, len(before), report.Count(MigrationShapeMigrated))
+ exported := e.export(ContainerProfileExportOptions{BatchSize: 3})
+ require.Equal(t, len(before), exported.Exported)
+ require.Equal(t, 0, exported.LegacySkipped)
+ require.Equal(t, 0, exported.Undecodable)
+ e.stopNew()
+
+ for k, legacyBefore := range before {
+ name := filepath.Base(k)
+ fileNow := e.readFile(name)
+ require.Equal(t, canonicalCP(decodeGob(t, original[k])), canonicalCP(decodeGob(t, fileNow)), "the exported file decodes to the original for %s", k)
+ got := e.legacyGet(k)
+ require.Equal(t, canonicalCP(legacyBefore), canonicalCP(got), "the old binary serves the pre-migration object for %s", k)
+ require.Equal(t, legacyBefore.ResourceVersion, got.ResourceVersion)
+ require.Equal(t, legacyBefore.UID, got.UID)
+ staged, err := afero.Exists(e.fs, e.filePath(name)+".t")
+ require.NoError(t, err)
+ require.False(t, staged, "no staging file left behind")
+ }
+ // The old binary keeps working on the exported files.
+ k := e.key("plain-01")
+ after := e.legacyUpdate(k, func(cp *softwarecomposition.ContainerProfile) { cp.Annotations["export-test"] = "old-binary-write" })
+ require.Equal(t, "2", after.ResourceVersion)
+ require.Equal(t, "old-binary-write", e.legacyGet(k).Annotations["export-test"])
+}
+
+// The full rollback cycle in the design's order: export, downgrade, the old
+// binary reads and writes, re-enable — the reconcile repairs what the old
+// binary rewrote and nothing is lost.
+func TestExport_ThenDowngradeThenReEnable(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ e.seed(2)
+ updated, created, untouched := e.key("plain-00"), e.key("created-under-the-new-store"), e.key("plain-01")
+ e.startNew()
+ e.mustMigrate(ContainerProfileMigrationOptions{})
+ e.storeCreate("created-under-the-new-store")
+ require.NoError(t, e.storeUpdate(e.ctx, updated))
+ newView := map[string]*softwarecomposition.ContainerProfile{
+ updated: e.mustStoreGet(updated), created: e.mustStoreGet(created), untouched: e.mustStoreGet(untouched),
+ }
+
+ // Export, then downgrade.
+ report := e.export(ContainerProfileExportOptions{})
+ require.Equal(t, 5, report.Exported, "2 plain + 2 TS seeded, 1 created")
+ e.stopNew()
+
+ // The old binary sees every object at its post-flip state.
+ for k, want := range newView {
+ got := e.legacyGet(k)
+ require.Equal(t, canonicalCP(want), canonicalCP(got), "%s", k)
+ require.Equal(t, want.ResourceVersion, got.ResourceVersion)
+ require.Equal(t, want.UID, got.UID)
+ }
+ require.True(t, e.inspect(created).metaExists, "nothing was deleted")
+
+ // The old binary writes one key and deletes another.
+ e.legacyUpdate(created, func(cp *softwarecomposition.ContainerProfile) { cp.Annotations["export-test"] = "old-binary-write" })
+ require.Nil(t, e.inspect(created).rv)
+ require.NoError(t, e.legacy.Delete(e.ctx, untouched, &softwarecomposition.ContainerProfile{}, nil, nil, nil, storage.DeleteOptions{}))
+ require.False(t, e.inspect(untouched).payloadExists, "Part 1: the old binary's delete now cleans up the payloads row too")
+
+ // Re-enable: the every-start reconcile repairs both shapes.
+ e.startNew()
+ again := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 1, again.Counts[MigrationShapeLegacyRewrite+"/"+MigrationSourceFile], "%v", again.Counts)
+ require.Equal(t, 0, again.Count(MigrationShapeOrphanPayload), "%v", again.Counts)
+ require.Equal(t, 0, again.Count(MigrationShapeRowWithoutFile))
+ e.assertINV2(updated, created, untouched)
+ got := e.mustStoreGet(created)
+ require.Equal(t, "old-binary-write", got.Annotations["export-test"], "the old binary's write survives the re-enable")
+ require.Equal(t, "2", got.ResourceVersion)
+ require.Equal(t, canonicalCP(newView[updated]), canonicalCP(e.mustStoreGet(updated)), "an untouched key is exactly as the new store left it")
+ _, err := e.storeGet(untouched)
+ require.True(t, storage.IsNotFound(err), "the old binary's delete holds")
+ require.NoError(t, e.storeUpdate(e.ctx, created), "the CAS is live again")
+ require.NoError(t, e.storeUpdate(e.ctx, updated))
+}
+
+func TestExport_SkipsLegacyRowsAndDryRunWritesNothing(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ e.seed(1)
+ migratedKey := e.key("plain-00")
+ e.startNew()
+ e.mustMigrate(ContainerProfileMigrationOptions{})
+ e.storeCreate("created-under-the-new-store")
+ // A row a legacy writer already rewrote (rv NULL): its file is the legacy
+ // writer's, and the export must not overwrite it with the stale body.
+ e.stopNew()
+ e.legacyUpdate(migratedKey, func(cp *softwarecomposition.ContainerProfile) { cp.Annotations["export-test"] = "legacy" })
+ legacyFile := e.readFile("plain-00")
+
+ dry := e.export(ContainerProfileExportOptions{DryRun: true})
+ require.Equal(t, 3, dry.Exported, "1 plain + 2 TS seeded, 1 created, 1 legacy-rewritten")
+ require.Equal(t, 1, dry.LegacySkipped)
+ exists, err := afero.Exists(e.fs, e.filePath("created-under-the-new-store"))
+ require.NoError(t, err)
+ require.False(t, exists, "dry-run wrote nothing")
+
+ real := e.export(ContainerProfileExportOptions{})
+ require.Equal(t, 3, real.Exported)
+ require.Equal(t, 1, real.LegacySkipped)
+ require.Equal(t, legacyFile, e.readFile("plain-00"), "the legacy writer's file is untouched")
+
+ // An rv NULL row with NO file (no known producer): the body is the only
+ // copy, exported at the JSON's resourceVersion rather than skipped.
+ require.NoError(t, e.fs.Remove(e.filePath("plain-00")))
+ filled := e.export(ContainerProfileExportOptions{})
+ require.Equal(t, 4, filled.Exported)
+ require.Equal(t, 0, filled.LegacySkipped)
+ filledObj := e.legacyGet(migratedKey)
+ require.Equal(t, "3", filledObj.ResourceVersion, "the row JSON's version (seed updated it to 2, the legacy write to 3)")
+ require.Empty(t, filledObj.Annotations["export-test"], "the payloads body (pre-legacy-write) is what was left")
+ got := e.legacyGet(e.key("created-under-the-new-store"))
+ require.Equal(t, "1", got.ResourceVersion)
+ require.Equal(t, helpersv1.Learning, got.Annotations[helpersv1.StatusMetadataKey])
+}
+
+// TestExport_RemovesStaleFileOfKeyDeletedUnderTheNewStore reproduces the
+// rollback-resurrection sequence: migrate A, delete A under the ObjectStore
+// backend (which removes only the database rows and leaves A's pre-flip .g
+// file on disk), export, downgrade. Without reconcileStaleExportedFiles, the
+// old binary's get() finds the stale file and A is readable again after
+// being deleted.
+func TestExport_RemovesStaleFileOfKeyDeletedUnderTheNewStore(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ e.seed(1)
+ key := e.key("plain-00")
+ e.startNew()
+ e.mustMigrate(ContainerProfileMigrationOptions{})
+
+ out := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, e.store.Delete(e.ctx, key, out, nil, nil, nil, storage.DeleteOptions{}))
+ row := e.inspect(key)
+ require.False(t, row.metaExists, "the delete removed the metadata row")
+ require.False(t, row.payloadExists, "and the payloads row")
+ exists, err := afero.Exists(e.fs, e.filePath("plain-00"))
+ require.NoError(t, err)
+ require.True(t, exists, "the pre-flip legacy file survives an ObjectStore delete")
+
+ report := e.export(ContainerProfileExportOptions{})
+ require.Equal(t, 1, report.StaleFilesRemoved)
+ exists, err = afero.Exists(e.fs, e.filePath("plain-00"))
+ require.NoError(t, err)
+ require.False(t, exists, "the export removed the stale file")
+
+ e.stopNew()
+ getOut := &softwarecomposition.ContainerProfile{}
+ err = e.legacy.Get(e.ctx, key, storage.GetOptions{}, getOut)
+ require.True(t, storage.IsNotFound(err), "the old binary must not resurrect the deleted object")
+}
+
+// TestExport_DryRunLeavesStaleFiles confirms the dry-run count matches what
+// a real run would remove, without touching the filesystem.
+func TestExport_DryRunLeavesStaleFiles(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ e.seed(1)
+ key := e.key("plain-00")
+ e.startNew()
+ e.mustMigrate(ContainerProfileMigrationOptions{})
+ out := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, e.store.Delete(e.ctx, key, out, nil, nil, nil, storage.DeleteOptions{}))
+
+ dry := e.export(ContainerProfileExportOptions{DryRun: true})
+ require.Equal(t, 1, dry.StaleFilesRemoved)
+ exists, err := afero.Exists(e.fs, e.filePath("plain-00"))
+ require.NoError(t, err)
+ require.True(t, exists, "dry-run removed nothing")
+}
+
+// TestExport_TrailingSlashRootDoesNotDeleteLiveFiles: reconcileStaleExportedFiles
+// derives a key by slicing a Walk()-reported path (always cleaned, via
+// filepath.Join) at len(root). An uncleaned root with a trailing slash (e.g.
+// the CLI's -root /data/) makes that length one too many, silently dropping
+// the key's required leading '/' -- ReadMetadata then misses the live row
+// for a key that was NEVER deleted, and the stale-file pass wrongly deletes
+// its just-exported file. Both a live object and a genuinely deleted one are
+// present so the fix is proven both ways: the live file must survive, the
+// deleted one's stale file must still be removed.
+func TestExport_TrailingSlashRootDoesNotDeleteLiveFiles(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ e.seed(2)
+ liveKey, deletedKey := e.key("plain-00"), e.key("plain-01")
+ e.startNew()
+ e.mustMigrate(ContainerProfileMigrationOptions{})
+
+ out := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, e.store.Delete(e.ctx, deletedKey, out, nil, nil, nil, storage.DeleteOptions{}))
+ liveExists, err := afero.Exists(e.fs, e.filePath("plain-00"))
+ require.NoError(t, err)
+ require.True(t, liveExists, "plain-00's row is live, its pre-flip file still on disk")
+
+ report, err := ExportContainerProfiles(e.ctx, e.pool, e.fs, DefaultStorageRoot+"/", e.scheme, ContainerProfileExportOptions{})
+ require.NoError(t, err)
+ require.Equal(t, 1, report.StaleFilesRemoved, "only plain-01's stale file, not plain-00's live one")
+
+ liveExists, err = afero.Exists(e.fs, e.filePath("plain-00"))
+ require.NoError(t, err)
+ require.True(t, liveExists, "the trailing slash must not make the live file look stale")
+
+ deletedExists, err := afero.Exists(e.fs, e.filePath("plain-01"))
+ require.NoError(t, err)
+ require.False(t, deletedExists, "the genuinely deleted key's stale file is still removed")
+
+ e.stopNew()
+ getOut := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, e.legacy.Get(e.ctx, liveKey, storage.GetOptions{}, getOut), "the live object must still be readable by an old binary")
+}
+
+// failOpenFs fails Open for one target path while armed, otherwise
+// delegates -- reproduces a permission-denied/I/O error reading a specific
+// file's content (what decodeLegacyFileAt hits), distinct from toggleFailFs
+// (migration test helper, same package) which fails Stat for directory/walk
+// -level errors. afero.Walk itself never calls Open, only Stat/ReadDir, so
+// arming this on a file already discovered by Walk reproduces the failure
+// happening exactly where reconcileStaleExportedFiles decodes it.
+type failOpenFs struct {
+ afero.Fs
+ target string
+ fail atomic.Bool
+}
+
+func (f *failOpenFs) Open(name string) (afero.File, error) {
+ if f.fail.Load() && name == f.target {
+ return nil, fmt.Errorf("failOpenFs: simulated open failure for %s", name)
+ }
+ return f.Fs.Open(name)
+}
+
+// failReadFs succeeds Open for one target path while armed, but returns a
+// file whose Read always errors -- reproduces a device/mount that opens
+// fine but errors mid-read (a bad sector, a network filesystem hiccup),
+// distinct from failOpenFs (Open itself fails). decodeLegacyFileAt must
+// treat this exactly like an Open failure (errLegacyFileAccess), not let
+// gob's Decode fold it into an indistinguishable "content is malformed"
+// error.
+type failReadFs struct {
+ afero.Fs
+ target string
+ fail atomic.Bool
+}
+
+func (f *failReadFs) Open(name string) (afero.File, error) {
+ file, err := f.Fs.Open(name)
+ if err != nil || !f.fail.Load() || name != f.target {
+ return file, err
+ }
+ return &failReadFile{File: file}, nil
+}
+
+type failReadFile struct{ afero.File }
+
+func (f *failReadFile) Read(_ []byte) (int, error) {
+ return 0, fmt.Errorf("failReadFile: simulated read failure for %s", f.Name())
+}
+
+// TestExport_ReconcileTopLevelStatErrorFailsExport: the same class of bug as
+// TestMigration_SweepTopLevelStatErrorDoesNotMarkDone, on the export side --
+// afero.DirExists returns exists=false on ANY stat error, not just "does
+// not exist", and reconcileStaleExportedFiles used to read that as "nothing
+// to reconcile" and report success. A stat error there must instead fail
+// the export, since a directory that could not be checked might hold a
+// stale, resurrection-capable file the export never got to look at.
+func TestExport_ReconcileTopLevelStatErrorFailsExport(t *testing.T) {
+ failing := &toggleFailFs{Fs: afero.NewMemMapFs()}
+ e := newMigrationEnv(t, failing)
+ e.seed(1)
+ e.startNew()
+ e.mustMigrate(ContainerProfileMigrationOptions{})
+
+ failing.target = filepath.Join(DefaultStorageRoot, softwarecomposition.GroupName, ContainerProfileKind)
+ failing.fail.Store(true)
+ _, err := ExportContainerProfiles(e.ctx, e.pool, failing, DefaultStorageRoot, e.scheme, ContainerProfileExportOptions{})
+ require.Error(t, err, "a stat error checking the reconcile directory must fail the export, not read as \"nothing to reconcile\"")
+}
+
+// TestExport_ReconcileFileAccessErrorFailsExport: a permission/I/O error
+// opening a stale candidate's file (its metadata row is gone, its file
+// still exists) used to be treated identically to the file's content being
+// genuinely undecodable garbage -- reconcileStaleExportedFiles left it in
+// place and the export still reported success. But an access error means
+// the tool could not tell whether that file is content an old binary would
+// resurrect; unlike truly undecodable content (safe to leave, an old binary
+// can't read it either), it must fail the export instead of silently
+// leaving a landmine an operator believes was checked.
+func TestExport_ReconcileFileAccessErrorFailsExport(t *testing.T) {
+ failing := &failOpenFs{Fs: afero.NewMemMapFs()}
+ e := newMigrationEnv(t, failing)
+ e.seed(1)
+ key := e.key("plain-00")
+ e.startNew()
+ e.mustMigrate(ContainerProfileMigrationOptions{})
+
+ out := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, e.store.Delete(e.ctx, key, out, nil, nil, nil, storage.DeleteOptions{}))
+ exists, err := afero.Exists(e.fs, e.filePath("plain-00"))
+ require.NoError(t, err)
+ require.True(t, exists, "the pre-flip legacy file survives an ObjectStore delete")
+
+ failing.target = e.filePath("plain-00")
+ failing.fail.Store(true)
+ _, err = ExportContainerProfiles(e.ctx, e.pool, failing, DefaultStorageRoot, e.scheme, ContainerProfileExportOptions{})
+ require.Error(t, err, "an access error on a stale candidate must fail the export, not be treated as harmless undecodable content")
+
+ failing.fail.Store(false)
+ exists, err = afero.Exists(e.fs, e.filePath("plain-00"))
+ require.NoError(t, err)
+ require.True(t, exists, "the file was never classified, so it must not have been removed either")
+}
+
+// TestExport_ReconcileOpenSucceedsReadFailsFailsExport: a stale candidate's
+// file can open successfully and still fail to be read (a bad sector, a
+// device/mount that opens fine but errors mid-read) -- errLegacyFileAccess
+// used to wrap only fs.Open's own error, so a Read failure surfaced as an
+// opaque, untagged error from gob's Decode, indistinguishable from the
+// file's content genuinely being malformed. reconcileStaleExportedFiles
+// then left it in place and the export still reported success; after
+// downgrade, an old binary reading that now-otherwise-fine file could
+// resurrect a deleted object.
+func TestExport_ReconcileOpenSucceedsReadFailsFailsExport(t *testing.T) {
+ failing := &failReadFs{Fs: afero.NewMemMapFs()}
+ e := newMigrationEnv(t, failing)
+ e.seed(1)
+ key := e.key("plain-00")
+ e.startNew()
+ e.mustMigrate(ContainerProfileMigrationOptions{})
+
+ out := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, e.store.Delete(e.ctx, key, out, nil, nil, nil, storage.DeleteOptions{}))
+ exists, err := afero.Exists(e.fs, e.filePath("plain-00"))
+ require.NoError(t, err)
+ require.True(t, exists, "the pre-flip legacy file survives an ObjectStore delete")
+
+ failing.target = e.filePath("plain-00")
+ failing.fail.Store(true)
+ _, err = ExportContainerProfiles(e.ctx, e.pool, failing, DefaultStorageRoot, e.scheme, ContainerProfileExportOptions{})
+ require.Error(t, err, "Open succeeding then Read failing must fail the export, not be silently folded into \"undecodable content\"")
+
+ failing.fail.Store(false)
+ exists, err = afero.Exists(e.fs, e.filePath("plain-00"))
+ require.NoError(t, err)
+ require.True(t, exists, "the file was never classified, so it must not have been removed either")
+}
diff --git a/pkg/registry/file/sqliteobject_gate.go b/pkg/registry/file/sqliteobject_gate.go
new file mode 100644
index 000000000..1f1f6b430
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_gate.go
@@ -0,0 +1,467 @@
+package file
+
+// The process's write gate: a caller-side two-lane FIFO ticket semaphore
+// owning ONE dedicated write connection (design:
+// .omc/plans/full-acid-storage-architecture.md §6.4, shared with the legacy
+// kinds per .omc/plans/write-gate-sharing.md §3).
+//
+// Acquisition order, fixed: prepare (pool connection for reads, released) →
+// gate ticket (queued on ctx) → BEGIN IMMEDIATE on the gate's connection →
+// statements → COMMIT → release ticket → dispatch. No pool connection is held
+// while queued on the gate on the hot paths, and no ticket is held while
+// waiting on the pool.
+//
+// Not a sync.Mutex: Go's mutex is not FIFO and cannot be abandoned on ctx
+// cancellation. The releaser hands the ticket directly to the next waiter
+// (high lane first, a waiting low job forced through after highBurstLimit
+// consecutive high commits — the policy of singleWriter.run), so fairness is
+// FIFO among gated writers. A waiter whose ctx fires after the ticket was
+// granted hands the ticket back (INV-5): a leaked ticket would wedge every
+// writer forever.
+//
+// One gate per pool (writegate_registry.go): after R1 the gate is the only
+// code that acquires SQLite's write lock; any other writer busy-waits against
+// it for the whole busy timeout, invisible to the gate's own histograms.
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "runtime"
+ "runtime/debug"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/kubescape/go-logger"
+ "github.com/kubescape/go-logger/helpers"
+ "github.com/kubescape/storage/pkg/metrics"
+ "k8s.io/apiserver/pkg/storage"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitemigration"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+// errGateClosed is returned to writers queued on, or arriving at, a gate whose
+// Close has begun.
+var errGateClosed = errors.New("write gate: closed")
+
+// errGateReentrant is returned when a gated fn tries to acquire the gate again
+// through the ctx it was handed: the gate is not re-entrant, and the nested
+// acquire would queue behind its own holder forever (INV-1′). In tests the
+// same condition panics, so a site that swallows the error (`_ =
+// DeleteMetadata(...)`) still fails loudly.
+var errGateReentrant = errors.New("write gate: re-entrant acquire from inside a gated transaction")
+
+// gateReentrantPanics makes a re-entrant acquire panic instead of returning
+// errGateReentrant. Set by the package's TestMain; false in production.
+var gateReentrantPanics atomic.Bool
+
+// gateCtxKey marks the ctx a gated fn receives; valid only for the dynamic
+// extent of fn (R-5): a site must not store it in anything that outlives the
+// call, or a legitimate later write through the stored ctx is refused.
+type gateCtxKey struct{}
+
+// ErrWriteConflict is the exported alias of the single writer's conflict
+// sentinel: an ObjectStore compare-and-swap (UPDATE/DELETE … WHERE rv=:rv AND
+// uid=:uid) matched no row because another write committed first. Callers
+// re-read and retry; the consolidation pass retries once.
+var ErrWriteConflict = errWriteConflict
+
+// Watchdog tunables (PM-G2). Package-level vars so tests can shrink them.
+var (
+ // gateWatchdogInterval is how often the watchdog samples the hold.
+ gateWatchdogInterval = time.Second
+ // gateWatchdogThreshold is the hold age past which the watchdog logs the
+ // holder once, with every goroutine's stack: a leaked ticket, a re-entrant
+ // acquire the ctx marker did not see, or a holder blocked on a PV that
+ // stopped responding.
+ gateWatchdogThreshold = 60 * time.Second
+)
+
+// WriteGate is the exported name of the process's write gate for main.go's
+// wiring; everything else in this package uses writeGate.
+type WriteGate = writeGate
+
+// NewWriteGate builds the process's one write gate over pool. It is created
+// beside the pool when config.ContainerProfileSqliteBackend is on and handed
+// to the ObjectStore, the legacy StorageImpl and the cleanup handler; with the
+// flag off no gate exists and every legacy write site runs today's code.
+func NewWriteGate(ctx context.Context, pool *sqlitemigration.Pool) (*WriteGate, error) {
+ return newWriteGate(ctx, pool)
+}
+
+type gateWaiter struct {
+ ch chan struct{}
+ priority writePriority
+ // granted is set under writeGate.mu at the instant the ticket is handed
+ // over; a cancelled waiter that finds it set owns the gate and must release.
+ granted bool
+ // rejected is set under writeGate.mu when Close drains the queue.
+ rejected bool
+}
+
+type writeGate struct {
+ pool *sqlitemigration.Pool
+
+ mu sync.Mutex
+ idle *sync.Cond
+ busy bool
+ high, low []*gateWaiter
+ highStreak int
+ closed bool
+ // holdStart/holdPath/holdKind describe the current holder (busy) for the
+ // watchdog and the hold-age gauge.
+ holdStart time.Time
+ holdPath, holdKind string
+ // conn is the dedicated write connection, taken once from the pool and
+ // returned only by Close (K-5: sqlitex.Pool.Close blocks until every
+ // connection is back).
+ conn *sqlite.Conn
+ // owned is every connection this gate has EVER held — the initial one and
+ // each replacement, never removed — mapped to the write-statement sequence
+ // at which the gate took it. A write recorded on a connection the gate
+ // held at the time is a gated write even after cleanOrReplace swapped it
+ // out (AC-G1, CR-1); a write recorded on it BEFORE the gate took it (a pool
+ // taker's, when it was still a pool connection) is not. A closed pointer
+ // stays reachable from the records, so the runtime cannot reuse it.
+ // Bounded at pool size + 1: each replacement consumes one pool connection
+ // for good.
+ owned map[*sqlite.Conn]uint64
+ // closeSeq is the write-statement sequence recorded by Close. Close puts
+ // the dedicated connection back in the pool, so a later non-gate taker
+ // could write on an owned pointer: only records older than closeSeq are
+ // the gate's (R-8).
+ closeSeq uint64
+ // replaced counts connections that could not be cleaned after a failed
+ // transaction and were replaced from the pool.
+ replaced int
+
+ watchdogStop chan struct{}
+ watchdogDone chan struct{}
+ watchdogFired atomic.Int64
+}
+
+// newWriteGate takes the gate's dedicated connection from pool and registers
+// the gate as the pool's one writer; a pool that already has a live gate is
+// refused (errGateExists) — two gates on one pool would busy-wait against
+// each other while each reports every write as gated (PM-G8).
+func newWriteGate(ctx context.Context, pool *sqlitemigration.Pool) (*writeGate, error) {
+ conn, err := pool.Take(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("write gate: take dedicated connection: %w", err)
+ }
+ // Pool.Take bound the interrupt to ctx; the connection outlives it.
+ conn.SetInterrupt(nil)
+ g := &writeGate{
+ pool: pool,
+ conn: conn,
+ owned: map[*sqlite.Conn]uint64{conn: writeStmtSeq.Load()},
+ watchdogStop: make(chan struct{}),
+ watchdogDone: make(chan struct{}),
+ }
+ g.idle = sync.NewCond(&g.mu)
+ if err := registerWriteGate(pool, g); err != nil {
+ pool.Put(conn)
+ return nil, err
+ }
+ if obs := writeGateObserver.Load(); obs != nil {
+ (*obs)(g)
+ }
+ go g.watchdog()
+ return g, nil
+}
+
+// owns reports whether a write statement recorded as seq on conn was the
+// gate's own: conn is one the gate has ever held, and the record falls
+// inside the gate's tenure of it — after the gate took it and before Close
+// (a live gate has closeSeq 0).
+func (g *writeGate) owns(conn *sqlite.Conn, seq uint64) bool {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ openSeq, ok := g.owned[conn]
+ if !ok || seq <= openSeq {
+ return false
+ }
+ return g.closeSeq == 0 || seq < g.closeSeq
+}
+
+// acquire blocks until the caller owns the gate or ctx is done. On a nil
+// return the caller MUST call release.
+func (g *writeGate) acquire(ctx context.Context, priority writePriority) error {
+ g.mu.Lock()
+ if g.closed {
+ g.mu.Unlock()
+ return errGateClosed
+ }
+ if !g.busy {
+ g.busy = true
+ g.mu.Unlock()
+ metrics.ObserveWriteGateWait(priority.label(), 0)
+ return nil
+ }
+ w := &gateWaiter{ch: make(chan struct{}, 1), priority: priority}
+ if priority == priorityHigh {
+ g.high = append(g.high, w)
+ } else {
+ g.low = append(g.low, w)
+ }
+ g.mu.Unlock()
+
+ start := time.Now()
+ select {
+ case <-w.ch:
+ metrics.ObserveWriteGateWait(priority.label(), time.Since(start))
+ if w.rejected {
+ return errGateClosed
+ }
+ return nil
+ case <-ctx.Done():
+ g.mu.Lock()
+ if w.granted {
+ // The ticket arrived in the same instant: we own the gate. Hand it
+ // back instead of leaking it (INV-5).
+ g.mu.Unlock()
+ <-w.ch
+ g.release()
+ return ctx.Err()
+ }
+ g.removeWaiter(w)
+ g.mu.Unlock()
+ return ctx.Err()
+ }
+}
+
+// removeWaiter removes w from its lane; caller holds g.mu.
+func (g *writeGate) removeWaiter(w *gateWaiter) {
+ lane := &g.low
+ if w.priority == priorityHigh {
+ lane = &g.high
+ }
+ for i, x := range *lane {
+ if x == w {
+ *lane = append((*lane)[:i], (*lane)[i+1:]...)
+ return
+ }
+ }
+}
+
+// pickNext implements the two-lane policy of singleWriter.run: high first,
+// unless highBurstLimit consecutive high commits have run while a low job
+// waited, in which case the low job goes through. Caller holds g.mu.
+func (g *writeGate) pickNext() *gateWaiter {
+ if g.highStreak < highBurstLimit && len(g.high) > 0 {
+ w := g.high[0]
+ g.high = g.high[1:]
+ g.highStreak++
+ return w
+ }
+ if len(g.low) > 0 {
+ w := g.low[0]
+ g.low = g.low[1:]
+ g.highStreak = 0
+ return w
+ }
+ if len(g.high) > 0 {
+ w := g.high[0]
+ g.high = g.high[1:]
+ g.highStreak++
+ return w
+ }
+ return nil
+}
+
+// release hands the gate to the next queued writer, or marks it idle.
+func (g *writeGate) release() {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ g.holdStart, g.holdPath, g.holdKind = time.Time{}, "", ""
+ if next := g.pickNext(); next != nil {
+ next.granted = true
+ next.ch <- struct{}{}
+ return
+ }
+ g.busy = false
+ g.idle.Broadcast()
+}
+
+// queued reports how many writers are waiting, for tests and gauges.
+func (g *writeGate) queued() (high, low int) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ return len(g.high), len(g.low)
+}
+
+// held reports whether some writer currently owns the gate.
+func (g *writeGate) held() bool {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ return g.busy
+}
+
+// run executes fn inside BEGIN IMMEDIATE … COMMIT on the gate's connection,
+// holding the gate for exactly that span. fn must contain only SQL on
+// already-prepared bytes (INV-1; the legacy kinds' INV-1′ adds one rename of
+// a file this process finished writing before the ticket). fn receives a
+// ctx marked as gate-held: a nested acquire through it fails at O(1)
+// (errGateReentrant) instead of queuing behind its own holder. A non-nil
+// error from fn rolls the transaction back and is returned; a panic in fn is
+// recovered, rolled back, counted under CommitOutcomePanic and returned as
+// an error (L0-B). path labels the hold, kind the commit outcome.
+func (g *writeGate) run(ctx context.Context, priority writePriority, path, kind string, fn func(ctx context.Context, conn *sqlite.Conn) error) (err error) {
+ if held := ctx.Value(gateCtxKey{}); held != nil {
+ metrics.IncWriteGateReentrant(path)
+ if gateReentrantPanics.Load() {
+ panic(fmt.Sprintf("%v: %s inside %s", errGateReentrant, path, held))
+ }
+ return fmt.Errorf("%w: %s inside %s", errGateReentrant, path, held)
+ }
+ if err := g.acquire(ctx, priority); err != nil {
+ return err
+ }
+ defer g.release()
+ g.mu.Lock()
+ g.holdStart, g.holdPath, g.holdKind = time.Now(), path, kind
+ conn := g.conn
+ g.mu.Unlock()
+
+ start := time.Now()
+ defer func() { metrics.ObserveSqliteWriteHold(path, kind, time.Since(start)) }()
+
+ defer func() {
+ if r := recover(); r != nil {
+ logger.L().Error("write gate: panic inside the gated transaction, rolled back",
+ helpers.String("path", path), helpers.String("kind", kind), helpers.Interface("panic", r), helpers.String("stack", string(debug.Stack())))
+ metrics.IncSingleWriterCommit(kind, priority.label(), metrics.CommitOutcomePanic)
+ g.cleanOrReplace(conn)
+ if perr, ok := r.(error); ok {
+ err = fmt.Errorf("write gate: panic during %s transaction: %w", path, perr)
+ } else {
+ err = fmt.Errorf("write gate: panic during %s transaction: %v", path, r)
+ }
+ }
+ }()
+
+ beforeBegin := time.Now()
+ endFn, err := sqlitex.ImmediateTransaction(conn)
+ metrics.ObserveSqliteBusyWait(time.Since(beforeBegin))
+ if err != nil {
+ g.cleanOrReplace(conn)
+ return fmt.Errorf("BEGIN IMMEDIATE: %w", err)
+ }
+ err = fn(context.WithValue(ctx, gateCtxKey{}, path), conn)
+ endFn(&err)
+ if err != nil {
+ g.cleanOrReplace(conn)
+ if errors.Is(err, errWriteConflict) || storage.IsExist(err) {
+ metrics.IncSingleWriterCommit(kind, priority.label(), metrics.CommitOutcomeConflict)
+ } else {
+ metrics.IncSingleWriterCommit(kind, priority.label(), metrics.CommitOutcomeError)
+ }
+ return err
+ }
+ metrics.IncSingleWriterCommit(kind, priority.label(), metrics.CommitOutcomeCommitted)
+ return nil
+}
+
+// cleanOrReplace makes sure the gate's connection is out of any transaction
+// after a failure. A connection that cannot be cleaned is replaced from the
+// pool, never dropped: the gate must never end up without a connection.
+// Caller owns the gate.
+func (g *writeGate) cleanOrReplace(conn *sqlite.Conn) {
+ if conn.AutocommitEnabled() {
+ return
+ }
+ _ = sqlitex.ExecuteTransient(conn, "ROLLBACK", nil)
+ if conn.AutocommitEnabled() {
+ return
+ }
+ logger.L().Error("write gate: connection could not be cleaned, replacing it from the pool")
+ ctx, cancel := context.WithTimeout(context.Background(), poolTimeout)
+ defer cancel()
+ fresh, err := g.pool.Take(ctx)
+ if err != nil {
+ logger.L().Error("write gate: could not take a replacement connection; keeping the dirty one", helpers.Error(err))
+ return
+ }
+ fresh.SetInterrupt(nil)
+ openSeq := writeStmtSeq.Load()
+ // The dirty connection is closed, not returned: a mid-transaction
+ // connection in the pool would poison whoever takes it next. This leaves
+ // the pool one short for Pool.Close (the K-5 note on Put(nil)).
+ _ = conn.Close()
+ g.mu.Lock()
+ g.conn = fresh
+ g.owned[fresh] = openSeq
+ g.replaced++
+ g.mu.Unlock()
+}
+
+// watchdog samples the hold every gateWatchdogInterval: it keeps the
+// hold-age gauge current and, once per hold, logs the holder with every
+// goroutine's stack when the hold outlives gateWatchdogThreshold (PM-G2).
+func (g *writeGate) watchdog() {
+ defer close(g.watchdogDone)
+ ticker := time.NewTicker(gateWatchdogInterval)
+ defer ticker.Stop()
+ logged := false
+ for {
+ select {
+ case <-g.watchdogStop:
+ metrics.SetWriteGateHoldAge(0)
+ return
+ case <-ticker.C:
+ }
+ g.mu.Lock()
+ busy, start, path, kind := g.busy, g.holdStart, g.holdPath, g.holdKind
+ g.mu.Unlock()
+ if !busy || start.IsZero() {
+ metrics.SetWriteGateHoldAge(0)
+ logged = false
+ continue
+ }
+ age := time.Since(start)
+ metrics.SetWriteGateHoldAge(age)
+ if age > gateWatchdogThreshold && !logged {
+ logged = true
+ g.watchdogFired.Add(1)
+ buf := make([]byte, 1<<20)
+ n := runtime.Stack(buf, true)
+ logger.L().Error("write gate: held past the watchdog threshold; every writer of every kind is queued behind it",
+ helpers.String("path", path), helpers.String("kind", kind), helpers.String("age", age.String()), helpers.String("stacks", string(buf[:n])))
+ }
+ }
+}
+
+// Close stops admitting writers, waits for the current holder to finish, and
+// returns the dedicated connection to the pool so that Pool.Close can
+// complete (K-5). Idempotent.
+func (g *writeGate) Close() error {
+ g.mu.Lock()
+ if g.closed {
+ g.mu.Unlock()
+ return nil
+ }
+ g.closed = true
+ for _, w := range append(g.high, g.low...) {
+ w.rejected = true
+ w.ch <- struct{}{}
+ }
+ g.high, g.low = nil, nil
+ for g.busy {
+ g.idle.Wait()
+ }
+ conn := g.conn
+ g.conn = nil
+ // Every gated write was recorded before this point; anything on the
+ // returned connection from here on is somebody else's (R-8).
+ g.closeSeq = writeStmtSeq.Add(1)
+ g.mu.Unlock()
+ close(g.watchdogStop)
+ <-g.watchdogDone
+ unregisterWriteGate(g.pool, g)
+ if conn != nil {
+ g.pool.Put(conn)
+ }
+ return nil
+}
diff --git a/pkg/registry/file/sqliteobject_gate_test.go b/pkg/registry/file/sqliteobject_gate_test.go
new file mode 100644
index 000000000..47e3505ec
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_gate_test.go
@@ -0,0 +1,312 @@
+package file
+
+import (
+ "context"
+ "errors"
+ "math/rand"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+func newTestGate(t *testing.T, poolSize int) (*writeGate, func()) {
+ t.Helper()
+ pool := NewPoolWithOptions(t.TempDir()+"/gate.sq3", PoolOptions{Size: poolSize, DisableAutoCheckpoint: true})
+ armUngatedWriteCheck(t, pool)
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ g, err := newWriteGate(ctx, pool)
+ require.NoError(t, err)
+ return g, func() {
+ require.NoError(t, g.Close())
+ done := make(chan error, 1)
+ go func() { done <- pool.Close() }()
+ select {
+ case err := <-done:
+ require.NoError(t, err)
+ case <-time.After(10 * time.Second):
+ t.Fatal("pool.Close hung: the gate did not return its connection (K-5)")
+ }
+ }
+}
+
+// TestWriteGate_FIFOWithinLane: the holder hands the ticket to waiters in
+// enqueue order.
+func TestWriteGate_FIFOWithinLane(t *testing.T) {
+ g, done := newTestGate(t, 2)
+ defer done()
+ require.NoError(t, g.acquire(context.Background(), priorityHigh))
+
+ const n = 8
+ var order []int
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+ for i := 0; i < n; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ require.NoError(t, g.acquire(context.Background(), priorityHigh))
+ mu.Lock()
+ order = append(order, i)
+ mu.Unlock()
+ g.release()
+ }(i)
+ // Enqueue deterministically: wait until this waiter is queued.
+ require.Eventually(t, func() bool { h, _ := g.queued(); return h == i+1 }, 5*time.Second, time.Millisecond)
+ }
+ g.release()
+ wg.Wait()
+ want := make([]int, n)
+ for i := range want {
+ want[i] = i
+ }
+ assert.Equal(t, want, order)
+}
+
+// TestWriteGate_HighBurstLimitForcesLow: with a low job queued, at most
+// highBurstLimit consecutive high jobs run before it (singleWriter.run's
+// policy, re-hosted).
+func TestWriteGate_HighBurstLimitForcesLow(t *testing.T) {
+ g, done := newTestGate(t, 2)
+ defer done()
+ require.NoError(t, g.acquire(context.Background(), priorityHigh))
+
+ var order []string
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+ enqueue := func(label string, p writePriority, expectHigh, expectLow int) {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ require.NoError(t, g.acquire(context.Background(), p))
+ mu.Lock()
+ order = append(order, label)
+ mu.Unlock()
+ g.release()
+ }()
+ require.Eventually(t, func() bool { h, l := g.queued(); return h == expectHigh && l == expectLow }, 5*time.Second, time.Millisecond)
+ }
+ enqueue("low", priorityLow, 0, 1)
+ for i := 0; i < highBurstLimit+5; i++ {
+ enqueue("high", priorityHigh, i+1, 1)
+ }
+ g.release()
+ wg.Wait()
+ lowAt := -1
+ for i, l := range order {
+ if l == "low" {
+ lowAt = i
+ }
+ }
+ assert.Equal(t, highBurstLimit, lowAt, "the low job runs after exactly highBurstLimit high jobs")
+}
+
+// TestWriteGate_INV5_NoTicketLeakUnderCancellation cancels callers at every
+// point of the hand-off — before enqueue, while queued, at the instant of
+// grant, and while holding — under a race-heavy schedule, and asserts the
+// gate always drains to idle and a fresh writer acquires promptly.
+func TestWriteGate_INV5_NoTicketLeakUnderCancellation(t *testing.T) {
+ g, done := newTestGate(t, 2)
+ defer done()
+ rng := rand.New(rand.NewSource(1))
+
+ for round := 0; round < 40; round++ {
+ var wg sync.WaitGroup
+ var granted atomic.Int64
+ for i := 0; i < 24; i++ {
+ wg.Add(1)
+ // draw on the test goroutine: math/rand.Rand is not goroutine-safe
+ timeout := time.Duration(rng.Intn(600)) * time.Microsecond
+ hold := time.Duration(rng.Intn(50)) * time.Microsecond
+ p := priorityHigh
+ if i%3 == 0 {
+ p = priorityLow
+ }
+ go func() {
+ defer wg.Done()
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ if err := g.acquire(ctx, p); err != nil {
+ return
+ }
+ granted.Add(1)
+ // hold briefly; sometimes our own ctx has already fired
+ time.Sleep(hold)
+ g.release()
+ }()
+ }
+ // The instant-of-grant race: a holder releases while a waiter's ctx
+ // expires; the waiter must either run or hand the ticket back.
+ wg.Wait()
+ require.Eventually(t, func() bool {
+ h, l := g.queued()
+ return !g.held() && h == 0 && l == 0
+ }, 5*time.Second, 100*time.Microsecond, "round %d: gate did not drain (in-flight ticket leaked)", round)
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ require.NoError(t, g.acquire(ctx, priorityHigh), "round %d: fresh writer could not acquire", round)
+ cancel()
+ g.release()
+ }
+}
+
+// TestWriteGate_GrantAtCancelInstantHandsBack pins the exact hand-off race:
+// the waiter's ctx is already done when the holder grants it the ticket.
+func TestWriteGate_GrantAtCancelInstantHandsBack(t *testing.T) {
+ g, done := newTestGate(t, 2)
+ defer done()
+ for i := 0; i < 500; i++ {
+ require.NoError(t, g.acquire(context.Background(), priorityHigh))
+ ctx, cancel := context.WithCancel(context.Background())
+ errCh := make(chan error, 1)
+ go func() { errCh <- g.acquire(ctx, priorityHigh) }()
+ require.Eventually(t, func() bool { h, _ := g.queued(); return h == 1 }, 5*time.Second, 10*time.Microsecond)
+ // Cancel and release "simultaneously".
+ var wg sync.WaitGroup
+ wg.Add(2)
+ go func() { defer wg.Done(); cancel() }()
+ go func() { defer wg.Done(); g.release() }()
+ wg.Wait()
+ err := <-errCh
+ if err == nil {
+ g.release()
+ } else {
+ assert.ErrorIs(t, err, context.Canceled)
+ }
+ require.Eventually(t, func() bool { return !g.held() }, 2*time.Second, 10*time.Microsecond, "iteration %d leaked the ticket", i)
+ }
+}
+
+// TestWriteGate_CloseRejectsWaiters: Close stops admitting, wakes queued
+// writers with errGateClosed, waits for the holder and returns the connection
+// (the deferred pool.Close proves K-5).
+func TestWriteGate_CloseRejectsWaiters(t *testing.T) {
+ g, done := newTestGate(t, 2)
+ require.NoError(t, g.acquire(context.Background(), priorityHigh))
+ waiterErr := make(chan error, 1)
+ go func() { waiterErr <- g.acquire(context.Background(), priorityLow) }()
+ require.Eventually(t, func() bool { _, l := g.queued(); return l == 1 }, 5*time.Second, time.Millisecond)
+
+ closed := make(chan struct{})
+ go func() { _ = g.Close(); close(closed) }()
+ select {
+ case <-closed:
+ t.Fatal("Close returned while the gate was still held")
+ case <-time.After(50 * time.Millisecond):
+ }
+ assert.ErrorIs(t, <-waiterErr, errGateClosed)
+ g.release()
+ select {
+ case <-closed:
+ case <-time.After(5 * time.Second):
+ t.Fatal("Close did not return after the holder released")
+ }
+ assert.ErrorIs(t, g.acquire(context.Background(), priorityHigh), errGateClosed)
+ done()
+}
+
+// TestWriteGate_RunPanicContainment: a panic inside the transaction body is
+// recovered, rolled back, and leaves the connection clean for the next writer.
+func TestWriteGate_RunPanicContainment(t *testing.T) {
+ g, done := newTestGate(t, 2)
+ defer done()
+ err := g.run(context.Background(), priorityHigh, "test", "test", func(_ context.Context, conn *sqlite.Conn) error {
+ require.NoError(t, sqlitex.ExecuteTransient(conn, `INSERT INTO metadata (kind,namespace,name,metadata) VALUES ('k','n','panic','{}')`, nil))
+ panic("boom")
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "panic")
+ assert.True(t, g.conn.AutocommitEnabled(), "connection left inside a transaction")
+ assert.False(t, g.held())
+
+ var n int64
+ require.NoError(t, g.run(context.Background(), priorityHigh, "test", "test", func(_ context.Context, conn *sqlite.Conn) error {
+ return sqlitex.ExecuteTransient(conn, `SELECT count(*) FROM metadata WHERE name='panic'`, &sqlitex.ExecOptions{
+ ResultFunc: func(stmt *sqlite.Stmt) error { n = stmt.ColumnInt64(0); return nil },
+ })
+ }))
+ assert.Equal(t, int64(0), n, "the panicked transaction must have been rolled back")
+
+ err = g.run(context.Background(), priorityHigh, "test", "test", func(context.Context, *sqlite.Conn) error { return errors.New("no") })
+ assert.EqualError(t, err, "no")
+ assert.True(t, g.conn.AutocommitEnabled())
+}
+
+// TestWriteGate_SecondGateOnPoolRefused (R-7, PM-G8): a pool carries one
+// live gate. Two gates on two dedicated connections would each gate their
+// own writes while busy-waiting against each other — the class, visible from
+// neither side — so the second construction fails, and a closed gate frees
+// the pool for a new one.
+func TestWriteGate_SecondGateOnPoolRefused(t *testing.T) {
+ g, done := newTestGate(t, 3)
+ defer done()
+ ctx := context.Background()
+ second, err := newWriteGate(ctx, g.pool)
+ require.ErrorIs(t, err, errGateExists)
+ require.Nil(t, second)
+ require.NoError(t, g.Close())
+ replacement, err := newWriteGate(ctx, g.pool)
+ require.NoError(t, err, "a closed gate frees the pool")
+ require.NoError(t, replacement.Close())
+}
+
+// TestWriteGate_SwapKeepsPreSwapConnectionOwned (T-G2, CR-1/CR-5): a gated
+// write recorded on the gate's connection before cleanOrReplace swapped it
+// out is still the gate's write. The swap is forced explicitly: fn runs a
+// recorded INSERT, installs a closed interrupt channel on the connection and
+// panics — the gate's own recovery path calls cleanOrReplace directly, whose
+// ROLLBACK is then interrupted (the sqlitex end function is not on that path;
+// it clears the interrupt before its own ROLLBACK, so an error return would
+// not swap). Plain ctx cancellation never reaches this branch: the gate never
+// binds a ctx to its connection's interrupt.
+func TestWriteGate_SwapKeepsPreSwapConnectionOwned(t *testing.T) {
+ pool := NewPoolWithOptions(t.TempDir()+"/swap.sq3", PoolOptions{Size: 3, DisableAutoCheckpoint: true})
+ armUngatedWriteCheck(t, pool)
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ g, err := newWriteGate(ctx, pool)
+ require.NoError(t, err)
+ // No pool.Close here: the swapped-out connection is closed, never
+ // returned, and sqlitex.Pool.Close waits for it forever — the K-5
+ // residual cleanOrReplace documents.
+ t.Cleanup(func() { require.NoError(t, g.Close()) })
+
+ c0 := g.conn
+ closed := make(chan struct{})
+ close(closed)
+ err = g.run(ctx, priorityHigh, "swap", "test", func(_ context.Context, conn *sqlite.Conn) error {
+ require.Same(t, c0, conn)
+ require.NoError(t, sqlitex.ExecuteTransient(conn, `INSERT INTO metadata (kind,namespace,name,metadata) VALUES ('k','n','swap','{}')`, nil))
+ conn.SetInterrupt(closed)
+ panic("forced failure after the recorded INSERT")
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "panic")
+ assert.Equal(t, 1, g.replaced, "the dirty connection must have been replaced")
+ assert.NotSame(t, c0, g.conn)
+ now := writeStmtSeq.Load()
+ assert.True(t, g.owns(c0, now), "the pre-swap connection stays owned: its recorded INSERT was a gated write")
+ // Nothing has been recorded on the replacement yet: the next write is the
+ // gate's (owns is bounded below by the take, WF-2).
+ assert.True(t, g.owns(g.conn, now+1), "the replacement is owned from its take on")
+ assert.False(t, g.owns(g.conn, now), "a write predating the replacement's take is not the gate's")
+ assert.False(t, g.held())
+
+ // The gate keeps working on the replacement, and the panicked INSERT
+ // never committed.
+ var n int64
+ require.NoError(t, g.run(ctx, priorityHigh, "swap", "test", func(_ context.Context, conn *sqlite.Conn) error {
+ require.Same(t, g.conn, conn)
+ return sqlitex.ExecuteTransient(conn, `SELECT count(*) FROM metadata WHERE name='swap'`, &sqlitex.ExecOptions{
+ ResultFunc: func(stmt *sqlite.Stmt) error { n = stmt.ColumnInt64(0); return nil },
+ })
+ }))
+ assert.Equal(t, int64(0), n)
+ // The armed AC-G1 check at cleanup is the CR-1 assertion: the INSERT on
+ // c0 must be judged owned, not flagged as ungated.
+}
diff --git a/pkg/registry/file/sqliteobject_gnp_test.go b/pkg/registry/file/sqliteobject_gnp_test.go
new file mode 100644
index 000000000..46b8d65ed
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_gnp_test.go
@@ -0,0 +1,72 @@
+package file
+
+// §5.6 row 9 of .omc/plans/full-acid-storage-architecture.md:
+// GeneratedNetworkPolicyStorage's full-spec ContainerProfile list must read
+// through the ContainerProfile storage.Interface (the ObjectStore under the
+// flag), not the default StorageImpl — whose get() would delete a row the
+// ObjectStore owns when the file it expects is absent (and, guarded, refuses
+// the key outright). Its knownservers read stays on the default instance.
+
+import (
+ "context"
+ "testing"
+
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/storage"
+)
+
+// querierSpy counts the calls GeneratedNetworkPolicyStorage makes on the
+// default StorageQuerier.
+type querierSpy struct {
+ StorageQuerier
+ getList int
+ getByCluster int
+}
+
+func (s *querierSpy) GetList(ctx context.Context, key string, opts storage.ListOptions, listObj runtime.Object) error {
+ s.getList++
+ return s.StorageQuerier.GetList(ctx, key, opts, listObj)
+}
+
+func (s *querierSpy) GetByCluster(ctx context.Context, apiVersion, kind string, listObj runtime.Object) error {
+ s.getByCluster++
+ return s.StorageQuerier.GetByCluster(ctx, apiVersion, kind, listObj)
+}
+
+const testGNPPrefix = "/spdx.softwarecomposition.kubescape.io/generatednetworkpolicies/"
+
+func TestGNP_ContainerProfileReadsGoThroughTheCPStore(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ // The template carries workload-kind Deployment / workload-name coredns
+ // and status ready, so it is available and maps to "deployment-coredns".
+ e.create(e.plain("deployment-coredns-coredns-1111-2222"))
+
+ spy := &querierSpy{StorageQuerier: e.legacy}
+ gnp := NewGeneratedNetworkPolicyStorage(spy, e.store)
+
+ out := &softwarecomposition.GeneratedNetworkPolicy{}
+ require.NoError(t, gnp.Get(e.ctx, testGNPPrefix+e.ns+"/deployment-coredns", storage.GetOptions{}, out))
+ require.Equal(t, "deployment-coredns", out.Name)
+
+ list := &softwarecomposition.GeneratedNetworkPolicyList{}
+ require.NoError(t, gnp.GetList(e.ctx, testGNPPrefix+e.ns, storage.ListOptions{Predicate: storage.Everything}, list))
+ require.Len(t, list.Items, 1)
+ require.Equal(t, "deployment-coredns", list.Items[0].Name)
+
+ // Non-CP reads are unchanged: knownservers come from the default
+ // instance (once per Get/GetList); no CP list ever reaches it.
+ require.Equal(t, 2, spy.getByCluster, "knownservers must still be read through the default StorageQuerier")
+ require.Equal(t, 0, spy.getList, "no ContainerProfile list may reach the default StorageQuerier")
+
+ // The guarded default instance still refuses the CP key: the old wiring
+ // (CP reads through the default instance) is not silently working.
+ cpList := &softwarecomposition.ContainerProfileList{}
+ err := e.legacy.GetList(e.ctx, testCPPrefix+e.ns, storage.ListOptions{
+ ResourceVersion: softwarecomposition.ResourceVersionFullSpec,
+ Predicate: storage.SelectionPredicate{Limit: containerProfileListLimit},
+ }, cpList)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "owned by the ContainerProfile SQLite backend")
+}
diff --git a/pkg/registry/file/sqliteobject_guard_test.go b/pkg/registry/file/sqliteobject_guard_test.go
new file mode 100644
index 000000000..80f2d125b
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_guard_test.go
@@ -0,0 +1,222 @@
+package file
+
+import (
+ "context"
+ "os"
+ "reflect"
+ "sync"
+ "sync/atomic"
+ "testing"
+
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apiserver/pkg/storage"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+// guardFs counts every payload-file operation the legacy store issues.
+type guardFs struct {
+ afero.Fs
+ ops atomic.Int64
+}
+
+func (g *guardFs) Open(name string) (afero.File, error) { g.ops.Add(1); return g.Fs.Open(name) }
+func (g *guardFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
+ g.ops.Add(1)
+ return g.Fs.OpenFile(name, flag, perm)
+}
+func (g *guardFs) Rename(o, n string) error { g.ops.Add(1); return g.Fs.Rename(o, n) }
+func (g *guardFs) Remove(name string) error { g.ops.Add(1); return g.Fs.Remove(name) }
+func (g *guardFs) Stat(name string) (os.FileInfo, error) {
+ g.ops.Add(1)
+ return g.Fs.Stat(name)
+}
+
+// TestINV4_LegacyStoreRefusesContainerProfileKeys runs every full-object
+// operation of the legacy StorageImpl (the guarded paths of design §5.6) on a
+// containerprofile key while the ObjectStore owns the kind, and asserts: the
+// ownership refusal (InternalError), ZERO statements on metadata / payloads /
+// time_series, ZERO payload-file operations, the rows untouched — and that the
+// metadata-only reads, which both backends agree on, are NOT refused.
+func TestINV4_LegacyStoreRefusesContainerProfileKeys(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ fs := &guardFs{Fs: e.legacyFs}
+ e.legacy.appFs = fs
+
+ // Seed through the owner.
+ e.create(e.ts("r1", 1, helpersv1.Learning, helpersv1.Partial))
+ e.tick()
+ base := e.mustGet(e.baseKey)
+ before := e.inspect(e.baseKey)
+ listKey := testCPPrefix + e.ns
+ cp := func() *softwarecomposition.ContainerProfile { return &softwarecomposition.ContainerProfile{} }
+ identity := identityTryUpdate
+
+ type op struct {
+ name string
+ run func() error
+ }
+ ops := []op{
+ {"Get(full)", func() error { return e.legacy.Get(e.ctx, e.baseKey, storage.GetOptions{}, cp()) }},
+ {"GetList(fullSpec)", func() error {
+ return e.legacy.GetList(e.ctx, listKey, storage.ListOptions{ResourceVersion: softwarecomposition.ResourceVersionFullSpec, Recursive: true}, &softwarecomposition.ContainerProfileList{})
+ }},
+ {"Create", func() error { return e.legacy.Create(e.ctx, e.key("guard-new"), e.plain("guard-new"), nil, 0) }},
+ {"GuaranteedUpdate", func() error { return e.legacy.GuaranteedUpdate(e.ctx, e.baseKey, cp(), false, nil, identity, nil) }},
+ {"Delete", func() error { return e.legacy.Delete(e.ctx, e.baseKey, cp(), nil, nil, nil, storage.DeleteOptions{}) }},
+ {"GetByNamespace", func() error {
+ return e.legacy.GetByNamespace(e.ctx, "spdx.softwarecomposition.kubescape.io", ContainerProfileKind, e.ns, &softwarecomposition.ContainerProfileList{})
+ }},
+ {"GetByCluster", func() error {
+ return e.legacy.GetByCluster(e.ctx, "spdx.softwarecomposition.kubescape.io", ContainerProfileKind, &softwarecomposition.ContainerProfileList{})
+ }},
+ {"appendGobObjectFromFile", func() error {
+ list := &softwarecomposition.ContainerProfileList{}
+ v := reflect.ValueOf(&list.Items).Elem()
+ return e.legacy.appendGobObjectFromFile(e.ctx, DefaultStorageRoot+e.baseKey+GobExt, v)
+ }},
+ {"CreateWithConn", func() error {
+ return e.withLegacyConn(func(conn *sqlite.Conn) error {
+ return e.legacy.CreateWithConn(e.ctx, conn, e.key("guard-new2"), e.plain("guard-new2"), nil, 0)
+ })
+ }},
+ {"GuaranteedUpdateWithConn", func() error {
+ return e.withLegacyConn(func(conn *sqlite.Conn) error {
+ return e.legacy.GuaranteedUpdateWithConn(e.ctx, conn, e.baseKey, cp(), false, nil, identity, nil, "")
+ })
+ }},
+ }
+ // The flag-off write path (singleWriterEnabled=false) reaches
+ // CreateWithConn/GuaranteedUpdateWithConn through Create/GuaranteedUpdate.
+ ops = append(ops,
+ op{"Create(singleWriter=false)", func() error {
+ old := singleWriterEnabled
+ singleWriterEnabled = false
+ defer func() { singleWriterEnabled = old }()
+ return e.legacy.Create(e.ctx, e.key("guard-new3"), e.plain("guard-new3"), nil, 0)
+ }},
+ op{"GuaranteedUpdate(singleWriter=false)", func() error {
+ old := singleWriterEnabled
+ singleWriterEnabled = false
+ defer func() { singleWriterEnabled = old }()
+ return e.legacy.GuaranteedUpdate(e.ctx, e.baseKey, cp(), false, nil, identity, nil)
+ }},
+ )
+
+ // An observer connection: PRAGMA data_version changes whenever ANY other
+ // connection commits a change to the database — a write detector that does
+ // not depend on statement caching (the authorizer fires at prepare time).
+ observer, err := e.pool.Take(e.ctx)
+ require.NoError(t, err)
+ defer e.pool.Put(observer)
+ dataVersion := func() int64 {
+ var v int64
+ require.NoError(t, sqlitex.ExecuteTransient(observer, `PRAGMA data_version`, &sqlitex.ExecOptions{ResultFunc: func(stmt *sqlite.Stmt) error { v = stmt.ColumnInt64(0); return nil }}))
+ return v
+ }
+
+ // Step 1-L's statement observer: every sqlitex.Execute call site in this
+ // package reports its name at execution time (the authorizer only sees
+ // prepares). The design's INV-4 instrument.
+ var stmts []string
+ var stmtsMu sync.Mutex
+ setStmtObserver(func(site string) { stmtsMu.Lock(); stmts = append(stmts, site); stmtsMu.Unlock() })
+ defer setStmtObserver(nil)
+
+ for _, o := range ops {
+ t.Run(o.name, func(t *testing.T) {
+ fs.ops.Store(0)
+ dv := dataVersion()
+ stmtsMu.Lock()
+ stmts = nil
+ stmtsMu.Unlock()
+ mark := e.rec.mark()
+ err := o.run()
+ actions := e.rec.since(mark)
+ stmtsMu.Lock()
+ executed := append([]string(nil), stmts...)
+ stmtsMu.Unlock()
+ assert.Empty(t, executed, "%s: legacy call sites executed statements after refusal", o.name)
+ require.Error(t, err, "must refuse")
+ assert.True(t, apierrors.IsInternalError(err), "refusal must be an InternalError, got %T: %v", err, err)
+ assert.Contains(t, err.Error(), "owned by the ContainerProfile SQLite backend")
+ for _, a := range actions {
+ assert.Empty(t, a.table, "%s: statement on %s (%v) after refusal", o.name, a.table, a.op)
+ }
+ assert.Equal(t, dv, dataVersion(), "%s: the database was written after refusal", o.name)
+ assert.Equal(t, int64(0), fs.ops.Load(), "%s: payload-file operation after refusal", o.name)
+ assert.Equal(t, before, e.inspect(e.baseKey), "%s: rows changed", o.name)
+ })
+ }
+
+ t.Run("metadata-only reads are not refused", func(t *testing.T) {
+ out := cp()
+ require.NoError(t, e.legacy.Get(e.ctx, e.baseKey, storage.GetOptions{ResourceVersion: softwarecomposition.ResourceVersionMetadata}, out))
+ assert.Equal(t, base.ResourceVersion, out.ResourceVersion)
+ assert.Equal(t, base.UID, out.UID)
+ list := &softwarecomposition.ContainerProfileList{}
+ require.NoError(t, e.legacy.GetList(e.ctx, listKey, storage.ListOptions{ResourceVersion: softwarecomposition.ResourceVersionMetadata, Recursive: true}, list))
+ require.Len(t, list.Items, 1)
+ assert.Equal(t, base.Name, list.Items[0].Name)
+ n, err := e.legacy.Count(listKey)
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), n)
+ })
+
+ t.Run("other kinds are served", func(t *testing.T) {
+ key := "/spdx.softwarecomposition.kubescape.io/sbomsyft/" + e.ns + "/img"
+ require.NoError(t, e.legacy.Create(e.ctx, key, &softwarecomposition.SBOMSyft{ObjectMeta: metav1.ObjectMeta{Name: "img", Namespace: e.ns}}, nil, 0))
+ require.NoError(t, e.legacy.Get(e.ctx, key, storage.GetOptions{}, &softwarecomposition.SBOMSyft{}))
+ require.NoError(t, e.legacy.Delete(e.ctx, key, &softwarecomposition.SBOMSyft{}, nil, nil, nil, storage.DeleteOptions{}))
+ })
+
+ t.Run("guard removed serves CP again", func(t *testing.T) {
+ e.legacy.SetForeignKinds(nil)
+ defer e.legacy.SetForeignKinds(IsContainerProfileKind)
+ // The legacy full read of an ObjectStore row finds no gob file. It
+ // used to delete the shared row (PM-4) — which is why the guard
+ // exists; the rollback-safety guard's read fallback now serves it
+ // from payloads instead (see
+ // TestINV4_UnguardedLegacyFullReadNoLongerDeletesOwnedRow). The
+ // metadata read asserted here is unaffected either way.
+ require.NoError(t, e.legacy.Get(e.ctx, e.baseKey, storage.GetOptions{ResourceVersion: softwarecomposition.ResourceVersionMetadata}, cp()))
+ })
+}
+
+func (e *objectStoreEnv) withLegacyConn(fn func(conn *sqlite.Conn) error) error {
+ conn, err := e.pool.Take(context.Background())
+ if err != nil {
+ return err
+ }
+ defer e.pool.Put(conn)
+ return fn(conn)
+}
+
+// TestINV4_UnguardedLegacyFullReadNoLongerDeletesOwnedRow pins what the
+// PM-4 hazard became: without the guard, a legacy full GET on an ObjectStore
+// key used to delete the shared metadata row because the gob file is
+// missing. Rollback-safety guard Part 2 closes that at this site by
+// construction — such a key satisfies all four fallback conditions (a
+// metadata row, rv non-NULL, not a time-series row, a payloads row), so the
+// read is served from the payloads body and the row is left untouched. The
+// guard still exists for every write-side operation.
+func TestINV4_UnguardedLegacyFullReadNoLongerDeletesOwnedRow(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ created := e.create(e.plain("pm4"))
+ e.legacy.SetForeignKinds(nil)
+ out := &softwarecomposition.ContainerProfile{}
+ err := e.legacy.Get(e.ctx, e.key("pm4"), storage.GetOptions{}, out)
+ assert.NoError(t, err, "Part 2: the fallback serves this key instead of self-repairing it")
+ assert.Equal(t, created.ResourceVersion, out.ResourceVersion, "stamped from the row's rv column")
+ assert.Equal(t, created.UID, out.UID, "stamped from the row's uid column")
+ row := e.inspect(e.key("pm4"))
+ assert.True(t, row.metaExists, "the unguarded legacy read no longer deletes the row (the PM-4 hazard)")
+ assert.True(t, row.payloadExists, "and the payloads row it was served from survives")
+ e.legacy.SetForeignKinds(IsContainerProfileKind)
+}
diff --git a/pkg/registry/file/sqliteobject_inv1_test.go b/pkg/registry/file/sqliteobject_inv1_test.go
new file mode 100644
index 000000000..641fe74bb
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_inv1_test.go
@@ -0,0 +1,137 @@
+package file
+
+import (
+ "context"
+ "reflect"
+ "sync"
+ "sync/atomic"
+ "testing"
+
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/kubescape/storage/pkg/utils"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/storage"
+)
+
+// recordingProcessor notes whether the gate was held when a callback ran.
+type recordingProcessor struct {
+ inner Processor
+ gate *writeGate
+ mu sync.Mutex
+ calls []string
+}
+
+func (r *recordingProcessor) note(what string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.gate.held() {
+ r.calls = append(r.calls, what+" (GATE HELD)")
+ } else {
+ r.calls = append(r.calls, what)
+ }
+}
+func (r *recordingProcessor) AfterCreate(ctx context.Context, o runtime.Object) error {
+ r.note("AfterCreate")
+ return r.inner.AfterCreate(ctx, o)
+}
+func (r *recordingProcessor) PreSave(ctx context.Context, o runtime.Object) error {
+ r.note("PreSave")
+ return r.inner.PreSave(ctx, o)
+}
+func (r *recordingProcessor) SetStorage(s ContainerProfileStorage) { r.inner.SetStorage(s) }
+func (r *recordingProcessor) TimeSeriesRowFor(o runtime.Object) (TimeSeriesRow, string, bool) {
+ r.note("TimeSeriesRowFor")
+ return r.inner.(TimeSeriesRowProvider).TimeSeriesRowFor(o)
+}
+
+type recordingDispatcher struct {
+ inner eventDispatcher
+ gate *writeGate
+ mu sync.Mutex
+ calls []string
+}
+
+func (r *recordingDispatcher) note(what string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.gate.held() {
+ r.calls = append(r.calls, what+" (GATE HELD)")
+ } else {
+ r.calls = append(r.calls, what)
+ }
+}
+func (r *recordingDispatcher) Added(k string, m, o runtime.Object) {
+ r.note("Added")
+ r.inner.Added(k, m, o)
+}
+func (r *recordingDispatcher) Modified(k string, m, o runtime.Object) {
+ r.note("Modified")
+ r.inner.Modified(k, m, o)
+}
+func (r *recordingDispatcher) Deleted(k string, m runtime.Object) {
+ r.note("Deleted")
+ r.inner.Deleted(k, m)
+}
+
+// TestINV1_GateHolderExecutesOnlySQL: between BEGIN IMMEDIATE and COMMIT on
+// the gate's connection, the only activity in the process is SQL on that
+// connection against metadata/payloads/time_series — no PreSave, no
+// AfterCreate, no watch dispatch, no statement on any other connection (a pool
+// take), and the store owns no per-key MapMutex at all.
+func TestINV1_GateHolderExecutesOnlySQL(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ rp := &recordingProcessor{inner: e.processor, gate: e.store.gate}
+ rd := &recordingDispatcher{inner: e.wd, gate: e.store.gate}
+ e.store.processor = rp
+ e.store.watchDispatcher = rd
+
+ // MapMutex: assert absent by type.
+ st := reflect.TypeOf(ObjectStore{})
+ for i := 0; i < st.NumField(); i++ {
+ assert.NotEqual(t, reflect.TypeOf(utils.MapMutex[string]{}), st.Field(i).Type, "ObjectStore must not carry a per-key MapMutex")
+ }
+
+ var poolTakesUnderGate atomic.Int64
+ var poolTakes atomic.Int64
+ e.store.hooks.onPoolTake = func() {
+ poolTakes.Add(1)
+ if e.store.gate.held() {
+ poolTakesUnderGate.Add(1)
+ }
+ }
+
+ mark := e.rec.mark()
+ // Every write path: TS create (with AfterCreate's row), REST update, a
+ // consolidation tick (write set), and a delete.
+ e.create(e.ts("r1", 1, helpersv1.Learning, helpersv1.Partial))
+ require.NoError(t, e.store.GuaranteedUpdate(e.ctx, e.tsKey("r1"), &softwarecomposition.ContainerProfile{}, false, nil, setLabel("inv", "1"), nil))
+ e.tick()
+ require.NoError(t, e.store.Delete(e.ctx, e.baseKey, nil, nil, nil, nil, storage.DeleteOptions{}))
+ actions := e.rec.since(mark)
+
+ for _, c := range append(rp.calls, rd.calls...) {
+ assert.NotContains(t, c, "GATE HELD", "callback ran while the gate was held: %s", c)
+ }
+ assert.Contains(t, rp.calls, "PreSave")
+ assert.Contains(t, rd.calls, "Added")
+ assert.Contains(t, rd.calls, "Modified")
+ assert.Contains(t, rd.calls, "Deleted")
+ assert.NotContains(t, rp.calls, "AfterCreate", "the row provider path never calls AfterCreate")
+
+ assert.Greater(t, poolTakes.Load(), int64(0))
+ assert.Equal(t, int64(0), poolTakesUnderGate.Load(), "a pool connection was taken while the gate was held (acquisition order violated)")
+
+ // The gate connection is used for nothing but the three tables. (The
+ // authorizer fires at prepare time; every statement the gate connection
+ // ever runs is prepared on it first, so the set of tables is exact.)
+ now := writeStmtSeq.Load()
+ for _, a := range actions {
+ if !e.store.gate.owns(a.conn, now) || a.table == "" {
+ continue
+ }
+ assert.Contains(t, []string{"metadata", "payloads", "time_series", "json_each"}, a.table, "unexpected table on the gate connection")
+ }
+}
diff --git a/pkg/registry/file/sqliteobject_inv2_rapid_test.go b/pkg/registry/file/sqliteobject_inv2_rapid_test.go
new file mode 100644
index 000000000..449a51908
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_inv2_rapid_test.go
@@ -0,0 +1,393 @@
+package file
+
+// INV-2 as a property: for every ContainerProfile key, at every point observable
+// from a second connection, `metadata row exists ⇔ payloads row exists`,
+// `rv == json_extract(metadata,'$.resourceVersion')` and
+// `uid == json_extract(metadata,'$.uid')`. A rapid state machine generates
+// Create / Update / Delete / TS-create / consolidation-tick sequences against
+// the ObjectStore with a model of the expected key set, and a crash injector
+// interrupts the gated transaction at EVERY statement boundary in three ways:
+// returning an error (ROLLBACK), panicking (containment + ROLLBACK), and
+// rolling the transaction back underneath the holder (what a process crash
+// looks like to SQLite: the uncommitted WAL frames are discarded). After every
+// step INV-2 is asserted from a fresh connection, along with the model.
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apiserver/pkg/storage"
+ "pgregory.net/rapid"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+type crashKind int
+
+const (
+ crashNone crashKind = iota
+ crashError
+ crashPanic
+ crashRollbackUnderneath
+)
+
+var errInjectedCrash = errors.New("injected crash")
+
+// inv2Machine is the rapid state machine.
+type inv2Machine struct {
+ t *rapid.T
+ env *objectStoreEnv
+ // live is the model: keys the store must hold (base and TS), with the
+ // last RV observed for each.
+ live map[string]string
+ // crash configures the next gated transaction's interruption.
+ crashAt int
+ crashKind crashKind
+ armed bool
+ // tsBase remembers which TS suffixes have been created for the base.
+ tsSuffixes []string
+ names []string
+}
+
+func (m *inv2Machine) armCrash(idx int, kind crashKind) {
+ m.crashAt, m.crashKind, m.armed = idx, kind, true
+}
+
+// hook is the store's beforeStatement seam: fires the armed crash once.
+func (m *inv2Machine) hook(conn *sqlite.Conn, path string, idx int, name string) error {
+ if !m.armed || idx != m.crashAt {
+ return nil
+ }
+ m.armed = false
+ switch m.crashKind {
+ case crashError:
+ return errInjectedCrash
+ case crashPanic:
+ panic(errInjectedCrash)
+ case crashRollbackUnderneath:
+ // The process "dies": SQLite discards the uncommitted WAL frames and
+ // nothing after this point runs. Emulated by rolling back on the
+ // holder's own connection AND aborting the holder (a first version
+ // let the remaining statements run in autocommit — which is not a
+ // crash, and did produce an orphan payload row).
+ _ = sqlitex.ExecuteTransient(conn, "ROLLBACK", nil)
+ return errInjectedCrash
+ }
+ return nil
+}
+
+// disarm clears a crash that did not fire (fewer statements than crashAt).
+func (m *inv2Machine) disarm() { m.armed = false }
+
+func (m *inv2Machine) checkINV2() {
+ m.env.withConn(func(conn *sqlite.Conn) {
+ keys := allCPKeys(m.env.t, conn)
+ for _, k := range keys {
+ assertINV2(m.env.t, conn, k)
+ }
+ // model: every live key is present, nothing else is
+ present := map[string]bool{}
+ for _, k := range keys {
+ present[k] = true
+ }
+ for k := range m.live {
+ if !present[k] {
+ m.t.Fatalf("model says %s is live but the store has no row", k)
+ }
+ }
+ for _, k := range keys {
+ if _, ok := m.live[k]; !ok {
+ m.t.Fatalf("store has %s but the model says it was never created or was deleted", k)
+ }
+ }
+ // rv agrees with the last observed RV
+ for k, rv := range m.live {
+ row := inspectRow(m.env.t, conn, k)
+ if row.jsonRV != rv {
+ m.t.Fatalf("%s: model rv %s, store rv %s", k, rv, row.jsonRV)
+ }
+ }
+ })
+}
+
+func (m *inv2Machine) maybeCrash(maxIdx int) bool {
+ if rapid.Bool().Draw(m.t, "crash") {
+ idx := rapid.IntRange(1, maxIdx).Draw(m.t, "crashAt")
+ kind := crashKind(rapid.IntRange(int(crashError), int(crashRollbackUnderneath)).Draw(m.t, "crashKind"))
+ m.armCrash(idx, kind)
+ return true
+ }
+ return false
+}
+
+func (m *inv2Machine) pickName() string {
+ return m.names[rapid.IntRange(0, len(m.names)-1).Draw(m.t, "name")]
+}
+
+// ---- actions ----
+
+func (m *inv2Machine) Create(t *rapid.T) {
+ name := m.pickName()
+ key := m.env.key(name)
+ crashed := m.maybeCrash(4)
+ p := m.env.plain(name)
+ out := &softwarecomposition.ContainerProfile{}
+ err := m.env.store.Create(m.env.ctx, key, p, out, 0)
+ m.disarm()
+ _, existed := m.live[key]
+ switch {
+ case crashed && errors.Is(err, errInjectedCrash):
+ // interrupted: nothing changed
+ case existed:
+ if !storage.IsExist(err) {
+ t.Fatalf("create of existing %s: want KeyExists, got %v", key, err)
+ }
+ default:
+ if err != nil {
+ t.Fatalf("create %s: %v", key, err)
+ }
+ m.live[key] = out.ResourceVersion
+ }
+ m.checkINV2()
+}
+
+func (m *inv2Machine) Update(t *rapid.T) {
+ name := m.pickName()
+ key := m.env.key(name)
+ crashed := m.maybeCrash(3)
+ out := &softwarecomposition.ContainerProfile{}
+ label := fmt.Sprintf("v%d", rapid.IntRange(0, 1000).Draw(t, "label"))
+ err := m.env.store.GuaranteedUpdate(m.env.ctx, key, out, false, nil, setLabel("l", label), nil)
+ m.disarm()
+ _, existed := m.live[key]
+ switch {
+ case crashed && errors.Is(err, errInjectedCrash):
+ // interrupted: rv unchanged
+ case !existed:
+ if !storage.IsNotFound(err) {
+ t.Fatalf("update of absent %s: want NotFound, got %v", key, err)
+ }
+ default:
+ if err != nil {
+ t.Fatalf("update %s: %v", key, err)
+ }
+ m.live[key] = out.ResourceVersion
+ }
+ m.checkINV2()
+}
+
+func (m *inv2Machine) Delete(t *rapid.T) {
+ name := m.pickName()
+ key := m.env.key(name)
+ crashed := m.maybeCrash(4)
+ err := m.env.store.Delete(m.env.ctx, key, &softwarecomposition.ContainerProfile{}, nil, nil, nil, storage.DeleteOptions{})
+ m.disarm()
+ _, existed := m.live[key]
+ switch {
+ case crashed && errors.Is(err, errInjectedCrash):
+ case !existed:
+ if !storage.IsNotFound(err) {
+ t.Fatalf("delete of absent %s: want NotFound, got %v", key, err)
+ }
+ default:
+ if err != nil {
+ t.Fatalf("delete %s: %v", key, err)
+ }
+ delete(m.live, key)
+ }
+ m.checkINV2()
+}
+
+// CreateTS creates a time-series profile of the base (a chained report), so
+// consolidation has something to merge; its row joins the transaction.
+func (m *inv2Machine) CreateTS(t *rapid.T) {
+ n := len(m.tsSuffixes) + 1
+ suffix := fmt.Sprintf("r%d", n)
+ key := m.env.tsKey(suffix)
+ crashed := m.maybeCrash(5)
+ out := &softwarecomposition.ContainerProfile{}
+ err := m.env.store.Create(m.env.ctx, key, m.env.ts(suffix, n, helpersv1.Learning, helpersv1.Partial), out, 0)
+ m.disarm()
+ switch {
+ case errors.Is(err, ObjectCompletedError):
+ // the base is Completed/Full: refused, nothing written
+ case crashed && errors.Is(err, errInjectedCrash):
+ default:
+ if err != nil {
+ t.Fatalf("create ts %s: %v", key, err)
+ }
+ m.live[key] = out.ResourceVersion
+ m.tsSuffixes = append(m.tsSuffixes, suffix)
+ }
+ m.checkINV2()
+}
+
+// Tick runs one consolidation pass: the write set (base save + time_series
+// rewrite + processed TS deletes) commits atomically or not at all.
+func (m *inv2Machine) Tick(t *rapid.T) {
+ crashed := m.maybeCrash(4)
+ err := m.env.processor.ConsolidateTimeSeries(m.env.ctx)
+ m.disarm()
+ if err != nil && (!crashed || !errors.Is(err, errInjectedCrash)) {
+ t.Fatalf("tick: %v", err)
+ }
+ // Re-derive the model from the store: a committed tick deleted the
+ // processed TS objects and created/updated the base; an interrupted one
+ // changed nothing. Which happened is decided by whether the TS keys are
+ // still there — but INV-2 and "pre-tick or post-tick, nothing between" are
+ // what we assert: either ALL processed TS keys are gone and the base
+ // exists, or NONE are gone.
+ m.env.withConn(func(conn *sqlite.Conn) {
+ present := map[string]bool{}
+ for _, k := range allCPKeys(m.env.t, conn) {
+ present[k] = true
+ }
+ gone, kept := 0, 0
+ for _, sfx := range m.tsSuffixes {
+ k := m.env.tsKey(sfx)
+ if _, live := m.live[k]; !live {
+ continue
+ }
+ if present[k] {
+ kept++
+ } else {
+ gone++
+ }
+ }
+ if gone > 0 && kept > 0 {
+ t.Fatalf("tick left a partial state: %d TS objects deleted, %d kept (crashed=%v err=%v)", gone, kept, crashed, err)
+ }
+ if gone > 0 {
+ if !present[m.env.baseKey] {
+ t.Fatalf("tick deleted TS objects but the base is absent")
+ }
+ for _, sfx := range m.tsSuffixes {
+ delete(m.live, m.env.tsKey(sfx))
+ }
+ m.tsSuffixes = nil
+ m.live[m.env.baseKey] = inspectRow(m.env.t, conn, m.env.baseKey).jsonRV
+ } else if present[m.env.baseKey] {
+ m.live[m.env.baseKey] = inspectRow(m.env.t, conn, m.env.baseKey).jsonRV
+ }
+ })
+ m.checkINV2()
+}
+
+func (m *inv2Machine) Check(t *rapid.T) {
+ m.checkINV2()
+}
+
+func TestINV2_RapidStateMachineWithCrashInjection(t *testing.T) {
+ rapid.Check(t, func(rt *rapid.T) {
+ e := newObjectStoreEnv(t)
+ m := &inv2Machine{t: rt, env: e, live: map[string]string{}, names: []string{"a", "b", "c"}}
+ e.store.hooks.beforeStatement = m.hook
+ // the base key must be part of the model once consolidation creates it
+ defer func() { e.store.hooks.beforeStatement = nil }()
+ rt.Repeat(rapid.StateMachineActions(m))
+ })
+}
+
+// TestINV2_CrashAtEveryBoundaryDeterministic walks every statement boundary
+// of the three transactions with each crash kind, deterministically, so the
+// property has an exhaustive floor under the randomised machine.
+func TestINV2_CrashAtEveryBoundaryDeterministic(t *testing.T) {
+ kinds := []crashKind{crashError, crashPanic, crashRollbackUnderneath}
+ for _, kind := range kinds {
+ for idx := 1; idx <= 6; idx++ {
+ t.Run(fmt.Sprintf("kind=%d/idx=%d", kind, idx), func(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ m := &inv2Machine{env: e, live: map[string]string{}}
+ e.store.hooks.beforeStatement = m.hook
+ seedTS := e.ts("r1", 1, helpersv1.Learning, helpersv1.Partial)
+
+ // Create (TS admission, insert-metadata, insert-payload,
+ // insert-time-series, commit = 5 boundaries; idx 6 never fires)
+ m.armCrash(idx, kind)
+ err := e.store.Create(e.ctx, e.tsKey("r1"), seedTS, nil, 0)
+ fired := !m.armed
+ m.disarm()
+ e.withConn(func(c *sqlite.Conn) {
+ for _, k := range allCPKeys(t, c) {
+ assertINV2(t, c, k)
+ }
+ })
+ row := e.inspect(e.tsKey("r1"))
+ base := e.inspect(e.baseKey)
+ if fired {
+ require.ErrorIs(t, err, errInjectedCrash)
+ require.False(t, row.metaExists || row.payloadExists, "interrupted create left rows")
+ require.Equal(t, 0, base.tsRows, "interrupted create left a time_series row")
+ // re-create cleanly for the next transactions
+ require.NoError(t, e.store.Create(e.ctx, e.tsKey("r1"), e.ts("r1", 1, helpersv1.Learning, helpersv1.Partial), nil, 0))
+ } else {
+ require.NoError(t, err)
+ require.True(t, row.metaExists && row.payloadExists)
+ require.Equal(t, 1, base.tsRows)
+ }
+
+ // Update (update-metadata, update-payload, commit = 3 boundaries)
+ m.armCrash(idx, kind)
+ err = e.store.GuaranteedUpdate(e.ctx, e.tsKey("r1"), &softwarecomposition.ContainerProfile{}, false, nil, setLabel("x", "y"), nil)
+ fired = !m.armed
+ m.disarm()
+ e.withConn(func(c *sqlite.Conn) { assertINV2(t, c, e.tsKey("r1")) })
+ got := e.mustGet(e.tsKey("r1"))
+ if fired {
+ require.ErrorIs(t, err, errInjectedCrash)
+ require.Equal(t, "1", got.ResourceVersion, "interrupted update must leave rv 1")
+ require.Empty(t, got.Labels["x"])
+ } else {
+ require.NoError(t, err)
+ require.Equal(t, "2", got.ResourceVersion)
+ }
+
+ // Consolidation tick: replace-time-series, save-base, delete-ts,
+ // commit = 4 boundaries
+ m.armCrash(idx, kind)
+ err = e.processor.ConsolidateTimeSeries(e.ctx)
+ fired = !m.armed
+ m.disarm()
+ e.withConn(func(c *sqlite.Conn) {
+ for _, k := range allCPKeys(t, c) {
+ assertINV2(t, c, k)
+ }
+ })
+ tsRow := e.inspect(e.tsKey("r1"))
+ base = e.inspect(e.baseKey)
+ if fired {
+ require.ErrorIs(t, err, errInjectedCrash, "interrupted tick must report the error")
+ require.True(t, tsRow.metaExists, "pre-tick state: TS object still there")
+ require.False(t, base.metaExists, "pre-tick state: no base")
+ require.Equal(t, 1, base.tsRows, "pre-tick state: time_series row still there")
+ } else {
+ require.NoError(t, err)
+ require.False(t, tsRow.metaExists, "post-tick state: TS object deleted")
+ require.True(t, base.metaExists && base.payloadExists, "post-tick state: base created")
+ }
+
+ // Delete of the base (delete-metadata, delete-payload, delete-time-series, commit)
+ if base.metaExists {
+ m.armCrash(idx, kind)
+ err = e.store.Delete(e.ctx, e.baseKey, nil, nil, nil, nil, storage.DeleteOptions{})
+ fired = !m.armed
+ m.disarm()
+ e.withConn(func(c *sqlite.Conn) { assertINV2(t, c, e.baseKey) })
+ after := e.inspect(e.baseKey)
+ if fired {
+ require.ErrorIs(t, err, errInjectedCrash)
+ require.True(t, after.metaExists && after.payloadExists, "interrupted delete must leave both rows")
+ } else {
+ require.NoError(t, err)
+ require.False(t, after.metaExists || after.payloadExists)
+ }
+ }
+ require.True(t, e.store.gate.conn.AutocommitEnabled(), "gate connection left dirty")
+ })
+ }
+ }
+}
diff --git a/pkg/registry/file/sqliteobject_keyreserve.go b/pkg/registry/file/sqliteobject_keyreserve.go
new file mode 100644
index 000000000..48fa7218b
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_keyreserve.go
@@ -0,0 +1,235 @@
+package file
+
+// Consolidation's fairness escalation against a same-series writer (design
+// §3.7 Phase 3: "conflict → one re-read and retry, N=2").
+//
+// The pass's Phase 1 read → merge → encode → gate → commit window is longer
+// than a REST writer's whole read → encode → commit cycle, so a writer
+// committing base updates back-to-back on the same key beats the pass's CAS
+// on every attempt: the once-retry re-runs the same window and loses again,
+// and nothing escalates across ticks. The gate's lanes do not help — a single
+// writer never has a second commit queued while its first holds the gate, so
+// the pass loses in its prepare window, outside the gate, not in the queue.
+//
+// The escalation is a per-series reservation the pass takes for its retry
+// only, after a real conflict. While it is held, an ObjectStore write to the
+// series (the base key or one of its TS keys) that has not started yet waits
+// for the retry to end (bounded by keyReserveWaitMax), and the retry itself
+// starts only once every same-series write already in flight has finished
+// (bounded by keyReserveDrainMax). Absent those two timeouts, no same-series
+// write commits inside the retry's window, so its CAS cannot fail from one:
+// the series consolidates within the tick, at attempt 2. The reservation is
+// released as soon as the retry ends, whichever way, so consolidation never
+// holds writers across ticks; a writer whose wait times out proceeds anyway.
+// The gate's priority scheme is untouched: writers to any other series are
+// not affected at all.
+
+import (
+ "context"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/kubescape/storage/pkg/metrics"
+)
+
+// Package-level so tests can shrink them.
+var (
+ // keyReserveWaitMax bounds an ordinary writer's wait on a series
+ // reservation in enter() (retry-vs-write: common, on every write to a
+ // contended series). Tier B (droplet run, b8e42f76) showed a 1s cap
+ // here shifting +47% onto update-p95-ms; a clean dedicated-CPU rerun
+ // at 200ms (commit d4e8090d) still showed +70.6%. 50ms fixed it
+ // (-9% to -10.5% across two runs) with no correctness cost: the
+ // starvation-fix regression tests still show consolidation reserving
+ // and committing every round via a normal release, never a timeout.
+ keyReserveWaitMax = 50 * time.Millisecond
+ // keyReserveQueueMax bounds one consolidation retry's wait to reserve
+ // a series another retry already holds, in reserve() (retry-vs-retry:
+ // overlapping ticks on the same series, rarer than every write). This
+ // used to share keyReserveWaitMax with the writer-wait above; shrinking
+ // that to 50ms for update-p95-ms made overlapping retries give up and
+ // run unreserved far more often, producing extra unconsolidated/
+ // duplicate TS rows that List's scan/merge then paid for — list-p95-ms
+ // regressed +93.7% to +121.1% even though bumping the SQLite pool size
+ // (LOAD_POOL) made it worse, not better, ruling out pool contention as
+ // the cause. Split into its own constant, left generous like
+ // keyReserveDrainMax: retry-vs-retry collisions are uncommon enough
+ // that patiently waiting here costs little, and correctness (never
+ // racing two retries unreserved against each other) matters more than
+ // shaving this specific wait.
+ keyReserveQueueMax = time.Second
+ // keyReserveDrainMax bounds the reserving pass's wait for in-flight
+ // same-series writes to finish. Left at 1s: this is the tail-latency
+ // bound that fixed consolidation starvation and must stay generous.
+ keyReserveDrainMax = time.Second
+)
+
+type keyReservationCtxKey struct{}
+
+// keyReservation is one series reserved by a consolidation retry.
+type keyReservation struct {
+ key string
+ released chan struct{}
+ // drained is signalled (non-blocking) by every leave of a same-series
+ // write; the reserver re-counts on each signal.
+ drained chan struct{}
+ once sync.Once
+}
+
+// keyReservations tracks reserved series and in-flight writes per key.
+type keyReservations struct {
+ mu sync.Mutex
+ reserved map[string]*keyReservation
+ inflight map[string]int
+}
+
+func newKeyReservations() *keyReservations {
+ return &keyReservations{reserved: map[string]*keyReservation{}, inflight: map[string]int{}}
+}
+
+// sameSeries reports whether key is base itself or one of its TS keys
+// (base + "-" + suffix, SplitProfileName's shape). A base named with a
+// trailing "-x" segment matches another base's series by prefix; the cost is
+// one bounded wait during that series' reserved retry, never a wrong write.
+func sameSeries(key, base string) bool {
+ return key == base || (len(key) > len(base) && key[len(base)] == '-' && strings.HasPrefix(key, base))
+}
+
+// enter registers a write to key as in flight and returns its leave. When a
+// reservation covers key's series, the write first waits for it to be
+// released (bounded by keyReserveWaitMax and ctx); the reserving pass's own
+// writes (ctx carries the reservation) never wait on it.
+func (r *keyReservations) enter(ctx context.Context, key string) (leave func()) {
+ own, _ := ctx.Value(keyReservationCtxKey{}).(*keyReservation)
+ waited := false
+ for {
+ r.mu.Lock()
+ var blocking *keyReservation
+ for _, res := range r.reserved {
+ if res != own && sameSeries(key, res.key) {
+ blocking = res
+ break
+ }
+ }
+ if blocking == nil || waited {
+ r.inflight[key]++
+ r.mu.Unlock()
+ return func() { r.leave(key) }
+ }
+ r.mu.Unlock()
+
+ timer := time.NewTimer(keyReserveWaitMax)
+ select {
+ case <-blocking.released:
+ timer.Stop()
+ metrics.IncCPKeyYield(metrics.KeyYieldReleased)
+ case <-timer.C:
+ metrics.IncCPKeyYield(metrics.KeyYieldTimeout)
+ waited = true
+ case <-ctx.Done():
+ timer.Stop()
+ // The write fails on its own ctx at the next step; register it so
+ // the leave stays uniform.
+ waited = true
+ }
+ }
+}
+
+func (r *keyReservations) leave(key string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.inflight[key] <= 1 {
+ delete(r.inflight, key)
+ } else {
+ r.inflight[key]--
+ }
+ for _, res := range r.reserved {
+ if sameSeries(key, res.key) {
+ select {
+ case res.drained <- struct{}{}:
+ default:
+ }
+ }
+ }
+}
+
+// reserve reserves key's series for the caller and waits for in-flight
+// same-series writes to finish (bounded by keyReserveDrainMax and ctx). It
+// returns the ctx the reserving pass must use for its own writes and the
+// release, which is idempotent and must be called. drained is false when the
+// drain timed out: writes were still in flight when the reservation began.
+func (r *keyReservations) reserve(ctx context.Context, key string) (reservedCtx context.Context, release func(), drained bool) {
+ res := &keyReservation{key: key, released: make(chan struct{}), drained: make(chan struct{}, 1)}
+ for {
+ r.mu.Lock()
+ prev, taken := r.reserved[key]
+ if !taken {
+ r.reserved[key] = res
+ r.mu.Unlock()
+ break
+ }
+ r.mu.Unlock()
+ // Another pass reserved the same key (two overlapping ticks): queue
+ // behind it, bounded; past the bound the retry runs unreserved.
+ timer := time.NewTimer(keyReserveQueueMax)
+ select {
+ case <-prev.released:
+ timer.Stop()
+ case <-timer.C:
+ return ctx, func() {}, false
+ case <-ctx.Done():
+ timer.Stop()
+ return ctx, func() {}, false
+ }
+ }
+ release = func() { r.mu.Lock(); res.close(r); r.mu.Unlock() }
+
+ timer := time.NewTimer(keyReserveDrainMax)
+ defer timer.Stop()
+ drained = true
+ for r.inflightSameSeries(key) > 0 {
+ select {
+ case <-res.drained:
+ case <-timer.C:
+ return context.WithValue(ctx, keyReservationCtxKey{}, res), release, false
+ case <-ctx.Done():
+ return context.WithValue(ctx, keyReservationCtxKey{}, res), release, false
+ }
+ }
+ return context.WithValue(ctx, keyReservationCtxKey{}, res), release, drained
+}
+
+// close removes res from the map (if still registered) and releases its
+// waiters. Caller holds r.mu.
+func (res *keyReservation) close(r *keyReservations) {
+ res.once.Do(func() {
+ if r.reserved[res.key] == res {
+ delete(r.reserved, res.key)
+ }
+ close(res.released)
+ })
+}
+
+func (r *keyReservations) inflightSameSeries(base string) int {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ n := 0
+ for key, c := range r.inflight {
+ if sameSeries(key, base) {
+ n += c
+ }
+ }
+ return n
+}
+
+// reservedKeys reports the currently reserved series, for tests.
+func (r *keyReservations) reservedKeys() []string {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ out := make([]string, 0, len(r.reserved))
+ for k := range r.reserved {
+ out = append(out, k)
+ }
+ return out
+}
diff --git a/pkg/registry/file/sqliteobject_keyreserve_test.go b/pkg/registry/file/sqliteobject_keyreserve_test.go
new file mode 100644
index 000000000..8de11e409
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_keyreserve_test.go
@@ -0,0 +1,163 @@
+package file
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func shrinkKeyReserveBounds(t *testing.T, wait, drain time.Duration) {
+ t.Helper()
+ prevWait, prevDrain := keyReserveWaitMax, keyReserveDrainMax
+ keyReserveWaitMax, keyReserveDrainMax = wait, drain
+ t.Cleanup(func() { keyReserveWaitMax, keyReserveDrainMax = prevWait, prevDrain })
+}
+
+func TestSameSeries(t *testing.T) {
+ const base = "/prefix/ns/replicaset-nginx-6d4cf56db6-nginx"
+ require.True(t, sameSeries(base, base))
+ require.True(t, sameSeries(base+"-3f9a", base))
+ require.False(t, sameSeries(base+"3f9a", base))
+ require.False(t, sameSeries("/prefix/ns/replicaset-nginx", base))
+ require.False(t, sameSeries("/prefix/ns/other", base))
+ require.False(t, sameSeries("/prefix/other/replicaset-nginx-6d4cf56db6-nginx", base))
+}
+
+// A write that starts while the series is reserved waits for the release;
+// one that started before the reservation is waited for by the reserver.
+func TestKeyReservations_WritersYieldAndReserverDrains(t *testing.T) {
+ shrinkKeyReserveBounds(t, 5*time.Second, 5*time.Second)
+ r := newKeyReservations()
+ const base = "/p/ns/base"
+ ctx := context.Background()
+
+ // An in-flight writer on a TS key of the series.
+ leaveInflight := r.enter(ctx, base+"-r7")
+
+ reserved := make(chan struct{})
+ var reservedCtx context.Context
+ var release func()
+ var drained bool
+ go func() {
+ reservedCtx, release, drained = r.reserve(ctx, base)
+ close(reserved)
+ }()
+ select {
+ case <-reserved:
+ t.Fatal("reserve returned while a same-series write was in flight")
+ case <-time.After(50 * time.Millisecond):
+ }
+ leaveInflight()
+ select {
+ case <-reserved:
+ case <-time.After(2 * time.Second):
+ t.Fatal("reserve did not return after the in-flight write left")
+ }
+ require.True(t, drained)
+ require.Equal(t, []string{base}, r.reservedKeys())
+
+ // A new writer on the base key, and one on a TS key, block on the reservation.
+ entered := make(chan string, 2)
+ for _, k := range []string{base, base + "-r8"} {
+ go func(k string) {
+ leave := r.enter(ctx, k)
+ entered <- k
+ leave()
+ }(k)
+ }
+ select {
+ case k := <-entered:
+ t.Fatalf("writer on %s entered while the series was reserved", k)
+ case <-time.After(50 * time.Millisecond):
+ }
+ // A writer on another series is not affected.
+ leaveOther := r.enter(ctx, "/p/ns/other")
+ leaveOther()
+ // The reserver's own writes (ctx carries the reservation) do not wait.
+ leaveOwn := r.enter(reservedCtx, base)
+ leaveOwn()
+
+ release()
+ for i := 0; i < 2; i++ {
+ select {
+ case <-entered:
+ case <-time.After(2 * time.Second):
+ t.Fatal("writer did not enter after the release")
+ }
+ }
+ require.Empty(t, r.reservedKeys())
+ release() // idempotent
+}
+
+// Both waits are bounded: a writer proceeds after keyReserveWaitMax even if
+// the reservation is never released, and the reserver proceeds (drained=false)
+// after keyReserveDrainMax even if an in-flight write never leaves.
+func TestKeyReservations_BoundedWaits(t *testing.T) {
+ shrinkKeyReserveBounds(t, 30*time.Millisecond, 30*time.Millisecond)
+ r := newKeyReservations()
+ const base = "/p/ns/base"
+ ctx := context.Background()
+
+ stuck := r.enter(ctx, base)
+ t0 := time.Now()
+ _, release, drained := r.reserve(ctx, base)
+ require.False(t, drained)
+ require.GreaterOrEqual(t, time.Since(t0), 30*time.Millisecond)
+
+ t0 = time.Now()
+ leave := r.enter(ctx, base+"-r1")
+ require.GreaterOrEqual(t, time.Since(t0), 30*time.Millisecond)
+ leave()
+ release()
+ stuck()
+ require.Empty(t, r.reservedKeys())
+}
+
+// A cancelled writer ctx ends the wait at once.
+func TestKeyReservations_WriterCtxCancel(t *testing.T) {
+ shrinkKeyReserveBounds(t, 5*time.Second, 5*time.Second)
+ r := newKeyReservations()
+ const base = "/p/ns/base"
+ _, release, _ := r.reserve(context.Background(), base)
+ defer release()
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ done := make(chan struct{})
+ go func() { r.enter(ctx, base)(); close(done) }()
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ t.Fatal("a cancelled writer kept waiting on the reservation")
+ }
+}
+
+// A second reserver of the same key queues behind the first.
+func TestKeyReservations_SameKeyReserversQueue(t *testing.T) {
+ shrinkKeyReserveBounds(t, 5*time.Second, 5*time.Second)
+ r := newKeyReservations()
+ const base = "/p/ns/base"
+ ctx := context.Background()
+ _, release1, _ := r.reserve(ctx, base)
+ second := make(chan struct{})
+ var release2 func()
+ go func() {
+ _, release2, _ = r.reserve(ctx, base)
+ close(second)
+ }()
+ select {
+ case <-second:
+ t.Fatal("second reserve returned while the first was held")
+ case <-time.After(50 * time.Millisecond):
+ }
+ release1()
+ select {
+ case <-second:
+ case <-time.After(2 * time.Second):
+ t.Fatal("second reserve did not return after the first released")
+ }
+ require.Equal(t, []string{base}, r.reservedKeys())
+ release2()
+ require.Empty(t, r.reservedKeys())
+}
diff --git a/pkg/registry/file/sqliteobject_migration.go b/pkg/registry/file/sqliteobject_migration.go
new file mode 100644
index 000000000..37ebc5ece
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_migration.go
@@ -0,0 +1,843 @@
+package file
+
+// Startup data migration of ContainerProfile rows into the ObjectStore schema
+// (design: .omc/plans/full-acid-storage-architecture.md §8.2, with K-1 and
+// K-2's corrected reconcile rules).
+//
+// It runs synchronously in main.go, after the pool and the write gate exist
+// and before the cleanup goroutine and the API server (R2: a concurrent
+// cleanup tick could delete a row after its payload was copied). Every
+// batch is one gated BEGIN IMMEDIATE … COMMIT containing only SQL on bytes
+// prepared before the ticket (INV-1); the gob decode of a legacy file, the
+// external migration tool and the JSON encode all run in the prepare phase
+// on a pool connection that is released before the ticket is taken.
+//
+// The reconcile (steps 2 and 2b) runs on EVERY start: an older binary opens
+// the migrated database without error and its INSERT OR REPLACE nulls rv/uid
+// (R3), and its delete leaves the payloads row (K-2). The done-flag gates
+// only the file sweeps (steps 3-4). Idempotent: a repaired row no longer
+// matches the predicate. Resumable: a crash rolls back the batch in flight
+// and the next start picks it up from the same predicate.
+
+import (
+ "bytes"
+ "context"
+ "encoding/gob"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/kubescape/go-logger"
+ "github.com/kubescape/go-logger/helpers"
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/kubescape/storage/pkg/metrics"
+ "github.com/spf13/afero"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/apimachinery/pkg/util/uuid"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitemigration"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+// Reconcile shapes: the storage_cp_migration_total{shape} label values.
+const (
+ // MigrationShapeMigrated: a legacy row (rv NULL, no payloads row) whose
+ // file decoded and agrees with the row on resourceVersion.
+ MigrationShapeMigrated = "migrated"
+ // MigrationShapeDiverged: same, but the row and the file disagree on
+ // resourceVersion — one of the PC-DIV shapes, met once; the object gets
+ // max(rowRV, payloadRV)+1.
+ MigrationShapeDiverged = "diverged"
+ // MigrationShapeRowWithoutFile: a legacy row with no payload file — the
+ // row is deleted (today's get() self-repair, done once).
+ MigrationShapeRowWithoutFile = "row_without_file"
+ // MigrationShapeFileWithoutRow: a payload file with no metadata row (the
+ // file sweep) — imported at the file's resourceVersion and UID.
+ MigrationShapeFileWithoutRow = "file_without_row"
+ // MigrationShapeLegacyRewrite: rv NULL with a payloads row — a legacy
+ // writer replaced the row after a rollback (K-1). source=file when the
+ // legacy writer's .g file exists (the payloads body is the stale side);
+ // source=payloads for a row-only legacy write.
+ MigrationShapeLegacyRewrite = "legacy_rewrite"
+ // MigrationShapeOrphanPayload: a payloads row with no metadata row — a
+ // legacy delete after a rollback (K-2); deleted, or every Create of the
+ // key fails on the UNIQUE constraint forever.
+ MigrationShapeOrphanPayload = "orphan_payload"
+ // MigrationShapeUndecodable: a payload that neither gob nor the external
+ // tool could decode — skipped and counted, never deleted (PM-2).
+ MigrationShapeUndecodable = "undecodable"
+ // MigrationShapeTempFile: a *.g.t* staging file a legacy writer never
+ // committed — removed.
+ MigrationShapeTempFile = "temp_file"
+
+ MigrationSourceFile = "file"
+ MigrationSourcePayloads = "payloads"
+
+ migrationStateName = "containerprofile"
+ migrationStateDone = "done"
+
+ // DefaultMigrationBatchSize is the number of objects per gated
+ // transaction (§8.2: B ≈ 50).
+ DefaultMigrationBatchSize = 50
+)
+
+// ContainerProfileMigrationOptions tunes MigrateContainerProfiles.
+type ContainerProfileMigrationOptions struct {
+ // BatchSize is the number of objects per transaction; non-positive =
+ // DefaultMigrationBatchSize.
+ BatchSize int
+ // DryRun reconciles and counts without writing anything (§8.3): no
+ // gate is needed, no row, payload or file is touched, the done-flag is
+ // not set.
+ DryRun bool
+
+ hooks migrationHooks
+}
+
+// migrationHooks are test seams; nil in production.
+type migrationHooks struct {
+ // beforeStatement runs inside the gated transaction of batch n before
+ // its statement idx (name labels it). Crash-injection tests return an
+ // error, panic, or exit the process from it.
+ beforeStatement func(batch, idx int, name string) error
+}
+
+// ContainerProfileMigrationReport is what one run did.
+type ContainerProfileMigrationReport struct {
+ // Counts is keyed by shape, or "shape/source" for legacy_rewrite.
+ Counts map[string]int
+ // Batches is the number of transactions committed (prepared, in dry-run).
+ Batches int
+ // SweepsRun reports whether the file sweeps (steps 3-4) ran this start.
+ SweepsRun bool
+ DryRun bool
+ Elapsed time.Duration
+}
+
+func (r *ContainerProfileMigrationReport) add(shape, source string) {
+ key := shape
+ if source != "" {
+ key = shape + "/" + source
+ }
+ r.Counts[key]++
+ if !r.DryRun {
+ metrics.IncCPMigration(shape, source)
+ }
+}
+
+// Count returns the total for shape across sources.
+func (r *ContainerProfileMigrationReport) Count(shape string) int {
+ n := r.Counts[shape]
+ for k, v := range r.Counts {
+ if strings.HasPrefix(k, shape+"/") {
+ n += v
+ }
+ }
+ return n
+}
+
+// Work reports whether the run reconciled anything.
+func (r *ContainerProfileMigrationReport) Work() int {
+ n := 0
+ for _, v := range r.Counts {
+ n += v
+ }
+ return n
+}
+
+func (r *ContainerProfileMigrationReport) logFields() []helpers.IDetails {
+ fields := []helpers.IDetails{
+ helpers.Int("batches", r.Batches),
+ helpers.String("elapsed", r.Elapsed.String()),
+ helpers.Interface("sweepsRun", r.SweepsRun),
+ helpers.Interface("dryRun", r.DryRun),
+ }
+ for k, v := range r.Counts {
+ fields = append(fields, helpers.Int(k, v))
+ }
+ return fields
+}
+
+type containerProfileMigrator struct {
+ pool *sqlitemigration.Pool
+ gate *writeGate
+ fs afero.Fs
+ root string
+ scheme *runtime.Scheme
+ opts ContainerProfileMigrationOptions
+ report *ContainerProfileMigrationReport
+}
+
+// migrationAction is one prepared write of a batch: SQL on bytes only.
+type migrationAction struct {
+ name string
+ exec func(conn *sqlite.Conn) error
+}
+
+// MigrateContainerProfiles reconciles every ContainerProfile row, payload
+// and legacy file into the ObjectStore schema (see the file comment). gate
+// may be nil only with opts.DryRun. fs/root are the legacy payload
+// filesystem and its root (DefaultStorageRoot in production).
+func MigrateContainerProfiles(ctx context.Context, pool *sqlitemigration.Pool, gate *WriteGate, fs afero.Fs, root string, scheme *runtime.Scheme, opts ContainerProfileMigrationOptions) (*ContainerProfileMigrationReport, error) {
+ if gate == nil && !opts.DryRun {
+ return nil, errors.New("containerprofile migration: a write gate is required (AC-G1: the migration's batches are gated writes)")
+ }
+ if opts.BatchSize <= 0 {
+ opts.BatchSize = DefaultMigrationBatchSize
+ }
+ start := time.Now()
+ // Cleaned once, as general hygiene. sweepFiles' key derivation no
+ // longer depends on root's exact textual form: see keyFromPayloadPath
+ // (same fix as ExportContainerProfiles, and for the same reason -- byte-
+ // length slicing at len(m.root) mis-derived the key for a trailing-slash
+ // root, and would have for "/" and "." too).
+ root = filepath.Clean(root)
+ m := &containerProfileMigrator{
+ pool: pool, gate: gate, fs: fs, root: root, scheme: scheme, opts: opts,
+ report: &ContainerProfileMigrationReport{Counts: map[string]int{}, DryRun: opts.DryRun},
+ }
+ if err := m.reconcileRows(ctx); err != nil {
+ return m.report, err
+ }
+ if err := m.reconcileOrphanPayloads(ctx); err != nil {
+ return m.report, err
+ }
+ done, err := m.readDone(ctx)
+ if err != nil {
+ return m.report, err
+ }
+ if !done {
+ m.report.SweepsRun = true
+ undecodable, err := m.sweepFiles(ctx)
+ if err != nil {
+ return m.report, err
+ }
+ // An undecodable file is left in place and counted; the sweep runs
+ // again next start so it stays visible until an operator acts.
+ if !opts.DryRun && undecodable == 0 {
+ if err := m.markDone(ctx); err != nil {
+ return m.report, err
+ }
+ }
+ }
+ if !opts.DryRun {
+ if err := m.checkpoint(ctx); err != nil {
+ return m.report, err
+ }
+ }
+ m.report.Elapsed = time.Since(start)
+ logger.L().Debug("containerprofile migration: done", m.report.logFields()...)
+ return m.report, nil
+}
+
+// ---- step 2: rows with rv NULL or no payloads row ----
+
+type migrationCandidate struct {
+ rowid int64
+ namespace string
+ name string
+ metadataJSON []byte
+ rvNull bool
+ hasPayload bool
+ encoding string
+ body []byte
+}
+
+func (m *containerProfileMigrator) readCandidates(ctx context.Context, cursor int64) ([]migrationCandidate, error) {
+ conn, err := m.pool.Take(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("containerprofile migration: take connection: %w", err)
+ }
+ defer m.pool.Put(conn)
+ var out []migrationCandidate
+ err = sqlitex.Execute(conn,
+ `SELECT m.rowid, m.namespace, m.name, m.metadata, m.rv IS NULL, p.encoding IS NOT NULL, p.encoding, p.body
+ FROM metadata m LEFT JOIN payloads p USING (kind, namespace, name)
+ WHERE m.kind = :kind AND m.rowid > :cursor AND (m.rv IS NULL OR p.encoding IS NULL)
+ ORDER BY m.rowid LIMIT :limit`,
+ &sqlitex.ExecOptions{
+ Named: map[string]any{":kind": ContainerProfileKind, ":cursor": cursor, ":limit": m.opts.BatchSize},
+ ResultFunc: func(stmt *sqlite.Stmt) error {
+ c := migrationCandidate{
+ rowid: stmt.ColumnInt64(0),
+ namespace: stmt.ColumnText(1),
+ name: stmt.ColumnText(2),
+ metadataJSON: []byte(stmt.ColumnText(3)),
+ rvNull: stmt.ColumnInt64(4) == 1,
+ hasPayload: stmt.ColumnInt64(5) == 1,
+ encoding: stmt.ColumnText(6),
+ }
+ if c.hasPayload {
+ c.body = make([]byte, stmt.ColumnLen(7))
+ stmt.ColumnBytes(7, c.body)
+ }
+ out = append(out, c)
+ return nil
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("containerprofile migration: read candidates: %w", err)
+ }
+ return out, nil
+}
+
+func (m *containerProfileMigrator) reconcileRows(ctx context.Context) error {
+ cursor := int64(0)
+ for {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ cands, err := m.readCandidates(ctx, cursor)
+ if err != nil {
+ return err
+ }
+ if len(cands) == 0 {
+ return nil
+ }
+ cursor = cands[len(cands)-1].rowid
+ var actions []migrationAction
+ for i := range cands {
+ actions = append(actions, m.prepareCandidate(ctx, &cands[i])...)
+ }
+ if err := m.commit(ctx, actions); err != nil {
+ return err
+ }
+ }
+}
+
+func (m *containerProfileMigrator) key(namespace, name string) string {
+ return K8sKeysToPath("", softwarecomposition.GroupName, ContainerProfileKind, "", namespace, name)
+}
+
+func parseRV(s string) int64 {
+ rv, err := strconv.ParseInt(s, 10, 64)
+ if err != nil || rv < 0 {
+ return 0
+ }
+ return rv
+}
+
+// prepareCandidate decides and prepares one row's reconcile; it returns the
+// SQL to run under the gate (none when the row is skipped).
+func (m *containerProfileMigrator) prepareCandidate(ctx context.Context, c *migrationCandidate) []migrationAction {
+ key := m.key(c.namespace, c.name)
+ row := &PartialObjectMetadata{}
+ if err := json.Unmarshal(c.metadataJSON, row); err != nil {
+ m.report.add(MigrationShapeUndecodable, "row")
+ logger.L().Warning("containerprofile migration: metadata row is not JSON; skipped", helpers.Error(err), helpers.String("key", key))
+ return nil
+ }
+ rowRV := parseRV(row.ResourceVersion)
+ obj, found, err := m.decodeLegacyFile(ctx, key)
+ if err != nil {
+ m.report.add(MigrationShapeUndecodable, MigrationSourceFile)
+ logger.L().Warning("containerprofile migration: payload file undecodable; row left as is, file left for the export tool", helpers.Error(err), helpers.String("key", key))
+ return nil
+ }
+
+ if !c.hasPayload {
+ if !found {
+ m.report.add(MigrationShapeRowWithoutFile, "")
+ logger.L().Warning("containerprofile migration: row without payload file; row deleted", helpers.String("key", key))
+ return []migrationAction{m.deleteRowAction(key)}
+ }
+ payloadRV := parseRV(obj.ResourceVersion)
+ rv := rowRV
+ shape := MigrationShapeMigrated
+ switch {
+ case rowRV != payloadRV:
+ rv = max(rowRV, payloadRV) + 1
+ shape = MigrationShapeDiverged
+ case rv == 0:
+ rv = 1
+ }
+ // PreSave's non-TS revert (processor.go: a Completed base never
+ // regresses to Learning): the row's Completed beats the file's Learning.
+ if obj.Annotations[helpersv1.ReportSeriesIdMetadataKey] == "" &&
+ row.Annotations[helpersv1.StatusMetadataKey] == helpersv1.Completed &&
+ obj.Annotations[helpersv1.StatusMetadataKey] == helpersv1.Learning {
+ obj.Annotations[helpersv1.StatusMetadataKey] = helpersv1.Completed
+ }
+ uid := obj.UID
+ if uid == "" {
+ uid = row.UID
+ }
+ m.report.add(shape, "")
+ if shape != MigrationShapeMigrated {
+ logger.L().Warning("containerprofile migration: row and payload file disagree on resourceVersion; reconciled at max+1",
+ helpers.String("key", key), helpers.Interface("rowRV", rowRV), helpers.Interface("payloadRV", payloadRV), helpers.Interface("rv", rv))
+ }
+ return m.rewriteActions(key, obj, rv, uid, true)
+ }
+
+ // rv NULL with a payloads row: K-1's legacy_rewrite.
+ source := MigrationSourcePayloads
+ if found {
+ source = MigrationSourceFile
+ } else {
+ obj = &softwarecomposition.ContainerProfile{}
+ if err := decodePayloadBody(m.scheme, c.encoding, c.body, obj); err != nil {
+ m.report.add(MigrationShapeUndecodable, MigrationSourcePayloads)
+ logger.L().Warning("containerprofile migration: payloads body undecodable; row left as is", helpers.Error(err), helpers.String("key", key))
+ return nil
+ }
+ }
+ // rv := the row JSON's resourceVersion, no +1 (the legacy writer already
+ // bumped it). When a crash left the legacy writer's file one version
+ // ahead of its row, or a stale pre-migration file sits behind a newer
+ // payloads body, the largest persisted version wins so no client sees a
+ // version regress; the row JSON is rewritten to match (INV-2).
+ rv := max(rowRV, parseRV(obj.ResourceVersion))
+ if found {
+ bodyObj := &softwarecomposition.ContainerProfile{}
+ if err := decodePayloadBody(m.scheme, c.encoding, c.body, bodyObj); err == nil {
+ rv = max(rv, parseRV(bodyObj.ResourceVersion))
+ }
+ }
+ if rv == 0 {
+ rv = 1
+ }
+ uid := row.UID
+ if uid == "" {
+ uid = obj.UID
+ }
+ m.report.add(MigrationShapeLegacyRewrite, source)
+ logger.L().Warning("containerprofile migration: legacy writer replaced a migrated row (rv NULL with a payloads row); repaired",
+ helpers.String("key", key), helpers.String("source", source), helpers.Interface("rv", rv))
+ return m.rewriteActions(key, obj, rv, uid, false)
+}
+
+// rewriteActions stamps obj at (rv, uid), encodes the metadata JSON and the
+// body, and returns the UPDATE of the metadata row plus the INSERT (fresh
+// payload) or UPDATE (existing payload) of the payloads row.
+func (m *containerProfileMigrator) rewriteActions(key string, obj *softwarecomposition.ContainerProfile, rv int64, uid types.UID, insertPayload bool) []migrationAction {
+ if uid == "" {
+ uid = uuid.NewUUID()
+ }
+ obj.ResourceVersion = strconv.FormatInt(rv, 10)
+ obj.UID = uid
+ metadataJSON, err := json.Marshal(extractFields(obj, []string{"ObjectMeta", "SchemaVersion"}))
+ if err != nil {
+ m.report.add(MigrationShapeUndecodable, "encode")
+ logger.L().Warning("containerprofile migration: marshal metadata failed; skipped", helpers.Error(err), helpers.String("key", key))
+ return nil
+ }
+ body, err := encodePayloadBody(m.scheme, obj)
+ if err != nil {
+ m.report.add(MigrationShapeUndecodable, "encode")
+ logger.L().Warning("containerprofile migration: encode payload failed; skipped", helpers.Error(err), helpers.String("key", key))
+ return nil
+ }
+ _, _, kind, _, namespace, name := K8sPathToKeys(key)
+ actions := []migrationAction{{
+ name: "update-metadata",
+ exec: func(conn *sqlite.Conn) error {
+ if err := sqlitex.Execute(conn,
+ `UPDATE metadata SET metadata = ?, rv = ?, uid = ? WHERE kind = ? AND namespace = ? AND name = ?`,
+ &sqlitex.ExecOptions{Args: []any{string(metadataJSON), rv, string(uid), kind, namespace, name}}); err != nil {
+ return fmt.Errorf("update metadata %s: %w", key, err)
+ }
+ if n := conn.Changes(); n != 1 {
+ return fmt.Errorf("update metadata %s: %d rows", key, n)
+ }
+ return nil
+ },
+ }}
+ if insertPayload {
+ actions = append(actions, migrationAction{
+ name: "insert-payload",
+ exec: func(conn *sqlite.Conn) error {
+ return execInsertPayload(conn, key, kind, namespace, name, body)
+ },
+ })
+ } else {
+ actions = append(actions, migrationAction{
+ name: "update-payload",
+ exec: func(conn *sqlite.Conn) error {
+ if err := sqlitex.Execute(conn,
+ `UPDATE payloads SET encoding = ?, body = ? WHERE kind = ? AND namespace = ? AND name = ?`,
+ &sqlitex.ExecOptions{Args: []any{PayloadEncodingJSONV1Beta1, body, kind, namespace, name}}); err != nil {
+ return fmt.Errorf("update payload %s: %w", key, err)
+ }
+ if n := conn.Changes(); n != 1 {
+ return fmt.Errorf("update payload %s: %d rows", key, n)
+ }
+ return nil
+ },
+ })
+ }
+ return actions
+}
+
+func execInsertPayload(conn *sqlite.Conn, key, kind, namespace, name string, body []byte) error {
+ if err := sqlitex.Execute(conn,
+ `INSERT INTO payloads (kind, namespace, name, encoding, body) VALUES (?, ?, ?, ?, ?)`,
+ &sqlitex.ExecOptions{Args: []any{kind, namespace, name, PayloadEncodingJSONV1Beta1, body}}); err != nil {
+ return fmt.Errorf("insert payload %s: %w", key, err)
+ }
+ return nil
+}
+
+func (m *containerProfileMigrator) deleteRowAction(key string) migrationAction {
+ _, _, kind, _, namespace, name := K8sPathToKeys(key)
+ return migrationAction{
+ name: "delete-metadata",
+ exec: func(conn *sqlite.Conn) error {
+ if err := sqlitex.Execute(conn,
+ `DELETE FROM metadata WHERE kind = ? AND namespace = ? AND name = ?`,
+ &sqlitex.ExecOptions{Args: []any{kind, namespace, name}}); err != nil {
+ return fmt.Errorf("delete metadata %s: %w", key, err)
+ }
+ return nil
+ },
+ }
+}
+
+// decodeLegacyFile decodes the legacy gob payload of key. found is false
+// when there is no file; err is set when the file exists but neither gob
+// nor the external migration tool could decode it.
+func (m *containerProfileMigrator) decodeLegacyFile(ctx context.Context, key string) (*softwarecomposition.ContainerProfile, bool, error) {
+ return decodeLegacyFileAt(ctx, m.fs, m.root, key)
+}
+
+// errLegacyFileAccess wraps a decodeLegacyFileAt failure that is a real
+// filesystem access problem (permission denied, I/O error) rather than the
+// file's content being unreadable as a ContainerProfile. A caller that must
+// tell the two apart -- content it can safely leave alone vs. an access
+// failure that leaves it unable to say whether a file is safe to act on --
+// checks for this with errors.Is.
+var errLegacyFileAccess = errors.New("legacy file access")
+
+// decodeLegacyFileAt is decodeLegacyFile without a migrator receiver, for
+// callers (the export tool's stale-file reconciliation) that need the same
+// "would an old binary read this as a valid object" decode, outside of a
+// migration run.
+func decodeLegacyFileAt(ctx context.Context, fs afero.Fs, root, key string) (*softwarecomposition.ContainerProfile, bool, error) {
+ p := filepath.Join(root, key)
+ f, err := fs.Open(makePayloadPath(p))
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) || errors.Is(err, afero.ErrFileNotFound) {
+ return nil, false, nil
+ }
+ return nil, true, fmt.Errorf("open payload file: %w: %w", errLegacyFileAccess, err)
+ }
+ // Read fully before decoding, not decode straight off f: a Read error
+ // after a successful Open (a bad sector, a device/mount that opens fine
+ // but errors mid-read) would otherwise surface only inside gob's Decode
+ // as an opaque, untagged error -- indistinguishable from the file's
+ // content genuinely being malformed. Read errors are exactly as
+ // uncertain as Open errors (we cannot tell whether this object is safe
+ // to treat as absent/undecodable), so they get the same errLegacyFileAccess
+ // classification, not silently folded into "undecodable, safe to leave".
+ data, rerr := io.ReadAll(f)
+ _ = f.Close()
+ if rerr != nil {
+ return nil, true, fmt.Errorf("read payload file: %w: %w", errLegacyFileAccess, rerr)
+ }
+ obj := &softwarecomposition.ContainerProfile{}
+ err = gob.NewDecoder(bytes.NewReader(data)).Decode(obj)
+ if err == nil {
+ return obj, true, nil
+ }
+ if strings.Contains(err.Error(), "gob: wrong type") || strings.Contains(err.Error(), "extra fields") {
+ // The last time the external tool runs for this kind.
+ out, terr := execMigrationTool(ctx, p, "ContainerProfile")
+ if terr != nil {
+ // The tool's own exec/timeout/read failure is likewise not
+ // evidence the content is bad -- it can be a transient
+ // environment problem (missing binary, permission, timeout),
+ // so it gets the same errLegacyFileAccess classification as a
+ // direct file-access failure.
+ return nil, true, fmt.Errorf("gob decode: %v; migration tool: %w: %w", err, errLegacyFileAccess, terr)
+ }
+ obj = &softwarecomposition.ContainerProfile{}
+ if jerr := json.Unmarshal(out, obj); jerr != nil {
+ return nil, true, fmt.Errorf("gob decode: %v; migration tool output: %w", err, jerr)
+ }
+ return obj, true, nil
+ }
+ return nil, true, fmt.Errorf("gob decode: %w", err)
+}
+
+// ---- step 2b: payloads rows with no metadata row (K-2) ----
+
+func (m *containerProfileMigrator) reconcileOrphanPayloads(ctx context.Context) error {
+ cursor := int64(0)
+ for {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ conn, err := m.pool.Take(ctx)
+ if err != nil {
+ return fmt.Errorf("containerprofile migration: take connection: %w", err)
+ }
+ var keys []string
+ err = sqlitex.Execute(conn,
+ `SELECT p.rowid, p.namespace, p.name FROM payloads p
+ WHERE p.kind = :kind AND p.rowid > :cursor AND NOT EXISTS (
+ SELECT 1 FROM metadata m WHERE m.kind = p.kind AND m.namespace = p.namespace AND m.name = p.name)
+ ORDER BY p.rowid LIMIT :limit`,
+ &sqlitex.ExecOptions{
+ Named: map[string]any{":kind": ContainerProfileKind, ":cursor": cursor, ":limit": m.opts.BatchSize},
+ ResultFunc: func(stmt *sqlite.Stmt) error {
+ cursor = stmt.ColumnInt64(0)
+ keys = append(keys, m.key(stmt.ColumnText(1), stmt.ColumnText(2)))
+ return nil
+ },
+ })
+ m.pool.Put(conn)
+ if err != nil {
+ return fmt.Errorf("containerprofile migration: read orphan payloads: %w", err)
+ }
+ if len(keys) == 0 {
+ return nil
+ }
+ var actions []migrationAction
+ for _, key := range keys {
+ m.report.add(MigrationShapeOrphanPayload, "")
+ logger.L().Warning("containerprofile migration: payloads row without a metadata row (a legacy delete after a rollback); deleted", helpers.String("key", key))
+ _, _, kind, _, namespace, name := K8sPathToKeys(key)
+ actions = append(actions, migrationAction{
+ name: "delete-payload",
+ exec: func(conn *sqlite.Conn) error {
+ if err := sqlitex.Execute(conn,
+ `DELETE FROM payloads WHERE kind = ? AND namespace = ? AND name = ?`,
+ &sqlitex.ExecOptions{Args: []any{kind, namespace, name}}); err != nil {
+ return fmt.Errorf("delete payload %s: %w", key, err)
+ }
+ return nil
+ },
+ })
+ }
+ if err := m.commit(ctx, actions); err != nil {
+ return err
+ }
+ }
+}
+
+// ---- steps 3-4: the file sweeps, gated by the done-flag ----
+
+func (m *containerProfileMigrator) readDone(ctx context.Context) (bool, error) {
+ conn, err := m.pool.Take(ctx)
+ if err != nil {
+ return false, fmt.Errorf("containerprofile migration: take connection: %w", err)
+ }
+ defer m.pool.Put(conn)
+ var state string
+ err = sqlitex.Execute(conn, `SELECT state FROM migration_state WHERE name = ?`, &sqlitex.ExecOptions{
+ Args: []any{migrationStateName},
+ ResultFunc: func(stmt *sqlite.Stmt) error { state = stmt.ColumnText(0); return nil },
+ })
+ if err != nil {
+ return false, fmt.Errorf("containerprofile migration: read state: %w", err)
+ }
+ return state == migrationStateDone, nil
+}
+
+func (m *containerProfileMigrator) markDone(ctx context.Context) error {
+ counts, _ := json.Marshal(m.report.Counts)
+ return m.commit(ctx, []migrationAction{{
+ name: "mark-done",
+ exec: func(conn *sqlite.Conn) error {
+ return sqlitex.Execute(conn,
+ `INSERT OR REPLACE INTO migration_state (name, state, counts, updated_at) VALUES (?, ?, ?, ?)`,
+ &sqlitex.ExecOptions{Args: []any{migrationStateName, migrationStateDone, string(counts), time.Now().UTC().Format(time.RFC3339)}})
+ },
+ }})
+}
+
+// sweepFiles imports every payload file with no metadata row and removes
+// every staging file. It returns the number of undecodable files met.
+func (m *containerProfileMigrator) sweepFiles(ctx context.Context) (int, error) {
+ dir := filepath.Join(m.root, softwarecomposition.GroupName, ContainerProfileKind)
+ // A real filesystem error here (e.g. permission denied) must not read as
+ // "the directory doesn't exist": DirExists returns exists=false on ANY
+ // stat error, not just os.ErrNotExist, and the caller treats a nil error
+ // here as "sweep found nothing, mark done" -- an unreadable directory
+ // would then permanently persist the done marker over an incomplete
+ // sweep, with nothing left to trigger a retry on the next start.
+ exists, err := afero.DirExists(m.fs, dir)
+ if err != nil {
+ return 0, fmt.Errorf("containerprofile migration: stat %s: %w", dir, err)
+ }
+ if !exists {
+ return 0, nil
+ }
+ conn, err := m.pool.Take(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("containerprofile migration: take connection: %w", err)
+ }
+ var orphans []string
+ walkErr := afero.Walk(m.fs, dir, func(path string, info os.FileInfo, err error) error {
+ // A per-entry error (e.g. an unreadable subtree) must abort the walk,
+ // not be treated as "nothing interesting here": returning nil let the
+ // sweep silently skip whatever orphan payload files sat under that
+ // entry and still report success, persisting the done marker over an
+ // incomplete sweep (see the DirExists check above for the same class
+ // of bug at the top-level directory).
+ if err != nil {
+ return fmt.Errorf("containerprofile migration: walk %s: %w", path, err)
+ }
+ if info.IsDir() {
+ return nil
+ }
+ if !IsPayloadFile(path) {
+ // The legacy staging names are .g.t (saveObject) and
+ // .g.t.. (the single writer); neither ends in
+ // .g, whereas a payload of a workload named e.g. "app.g.tail" does
+ // — the payload test above must come first (a bare ".g.t"
+ // substring match would delete that payload as a staging file).
+ if isLegacyStagingFile(info.Name()) {
+ m.report.add(MigrationShapeTempFile, "")
+ if !m.opts.DryRun {
+ if rerr := m.fs.Remove(path); rerr != nil {
+ logger.L().Warning("containerprofile migration: remove staging file failed", helpers.Error(rerr), helpers.String("path", path))
+ }
+ }
+ }
+ return nil
+ }
+ key, kerr := keyFromPayloadPath(m.root, path)
+ if kerr != nil {
+ return fmt.Errorf("containerprofile migration: %w", kerr)
+ }
+ if _, rerr := ReadMetadata(conn, key); rerr == nil {
+ return nil
+ } else if !errors.Is(rerr, ErrMetadataNotFound) {
+ return fmt.Errorf("read metadata %s: %w", key, rerr)
+ }
+ orphans = append(orphans, key)
+ return nil
+ })
+ m.pool.Put(conn)
+ if walkErr != nil {
+ return 0, fmt.Errorf("containerprofile migration: sweep %s: %w", dir, walkErr)
+ }
+
+ undecodable := 0
+ for start := 0; start < len(orphans); start += m.opts.BatchSize {
+ if err := ctx.Err(); err != nil {
+ return undecodable, err
+ }
+ end := min(start+m.opts.BatchSize, len(orphans))
+ var actions []migrationAction
+ for _, key := range orphans[start:end] {
+ obj, found, err := m.decodeLegacyFile(ctx, key)
+ if err != nil || !found {
+ undecodable++
+ m.report.add(MigrationShapeUndecodable, MigrationSourceFile)
+ logger.L().Warning("containerprofile migration: payload file without a row is undecodable; left for the export tool", helpers.Error(err), helpers.String("key", key))
+ continue
+ }
+ m.report.add(MigrationShapeFileWithoutRow, "")
+ logger.L().Warning("containerprofile migration: payload file without a metadata row; imported", helpers.String("key", key))
+ actions = append(actions, m.importActions(key, obj)...)
+ }
+ if err := m.commit(ctx, actions); err != nil {
+ return undecodable, err
+ }
+ }
+ return undecodable, nil
+}
+
+// isLegacyStagingFile reports whether name is a legacy writer's staging
+// file: .g.t (saveObject) or .g.t.. (the single
+// writer). Callers exclude payload files (*.g) first.
+func isLegacyStagingFile(name string) bool {
+ return strings.HasSuffix(name, GobExt+".t") || strings.Contains(name, GobExt+".t.")
+}
+
+// importActions creates the metadata and payloads rows of a file with no row,
+// at the file's resourceVersion and UID.
+func (m *containerProfileMigrator) importActions(key string, obj *softwarecomposition.ContainerProfile) []migrationAction {
+ rv := max(parseRV(obj.ResourceVersion), 1)
+ uid := obj.UID
+ if uid == "" {
+ uid = uuid.NewUUID()
+ }
+ obj.ResourceVersion = strconv.FormatInt(rv, 10)
+ obj.UID = uid
+ metadataJSON, err := json.Marshal(extractFields(obj, []string{"ObjectMeta", "SchemaVersion"}))
+ if err != nil {
+ logger.L().Warning("containerprofile migration: marshal metadata failed; skipped", helpers.Error(err), helpers.String("key", key))
+ return nil
+ }
+ body, err := encodePayloadBody(m.scheme, obj)
+ if err != nil {
+ logger.L().Warning("containerprofile migration: encode payload failed; skipped", helpers.Error(err), helpers.String("key", key))
+ return nil
+ }
+ _, _, kind, _, namespace, name := K8sPathToKeys(key)
+ return []migrationAction{
+ {
+ name: "insert-metadata",
+ exec: func(conn *sqlite.Conn) error {
+ if err := sqlitex.Execute(conn,
+ `INSERT INTO metadata (kind, namespace, name, metadata, rv, uid) VALUES (?, ?, ?, ?, ?, ?)`,
+ &sqlitex.ExecOptions{Args: []any{kind, namespace, name, string(metadataJSON), rv, string(uid)}}); err != nil {
+ return fmt.Errorf("insert metadata %s: %w", key, err)
+ }
+ return nil
+ },
+ },
+ {
+ name: "insert-payload",
+ exec: func(conn *sqlite.Conn) error {
+ return execInsertPayload(conn, key, kind, namespace, name, body)
+ },
+ },
+ }
+}
+
+// ---- the gated batch ----
+
+// commit runs actions in one gated transaction (nothing in dry-run).
+func (m *containerProfileMigrator) commit(ctx context.Context, actions []migrationAction) error {
+ if len(actions) == 0 {
+ return nil
+ }
+ m.report.Batches++
+ if m.opts.DryRun {
+ return nil
+ }
+ batch := m.report.Batches
+ err := m.gate.run(ctx, priorityLow, holdPathCPMigration, ContainerProfileKindPlural, func(_ context.Context, conn *sqlite.Conn) error {
+ for i, a := range actions {
+ if m.opts.hooks.beforeStatement != nil {
+ if err := m.opts.hooks.beforeStatement(batch, i, a.name); err != nil {
+ return err
+ }
+ }
+ if err := a.exec(conn); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("containerprofile migration: batch %d rolled back: %w", batch, err)
+ }
+ return nil
+}
+
+// checkpoint runs one PASSIVE checkpoint so the server does not start with
+// the migration's whole WAL (wal_autocheckpoint is 0 on every connection).
+func (m *containerProfileMigrator) checkpoint(ctx context.Context) error {
+ conn, err := m.pool.Take(ctx)
+ if err != nil {
+ return fmt.Errorf("containerprofile migration: take connection: %w", err)
+ }
+ defer m.pool.Put(conn)
+ if err := sqlitex.ExecuteTransient(conn, `PRAGMA wal_checkpoint(PASSIVE)`, nil); err != nil {
+ return fmt.Errorf("containerprofile migration: checkpoint: %w", err)
+ }
+ return nil
+}
diff --git a/pkg/registry/file/sqliteobject_migration_scale_test.go b/pkg/registry/file/sqliteobject_migration_scale_test.go
new file mode 100644
index 000000000..d3ba55adf
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_migration_scale_test.go
@@ -0,0 +1,665 @@
+package file
+
+// The startup migration (§8.2) and the reverse export (§8.4) at a realistic
+// scale: ~5,000 ContainerProfile rows of every reconcile shape in production
+// proportions, seeded through the legacy StorageImpl over real files, run
+// with the production batch size. The small-scale tests in
+// sqliteobject_migration_test.go pin each shape's semantics; these pin that
+// the counts, INV-2, idempotence, dry-run prediction, crash resumption and
+// the export hold together on a corpus, and report the cost.
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math/rand"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/util/uuid"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+// scaleMix is the corpus shape: counts per reconcile outcome. The
+// proportions are the assessment's (~95 % clean, ~2 % diverged, ~1 % each
+// row/file-only, ~0.5 % each staging and orphan, a handful undecodable, a few
+// wrong-type gobs through the external tool).
+type scaleMix struct {
+ plain int // clean, non-TS
+ series int // clean TS series of two reports each
+ diverged int // row resourceVersion ahead of the file
+ rowOnly int // row without file
+ fileOnly int // file without row
+ stagingPairs int // objects named *.g.tail with staging siblings + plain staging files
+ orphans int // payloads rows without a metadata row
+ garbageRow int // undecodable gob, row present
+ garbageNoRow int // undecodable gob, no row (keeps the sweep armed)
+ toolOK int // wrong-type gob with a row, the external tool decodes it
+ toolNoRow int // wrong-type gob without a row, the tool decodes it (sweep)
+ toolFail int // wrong-type gob with a row, the tool fails
+}
+
+func defaultScaleMix() scaleMix {
+ return scaleMix{
+ plain: 4300, series: 260,
+ diverged: 100, rowOnly: 50, fileOnly: 50,
+ stagingPairs: 12, orphans: 25,
+ garbageRow: 5, garbageNoRow: 2,
+ toolOK: 4, toolNoRow: 1, toolFail: 2,
+ }
+}
+
+// rows is the number of metadata rows the legacy binary leaves behind.
+func (m scaleMix) rows() int {
+ return m.plain + 2*m.series + m.diverged + m.rowOnly + m.stagingPairs + m.garbageRow + m.toolOK + m.toolFail
+}
+
+// expectedCounts is the report the migration must produce for the mix.
+func (m scaleMix) expectedCounts() map[string]int {
+ return map[string]int{
+ MigrationShapeMigrated: m.plain + 2*m.series + m.stagingPairs + m.toolOK,
+ MigrationShapeDiverged: m.diverged,
+ MigrationShapeRowWithoutFile: m.rowOnly,
+ MigrationShapeFileWithoutRow: m.fileOnly + m.toolNoRow,
+ MigrationShapeTempFile: 3 * m.stagingPairs,
+ MigrationShapeOrphanPayload: m.orphans,
+ MigrationShapeUndecodable + "/" + MigrationSourceFile: m.garbageRow + m.garbageNoRow + m.toolFail,
+ }
+}
+
+// scaleCorpus is what the seeding left: every key with the object the legacy
+// binary served for it (nil for keys that must not survive), plus the keys
+// per shape for targeted assertions.
+type scaleCorpus struct {
+ mix scaleMix
+ before map[string]*softwarecomposition.ContainerProfile
+ gone []string // rowOnly + orphans: no object after the migration
+ unchanged []string // garbageRow + toolFail: row left as is (rv NULL)
+ files int // payload files the legacy binary wrote
+}
+
+// installSidecarMigrationTool points execMigrationTool at a script that
+// answers from a JSON sidecar named after the payload file's basename, and
+// fails when there is none: the tool-succeeds and tool-fails arms of
+// decodeLegacyFile from one fixture.
+func installSidecarMigrationTool(t *testing.T) (sidecarDir string) {
+ t.Helper()
+ sidecarDir = t.TempDir()
+ script := filepath.Join(sidecarDir, "fake-migration.sh")
+ body := "#!/bin/sh\n" +
+ "# args: -file -type \n" +
+ "f=\"$FAKE_SIDECAR_DIR/$(basename \"$2\").json\"\n" +
+ "if [ ! -f \"$f\" ]; then echo \"fake migration: no sidecar for $2\" >&2; exit 1; fi\n" +
+ "cat \"$f\"\n"
+ require.NoError(t, os.WriteFile(script, []byte(body), 0755))
+ t.Setenv("FAKE_SIDECAR_DIR", sidecarDir)
+ old := migrationBinaryPath
+ migrationBinaryPath = script
+ t.Cleanup(func() { migrationBinaryPath = old })
+ return sidecarDir
+}
+
+// seedScaleCorpus writes the mix through the old binary and then applies
+// each shape's damage (row edits through the fixture, file removals,
+// staging and garbage files). Objects rotate through every template.
+func (e *migrationEnv) seedScaleCorpus(mix scaleMix, sidecarDir string) *scaleCorpus {
+ t := e.t
+ t.Helper()
+ tpls := loadTemplates(t) // every testdata/p*.json (containerprofile_load_test.go)
+ c := &scaleCorpus{mix: mix, before: map[string]*softwarecomposition.ContainerProfile{}}
+ rng := rand.New(rand.NewSource(1))
+ next := 0
+ obj := func(name string, ts bool) *softwarecomposition.ContainerProfile {
+ p := tpls[next%len(tpls)].profile.DeepCopy()
+ next++
+ p.Name = name
+ p.Namespace = e.ns
+ p.ResourceVersion = ""
+ p.UID = uuid.NewUUID()
+ if !ts {
+ delete(p.Annotations, helpersv1.ReportSeriesIdMetadataKey)
+ }
+ return p
+ }
+ create := func(p *softwarecomposition.ContainerProfile) string {
+ out := e.legacyCreate(p)
+ c.before[e.key(out.Name)] = out
+ c.files++
+ return e.key(out.Name)
+ }
+
+ for i := 0; i < mix.plain; i++ {
+ k := create(obj(fmt.Sprintf("plain-%05d", i), false))
+ // A fifth of the clean rows sit at RV 2 or 3, as a live cluster's do.
+ if i%5 == 0 {
+ for n := 0; n < 1+i%2; n++ {
+ c.before[k] = e.legacyUpdate(k, func(cp *softwarecomposition.ContainerProfile) {
+ cp.Annotations["scale-test/updated"] = strconv.Itoa(n + 1)
+ })
+ }
+ }
+ }
+ for i := 0; i < mix.series; i++ {
+ base := fmt.Sprintf("replicaset-scale-%04d-c-1111-2222", i)
+ for _, suffix := range []string{"0001", "0002"} {
+ create(obj(base+"-"+suffix, true))
+ }
+ }
+ for i := 0; i < mix.diverged; i++ {
+ k := create(obj(fmt.Sprintf("diverged-%03d", i), false))
+ setRowJSON(t, e.fixture, k, `$.resourceVersion`, strconv.Itoa(5+rng.Intn(20)))
+ }
+ for i := 0; i < mix.rowOnly; i++ {
+ k := create(obj(fmt.Sprintf("rowonly-%03d", i), false))
+ require.NoError(t, e.fs.Remove(e.filePath(filepath.Base(k))))
+ c.gone = append(c.gone, k)
+ delete(c.before, k)
+ c.files--
+ }
+ for i := 0; i < mix.fileOnly; i++ {
+ k := create(obj(fmt.Sprintf("fileonly-%03d", i), false))
+ if i%2 == 0 {
+ c.before[k] = e.legacyUpdate(k, func(cp *softwarecomposition.ContainerProfile) { cp.Annotations["scale-test/updated"] = "1" })
+ }
+ _, _, kind, _, ns, name := K8sPathToKeys(k)
+ require.NoError(t, sqlitex.Execute(e.fixture, `DELETE FROM metadata WHERE kind = ? AND namespace = ? AND name = ?`, &sqlitex.ExecOptions{Args: []any{kind, ns, name}}))
+ }
+ dir := filepath.Dir(e.filePath("x"))
+ for i := 0; i < mix.stagingPairs; i++ {
+ // A workload legally named "*.g.tail": its payload file name contains
+ // ".g.t" and must survive; its staging siblings must not.
+ name := fmt.Sprintf("trap-%03d.g.tail", i)
+ create(obj(name, false))
+ for _, n := range []string{name + ".g.t", fmt.Sprintf("%s.g.t.%d.%d", name, 1700000000000000000+i, i), fmt.Sprintf("staged-%03d.g.t", i)} {
+ require.NoError(t, afero.WriteFile(e.fs, filepath.Join(dir, n), []byte("staged"), 0644))
+ }
+ }
+ for i := 0; i < mix.orphans; i++ {
+ name := fmt.Sprintf("orphan-%03d", i)
+ require.NoError(t, sqlitex.Execute(e.fixture,
+ `INSERT INTO payloads (kind, namespace, name, encoding, body) VALUES (?, ?, ?, ?, ?)`,
+ &sqlitex.ExecOptions{Args: []any{ContainerProfileKind, e.ns, name, PayloadEncodingJSONV1Beta1, []byte("{}")}}))
+ c.gone = append(c.gone, e.key(name))
+ }
+ for i := 0; i < mix.garbageRow; i++ {
+ k := create(obj(fmt.Sprintf("garbage-%03d", i), false))
+ require.NoError(t, afero.WriteFile(e.fs, e.filePath(filepath.Base(k)), []byte("not a gob stream"), 0644))
+ c.unchanged = append(c.unchanged, k)
+ delete(c.before, k)
+ }
+ for i := 0; i < mix.garbageNoRow; i++ {
+ require.NoError(t, afero.WriteFile(e.fs, e.filePath(fmt.Sprintf("garbage-norow-%03d", i)), []byte("not a gob stream"), 0644))
+ c.files++
+ }
+ wrongType := gobPayloadNeedingMigration(t)
+ sidecar := func(k string) {
+ // What the real tool would print: the object as JSON.
+ b, err := json.Marshal(c.before[k])
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(sidecarDir, filepath.Base(k)+GobExt+".json"), b, 0644))
+ }
+ for i := 0; i < mix.toolOK; i++ {
+ k := create(obj(fmt.Sprintf("toolok-%03d", i), false))
+ sidecar(k)
+ require.NoError(t, afero.WriteFile(e.fs, e.filePath(filepath.Base(k)), wrongType, 0644))
+ }
+ for i := 0; i < mix.toolNoRow; i++ {
+ k := create(obj(fmt.Sprintf("toolnorow-%03d", i), false))
+ sidecar(k)
+ require.NoError(t, afero.WriteFile(e.fs, e.filePath(filepath.Base(k)), wrongType, 0644))
+ _, _, kind, _, ns, name := K8sPathToKeys(k)
+ require.NoError(t, sqlitex.Execute(e.fixture, `DELETE FROM metadata WHERE kind = ? AND namespace = ? AND name = ?`, &sqlitex.ExecOptions{Args: []any{kind, ns, name}}))
+ }
+ for i := 0; i < mix.toolFail; i++ {
+ k := create(obj(fmt.Sprintf("toolfail-%03d", i), false))
+ require.NoError(t, afero.WriteFile(e.fs, e.filePath(filepath.Base(k)), wrongType, 0644))
+ c.unchanged = append(c.unchanged, k)
+ delete(c.before, k)
+ }
+ return c
+}
+
+// assertScaleMigrated checks every surviving key against what the old
+// binary served, INV-2 on every CP key, and the damaged keys' fates.
+func (e *migrationEnv) assertScaleMigrated(c *scaleCorpus) {
+ t := e.t
+ t.Helper()
+ for k, legacyObj := range c.before {
+ got, err := e.storeGet(k)
+ require.NoError(t, err, k)
+ require.Equal(t, legacyObj.UID, got.UID, k)
+ want := legacyObj
+ if strings.Contains(k, "/diverged-") {
+ // The one intended difference: the row said 5..24, the file 1;
+ // the object is served at max+1 (the row JSON was rewritten to it).
+ require.Equal(t, strconv.FormatInt(parseRV(e.inspect(k).jsonRV), 10), got.ResourceVersion, k)
+ require.Greater(t, parseRV(got.ResourceVersion), int64(5), "%s: max(rowRV, payloadRV)+1", k)
+ want = legacyObj.DeepCopy()
+ want.ResourceVersion = got.ResourceVersion
+ } else {
+ require.Equal(t, legacyObj.ResourceVersion, got.ResourceVersion, k)
+ }
+ require.Equal(t, canonicalCP(want), canonicalCP(got), "the ObjectStore serves what the old binary served for %s", k)
+ }
+ for _, k := range c.gone {
+ row := e.inspect(k)
+ require.False(t, row.metaExists || row.payloadExists, "%s must not survive", k)
+ }
+ for _, k := range c.unchanged {
+ row := e.inspect(k)
+ require.True(t, row.metaExists && row.rv == nil && !row.payloadExists, "%s: an undecodable row is left exactly as it was", k)
+ exists, err := afero.Exists(e.fs, e.filePath(filepath.Base(k)))
+ require.NoError(t, err)
+ require.True(t, exists, "%s: an undecodable file is never deleted (PM-2)", k)
+ }
+ keys := allCPKeys(t, e.fixture)
+ require.Equal(t, len(c.before)+len(c.unchanged), len(keys), "exactly the surviving objects and the untouched undecodable rows remain")
+ for _, k := range keys {
+ if e.inspect(k).rv == nil {
+ continue // an unchanged undecodable row
+ }
+ assertINV2(t, e.fixture, k)
+ }
+}
+
+// memReport is the memory cost of one phase: heap growth and the process's
+// peak resident set (Linux VmHWM; 0 elsewhere).
+type memReport struct {
+ heapAllocBefore, heapAllocAfter, totalAllocDelta, sysAfter, peakRSS uint64
+}
+
+func (m memReport) String() string {
+ return fmt.Sprintf("heapAlloc %dMB→%dMB totalAlloc +%dMB sys %dMB peakRSS %dMB",
+ m.heapAllocBefore>>20, m.heapAllocAfter>>20, m.totalAllocDelta>>20, m.sysAfter>>20, m.peakRSS>>20)
+}
+
+func peakRSS() uint64 {
+ f, err := os.Open("/proc/self/status")
+ if err != nil {
+ return 0
+ }
+ defer func() { _ = f.Close() }()
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ if strings.HasPrefix(sc.Text(), "VmHWM:") {
+ fields := strings.Fields(sc.Text())
+ if len(fields) >= 2 {
+ kb, _ := strconv.ParseUint(fields[1], 10, 64)
+ return kb << 10
+ }
+ }
+ }
+ return 0
+}
+
+func measureMem(fn func()) memReport {
+ var before, after runtime.MemStats
+ runtime.GC()
+ runtime.ReadMemStats(&before)
+ fn()
+ runtime.ReadMemStats(&after)
+ return memReport{
+ heapAllocBefore: before.HeapAlloc, heapAllocAfter: after.HeapAlloc,
+ totalAllocDelta: after.TotalAlloc - before.TotalAlloc, sysAfter: after.Sys, peakRSS: peakRSS(),
+ }
+}
+
+// treeSize sums the sizes of every file under dir on the OS filesystem.
+func treeSize(t *testing.T, dir string) (files int, bytes int64) {
+ t.Helper()
+ err := filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() {
+ files++
+ bytes += info.Size()
+ }
+ return nil
+ })
+ require.NoError(t, err)
+ return files, bytes
+}
+
+func dbSize(t *testing.T, dbPath string) int64 {
+ t.Helper()
+ var total int64
+ for _, p := range []string{dbPath, dbPath + "-wal"} {
+ if st, err := os.Stat(p); err == nil {
+ total += st.Size()
+ }
+ }
+ return total
+}
+
+// scaleMigrationBound is the elapsed-time ceiling for the 5,000-row
+// migration. Measured at ~1.4 s on a laptop (tmpfs); a 40× margin absorbs a
+// slow CI runner while still failing a per-row full scan or a per-row
+// checkpoint, either of which is minutes at this size. The race detector
+// stretches the run ~20× (25 s measured), so its bound is four times wider.
+func scaleMigrationBound() time.Duration {
+ if raceDetectorEnabled {
+ return 4 * time.Minute
+ }
+ return time.Minute
+}
+
+// TestMigration_Scale_MixedCorpus: 5,000 rows of every shape, production
+// batch size, exact counts, INV-2 everywhere, dry-run prediction, idempotent
+// second run, elapsed under the bound, memory reported.
+func TestMigration_Scale_MixedCorpus(t *testing.T) {
+ if testing.Short() {
+ t.Skip("scale test")
+ }
+ base := t.TempDir()
+ e := newMigrationEnv(t, afero.NewBasePathFs(afero.NewOsFs(), base))
+ sidecars := installSidecarMigrationTool(t)
+ mix := defaultScaleMix()
+ seedStart := time.Now()
+ c := e.seedScaleCorpus(mix, sidecars)
+ t.Logf("seeded %d metadata rows, %d payload files, %d time_series rows in %s", mix.rows(), c.files, 2*mix.series, time.Since(seedStart))
+ tsRowsBefore := countTimeSeriesRows(t, e.fixture)
+ require.GreaterOrEqual(t, tsRowsBefore, 500)
+
+ // Dry-run first, with no gate (the flag-off operator check of §8.3).
+ dry, err := MigrateContainerProfiles(e.ctx, e.pool, nil, e.fs, DefaultStorageRoot, e.scheme, ContainerProfileMigrationOptions{DryRun: true})
+ require.NoError(t, err)
+ require.Equal(t, mix.expectedCounts(), dry.Counts, "dry-run counts")
+ require.Nil(t, e.inspect(e.key("plain-00000")).rv, "dry-run wrote nothing")
+
+ e.startNew()
+ var report *ContainerProfileMigrationReport
+ mem := measureMem(func() {
+ report = e.mustMigrate(ContainerProfileMigrationOptions{}) // production batch size
+ })
+ t.Logf("migration: elapsed=%s batches=%d counts=%v mem: %s", report.Elapsed, report.Batches, report.Counts, mem)
+ require.Less(t, report.Elapsed, scaleMigrationBound())
+ require.Equal(t, mix.expectedCounts(), report.Counts, "real-run counts")
+ require.Equal(t, dry.Counts, report.Counts, "the dry run predicted the real run exactly")
+ require.GreaterOrEqual(t, report.Batches, (mix.rows()+DefaultMigrationBatchSize-1)/DefaultMigrationBatchSize, "at least ceil(rows/%d) batches", DefaultMigrationBatchSize)
+ require.True(t, report.SweepsRun)
+ require.False(t, e.migrationDone(), "undecodable files without a row keep the sweep armed (PM-2)")
+ require.Equal(t, tsRowsBefore, countTimeSeriesRows(t, e.fixture), "time_series rows untouched")
+
+ verifyStart := time.Now()
+ e.assertScaleMigrated(c)
+ t.Logf("verified %d surviving objects + INV-2 on every key in %s", len(c.before), time.Since(verifyStart))
+
+ // The migrated rows are live: a CAS update on a sample of every clean shape.
+ for _, name := range []string{"plain-00000", "plain-04299", "replicaset-scale-0000-c-1111-2222-0001", "diverged-000", "trap-000.g.tail", "toolok-000", "fileonly-001", "toolnorow-000"} {
+ require.NoError(t, e.storeUpdate(e.ctx, e.key(name)), name)
+ assertINV2(t, e.fixture, e.key(name))
+ }
+
+ // Second run: only the undecodable rows are met again; nothing is written.
+ again := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 0, again.Batches, "a second run commits nothing: %v", again.Counts)
+ for shape, n := range again.Counts {
+ if strings.HasPrefix(shape, MigrationShapeUndecodable) {
+ continue
+ }
+ require.Equal(t, 0, n, "second run reconciled %s", shape)
+ }
+ require.Equal(t, report.Count(MigrationShapeUndecodable), again.Count(MigrationShapeUndecodable), "undecodable payloads stay visible on every start")
+ t.Logf("second run: elapsed=%s counts=%v", again.Elapsed, again.Counts)
+}
+
+func countTimeSeriesRows(t *testing.T, conn *sqlite.Conn) int {
+ t.Helper()
+ var n int
+ require.NoError(t, sqlitex.Execute(conn, `SELECT count(*) FROM time_series`, &sqlitex.ExecOptions{ResultFunc: func(stmt *sqlite.Stmt) error { n = int(stmt.ColumnInt64(0)); return nil }}))
+ return n
+}
+
+// TestMigration_Scale_ResumesAfterProcessKill is TestMigration_ResumesAfter
+// ProcessKill on the 5,000-row corpus: the child dies inside batch 40 of the
+// production batch size, the parent resumes and completes.
+func TestMigration_Scale_ResumesAfterProcessKill(t *testing.T) {
+ if testing.Short() {
+ t.Skip("scale test")
+ }
+ if os.Getenv("CP_MIGRATION_CRASH_DIR") != "" {
+ t.Skip("child helper only")
+ }
+ base := t.TempDir()
+ fs := afero.NewBasePathFs(afero.NewOsFs(), base)
+ e := newMigrationEnv(t, fs)
+ sidecars := installSidecarMigrationTool(t)
+ mix := defaultScaleMix()
+ c := e.seedScaleCorpus(mix, sidecars)
+ require.NoError(t, e.legacyPool.Close())
+ require.NoError(t, e.pool.Close())
+ e.pool, e.legacyPool = nil, nil
+
+ const crashBatch = 40
+ cmd := exec.Command(os.Args[0], "-test.run=^TestMigrationCrashChild$", "-test.v")
+ cmd.Env = append(os.Environ(),
+ "CP_MIGRATION_CRASH_DIR="+base, "CP_MIGRATION_CRASH_DB="+e.dbPath, "CP_MIGRATION_CRASH_TOOL="+migrationBinaryPath,
+ "CP_MIGRATION_CRASH_BATCH="+strconv.Itoa(crashBatch), "CP_MIGRATION_CRASH_BATCHSIZE="+strconv.Itoa(DefaultMigrationBatchSize))
+ childStart := time.Now()
+ out, err := cmd.CombinedOutput()
+ var exitErr *exec.ExitError
+ require.True(t, errors.As(err, &exitErr), "the child must die: %v\n%s", err, out)
+ require.Equal(t, 3, exitErr.ExitCode(), "%s", out)
+ require.Contains(t, string(out), fmt.Sprintf("child: crashing inside batch %d", crashBatch))
+ t.Logf("child ran %d batches and died in %s", crashBatch-1, time.Since(childStart))
+
+ pool := NewPoolWithOptions(e.dbPath, PoolOptions{Size: 4, BusyTimeout: 5 * time.Second, DisableAutoCheckpoint: true})
+ armUngatedWriteCheck(t, pool)
+ e.pool = pool
+ e.fixture = openFixtureConn(t, pool, e.dbPath, 5*time.Second)
+ // Every candidate row is either fully reconciled or untouched; the rows
+ // the child's committed batches covered are exactly (crashBatch-1)*B
+ // candidates — some of which are row_without_file deletes (no row left)
+ // rather than rewrites, so count outcomes, not rewrites.
+ durable, pending := 0, 0
+ for _, k := range allCPKeys(t, e.fixture) {
+ row := e.inspect(k)
+ if !row.metaExists {
+ continue
+ }
+ require.Equal(t, row.rv != nil, row.payloadExists, "no half-written key after the kill: %s", k)
+ if row.rv != nil {
+ durable++
+ } else {
+ pending++
+ }
+ }
+ require.Greater(t, durable, 0)
+ require.Greater(t, pending, 0)
+ t.Logf("after the kill: %d rows durable, %d pending", durable, pending)
+
+ e.startNew()
+ report := e.mustMigrate(ContainerProfileMigrationOptions{})
+ t.Logf("resumed: elapsed=%s batches=%d counts=%v", report.Elapsed, report.Batches, report.Counts)
+ require.Less(t, report.Elapsed, scaleMigrationBound())
+ want := mix.expectedCounts()
+ // The child's committed batches covered the first (crashBatch-1)*B
+ // candidate rows in rowid order (clean plain rows: seeded first); the
+ // resume reconciles every other row candidate, and the sweeps (file
+ // shapes) run for the first time.
+ require.Equal(t, (crashBatch-1)*DefaultMigrationBatchSize, durable, "exactly the child's committed batches are durable")
+ rowCandidates := want[MigrationShapeMigrated] + want[MigrationShapeDiverged] + want[MigrationShapeRowWithoutFile]
+ resumedRows := report.Count(MigrationShapeMigrated) + report.Count(MigrationShapeDiverged) + report.Count(MigrationShapeRowWithoutFile)
+ require.Equal(t, rowCandidates-durable, resumedRows, "the resume reconciles exactly the rows the child had not committed: %v", report.Counts)
+ require.Equal(t, want[MigrationShapeFileWithoutRow], report.Count(MigrationShapeFileWithoutRow))
+ require.Equal(t, want[MigrationShapeTempFile], report.Count(MigrationShapeTempFile))
+ require.Equal(t, want[MigrationShapeOrphanPayload], report.Count(MigrationShapeOrphanPayload))
+ e.assertScaleMigrated(c)
+ again := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 0, again.Batches, "%v", again.Counts)
+}
+
+// ---- the export at scale, through the real cpexport binary ----
+
+var (
+ cpexportBinOnce sync.Once
+ cpexportBinPath string
+ cpexportBinErr error
+)
+
+// buildCpexport compiles cmd/cpexport once per test binary.
+func buildCpexport(t *testing.T) string {
+ t.Helper()
+ cpexportBinOnce.Do(func() {
+ dir, err := os.MkdirTemp("", "cpexport-bin")
+ if err != nil {
+ cpexportBinErr = err
+ return
+ }
+ bin := filepath.Join(dir, "cpexport")
+ cmd := exec.Command("go", "build", "-o", bin, "github.com/kubescape/storage/cmd/cpexport")
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ cpexportBinErr = fmt.Errorf("%w: %s", err, stderr.String())
+ return
+ }
+ cpexportBinPath = bin
+ })
+ require.NoError(t, cpexportBinErr, "go build cmd/cpexport")
+ return cpexportBinPath
+}
+
+var cpexportReportRE = regexp.MustCompile(`cpexport: exported=(\d+) legacySkipped=(\d+) undecodable=(\d+) dryRun=(true|false) elapsed=(\S+)`)
+
+type cpexportResult struct {
+ exitCode int
+ exported, legacySkipped, undecodable int
+ dryRun bool
+ stdout, stderr string
+}
+
+// runCpexport runs the built binary and parses its report line.
+func runCpexport(t *testing.T, bin string, args ...string) cpexportResult {
+ t.Helper()
+ cmd := exec.Command(bin, args...)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout, cmd.Stderr = &stdout, &stderr
+ err := cmd.Run()
+ res := cpexportResult{stdout: stdout.String(), stderr: stderr.String()}
+ var exitErr *exec.ExitError
+ switch {
+ case err == nil:
+ case errors.As(err, &exitErr):
+ res.exitCode = exitErr.ExitCode()
+ default:
+ t.Fatalf("run cpexport: %v", err)
+ }
+ if m := cpexportReportRE.FindStringSubmatch(res.stdout); m != nil {
+ res.exported, _ = strconv.Atoi(m[1])
+ res.legacySkipped, _ = strconv.Atoi(m[2])
+ res.undecodable, _ = strconv.Atoi(m[3])
+ res.dryRun = m[4] == "true"
+ }
+ return res
+}
+
+// TestExport_Scale_BinaryAgainstMigratedCorpus: migrate the 5,000-row corpus,
+// then run the real cpexport binary against the root (dry-run, then for
+// real), and prove every exported file decodes to what the ObjectStore
+// serves. Files and payload rows coexist during the rollback window; the
+// disk overhead of that window is reported.
+func TestExport_Scale_BinaryAgainstMigratedCorpus(t *testing.T) {
+ if testing.Short() {
+ t.Skip("scale test")
+ }
+ bin := buildCpexport(t)
+ base := t.TempDir()
+ e := newMigrationEnv(t, afero.NewBasePathFs(afero.NewOsFs(), base))
+ sidecars := installSidecarMigrationTool(t)
+ mix := defaultScaleMix()
+ c := e.seedScaleCorpus(mix, sidecars)
+ e.startNew()
+ report := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, mix.expectedCounts(), report.Counts)
+ // The store keeps writing after the flip: keys the old binary has no
+ // file for (created) or a stale file for (updated) are what the export
+ // exists for.
+ created := 200
+ for i := 0; i < created; i++ {
+ e.storeCreate(fmt.Sprintf("created-under-the-new-store-%03d", i))
+ }
+ updated := 300
+ for i := 0; i < updated; i++ {
+ require.NoError(t, e.storeUpdate(e.ctx, e.key(fmt.Sprintf("plain-%05d", i))))
+ }
+ // The server is stopped for the export (the binary's contract).
+ e.stopNew()
+
+ root := filepath.Join(base, DefaultStorageRoot)
+ filesBefore, bytesBefore := treeSize(t, root)
+ dbBefore := dbSize(t, e.dbPath)
+ // Every migrated row plus the store's own writes. The rows the migration
+ // left rv NULL have no payloads row and are outside the export's join
+ // entirely (their file is the only copy and stays); LegacySkipped counts
+ // only rv NULL rows WITH a payloads row (legacy_rewrite), none here.
+ wantExported := len(c.before) + created
+ wantSkipped := 0
+
+ dry := runCpexport(t, bin, "-root", root, "-db", e.dbPath, "-dry-run")
+ require.Equal(t, 0, dry.exitCode, "stderr: %s", dry.stderr)
+ require.True(t, dry.dryRun)
+ require.Equal(t, wantExported, dry.exported, dry.stdout)
+ require.Equal(t, wantSkipped, dry.legacySkipped, dry.stdout)
+ require.Equal(t, 0, dry.undecodable, dry.stdout)
+ filesAfterDry, _ := treeSize(t, root)
+ require.Equal(t, filesBefore, filesAfterDry, "dry-run wrote nothing")
+
+ start := time.Now()
+ real := runCpexport(t, bin, "-root", root, "-db", e.dbPath)
+ elapsed := time.Since(start)
+ require.Equal(t, 0, real.exitCode, "stderr: %s", real.stderr)
+ require.False(t, real.dryRun)
+ require.Equal(t, dry.exported, real.exported, "the dry run predicted the export")
+ require.Equal(t, dry.legacySkipped, real.legacySkipped)
+ require.Equal(t, 0, real.undecodable)
+ filesAfter, bytesAfter := treeSize(t, root)
+ require.Equal(t, filesBefore+created, filesAfter, "exactly the store-created keys gained a file; no staging file left behind")
+ t.Logf("export: %d files in %s (binary wall); files %d→%d (%dMB→%dMB), db+wal %dMB→%dMB; during the rollback window the %dMB of files duplicate the rows' payloads (total on disk %dMB)",
+ real.exported, elapsed, filesBefore, filesAfter, bytesBefore>>20, bytesAfter>>20, dbBefore>>20, dbSize(t, e.dbPath)>>20, bytesAfter>>20, (bytesAfter+dbSize(t, e.dbPath))>>20)
+
+ // Every exported file decodes to what the ObjectStore serves, at the
+ // row's resourceVersion and UID, and the old binary reads it.
+ e.startNew()
+ verifyStart := time.Now()
+ decoded := 0
+ for k := range c.before {
+ want := e.mustStoreGet(k)
+ got := decodeGob(t, e.readFile(filepath.Base(k)))
+ require.Equal(t, canonicalCP(want), canonicalCP(got), "%s: the exported file is not what the store serves", k)
+ require.Equal(t, want.ResourceVersion, got.ResourceVersion, k)
+ require.Equal(t, want.UID, got.UID, k)
+ decoded++
+ }
+ for i := 0; i < created; i++ {
+ k := e.key(fmt.Sprintf("created-under-the-new-store-%03d", i))
+ want := e.mustStoreGet(k)
+ got := decodeGob(t, e.readFile(filepath.Base(k)))
+ require.Equal(t, canonicalCP(want), canonicalCP(got), k)
+ decoded++
+ }
+ require.Equal(t, real.exported, decoded, "every exported file was decoded")
+ e.stopNew()
+ // The old binary serves a sample at the post-flip state.
+ for _, name := range []string{"plain-00000", "plain-00299", "created-under-the-new-store-199", "diverged-042", "trap-003.g.tail"} {
+ got := e.legacyGet(e.key(name))
+ require.Equal(t, name, got.Name)
+ }
+ t.Logf("verified %d exported files in %s", decoded, time.Since(verifyStart))
+}
diff --git a/pkg/registry/file/sqliteobject_migration_test.go b/pkg/registry/file/sqliteobject_migration_test.go
new file mode 100644
index 000000000..c851eeef6
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_migration_test.go
@@ -0,0 +1,940 @@
+package file
+
+// Tests of the startup ContainerProfile data migration (§8.2, K-1, K-2, R3,
+// PM-2, the §9 "integration additions from Part C").
+//
+// The "old binary" is a real legacy StorageImpl (no guard, no gate) over a
+// second pool on the same database file and the same afero.Fs: it writes the
+// exact row + gob file a pre-flag process writes, and the AC-G1 ledger is
+// vacuous for its pool. The "new binary" is the gated pool (AC-G1 armed),
+// the migration and an ObjectStore built on it; startNew/stopNew are one boot.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition/install"
+ "github.com/kubescape/storage/pkg/config"
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/util/uuid"
+ "k8s.io/apiserver/pkg/storage"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitemigration"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+type migrationEnv struct {
+ t *testing.T
+ ctx context.Context
+ dir string
+ dbPath string
+ fs afero.Fs
+ scheme *runtime.Scheme
+ // pool is the new binary's pool: AC-G1 armed for the whole test.
+ pool *sqlitemigration.Pool
+ // legacyPool/legacy are the old binary: an unguarded, ungated
+ // StorageImpl with the CP processor, writing rows and gob files.
+ legacyPool *sqlitemigration.Pool
+ legacy *StorageImpl
+ fixture *sqlite.Conn
+ tpl softwarecomposition.ContainerProfile
+ ns string
+
+ gate *writeGate
+ store *ObjectStore
+}
+
+// newMigrationEnv builds the environment over fs (a MemMapFs unless the
+// test needs real files for a child process).
+func newMigrationEnv(t *testing.T, fs afero.Fs) *migrationEnv {
+ t.Helper()
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "metadata.sq3")
+ pool := NewPoolWithOptions(dbPath, PoolOptions{Size: 4, BusyTimeout: 5 * time.Second, DisableAutoCheckpoint: true})
+ armUngatedWriteCheck(t, pool)
+ sch := runtime.NewScheme()
+ install.Install(sch)
+ // The old binary: SQLite defaults (autocheckpoint on), no gate, no guard.
+ legacyPool := NewPoolWithOptions(dbPath, PoolOptions{Size: 4, BusyTimeout: 5 * time.Second})
+ legacyProcessor := NewContainerProfileProcessor(config.Config{DefaultNamespace: "kubescape", MaxContainerProfileSize: 40000}, nil)
+ legacyProcessor.Interval = 0
+ legacy := NewStorageImplWithCollector(fs, DefaultStorageRoot, legacyPool, NewWatchDispatcher(), sch, legacyProcessor).(*StorageImpl)
+
+ content, err := os.ReadFile("testdata/p1.json")
+ require.NoError(t, err)
+ var tpl softwarecomposition.ContainerProfile
+ require.NoError(t, json.Unmarshal(content, &tpl))
+
+ ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
+ t.Cleanup(cancel)
+ e := &migrationEnv{
+ t: t, ctx: ctx, dir: dir, dbPath: dbPath, fs: fs, scheme: sch,
+ pool: pool, legacyPool: legacyPool, legacy: legacy,
+ tpl: tpl, ns: tpl.Namespace,
+ }
+ e.fixture = openFixtureConn(t, pool, dbPath, 5*time.Second)
+ t.Cleanup(func() {
+ e.stopNew()
+ for _, p := range []*sqlitemigration.Pool{e.pool, e.legacyPool} {
+ if p == nil {
+ continue
+ }
+ closed := make(chan error, 1)
+ go func() { closed <- p.Close() }()
+ select {
+ case err := <-closed:
+ require.NoError(t, err)
+ case <-time.After(10 * time.Second):
+ t.Errorf("pool.Close did not return")
+ }
+ }
+ })
+ return e
+}
+
+func (e *migrationEnv) key(name string) string { return testCPPrefix + e.ns + "/" + name }
+
+func (e *migrationEnv) filePath(name string) string {
+ return filepath.Join(DefaultStorageRoot, e.key(name)) + GobExt
+}
+
+// plain returns a non-TS profile named name, with the UID the REST layer
+// assigns before storage sees an object.
+func (e *migrationEnv) plain(name string) *softwarecomposition.ContainerProfile {
+ p := e.tpl.DeepCopy()
+ p.Name = name
+ p.ResourceVersion = ""
+ p.UID = uuid.NewUUID()
+ delete(p.Annotations, helpersv1.ReportSeriesIdMetadataKey)
+ return p
+}
+
+// ts returns a TS profile of series suffix under base name base.
+func (e *migrationEnv) ts(base, suffix string) *softwarecomposition.ContainerProfile {
+ p := e.tpl.DeepCopy()
+ p.Name = base + "-" + suffix
+ p.ResourceVersion = ""
+ p.UID = uuid.NewUUID()
+ return p
+}
+
+// legacyCreate writes p through the old binary: row (rv NULL) + gob file.
+func (e *migrationEnv) legacyCreate(p *softwarecomposition.ContainerProfile) *softwarecomposition.ContainerProfile {
+ e.t.Helper()
+ out := &softwarecomposition.ContainerProfile{}
+ require.NoError(e.t, e.legacy.Create(e.ctx, e.key(p.Name), p, out, 0))
+ return out
+}
+
+// legacyUpdate mutates key through the old binary's GuaranteedUpdate: the
+// file is rewritten at RV+1, then the row is INSERT OR REPLACEd (rv NULL).
+func (e *migrationEnv) legacyUpdate(key string, mutate func(*softwarecomposition.ContainerProfile)) *softwarecomposition.ContainerProfile {
+ e.t.Helper()
+ out := &softwarecomposition.ContainerProfile{}
+ require.NoError(e.t, e.legacy.GuaranteedUpdate(e.ctx, key, out, false, nil,
+ func(input runtime.Object, _ storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ cp := input.(*softwarecomposition.ContainerProfile).DeepCopy()
+ mutate(cp)
+ return cp, nil, nil
+ }, nil))
+ return out
+}
+
+func (e *migrationEnv) legacyGet(key string) *softwarecomposition.ContainerProfile {
+ e.t.Helper()
+ out := &softwarecomposition.ContainerProfile{}
+ require.NoError(e.t, e.legacy.Get(e.ctx, key, storage.GetOptions{}, out))
+ return out
+}
+
+// startNew boots the new binary: the gate and an ObjectStore on the armed pool.
+func (e *migrationEnv) startNew() {
+ e.t.Helper()
+ require.Nil(e.t, e.gate, "startNew called twice")
+ gateCtx, cancel := context.WithTimeout(e.ctx, 30*time.Second)
+ defer cancel()
+ gate, err := newWriteGate(gateCtx, e.pool)
+ require.NoError(e.t, err)
+ e.gate = gate
+ processor := NewContainerProfileProcessor(config.Config{DefaultNamespace: "kubescape", MaxContainerProfileSize: 40000}, nil)
+ processor.Interval = 0
+ guarded := NewStorageImpl(afero.NewMemMapFs(), DefaultStorageRoot, e.pool, nil, e.scheme).(*StorageImpl)
+ guarded.SetForeignKinds(IsContainerProfileKind)
+ guarded.SetWriteGate(gate)
+ store, err := NewObjectStore(e.pool, e.dbPath, nil, e.scheme, processor, guarded, gate, ObjectStoreOptions{CheckpointInterval: time.Hour})
+ require.NoError(e.t, err)
+ e.store = store
+}
+
+// stopNew shuts the new binary down (a rollback, or a crash-free restart).
+func (e *migrationEnv) stopNew() {
+ e.t.Helper()
+ if e.store != nil {
+ require.NoError(e.t, e.store.Close())
+ e.store = nil
+ }
+ if e.gate != nil {
+ require.NoError(e.t, e.gate.Close())
+ e.gate = nil
+ }
+}
+
+func (e *migrationEnv) migrate(opts ContainerProfileMigrationOptions) (*ContainerProfileMigrationReport, error) {
+ e.t.Helper()
+ return MigrateContainerProfiles(e.ctx, e.pool, e.gate, e.fs, DefaultStorageRoot, e.scheme, opts)
+}
+
+func (e *migrationEnv) mustMigrate(opts ContainerProfileMigrationOptions) *ContainerProfileMigrationReport {
+ e.t.Helper()
+ report, err := e.migrate(opts)
+ require.NoError(e.t, err)
+ return report
+}
+
+func (e *migrationEnv) storeGet(key string) (*softwarecomposition.ContainerProfile, error) {
+ out := &softwarecomposition.ContainerProfile{}
+ err := e.store.Get(e.ctx, key, storage.GetOptions{}, out)
+ return out, err
+}
+
+func (e *migrationEnv) mustStoreGet(key string) *softwarecomposition.ContainerProfile {
+ e.t.Helper()
+ out, err := e.storeGet(key)
+ require.NoError(e.t, err)
+ return out
+}
+
+// storeUpdate bumps an annotation through the ObjectStore's CAS with a
+// bounded ctx: a row the CAS can never match (rv NULL) times out.
+func (e *migrationEnv) storeUpdate(ctx context.Context, key string) error {
+ out := &softwarecomposition.ContainerProfile{}
+ return e.store.GuaranteedUpdate(ctx, key, out, false, nil,
+ func(input runtime.Object, _ storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ cp := input.(*softwarecomposition.ContainerProfile).DeepCopy()
+ cp.Annotations["migration-test/touched"] = strconv.FormatInt(time.Now().UnixNano(), 10)
+ return cp, nil, nil
+ }, nil)
+}
+
+func (e *migrationEnv) inspect(key string) dbRow {
+ e.t.Helper()
+ return inspectRow(e.t, e.fixture, key)
+}
+
+func (e *migrationEnv) assertINV2(keys ...string) {
+ e.t.Helper()
+ for _, k := range keys {
+ assertINV2(e.t, e.fixture, k)
+ }
+}
+
+func (e *migrationEnv) migrationDone() bool {
+ e.t.Helper()
+ var state string
+ require.NoError(e.t, sqlitex.Execute(e.fixture, `SELECT state FROM migration_state WHERE name = ?`, &sqlitex.ExecOptions{
+ Args: []any{migrationStateName}, ResultFunc: func(stmt *sqlite.Stmt) error { state = stmt.ColumnText(0); return nil },
+ }))
+ return state == migrationStateDone
+}
+
+// seed writes n plain profiles and one two-report TS series through the old
+// binary and returns every key with the object the old binary served for it.
+func (e *migrationEnv) seed(n int) map[string]*softwarecomposition.ContainerProfile {
+ e.t.Helper()
+ before := map[string]*softwarecomposition.ContainerProfile{}
+ for i := 0; i < n; i++ {
+ p := e.legacyCreate(e.plain(fmt.Sprintf("plain-%02d", i)))
+ before[e.key(p.Name)] = p
+ }
+ base := "replicaset-x-y-1111-2222"
+ for _, suffix := range []string{"0001", "0002"} {
+ p := e.legacyCreate(e.ts(base, suffix))
+ before[e.key(p.Name)] = p
+ }
+ // One key at RV 2, so the migration meets a non-initial version.
+ first := e.key("plain-00")
+ before[first] = e.legacyUpdate(first, func(cp *softwarecomposition.ContainerProfile) {
+ cp.Annotations["migration-test/updated"] = "1"
+ })
+ for k := range before {
+ before[k] = e.legacyGet(k)
+ }
+ return before
+}
+
+func TestMigration_LegacyRowsBecomeObjectStoreRows(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ before := e.seed(5)
+ for k := range before {
+ row := e.inspect(k)
+ require.True(t, row.metaExists)
+ require.Nil(t, row.rv, "a legacy row has rv NULL")
+ require.False(t, row.payloadExists, "a legacy row has no payloads row")
+ }
+ tsRowsBefore := e.inspect(e.key("replicaset-x-y-1111-2222")).tsRows
+ require.Equal(t, 2, tsRowsBefore, "the old binary wrote the series' time_series rows")
+
+ e.startNew()
+ report := e.mustMigrate(ContainerProfileMigrationOptions{BatchSize: 3})
+ require.Equal(t, len(before), report.Count(MigrationShapeMigrated), "%v", report.Counts)
+ require.Equal(t, len(before), report.Work())
+ require.Equal(t, 4, report.Batches, "7 rows in batches of 3, plus the done-flag write: %v", report.Counts)
+ require.True(t, report.SweepsRun)
+ require.True(t, e.migrationDone())
+
+ keys := make([]string, 0, len(before))
+ for k, legacyObj := range before {
+ keys = append(keys, k)
+ got := e.mustStoreGet(k)
+ require.Equal(t, canonicalCP(legacyObj), canonicalCP(got), "the ObjectStore serves what the old binary served for %s", k)
+ require.Equal(t, legacyObj.ResourceVersion, got.ResourceVersion, "resourceVersion preserved for %s", k)
+ require.Equal(t, legacyObj.UID, got.UID)
+ row := e.inspect(k)
+ require.Equal(t, legacyObj.ResourceVersion, strconv.FormatInt(*row.rv, 10))
+ exists, err := afero.Exists(e.fs, filepath.Join(DefaultStorageRoot, k)+GobExt)
+ require.NoError(t, err)
+ require.True(t, exists, "legacy files are left in place for the export tool (§8.4)")
+ }
+ e.assertINV2(keys...)
+ require.Equal(t, tsRowsBefore, e.inspect(e.key("replicaset-x-y-1111-2222")).tsRows, "time_series rows untouched")
+
+ // The migrated rows are live: CAS updates and a fresh Create work.
+ for _, k := range keys {
+ require.NoError(t, e.storeUpdate(e.ctx, k), "CAS update of migrated key %s", k)
+ got := e.mustStoreGet(k)
+ require.Equal(t, strconv.FormatInt(parseRV(before[k].ResourceVersion)+1, 10), got.ResourceVersion)
+ }
+ created := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, e.store.Create(e.ctx, e.key("fresh"), e.plain("fresh"), created, 0))
+ e.assertINV2(append(keys, e.key("fresh"))...)
+
+ // Idempotent: the second start reconciles nothing and skips the sweeps.
+ again := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 0, again.Work(), "%v", again.Counts)
+ require.Equal(t, 0, again.Batches)
+ require.False(t, again.SweepsRun, "the done-flag gates the file sweeps")
+}
+
+func TestMigration_ReconcileShapes(t *testing.T) {
+ t.Run("diverged: row ahead of file gets max+1", func(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ p := e.legacyCreate(e.plain("diverged"))
+ key := e.key(p.Name)
+ // B16's shape: the row says 5, the file says 1.
+ setRowJSON(t, e.fixture, key, `$.resourceVersion`, "5")
+ e.startNew()
+ report := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 1, report.Count(MigrationShapeDiverged), "%v", report.Counts)
+ require.Equal(t, 0, report.Count(MigrationShapeMigrated))
+ got := e.mustStoreGet(key)
+ require.Equal(t, "6", got.ResourceVersion, "max(rowRV, payloadRV)+1")
+ e.assertINV2(key)
+ require.NoError(t, e.storeUpdate(e.ctx, key))
+ require.Equal(t, "7", e.mustStoreGet(key).ResourceVersion)
+ })
+
+ t.Run("completed row beats learning file (PreSave's non-TS revert)", func(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ p := e.legacyCreate(e.plain("revert"))
+ key := e.key(p.Name)
+ require.Equal(t, helpersv1.Learning, p.Annotations[helpersv1.StatusMetadataKey])
+ setRowJSON(t, e.fixture, key, fmt.Sprintf(`$.annotations."%s"`, helpersv1.StatusMetadataKey), helpersv1.Completed)
+ e.startNew()
+ report := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 1, report.Count(MigrationShapeMigrated), "%v", report.Counts)
+ require.Equal(t, helpersv1.Completed, e.mustStoreGet(key).Annotations[helpersv1.StatusMetadataKey])
+ e.assertINV2(key)
+ })
+
+ t.Run("row without file is deleted", func(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ p := e.legacyCreate(e.plain("nofile"))
+ key := e.key(p.Name)
+ require.NoError(t, e.fs.Remove(e.filePath(p.Name)))
+ e.startNew()
+ report := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 1, report.Count(MigrationShapeRowWithoutFile), "%v", report.Counts)
+ row := e.inspect(key)
+ require.False(t, row.metaExists)
+ require.False(t, row.payloadExists)
+ _, err := e.storeGet(key)
+ require.True(t, storage.IsNotFound(err))
+ })
+
+ t.Run("file without row is imported by the sweep", func(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ p := e.legacyCreate(e.plain("norow"))
+ key := e.key(p.Name)
+ e.legacyUpdate(key, func(cp *softwarecomposition.ContainerProfile) { cp.Annotations["x"] = "y" })
+ fileObj := e.legacyGet(key)
+ require.Equal(t, "2", fileObj.ResourceVersion)
+ _, _, kind, _, ns, name := K8sPathToKeys(key)
+ require.NoError(t, sqlitex.Execute(e.fixture, `DELETE FROM metadata WHERE kind = ? AND namespace = ? AND name = ?`, &sqlitex.ExecOptions{Args: []any{kind, ns, name}}))
+ e.startNew()
+ report := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 1, report.Count(MigrationShapeFileWithoutRow), "%v", report.Counts)
+ got := e.mustStoreGet(key)
+ require.Equal(t, canonicalCP(fileObj), canonicalCP(got))
+ require.Equal(t, "2", got.ResourceVersion, "imported at the file's resourceVersion")
+ require.Equal(t, fileObj.UID, got.UID, "imported at the file's UID")
+ e.assertINV2(key)
+ require.True(t, e.migrationDone())
+ })
+
+ t.Run("staging files are removed, payloads whose name contains .g.t are not", func(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ e.legacyCreate(e.plain("keep"))
+ // A workload named "keep.g.tail" (a legal DNS subdomain) has a payload
+ // file whose name contains ".g.t"; and an undecodable one of the same
+ // shape must survive as the only copy of its object (PM-2).
+ e.legacyCreate(e.plain("keep.g.tail"))
+ require.NoError(t, afero.WriteFile(e.fs, e.filePath("garbage.g.tail"), []byte("not a gob stream"), 0644))
+ require.NoError(t, sqlitex.Execute(e.fixture,
+ `INSERT INTO metadata (kind, namespace, name, metadata) VALUES (?, ?, ?, ?)`,
+ &sqlitex.ExecOptions{Args: []any{ContainerProfileKind, e.ns, "garbage.g.tail", `{"name":"garbage.g.tail","namespace":"` + e.ns + `","resourceVersion":"1"}`}}))
+ dir := filepath.Dir(e.filePath("keep"))
+ for _, n := range []string{"a.g.t", "b.g.t.1234.5", "keep.g.tail.g.t", "keep.g.tail.g.t.99.1"} {
+ require.NoError(t, afero.WriteFile(e.fs, filepath.Join(dir, n), []byte("staged"), 0644))
+ }
+ e.startNew()
+ report := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 4, report.Count(MigrationShapeTempFile), "%v", report.Counts)
+ require.Equal(t, 2, report.Count(MigrationShapeMigrated), "%v", report.Counts)
+ require.Equal(t, 1, report.Count(MigrationShapeUndecodable), "%v", report.Counts)
+ for _, n := range []string{"a.g.t", "b.g.t.1234.5", "keep.g.tail.g.t", "keep.g.tail.g.t.99.1"} {
+ exists, err := afero.Exists(e.fs, filepath.Join(dir, n))
+ require.NoError(t, err)
+ require.False(t, exists)
+ }
+ for _, n := range []string{"keep", "keep.g.tail", "garbage.g.tail"} {
+ exists, err := afero.Exists(e.fs, e.filePath(n))
+ require.NoError(t, err)
+ require.True(t, exists, "%s is a payload file, not a staging file", n)
+ }
+ e.assertINV2(e.key("keep.g.tail"))
+ require.Equal(t, "keep.g.tail", e.mustStoreGet(e.key("keep.g.tail")).Name)
+ row := e.inspect(e.key("garbage.g.tail"))
+ require.True(t, row.metaExists, "the undecodable object's row is left as is")
+ require.Nil(t, row.rv)
+ // The next start meets the same undecodable row again, never a row_without_file.
+ again := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 1, again.Count(MigrationShapeUndecodable), "%v", again.Counts)
+ require.Equal(t, 0, again.Count(MigrationShapeRowWithoutFile), "%v", again.Counts)
+ })
+
+ t.Run("undecodable payloads are skipped and counted, never deleted", func(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ p := e.legacyCreate(e.plain("garbage"))
+ key := e.key(p.Name)
+ require.NoError(t, afero.WriteFile(e.fs, e.filePath(p.Name), []byte("not a gob stream"), 0644))
+ // A garbage file with no row at all.
+ require.NoError(t, afero.WriteFile(e.fs, e.filePath("garbage-norow"), []byte("not a gob stream"), 0644))
+ e.startNew()
+ report := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 2, report.Count(MigrationShapeUndecodable), "%v", report.Counts)
+ require.Equal(t, 0, report.Count(MigrationShapeRowWithoutFile))
+ row := e.inspect(key)
+ require.True(t, row.metaExists, "the row is left as is")
+ require.Nil(t, row.rv)
+ require.False(t, row.payloadExists)
+ for _, n := range []string{"garbage", "garbage-norow"} {
+ exists, err := afero.Exists(e.fs, e.filePath(n))
+ require.NoError(t, err)
+ require.True(t, exists, "an undecodable file is never deleted (PM-2)")
+ }
+ require.False(t, e.migrationDone(), "an undecodable file without a row keeps the sweep armed for the next start")
+ again := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.True(t, again.SweepsRun)
+ require.Equal(t, 2, again.Count(MigrationShapeUndecodable), "still reported on every start: %v", again.Counts)
+ })
+}
+
+// R3 / K-1: after a rollback, the old binary's GuaranteedUpdate rewrites the
+// .g file at RV+1 and INSERT OR REPLACEs the row (rv NULL). Before the
+// reconcile the ObjectStore serves the stale payloads body and can never
+// update the key; the next start repairs it from the FILE.
+func TestMigration_R3_LegacyRewriteRepairsFromTheFile(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ before := e.seed(2)
+ e.startNew()
+ e.mustMigrate(ContainerProfileMigrationOptions{})
+ key := e.key("plain-01")
+ e.assertINV2(key)
+ migrated := e.mustStoreGet(key)
+ require.Equal(t, "1", migrated.ResourceVersion)
+
+ // Rollback: the old binary runs and updates the key.
+ e.stopNew()
+ e.legacyUpdate(key, func(cp *softwarecomposition.ContainerProfile) {
+ cp.Annotations["migration-test/legacy-write"] = "after-rollback"
+ })
+ legacyView := e.legacyGet(key)
+ require.Equal(t, "2", legacyView.ResourceVersion)
+ row := e.inspect(key)
+ require.Nil(t, row.rv, "INSERT OR REPLACE nulled rv")
+ require.Nil(t, row.uid)
+ require.True(t, row.payloadExists, "the payloads row (now stale) survived the legacy write")
+
+ // Re-enable WITHOUT the reconcile: the shape's two symptoms.
+ e.startNew()
+ stale := e.mustStoreGet(key)
+ require.Equal(t, "1", stale.ResourceVersion, "the payloads body is the stale side")
+ require.Empty(t, stale.Annotations["migration-test/legacy-write"], "the legacy write is invisible through the stale body")
+ shortCtx, cancel := context.WithTimeout(e.ctx, 700*time.Millisecond)
+ err := e.storeUpdate(shortCtx, key)
+ cancel()
+ require.Error(t, err, "rv NULL never matches the CAS: every update conflicts until the ctx expires")
+
+ // The every-start reconcile repairs it from the file.
+ report := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 1, report.Counts[MigrationShapeLegacyRewrite+"/"+MigrationSourceFile], "%v", report.Counts)
+ require.Equal(t, 0, report.Counts[MigrationShapeLegacyRewrite+"/"+MigrationSourcePayloads])
+ require.False(t, report.SweepsRun)
+ repaired := e.mustStoreGet(key)
+ require.Equal(t, canonicalCP(legacyView), canonicalCP(repaired), "the body after repair is the FILE's content, not the pre-rollback payload")
+ require.Equal(t, "after-rollback", repaired.Annotations["migration-test/legacy-write"])
+ require.Equal(t, "2", repaired.ResourceVersion, "rv := the JSON's resourceVersion, no +1")
+ row = e.inspect(key)
+ require.Equal(t, "2", row.jsonRV)
+ e.assertINV2(key)
+ require.NoError(t, e.storeUpdate(e.ctx, key), "the next CAS succeeds")
+ require.Equal(t, "3", e.mustStoreGet(key).ResourceVersion)
+ // The other key was never touched by the old binary and is not rewritten.
+ other := e.key("plain-00")
+ e.assertINV2(other)
+ require.Equal(t, before[other].ResourceVersion, e.mustStoreGet(other).ResourceVersion)
+}
+
+// K-1's row-only variant: a legacy row write with no file (readMetadata's
+// sidecar branch) keeps the payloads body as the source.
+func TestMigration_R3_LegacyRewriteRowOnlyKeepsThePayloadsBody(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ e.seed(1)
+ key := e.key("plain-00")
+ e.startNew()
+ e.mustMigrate(ContainerProfileMigrationOptions{})
+ migrated := e.mustStoreGet(key)
+ require.Equal(t, "2", migrated.ResourceVersion)
+ e.stopNew()
+
+ require.NoError(t, e.fs.Remove(e.filePath("plain-00")))
+ _, _, kind, _, ns, name := K8sPathToKeys(key)
+ require.NoError(t, sqlitex.Execute(e.fixture,
+ `INSERT OR REPLACE INTO metadata (kind, namespace, name, metadata) SELECT kind, namespace, name, metadata FROM metadata WHERE kind = ? AND namespace = ? AND name = ?`,
+ &sqlitex.ExecOptions{Args: []any{kind, ns, name}}))
+ row := e.inspect(key)
+ require.Nil(t, row.rv)
+ require.True(t, row.payloadExists)
+
+ e.startNew()
+ report := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 1, report.Counts[MigrationShapeLegacyRewrite+"/"+MigrationSourcePayloads], "%v", report.Counts)
+ repaired := e.mustStoreGet(key)
+ require.Equal(t, canonicalCP(migrated), canonicalCP(repaired), "the payloads body is kept")
+ require.Equal(t, "2", repaired.ResourceVersion)
+ e.assertINV2(key)
+ require.NoError(t, e.storeUpdate(e.ctx, key))
+}
+
+// legacy_rewrite from a stale file behind a newer payloads body (no
+// export before the rollback): the content is the file's (K-1) but the
+// version never regresses below what the body — and any watcher — saw.
+func TestMigration_R3_LegacyRewriteNeverRegressesBelowTheBodyVersion(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ e.seed(1)
+ key := e.key("plain-00")
+ e.startNew()
+ e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.NoError(t, e.storeUpdate(e.ctx, key))
+ require.Equal(t, "3", e.mustStoreGet(key).ResourceVersion, "the body is at 3; the pre-migration file stays at 2")
+ e.stopNew()
+ // The old binary rewrites the row from its stale file's view: JSON at 2, rv NULL.
+ _, _, kind, _, ns, name := K8sPathToKeys(key)
+ require.NoError(t, sqlitex.Execute(e.fixture,
+ `INSERT OR REPLACE INTO metadata (kind, namespace, name, metadata) SELECT kind, namespace, name, json_set(CAST(metadata AS TEXT), '$.resourceVersion', '2') FROM metadata WHERE kind = ? AND namespace = ? AND name = ?`,
+ &sqlitex.ExecOptions{Args: []any{kind, ns, name}}))
+ require.Nil(t, e.inspect(key).rv)
+
+ e.startNew()
+ report := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 1, report.Counts[MigrationShapeLegacyRewrite+"/"+MigrationSourceFile], "%v", report.Counts)
+ got := e.mustStoreGet(key)
+ require.Equal(t, "3", got.ResourceVersion, "max(json 2, file 2, body 3)")
+ require.Empty(t, got.Annotations["migration-test/touched"], "the content is the legacy writer's file (K-1)")
+ e.assertINV2(key)
+ require.NoError(t, e.storeUpdate(e.ctx, key))
+ require.Equal(t, "4", e.mustStoreGet(key).ResourceVersion)
+}
+
+// K-2 (fixed by rollback-safety-guard Part 1): a legacy delete after a
+// rollback used to remove the row and the file but leave the payloads row
+// behind, so every Create of the key failed on the UNIQUE constraint until
+// the mirror predicate deleted the orphan. deleteLocked's non-gated arm now
+// deletes the payloads row itself (before the metadata row, for crash
+// safety), so no orphan is created and Create succeeds immediately.
+func TestMigration_K2_OrphanPayloadAfterLegacyDelete(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ e.seed(2)
+ key := e.key("plain-01")
+ e.startNew()
+ e.mustMigrate(ContainerProfileMigrationOptions{})
+ e.stopNew()
+
+ require.NoError(t, e.legacy.Delete(e.ctx, key, &softwarecomposition.ContainerProfile{}, nil, nil, nil, storage.DeleteOptions{}))
+ row := e.inspect(key)
+ require.False(t, row.metaExists)
+ require.False(t, row.payloadExists, "Part 1: the old binary's delete now cleans up payloads too, so no orphan is left")
+ exists, err := afero.Exists(e.fs, e.filePath("plain-01"))
+ require.NoError(t, err)
+ require.False(t, exists)
+
+ e.startNew()
+ created := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, e.store.Create(e.ctx, key, e.plain("plain-01"), created, 0), "Create succeeds immediately: there is no orphan payloads row to conflict with")
+
+ report := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 0, report.Count(MigrationShapeOrphanPayload), "%v", report.Counts)
+ e.assertINV2(key, e.key("plain-00"))
+}
+
+// A crash in the middle of a batch rolls that batch back; the next start
+// completes the migration from the predicate with nothing lost or duplicated.
+func TestMigration_ResumesAfterCrashMidBatch(t *testing.T) {
+ for _, mode := range []string{"error", "panic"} {
+ t.Run(mode, func(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ before := e.seed(10) // 12 rows: batches of 5 → 5, 5, 2
+ e.startNew()
+ var crashed bool
+ opts := ContainerProfileMigrationOptions{BatchSize: 5}
+ opts.hooks.beforeStatement = func(batch, idx int, name string) error {
+ if batch == 2 && idx == 3 && !crashed {
+ crashed = true
+ if mode == "panic" {
+ panic(errInjectedCrash)
+ }
+ return errInjectedCrash
+ }
+ return nil
+ }
+ report, err := e.migrate(opts)
+ require.Error(t, err)
+ require.True(t, crashed)
+ require.Contains(t, err.Error(), "batch 2 rolled back")
+ require.Equal(t, 2, report.Batches)
+
+ // Exactly the first batch is durable; the second rolled back whole.
+ var done, pending int
+ for k := range before {
+ row := e.inspect(k)
+ require.True(t, row.metaExists, "no row is lost by a crash")
+ if row.rv != nil {
+ require.True(t, row.payloadExists, "a committed row has its payload")
+ done++
+ } else {
+ require.False(t, row.payloadExists, "a rolled-back row has no half-written payload")
+ pending++
+ }
+ }
+ require.Equal(t, 5, done)
+ require.Equal(t, 7, pending)
+ require.False(t, e.migrationDone())
+
+ // Restart.
+ e.stopNew()
+ e.startNew()
+ report = e.mustMigrate(ContainerProfileMigrationOptions{BatchSize: 5})
+ require.Equal(t, 7, report.Count(MigrationShapeMigrated), "only the pending rows are reconciled: %v", report.Counts)
+ require.Equal(t, 0, report.Count(MigrationShapeDiverged))
+ require.True(t, e.migrationDone())
+ keys := make([]string, 0, len(before))
+ for k, legacyObj := range before {
+ keys = append(keys, k)
+ got := e.mustStoreGet(k)
+ require.Equal(t, canonicalCP(legacyObj), canonicalCP(got))
+ require.Equal(t, legacyObj.ResourceVersion, got.ResourceVersion)
+ }
+ e.assertINV2(keys...)
+ require.Equal(t, 0, e.mustMigrate(ContainerProfileMigrationOptions{}).Work())
+ })
+ }
+}
+
+// The re-exec variant: the child process is killed (os.Exit inside the gated
+// transaction) with real files on disk; the parent resumes.
+func TestMigration_ResumesAfterProcessKill(t *testing.T) {
+ if os.Getenv("CP_MIGRATION_CRASH_DIR") != "" {
+ t.Skip("child helper only")
+ }
+ base := t.TempDir()
+ fs := afero.NewBasePathFs(afero.NewOsFs(), base)
+ e := newMigrationEnv(t, fs)
+ before := e.seed(10)
+ // Hand the database to the child: close our pools' hold on it first.
+ require.NoError(t, e.legacyPool.Close())
+ require.NoError(t, e.pool.Close())
+ e.pool, e.legacyPool = nil, nil
+
+ cmd := exec.Command(os.Args[0], "-test.run=^TestMigrationCrashChild$", "-test.v")
+ cmd.Env = append(os.Environ(), "CP_MIGRATION_CRASH_DIR="+base, "CP_MIGRATION_CRASH_DB="+e.dbPath)
+ out, err := cmd.CombinedOutput()
+ var exitErr *exec.ExitError
+ require.True(t, errors.As(err, &exitErr), "the child must die: %v\n%s", err, out)
+ require.Equal(t, 3, exitErr.ExitCode(), "the child exits from inside the batch\n%s", out)
+ require.Contains(t, string(out), "child: crashing inside batch 2")
+
+ // The parent boots a new binary on the same files and database.
+ pool := NewPoolWithOptions(e.dbPath, PoolOptions{Size: 4, BusyTimeout: 5 * time.Second, DisableAutoCheckpoint: true})
+ armUngatedWriteCheck(t, pool)
+ gate, err := newWriteGate(e.ctx, pool)
+ require.NoError(t, err)
+ processor := NewContainerProfileProcessor(config.Config{DefaultNamespace: "kubescape", MaxContainerProfileSize: 40000}, nil)
+ processor.Interval = 0
+ guarded := NewStorageImpl(afero.NewMemMapFs(), DefaultStorageRoot, pool, nil, e.scheme).(*StorageImpl)
+ guarded.SetForeignKinds(IsContainerProfileKind)
+ guarded.SetWriteGate(gate)
+ store, err := NewObjectStore(pool, e.dbPath, nil, e.scheme, processor, guarded, gate, ObjectStoreOptions{CheckpointInterval: time.Hour})
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ require.NoError(t, store.Close())
+ require.NoError(t, gate.Close())
+ require.NoError(t, pool.Close())
+ })
+ fixture := openFixtureConn(t, pool, e.dbPath, 5*time.Second)
+
+ done := 0
+ for k := range before {
+ row := inspectRow(t, fixture, k)
+ require.True(t, row.metaExists)
+ require.Equal(t, row.rv != nil, row.payloadExists, "no half-written key after the kill")
+ if row.rv != nil {
+ done++
+ }
+ }
+ require.Equal(t, 5, done, "exactly the child's first batch is durable")
+
+ report, err := MigrateContainerProfiles(e.ctx, pool, gate, fs, DefaultStorageRoot, e.scheme, ContainerProfileMigrationOptions{BatchSize: 5})
+ require.NoError(t, err)
+ require.Equal(t, 7, report.Count(MigrationShapeMigrated), "%v", report.Counts)
+ for k, legacyObj := range before {
+ assertINV2(t, fixture, k)
+ got := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, store.Get(e.ctx, k, storage.GetOptions{}, got))
+ require.Equal(t, canonicalCP(legacyObj), canonicalCP(got))
+ require.Equal(t, legacyObj.ResourceVersion, got.ResourceVersion)
+ }
+}
+
+// TestMigrationCrashChild is the child of TestMigration_ResumesAfterProcessKill
+// (and its scale variant): it runs the migration on the parent's files and
+// exits from inside the transaction of batch CP_MIGRATION_CRASH_BATCH
+// (default 2) at batch size CP_MIGRATION_CRASH_BATCHSIZE (default 5).
+func TestMigrationCrashChild(t *testing.T) {
+ base := os.Getenv("CP_MIGRATION_CRASH_DIR")
+ if base == "" {
+ t.Skip("child helper only")
+ }
+ dbPath := os.Getenv("CP_MIGRATION_CRASH_DB")
+ if tool := os.Getenv("CP_MIGRATION_CRASH_TOOL"); tool != "" {
+ migrationBinaryPath = tool
+ }
+ crashBatch, batchSize := 2, 5
+ if v, err := strconv.Atoi(os.Getenv("CP_MIGRATION_CRASH_BATCH")); err == nil {
+ crashBatch = v
+ }
+ if v, err := strconv.Atoi(os.Getenv("CP_MIGRATION_CRASH_BATCHSIZE")); err == nil {
+ batchSize = v
+ }
+ fs := afero.NewBasePathFs(afero.NewOsFs(), base)
+ pool := NewPoolWithOptions(dbPath, PoolOptions{Size: 4, BusyTimeout: 5 * time.Second, DisableAutoCheckpoint: true})
+ sch := runtime.NewScheme()
+ install.Install(sch)
+ gate, err := newWriteGate(context.Background(), pool)
+ require.NoError(t, err)
+ opts := ContainerProfileMigrationOptions{BatchSize: batchSize}
+ opts.hooks.beforeStatement = func(batch, idx int, name string) error {
+ if batch == crashBatch && idx == 3 {
+ fmt.Printf("child: crashing inside batch %d before %s\n", batch, name)
+ os.Exit(3)
+ }
+ return nil
+ }
+ _, err = MigrateContainerProfiles(context.Background(), pool, gate, fs, DefaultStorageRoot, sch, opts)
+ require.NoError(t, err)
+ t.Fatal("the child must not reach the end of the migration")
+}
+
+// Dry-run reconciles and counts the same shapes and writes nothing.
+func TestMigration_DryRunWritesNothing(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ before := e.seed(3)
+ // An orphan payloads row and a staging file, seeded through the fixture.
+ require.NoError(t, sqlitex.Execute(e.fixture,
+ `INSERT INTO payloads (kind, namespace, name, encoding, body) VALUES (?, ?, ?, ?, ?)`,
+ &sqlitex.ExecOptions{Args: []any{ContainerProfileKind, e.ns, "orphan", PayloadEncodingJSONV1Beta1, []byte("{}")}}))
+ staged := filepath.Join(filepath.Dir(e.filePath("plain-00")), "staged.g.t")
+ require.NoError(t, afero.WriteFile(e.fs, staged, []byte("staged"), 0644))
+
+ // No gate exists in a flag-off dry run.
+ dry, err := MigrateContainerProfiles(e.ctx, e.pool, nil, e.fs, DefaultStorageRoot, e.scheme, ContainerProfileMigrationOptions{DryRun: true})
+ require.NoError(t, err)
+ require.True(t, dry.DryRun)
+ require.Equal(t, len(before), dry.Count(MigrationShapeMigrated), "%v", dry.Counts)
+ require.Equal(t, 1, dry.Count(MigrationShapeOrphanPayload))
+ require.Equal(t, 1, dry.Count(MigrationShapeTempFile))
+ require.True(t, dry.SweepsRun)
+ for k := range before {
+ row := e.inspect(k)
+ require.Nil(t, row.rv, "dry-run wrote nothing")
+ require.False(t, row.payloadExists)
+ }
+ require.True(t, e.inspect(e.key("orphan")).payloadExists)
+ exists, err := afero.Exists(e.fs, staged)
+ require.NoError(t, err)
+ require.True(t, exists)
+ require.False(t, e.migrationDone())
+
+ _, err = MigrateContainerProfiles(e.ctx, e.pool, nil, e.fs, DefaultStorageRoot, e.scheme, ContainerProfileMigrationOptions{})
+ require.Error(t, err, "a real run needs the gate")
+
+ e.startNew()
+ real := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, dry.Counts, real.Counts, "the dry run predicted the real run")
+ require.True(t, e.migrationDone())
+}
+
+// The migration's batches are gated writes: with the gate held by another
+// writer the migration waits its turn rather than busy-waiting on SQLite's
+// lock (AC-G1 is armed on the env's pool and would fail an ungated batch).
+func TestMigration_BatchesAreGatedWrites(t *testing.T) {
+ e := newMigrationEnv(t, afero.NewMemMapFs())
+ e.seed(2)
+ e.startNew()
+ holderIn := make(chan struct{})
+ release := make(chan struct{})
+ holderDone := make(chan error, 1)
+ go func() {
+ holderDone <- e.gate.run(e.ctx, priorityHigh, "test-holder", "test", func(_ context.Context, _ *sqlite.Conn) error {
+ close(holderIn)
+ <-release
+ return nil
+ })
+ }()
+ <-holderIn
+ migrated := make(chan *ContainerProfileMigrationReport, 1)
+ go func() { migrated <- e.mustMigrate(ContainerProfileMigrationOptions{}) }()
+ select {
+ case <-migrated:
+ t.Fatal("the migration committed while another writer held the gate")
+ case <-time.After(300 * time.Millisecond):
+ }
+ close(release)
+ require.NoError(t, <-holderDone)
+ select {
+ case report := <-migrated:
+ require.Equal(t, 4, report.Count(MigrationShapeMigrated), "%v", report.Counts)
+ case <-time.After(10 * time.Second):
+ t.Fatal("the migration did not proceed after the gate was released")
+ }
+}
+
+// setRowJSON sets one json path of key's metadata JSON through the fixture
+// (a B16-style edit of the row only).
+func setRowJSON(t *testing.T, conn *sqlite.Conn, key, path, value string) {
+ t.Helper()
+ _, _, kind, _, ns, name := K8sPathToKeys(key)
+ require.NoError(t, sqlitex.Execute(conn,
+ `UPDATE metadata SET metadata = json_set(CAST(metadata AS TEXT), ?, ?) WHERE kind = ? AND namespace = ? AND name = ?`,
+ &sqlitex.ExecOptions{Args: []any{path, value, kind, ns, name}}))
+ require.Equal(t, int64(1), int64(conn.Changes()))
+}
+
+// toggleFailFs fails Stat for one target path while armed, otherwise
+// delegates. afero.Walk falls back to Stat (not Lstat) for entries when the
+// underlying Fs is not an Lstater, which afero.NewMemMapFs() is not, so
+// arming this on a file inside a walked directory reproduces a per-entry
+// stat error mid-walk (a real filesystem's unreadable subtree/permission
+// error) without needing an actual restricted filesystem.
+type toggleFailFs struct {
+ afero.Fs
+ target string
+ fail atomic.Bool
+}
+
+func (f *toggleFailFs) Stat(name string) (os.FileInfo, error) {
+ if f.fail.Load() && name == f.target {
+ return nil, fmt.Errorf("toggleFailFs: simulated stat failure for %s", name)
+ }
+ return f.Fs.Stat(name)
+}
+
+// TestMigration_SweepWalkErrorDoesNotMarkDone: a filesystem error hit while
+// walking one entry (a permission-denied subtree, in production) used to be
+// swallowed by the walk callback's `if err != nil { return nil }`, so the
+// sweep silently skipped whatever orphan payload sat under that entry and
+// still reported success -- MigrateContainerProfiles then persisted the
+// done marker over an incomplete sweep, with nothing left to trigger a
+// retry on a later restart. The walk error must instead fail the migration,
+// and a later restart (once the filesystem error clears) must complete the
+// sweep it never got to.
+func TestMigration_SweepWalkErrorDoesNotMarkDone(t *testing.T) {
+ failing := &toggleFailFs{Fs: afero.NewMemMapFs()}
+ e := newMigrationEnv(t, failing)
+ p := e.legacyCreate(e.plain("norow"))
+ key := e.key(p.Name)
+ failing.target = e.filePath(p.Name)
+ _, _, kind, _, ns, name := K8sPathToKeys(key)
+ require.NoError(t, sqlitex.Execute(e.fixture, `DELETE FROM metadata WHERE kind = ? AND namespace = ? AND name = ?`,
+ &sqlitex.ExecOptions{Args: []any{kind, ns, name}}))
+ e.startNew()
+
+ failing.fail.Store(true)
+ _, err := e.migrate(ContainerProfileMigrationOptions{})
+ require.Error(t, err, "a walk error must fail the migration, not silently succeed")
+ require.False(t, e.migrationDone(), "the done marker must not persist over an incomplete sweep")
+
+ failing.fail.Store(false)
+ report := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 1, report.Count(MigrationShapeFileWithoutRow), "%v",
+ "the retried sweep on restart imports the orphan it never reached before")
+ require.True(t, e.migrationDone())
+}
+
+// TestMigration_SweepTopLevelStatErrorDoesNotMarkDone: the same class of bug
+// at the top-level directory check -- DirExists returns exists=false on ANY
+// stat error, not just "does not exist", and the caller reads a nil error
+// here as "nothing to sweep, mark done".
+func TestMigration_SweepTopLevelStatErrorDoesNotMarkDone(t *testing.T) {
+ failing := &toggleFailFs{Fs: afero.NewMemMapFs()}
+ e := newMigrationEnv(t, failing)
+ p := e.legacyCreate(e.plain("norow"))
+ key := e.key(p.Name)
+ failing.target = filepath.Join(DefaultStorageRoot, softwarecomposition.GroupName, ContainerProfileKind)
+ _, _, kind, _, ns, name := K8sPathToKeys(key)
+ require.NoError(t, sqlitex.Execute(e.fixture, `DELETE FROM metadata WHERE kind = ? AND namespace = ? AND name = ?`,
+ &sqlitex.ExecOptions{Args: []any{kind, ns, name}}))
+ e.startNew()
+
+ failing.fail.Store(true)
+ _, err := e.migrate(ContainerProfileMigrationOptions{})
+ require.Error(t, err, "a top-level stat error must fail the migration, not read as \"directory absent\"")
+ require.False(t, e.migrationDone())
+
+ failing.fail.Store(false)
+ report := e.mustMigrate(ContainerProfileMigrationOptions{})
+ require.Equal(t, 1, report.Count(MigrationShapeFileWithoutRow))
+ require.True(t, e.migrationDone())
+}
diff --git a/pkg/registry/file/sqliteobject_store.go b/pkg/registry/file/sqliteobject_store.go
new file mode 100644
index 000000000..02473aa7c
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_store.go
@@ -0,0 +1,1085 @@
+package file
+
+// ObjectStore: the SQLite-native, fully-ACID storage.Interface for the
+// ContainerProfile kind (design: .omc/plans/full-acid-storage-architecture.md,
+// §3). The object's metadata row (with real rv/uid columns), its payload BLOB
+// (versioned JSON) and its time_series row are written in ONE BEGIN IMMEDIATE …
+// COMMIT on the gate's dedicated connection; the compare-and-swap is one UPDATE
+// … WHERE rv=:rv AND uid=:uid. Nothing but SQL on already-prepared bytes runs
+// while the gate is held (INV-1): no lock, no callback, no dispatch, no
+// decode, no encode.
+//
+// The startup data migration is sqliteobject_migration.go (§8); the cleanup
+// handler's CP arm and GeneratedNetworkPolicyStorage read through this store
+// (§5.6); the 13 legacy kinds share the gate (write-gate-sharing.md). Working
+// package name in the design is sqliteobject; it lives in package file so the
+// in-package measurement harness (containerprofile_load_test.go) can select it.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "reflect"
+ "time"
+
+ "github.com/kubescape/go-logger"
+ "github.com/kubescape/go-logger/helpers"
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1"
+ "github.com/kubescape/storage/pkg/metrics"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/conversion"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/watch"
+ "k8s.io/apiserver/pkg/storage"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitemigration"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+// PayloadEncodingJSONV1Beta1 is the payloads.encoding value for a body that is
+// the object converted to the v1beta1 storage version and JSON-marshalled.
+const PayloadEncodingJSONV1Beta1 = "json/v1beta1"
+
+// Write-path labels for storage_sqlite_write_hold_seconds: the ObjectStore's
+// own, then the legacy kinds' sites routed through the shared gate
+// (.omc/plans/write-gate-sharing.md §3.2).
+const (
+ holdPathCreate = "create"
+ holdPathUpdate = "update"
+ holdPathDelete = "delete"
+ holdPathConsolidate = "consolidate"
+ holdPathTimeSeries = "time_series"
+
+ holdPathLegacyCommit = "legacy_commit" // W1/W2: the shard commit and saveObject
+ holdPathLegacyDelete = "legacy_delete" // W3: deleteLocked
+ holdPathRepair = "repair" // W4/W5/W6a/W7a: get()'s self-repair deletes
+ holdPathMigrate = "migrate" // W6b/W7b/W8: the gob-migration rewrite
+ holdPathCleanup = "cleanup" // W9a: the cleanup tick's row delete
+ holdPathCleanupMigrate = "cleanup_migrate" // W9b: the cleanup tick's sidecar-file migration
+ holdPathCPMigration = "cp_migration" // §8.2: one startup data-migration batch
+)
+
+// ObjectStoreOptions tunes an ObjectStore.
+type ObjectStoreOptions struct {
+ // CheckpointThresholdBytes is the -wal size that triggers a background
+ // checkpoint after a gated commit; non-positive = DefaultCheckpointThresholdBytes.
+ CheckpointThresholdBytes int64
+ // CheckpointInterval is the timer fallback; non-positive = DefaultCheckpointInterval.
+ CheckpointInterval time.Duration
+}
+
+// objectStoreHooks are test seams. Every field is nil in production.
+type objectStoreHooks struct {
+ // beforeStatement runs inside the gated transaction before staged
+ // statement idx of a write set (crash-injection tests return an error or
+ // panic from it, or roll the transaction back underneath the holder).
+ beforeStatement func(conn *sqlite.Conn, path string, idx int, name string) error
+ // afterPrepare runs after a write's prepare phase, before its gate ticket.
+ afterPrepare func(path, key string)
+ // onPoolTake runs on every pool connection acquisition by the store or its
+ // ContainerProfileStorage (INV-1's "no pool wait inside the hold" probe).
+ onPoolTake func()
+}
+
+// ObjectStore implements storage.Interface over the metadata + payloads +
+// time_series tables. See the file comment.
+type ObjectStore struct {
+ pool *sqlitemigration.Pool
+ scheme *runtime.Scheme
+ versioner storage.Versioner
+ processor Processor
+ watchDispatcher eventDispatcher
+ gate *writeGate
+ checkpointer *checkpointer
+ reservations *keyReservations
+ hooks objectStoreHooks
+}
+
+// eventDispatcher is the slice of WatchDispatcher the store uses; an interface
+// so INV-1's test can wrap it and prove no dispatch happens under the gate.
+type eventDispatcher interface {
+ Added(key string, metaOut, obj runtime.Object)
+ Modified(key string, metaOut, obj runtime.Object)
+ Deleted(key string, metaOut runtime.Object)
+}
+
+var _ storage.Interface = (*ObjectStore)(nil)
+
+// NewObjectStore builds the store over pool (the same pool/database file the
+// legacy StorageImpl uses) and gate (the process's one write gate, shared
+// with the legacy StorageImpl and the cleanup handler; the store neither
+// builds nor closes it), starts the checkpointer and hands the processor its
+// ContainerProfileStorage. dbPath is the database file (for the -wal size
+// check); sbomStore is the legacy StorageImpl through which GetSbom reads the
+// sbomsyft kind (R7).
+func NewObjectStore(pool *sqlitemigration.Pool, dbPath string, watchDispatcher *WatchDispatcher, scheme *runtime.Scheme, processor Processor, sbomStore storage.Interface, gate *WriteGate, opts ObjectStoreOptions) (*ObjectStore, error) {
+ if watchDispatcher == nil {
+ watchDispatcher = NewWatchDispatcher()
+ }
+ if processor == nil {
+ processor = DefaultProcessor{}
+ }
+ if gate == nil {
+ return nil, errors.New("ObjectStore: a write gate is required")
+ }
+ if gate.pool != pool {
+ return nil, errors.New("ObjectStore: the write gate belongs to another pool")
+ }
+ s := &ObjectStore{
+ pool: pool,
+ scheme: scheme,
+ versioner: storage.APIObjectVersioner{},
+ processor: processor,
+ watchDispatcher: watchDispatcher,
+ gate: gate,
+ checkpointer: newCheckpointer(pool, dbPath, opts.CheckpointThresholdBytes, opts.CheckpointInterval),
+ reservations: newKeyReservations(),
+ }
+ s.checkpointer.start()
+ processor.SetStorage(newObjectStoreCPStorage(s, sbomStore))
+ return s, nil
+}
+
+// Close stops the checkpointer. The shared gate is closed by its owner
+// (main.go's pre-shutdown hook), after every store's Close and before
+// Pool.Close (K-5).
+func (s *ObjectStore) Close() error {
+ s.checkpointer.Stop()
+ return nil
+}
+
+// ---- storage.Interface plumbing ----
+
+func (s *ObjectStore) Versioner() storage.Versioner { return s.versioner }
+func (s *ObjectStore) ReadinessCheck() error { return nil }
+func (s *ObjectStore) RequestWatchProgress(context.Context) error { return nil }
+func (s *ObjectStore) GetCurrentResourceVersion(context.Context) (uint64, error) { return 0, nil }
+func (s *ObjectStore) EnableResourceSizeEstimation(storage.KeysFunc) error { return nil }
+func (s *ObjectStore) CompactRevision() int64 { return 0 }
+func (s *ObjectStore) SetKeysFunc(storage.KeysFunc) {}
+func (s *ObjectStore) Stats(context.Context) (storage.Stats, error) {
+ return storage.Stats{}, fmt.Errorf("unimplemented")
+}
+
+func (s *ObjectStore) Watch(ctx context.Context, key string, opts storage.ListOptions) (watch.Interface, error) {
+ nw := newWatcher(ctx, opts.ResourceVersion == softwarecomposition.ResourceVersionFullSpec)
+ if wd, ok := s.watchDispatcher.(*WatchDispatcher); ok {
+ wd.Register(key, nw)
+ } else if r, ok := s.watchDispatcher.(interface{ Register(string, *watcher) }); ok {
+ r.Register(key, nw)
+ }
+ return nw, nil
+}
+
+// takeConn takes a pool connection for a read or prepare phase.
+func (s *ObjectStore) takeConn(ctx context.Context, op, key string) (*sqlite.Conn, error) {
+ if s.hooks.onPoolTake != nil {
+ s.hooks.onPoolTake()
+ }
+ poolCtx, cancel := poolContext()
+ defer cancel()
+ before := time.Now()
+ conn, err := s.pool.Take(poolCtx)
+ if err != nil {
+ metrics.ObservePoolWait(resourceFromKey(key), metrics.OutcomeTimeout, time.Since(before))
+ return nil, newContentionTimeoutError(op, key, err)
+ }
+ metrics.ObservePoolWait(resourceFromKey(key), metrics.OutcomeAcquired, time.Since(before))
+ conn.SetInterrupt(ctx.Done())
+ return conn, nil
+}
+
+// ---- codec ----
+
+// encodeBody converts obj to the v1beta1 storage version and marshals it. The
+// original TypeMeta is kept (ConvertToVersion stamps the target GVK), so a
+// decode returns the object with exactly the apiVersion/kind it was saved with.
+func (s *ObjectStore) encodeBody(obj runtime.Object) ([]byte, error) {
+ return encodePayloadBody(s.scheme, obj)
+}
+
+// decodeBody unmarshals a payloads.body into objPtr through the v1beta1 type.
+func (s *ObjectStore) decodeBody(encoding string, body []byte, objPtr runtime.Object) error {
+ return decodePayloadBody(s.scheme, encoding, body, objPtr)
+}
+
+// encodePayloadBody is encodeBody without the receiver, shared with the
+// startup data migration.
+func encodePayloadBody(scheme *runtime.Scheme, obj runtime.Object) ([]byte, error) {
+ versioned, err := scheme.ConvertToVersion(obj, v1beta1.SchemeGroupVersion)
+ if err != nil {
+ return nil, fmt.Errorf("convert to v1beta1: %w", err)
+ }
+ versioned.GetObjectKind().SetGroupVersionKind(obj.GetObjectKind().GroupVersionKind())
+ body, err := json.Marshal(versioned)
+ if err != nil {
+ return nil, fmt.Errorf("marshal payload: %w", err)
+ }
+ return body, nil
+}
+
+// decodePayloadBody is decodeBody without the receiver.
+func decodePayloadBody(scheme *runtime.Scheme, encoding string, body []byte, objPtr runtime.Object) error {
+ if encoding != PayloadEncodingJSONV1Beta1 {
+ return fmt.Errorf("unsupported payload encoding %q", encoding)
+ }
+ gvks, _, err := scheme.ObjectKinds(objPtr)
+ if err != nil || len(gvks) == 0 {
+ return fmt.Errorf("object kinds: %w", err)
+ }
+ versioned, err := scheme.New(v1beta1.SchemeGroupVersion.WithKind(gvks[0].Kind))
+ if err != nil {
+ return fmt.Errorf("new v1beta1 %s: %w", gvks[0].Kind, err)
+ }
+ if err := json.Unmarshal(body, versioned); err != nil {
+ return fmt.Errorf("unmarshal payload: %w", err)
+ }
+ if err := scheme.Convert(versioned, objPtr, nil); err != nil {
+ return fmt.Errorf("convert from v1beta1: %w", err)
+ }
+ objPtr.GetObjectKind().SetGroupVersionKind(versioned.GetObjectKind().GroupVersionKind())
+ return nil
+}
+
+// calculateChecksum is StorageImpl.CalculateChecksum without the receiver.
+func (s *ObjectStore) calculateChecksum(in runtime.Object) (string, error) {
+ return (&StorageImpl{scheme: s.scheme}).CalculateChecksum(in)
+}
+
+// ---- rows ----
+
+// objRow is one (metadata JOIN payloads) row.
+type objRow struct {
+ metadataJSON []byte
+ rv int64
+ uid string
+ encoding string
+ body []byte
+}
+
+func (s *ObjectStore) readRow(conn *sqlite.Conn, key string) (*objRow, error) {
+ _, _, kind, _, namespace, name := K8sPathToKeys(key)
+ var row *objRow
+ err := sqlitex.Execute(conn,
+ `SELECT m.metadata, m.rv, m.uid, p.encoding, p.body
+ FROM metadata m JOIN payloads p USING (kind, namespace, name)
+ WHERE m.kind = :kind AND m.namespace = :namespace AND m.name = :name`,
+ &sqlitex.ExecOptions{
+ Named: map[string]any{":kind": kind, ":namespace": namespace, ":name": name},
+ ResultFunc: func(stmt *sqlite.Stmt) error {
+ r := &objRow{
+ metadataJSON: []byte(stmt.ColumnText(0)),
+ rv: stmt.ColumnInt64(1),
+ uid: stmt.ColumnText(2),
+ encoding: stmt.ColumnText(3),
+ }
+ r.body = make([]byte, stmt.ColumnLen(4))
+ stmt.ColumnBytes(4, r.body)
+ row = r
+ return nil
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("read object row: %w", err)
+ }
+ return row, nil
+}
+
+// getWithConn is Get on a given connection. The metadata variant reads the
+// shared metadata row exactly as the legacy store does; the full variant is
+// one autocommit SELECT over the join (a WAL snapshot; no per-key lock).
+func (s *ObjectStore) getWithConn(ctx context.Context, conn *sqlite.Conn, key string, opts storage.GetOptions, objPtr runtime.Object) (*objRow, error) {
+ if opts.ResourceVersion == softwarecomposition.ResourceVersionMetadata {
+ metadata, err := ReadMetadata(conn, key)
+ if err != nil {
+ if errors.Is(err, ErrMetadataNotFound) {
+ if opts.IgnoreNotFound {
+ return nil, runtime.SetZeroValue(objPtr)
+ }
+ return nil, storage.NewKeyNotFoundError(key, 0)
+ }
+ return nil, fmt.Errorf("read metadata: %w", err)
+ }
+ return nil, json.Unmarshal(metadata, objPtr)
+ }
+ row, err := s.readRow(conn, key)
+ if err != nil {
+ return nil, err
+ }
+ if row == nil {
+ if opts.IgnoreNotFound {
+ return nil, runtime.SetZeroValue(objPtr)
+ }
+ return nil, storage.NewKeyNotFoundError(key, 0)
+ }
+ if err := s.decodeBody(row.encoding, row.body, objPtr); err != nil {
+ logger.L().Ctx(ctx).Error("ObjectStore.Get - decode payload failed", helpers.Error(err), helpers.String("key", key))
+ return nil, err
+ }
+ return row, nil
+}
+
+// Get implements storage.Interface.
+func (s *ObjectStore) Get(ctx context.Context, key string, opts storage.GetOptions, objPtr runtime.Object) error {
+ conn, err := s.takeConn(ctx, "get", key)
+ if err != nil {
+ return err
+ }
+ defer s.pool.Put(conn)
+ _, err = s.getWithConn(ctx, conn, key, opts, objPtr)
+ return err
+}
+
+// GetList implements storage.Interface. The metadata variant is byte-identical
+// to the legacy store's (same listMetadata statement, same continue token =
+// rowid); the fullSpec variant is a paginated join, one statement per page.
+func (s *ObjectStore) GetList(ctx context.Context, key string, opts storage.ListOptions, listObj runtime.Object) error {
+ ctx, predicate, v, elem, limit, batchSize, cursor, isFullSpec, err := (&StorageImpl{}).prepareGetList(ctx, key, opts, listObj)
+ if err != nil {
+ return err
+ }
+ pageLast := ""
+ for limit == 0 || int64(v.Len()) < limit {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+ remaining := nextPageSize(limit, batchSize, int64(v.Len()))
+ conn, err := s.takeConn(ctx, "list", key)
+ if err != nil {
+ return err
+ }
+ fetched, err := s.fetchListPage(ctx, conn, key, cursor, remaining, isFullSpec, predicate, v, elem)
+ s.pool.Put(conn)
+ if err != nil {
+ return err
+ }
+ pageLast = fetched.pageLast
+ if int64(fetched.count) < remaining {
+ pageLast = ""
+ break
+ }
+ cursor = pageLast
+ }
+ return setListContinue(listObj, pageLast)
+}
+
+func (s *ObjectStore) fetchListPage(ctx context.Context, conn *sqlite.Conn, key, cursor string, remaining int64, isFullSpec bool, predicate storage.SelectionPredicate, v reflect.Value, elem reflect.Type) (listPageResult, error) {
+ var objs []runtime.Object
+ var pageLast string
+ var count int
+ if !isFullSpec {
+ entries, last, err := listMetadata(conn, key, cursor, remaining)
+ if err != nil {
+ return listPageResult{}, fmt.Errorf("list objects for %q: %w", key, err)
+ }
+ pageLast, count = last, len(entries)
+ for _, entry := range entries {
+ obj := reflect.New(elem).Interface().(runtime.Object)
+ if err := json.Unmarshal([]byte(entry), obj); err != nil {
+ logger.L().Ctx(ctx).Error("ObjectStore.GetList - unmarshal metadata failed", helpers.Error(err), helpers.String("key", key))
+ continue
+ }
+ objs = append(objs, obj)
+ }
+ } else {
+ _, _, kind, _, namespace, _ := K8sPathToKeys(key)
+ if cursor == "" {
+ cursor = "0"
+ }
+ type page struct {
+ encoding string
+ body []byte
+ }
+ var rows []page
+ // The page is selected on metadata by rowid FIRST (the legacy
+ // listMetadataKeys statement), then joined to payloads by primary key:
+ // design PM-1's "apply the predicate before the join". A plain join
+ // let SQLite drive from payloads and touch every body (Tier B: LIST
+ // p95 6.8 -> 80 ms).
+ //
+ // LEFT JOIN, not JOIN: count/pageLast below must reflect how many
+ // METADATA rows this page scanned (bounded by :limit), not how many
+ // of them had a payload row. A migration can leave a metadata row
+ // with no payload (an undecodable legacy file, left for the export
+ // tool); an inner join silently drops that row from the result, so
+ // count would undercount the page relative to :limit even when many
+ // more valid rows exist beyond it -- GetList's "count < remaining ->
+ // EOF" check would then stop pagination early and hide every row
+ // after the gap. Rows with no payload are logged and skipped from
+ // objs below, exactly as an undecodable one already is, but still
+ // counted and still advance pageLast.
+ err := sqlitex.Execute(conn,
+ `SELECT m.rowid, p.encoding, p.body, p.body IS NULL
+ FROM (SELECT rowid, kind, namespace, name FROM metadata
+ WHERE kind = :kind
+ AND (:namespace = '' OR namespace = :namespace)
+ AND rowid > :cont
+ AND is_time_series = 0
+ ORDER BY rowid
+ LIMIT :limit) m
+ LEFT JOIN payloads p ON p.kind = m.kind AND p.namespace = m.namespace AND p.name = m.name
+ ORDER BY m.rowid`,
+ &sqlitex.ExecOptions{
+ Named: map[string]any{":kind": kind, ":namespace": namespace, ":cont": cursor, ":limit": remaining},
+ ResultFunc: func(stmt *sqlite.Stmt) error {
+ pageLast = stmt.ColumnText(0)
+ if stmt.ColumnInt64(3) == 1 {
+ logger.L().Ctx(ctx).Warning("ObjectStore.GetList - metadata row has no payload row; skipped", helpers.String("key", key))
+ rows = append(rows, page{})
+ return nil
+ }
+ p := page{encoding: stmt.ColumnText(1), body: make([]byte, stmt.ColumnLen(2))}
+ stmt.ColumnBytes(2, p.body)
+ rows = append(rows, p)
+ return nil
+ },
+ })
+ if err != nil {
+ return listPageResult{}, fmt.Errorf("list objects for %q: %w", key, err)
+ }
+ count = len(rows)
+ for _, r := range rows {
+ if r.encoding == "" && r.body == nil {
+ continue
+ }
+ obj := reflect.New(elem).Interface().(runtime.Object)
+ if err := s.decodeBody(r.encoding, r.body, obj); err != nil {
+ logger.L().Ctx(ctx).Error("ObjectStore.GetList - decode payload failed", helpers.Error(err), helpers.String("key", key))
+ continue
+ }
+ objs = append(objs, obj)
+ }
+ }
+ v.Grow(len(objs))
+ for _, obj := range objs {
+ matched, err := predicate.Matches(obj)
+ if err != nil {
+ return listPageResult{}, fmt.Errorf("match selection predicate: %w", err)
+ }
+ if matched {
+ v.Set(reflect.Append(v, reflect.ValueOf(obj).Elem()))
+ }
+ }
+ return listPageResult{pageLast: pageLast, count: count}, nil
+}
+
+// ---- prepared writes ----
+
+// preparedWrite is one fully-prepared object write: bytes and predicates only,
+// so the gate holder has nothing to compute.
+type preparedWrite struct {
+ key string
+ kind, namespace, name string
+ metadataJSON []byte
+ body []byte
+ rv int64
+ uid string
+ // insert selects INSERT … ON CONFLICT DO NOTHING (create, or an
+ // ignoreNotFound update of an absent key) instead of the CAS UPDATE.
+ insert bool
+ expectRV int64
+ expectUID string
+ // candidate is the object as persisted (RV bumped, checksum stamped);
+ // metaObj is its ObjectMeta-only copy for the lightweight watch event.
+ candidate runtime.Object
+ metaObj runtime.Object
+ // tsRow / baseKey are set for a TS-profile create: the time_series row
+ // that joins the transaction and the base key whose admission is
+ // re-checked inside it.
+ tsRow *TimeSeriesRow
+ baseKey string
+ // tsCompletion is the incoming TS profile's completion annotation, for
+ // the in-transaction admission rule.
+ tsCompletion string
+}
+
+// stampAndEncode performs saveObject's pre-encode pipeline (RV bump,
+// ManagedFields zeroing, checksum annotation) on obj and encodes it.
+func (s *ObjectStore) stampAndEncode(key string, obj runtime.Object, checksum string) (*preparedWrite, error) {
+ version, err := s.versioner.ObjectResourceVersion(obj)
+ if err != nil {
+ return nil, fmt.Errorf("object resource version: %w", err)
+ }
+ if err := s.versioner.UpdateObject(obj, version+1); err != nil {
+ return nil, fmt.Errorf("set resourceVersion: %w", err)
+ }
+ managedFields := reflect.ValueOf(obj).Elem().FieldByName("ObjectMeta").FieldByName("ManagedFields")
+ if managedFields.IsValid() {
+ managedFields.Set(reflect.Zero(managedFields.Type()))
+ }
+ if checksum == "" {
+ checksum, err = s.calculateChecksum(obj)
+ if err != nil {
+ return nil, fmt.Errorf("calculate checksum: %w", err)
+ }
+ }
+ if anno := obj.(metav1.Object).GetAnnotations(); anno == nil {
+ obj.(metav1.Object).SetAnnotations(map[string]string{helpersv1.SyncChecksumMetadataKey: checksum})
+ } else {
+ anno[helpersv1.SyncChecksumMetadataKey] = checksum
+ }
+ metaObj := extractFields(obj, []string{"ObjectMeta", "SchemaVersion"})
+ metadataJSON, err := json.Marshal(metaObj)
+ if err != nil {
+ return nil, fmt.Errorf("marshal metadata: %w", err)
+ }
+ body, err := s.encodeBody(obj)
+ if err != nil {
+ return nil, err
+ }
+ _, _, kind, _, namespace, name := K8sPathToKeys(key)
+ return &preparedWrite{
+ key: key, kind: kind, namespace: namespace, name: name,
+ metadataJSON: metadataJSON,
+ body: body,
+ rv: int64(version + 1),
+ uid: string(obj.(metav1.Object).GetUID()),
+ expectRV: int64(version),
+ expectUID: string(obj.(metav1.Object).GetUID()),
+ candidate: obj,
+ metaObj: metaObj,
+ }, nil
+}
+
+// fillOut copies the persisted object into the REST layer's out parameter,
+// exactly as saveObject does (shallow struct copy).
+func fillOut(metaOut, candidate runtime.Object) {
+ if metaOut == nil {
+ return
+ }
+ val := reflect.ValueOf(metaOut)
+ if val.Kind() == reflect.Pointer {
+ val = val.Elem()
+ }
+ val.Set(reflect.ValueOf(candidate).Elem())
+}
+
+// applyTooLarge is the shared ObjectTooLargeError handling of Create and
+// GuaranteedUpdate: clear the spec and mark the object, then save it anyway.
+func applyTooLarge(obj runtime.Object) {
+ clearSpec(obj)
+ metadata := obj.(metav1.Object)
+ annotations := metadata.GetAnnotations()
+ if annotations == nil {
+ annotations = make(map[string]string)
+ }
+ annotations[helpersv1.StatusMetadataKey] = helpersv1.TooLarge
+ metadata.SetAnnotations(annotations)
+}
+
+// ---- the three transactions (§3.4), as statement lists ----
+
+// statusPath / completionPath are json_extract paths into the metadata JSON.
+// The internal ContainerProfile embeds ObjectMeta without a json tag, so
+// encoding/json inlines its fields at the top level ("$.annotations", not
+// "$.metadata.annotations"); the in-transaction admission check is therefore
+// SQL and a string compare, not a Go decode (INV-1). The legacy store binds
+// the JSON as a BLOB (json_extract would read it as JSONB and return NULL),
+// hence the CAST; this store binds TEXT.
+var (
+ statusPath = fmt.Sprintf(`$.annotations."%s"`, helpersv1.StatusMetadataKey)
+ completionPath = fmt.Sprintf(`$.annotations."%s"`, helpersv1.CompletionMetadataKey)
+)
+
+// execCreate runs Create's transaction body on conn.
+func (s *ObjectStore) execCreate(conn *sqlite.Conn, pw *preparedWrite, hook func(string) error) error {
+ if pw.tsRow != nil && pw.baseKey != "" {
+ if err := hook("ts-admission"); err != nil {
+ return err
+ }
+ _, _, bkind, _, bns, bname := K8sPathToKeys(pw.baseKey)
+ var status, completion string
+ var found bool
+ err := sqlitex.Execute(conn,
+ `SELECT json_extract(CAST(metadata AS TEXT), :statusPath), json_extract(CAST(metadata AS TEXT), :completionPath)
+ FROM metadata WHERE kind = :kind AND namespace = :namespace AND name = :name`,
+ &sqlitex.ExecOptions{
+ Named: map[string]any{":statusPath": statusPath, ":completionPath": completionPath, ":kind": bkind, ":namespace": bns, ":name": bname},
+ ResultFunc: func(stmt *sqlite.Stmt) error {
+ found = true
+ status = stmt.ColumnText(0)
+ completion = stmt.ColumnText(1)
+ return nil
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("ts admission read: %w", err)
+ }
+ if found {
+ switch {
+ case status == helpersv1.TooLarge:
+ return ObjectTooLargeError
+ case status == helpersv1.Completed && (completion == helpersv1.Full || pw.tsCompletion == helpersv1.Partial):
+ return ObjectCompletedError
+ }
+ }
+ }
+ if err := hook("insert-metadata"); err != nil {
+ return err
+ }
+ if err := sqlitex.Execute(conn,
+ `INSERT INTO metadata (kind, namespace, name, metadata, rv, uid, is_time_series)
+ VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`,
+ &sqlitex.ExecOptions{Args: []any{pw.kind, pw.namespace, pw.name, string(pw.metadataJSON), pw.rv, pw.uid, pw.tsRow != nil}}); err != nil {
+ return fmt.Errorf("insert metadata: %w", err)
+ }
+ if conn.Changes() == 0 {
+ return storage.NewKeyExistsError(pw.key, 0)
+ }
+ if err := hook("insert-payload"); err != nil {
+ return err
+ }
+ if err := sqlitex.Execute(conn,
+ `INSERT INTO payloads (kind, namespace, name, encoding, body) VALUES (?, ?, ?, ?, ?)`,
+ &sqlitex.ExecOptions{Args: []any{pw.kind, pw.namespace, pw.name, PayloadEncodingJSONV1Beta1, pw.body}}); err != nil {
+ // A UNIQUE failure here is an orphan payloads row (K-2's shape).
+ return apierrors.NewInternalError(fmt.Errorf("insert payload for %q: %w", pw.key, err))
+ }
+ if pw.tsRow != nil {
+ if err := hook("insert-time-series"); err != nil {
+ return err
+ }
+ if err := execTimeSeriesRow(conn, pw.tsRow); err != nil {
+ return err
+ }
+ }
+ return hook("commit")
+}
+
+func execTimeSeriesRow(conn *sqlite.Conn, r *TimeSeriesRow) error {
+ return WriteTimeSeriesEntry(conn, r.Kind, r.Namespace, r.Name, r.SeriesID, r.TsSuffix, r.ReportTimestamp, r.Status, r.Completion, r.PreviousReportTimestamp, r.HasData)
+}
+
+// execUpdate runs GuaranteedUpdate's transaction body on conn: the CAS UPDATE
+// (or the create-or-conflict INSERT for an absent key), then the payloads
+// UPDATE whose changes()==1 is asserted (K-6).
+func (s *ObjectStore) execUpdate(conn *sqlite.Conn, pw *preparedWrite, hook func(string) error) error {
+ if pw.insert {
+ if err := hook("insert-metadata"); err != nil {
+ return err
+ }
+ if err := sqlitex.Execute(conn,
+ `INSERT INTO metadata (kind, namespace, name, metadata, rv, uid)
+ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`,
+ &sqlitex.ExecOptions{Args: []any{pw.kind, pw.namespace, pw.name, string(pw.metadataJSON), pw.rv, pw.uid}}); err != nil {
+ return fmt.Errorf("insert metadata: %w", err)
+ }
+ if conn.Changes() == 0 {
+ metrics.IncCPCASConflict("update")
+ return errWriteConflict
+ }
+ if err := hook("insert-payload"); err != nil {
+ return err
+ }
+ if err := sqlitex.Execute(conn,
+ `INSERT INTO payloads (kind, namespace, name, encoding, body) VALUES (?, ?, ?, ?, ?)`,
+ &sqlitex.ExecOptions{Args: []any{pw.kind, pw.namespace, pw.name, PayloadEncodingJSONV1Beta1, pw.body}}); err != nil {
+ return apierrors.NewInternalError(fmt.Errorf("insert payload for %q: %w", pw.key, err))
+ }
+ return hook("commit")
+ }
+ if err := hook("update-metadata"); err != nil {
+ return err
+ }
+ if err := sqlitex.Execute(conn,
+ `UPDATE metadata SET metadata = ?, rv = ?, uid = ?
+ WHERE kind = ? AND namespace = ? AND name = ? AND rv = ? AND uid = ?`,
+ &sqlitex.ExecOptions{Args: []any{string(pw.metadataJSON), pw.rv, pw.uid, pw.kind, pw.namespace, pw.name, pw.expectRV, pw.expectUID}}); err != nil {
+ return fmt.Errorf("update metadata: %w", err)
+ }
+ if conn.Changes() == 0 {
+ metrics.IncCPCASConflict("update")
+ return errWriteConflict
+ }
+ if err := hook("update-payload"); err != nil {
+ return err
+ }
+ if err := sqlitex.Execute(conn,
+ `UPDATE payloads SET encoding = ?, body = ? WHERE kind = ? AND namespace = ? AND name = ?`,
+ &sqlitex.ExecOptions{Args: []any{PayloadEncodingJSONV1Beta1, pw.body, pw.kind, pw.namespace, pw.name}}); err != nil {
+ return fmt.Errorf("update payload: %w", err)
+ }
+ if n := conn.Changes(); n != 1 {
+ return apierrors.NewInternalError(fmt.Errorf("ObjectStore: metadata row for %q has %d payloads rows (INV-2 violated); transaction rolled back", pw.key, n))
+ }
+ return hook("commit")
+}
+
+// deleteResult carries the deleted metadata JSON out of the transaction so
+// the decode into metaOut happens after COMMIT.
+type deleteResult struct {
+ metadataJSON []byte
+ found bool
+}
+
+// execDelete runs Delete's transaction body on conn. When expect is non-nil
+// the metadata DELETE carries the per-object CAS (R4).
+func (s *ObjectStore) execDelete(conn *sqlite.Conn, key string, expect *rowVersion, out *deleteResult, hook func(string) error) error {
+ _, _, kind, _, namespace, name := K8sPathToKeys(key)
+ if err := hook("delete-metadata"); err != nil {
+ return err
+ }
+ query := `DELETE FROM metadata WHERE kind = :kind AND namespace = :namespace AND name = :name RETURNING metadata`
+ named := map[string]any{":kind": kind, ":namespace": namespace, ":name": name}
+ if expect != nil {
+ query = `DELETE FROM metadata WHERE kind = :kind AND namespace = :namespace AND name = :name AND rv = :rv AND uid = :uid RETURNING metadata`
+ named[":rv"] = expect.rv
+ named[":uid"] = expect.uid
+ }
+ if err := sqlitex.Execute(conn, query, &sqlitex.ExecOptions{
+ Named: named,
+ ResultFunc: func(stmt *sqlite.Stmt) error {
+ out.found = true
+ out.metadataJSON = []byte(stmt.ColumnText(0))
+ return nil
+ },
+ }); err != nil {
+ return fmt.Errorf("delete metadata: %w", err)
+ }
+ if !out.found {
+ if expect != nil {
+ // Distinguish "gone" from "changed under us": a row that still
+ // exists at another version is the R4 conflict.
+ var exists bool
+ if err := sqlitex.Execute(conn,
+ `SELECT 1 FROM metadata WHERE kind = :kind AND namespace = :namespace AND name = :name`,
+ &sqlitex.ExecOptions{
+ Named: map[string]any{":kind": kind, ":namespace": namespace, ":name": name},
+ ResultFunc: func(*sqlite.Stmt) error { exists = true; return nil },
+ }); err != nil {
+ return fmt.Errorf("delete metadata re-check: %w", err)
+ }
+ if exists {
+ metrics.IncCPCASConflict("delete")
+ return errWriteConflict
+ }
+ }
+ return storage.NewKeyNotFoundError(key, 0)
+ }
+ if err := hook("delete-payload"); err != nil {
+ return err
+ }
+ if err := sqlitex.Execute(conn,
+ `DELETE FROM payloads WHERE kind = ? AND namespace = ? AND name = ?`,
+ &sqlitex.ExecOptions{Args: []any{kind, namespace, name}}); err != nil {
+ return fmt.Errorf("delete payload: %w", err)
+ }
+ if IsContainerProfileKind(kind) {
+ if err := hook("delete-time-series"); err != nil {
+ return err
+ }
+ if err := DeleteTimeSeriesContainerEntries(conn, key); err != nil {
+ return err
+ }
+ }
+ return hook("commit")
+}
+
+// ---- Create ----
+
+// Create implements storage.Interface (§3.4 Create).
+func (s *ObjectStore) Create(ctx context.Context, key string, obj, metaOut runtime.Object, _ uint64) error {
+ if version, err := s.versioner.ObjectResourceVersion(obj); err == nil && version != 0 {
+ msg := "resourceVersion should not be set on objects to be created"
+ logger.L().Ctx(ctx).Error(msg)
+ return errors.New(msg)
+ }
+ defer s.reservations.enter(ctx, key)()
+
+ // prepare (caller goroutine, no transaction, one pool connection for reads)
+ conn, err := s.takeConn(ctx, "create", key)
+ if err != nil {
+ return err
+ }
+ h := &readHandle{store: s, conn: conn}
+ presaveCtx := withReadHandle(ctx, h)
+ if err := s.processor.PreSave(presaveCtx, obj); err != nil {
+ if errors.Is(err, ObjectTooLargeError) {
+ applyTooLarge(obj)
+ logger.L().Debug("Create - too large object, saving metadata only", helpers.String("key", key))
+ } else {
+ s.pool.Put(conn)
+ return err
+ }
+ }
+ s.pool.Put(conn)
+
+ pw, err := s.stampAndEncode(key, obj, "")
+ if err != nil {
+ return err
+ }
+ provider, hasProvider := s.processor.(TimeSeriesRowProvider)
+ if hasProvider {
+ if row, baseKey, ok := provider.TimeSeriesRowFor(obj); ok {
+ pw.tsRow = &row
+ pw.baseKey = baseKey
+ pw.tsCompletion = obj.(metav1.Object).GetAnnotations()[helpersv1.CompletionMetadataKey]
+ }
+ }
+ if s.hooks.afterPrepare != nil {
+ s.hooks.afterPrepare(holdPathCreate, key)
+ }
+
+ // transaction (gate held)
+ err = s.gate.run(ctx, priorityHigh, holdPathCreate, ContainerProfileKindPlural, func(_ context.Context, conn *sqlite.Conn) error {
+ return s.execCreate(conn, pw, s.stmtHook(conn, holdPathCreate))
+ })
+ if err != nil {
+ if !storage.IsExist(err) && !errors.Is(err, ObjectCompletedError) && !errors.Is(err, ObjectTooLargeError) {
+ logger.L().Ctx(ctx).Error("Create - save object failed", helpers.Error(err), helpers.String("key", key))
+ }
+ return err
+ }
+ s.checkpointer.afterCommit()
+
+ // after (caller goroutine, gate released)
+ if !hasProvider {
+ conn2, err := s.takeConn(ctx, "create", key)
+ if err != nil {
+ return err
+ }
+ afterCtx := withReadHandle(ctx, &readHandle{store: s, conn: conn2})
+ err = s.processor.AfterCreate(afterCtx, pw.candidate)
+ s.pool.Put(conn2)
+ if err != nil {
+ return fmt.Errorf("processor.AfterCreate: %w", err)
+ }
+ }
+ fillOut(metaOut, pw.candidate)
+ s.watchDispatcher.Added(key, pw.metaObj, pw.candidate)
+ return nil
+}
+
+// stmtHook returns the per-transaction statement-boundary hook for path: a
+// counter over the statements of ONE gated transaction, feeding the
+// beforeStatement seam. Nil-cheap in production.
+func (s *ObjectStore) stmtHook(conn *sqlite.Conn, path string) func(name string) error {
+ if s.hooks.beforeStatement == nil {
+ return noHook
+ }
+ idx := 0
+ return func(name string) error {
+ idx++
+ return s.hooks.beforeStatement(conn, path, idx, name)
+ }
+}
+
+func noHook(string) error { return nil }
+
+// ---- GuaranteedUpdate ----
+
+// updateState is one read of the current object with the row version the
+// payload came from: the CAS expectation.
+type updateState struct {
+ obj runtime.Object
+ rev int64
+ uid string
+ exists bool
+}
+
+// readState reads key on conn into a fresh object of v's type.
+func (s *ObjectStore) readState(ctx context.Context, conn *sqlite.Conn, key string, ignoreNotFound bool, v reflect.Value) (*updateState, error) {
+ objPtr := reflect.New(v.Type()).Interface().(runtime.Object)
+ row, err := s.getWithConn(ctx, conn, key, storage.GetOptions{IgnoreNotFound: ignoreNotFound}, objPtr)
+ if err != nil {
+ return nil, err
+ }
+ st := &updateState{obj: objPtr}
+ if row != nil {
+ st.exists = true
+ st.rev = row.rv
+ st.uid = row.uid
+ }
+ return st, nil
+}
+
+// prepareUpdate is guaranteedUpdateSingleWriter's prepare phase: preconditions,
+// tryUpdate, PreSave on both sides, the #315 DeepEqual short-circuit, then
+// stamp + encode with the CAS expectation taken from the row the payload came
+// from. It returns (nil, state, nil) for a no-op update; (pw, state, nil) for a
+// write to commit; the loop over conflicts belongs to the caller.
+//
+// Returned errors: retryable staleness is signalled by errStaleState.
+var errStaleState = errors.New("stale state")
+
+func (s *ObjectStore) prepareUpdate(ctx context.Context, h *readHandle, key string, ignoreNotFound bool,
+ preconditions *storage.Preconditions, tryUpdate storage.UpdateFunc, origState *updateState, origStateIsCurrent bool, checksum string) (*preparedWrite, error) {
+
+ if err := preconditions.Check(key, origState.obj); err != nil {
+ if origStateIsCurrent {
+ logger.L().Ctx(ctx).Error("GuaranteedUpdate - preconditions check failed", helpers.Error(err), helpers.String("key", key))
+ return nil, err
+ }
+ return nil, errStaleState
+ }
+
+ orig := origState.obj.DeepCopyObject()
+ presaveCtx := withReadHandle(ctx, h)
+ _ = s.processor.PreSave(presaveCtx, orig)
+
+ ret, _, err := tryUpdate(origState.obj, storage.ResponseMeta{})
+ if err != nil {
+ if origStateIsCurrent {
+ if !apierrors.IsNotFound(err) && !apierrors.IsInvalid(err) {
+ logger.L().Ctx(ctx).Error("GuaranteedUpdate - tryUpdate func failed", helpers.Error(err), helpers.String("key", key))
+ }
+ return nil, err
+ }
+ return nil, fmt.Errorf("%w: %w", errStaleState, err)
+ }
+
+ if err := s.processor.PreSave(presaveCtx, ret); err != nil {
+ if errors.Is(err, ObjectTooLargeError) {
+ applyTooLarge(ret)
+ logger.L().Debug("GuaranteedUpdate - too large object, skipping update", helpers.String("key", key))
+ } else {
+ logger.L().Debug("GuaranteedUpdate - processor.PreSave failed", helpers.Error(err), helpers.String("key", key))
+ return nil, err
+ }
+ }
+
+ if reflect.DeepEqual(orig, ret) {
+ logger.L().Debug("GuaranteedUpdate - tryUpdate returned the same object, no update needed", helpers.String("key", key))
+ return nil, nil
+ }
+
+ pw, err := s.stampAndEncode(key, ret, checksum)
+ if err != nil {
+ return nil, err
+ }
+ pw.expectRV = origState.rev
+ pw.expectUID = origState.uid
+ pw.insert = !origState.exists
+ return pw, nil
+}
+
+// GuaranteedUpdate implements storage.Interface (§3.4 Update): read → prepare →
+// one CAS transaction under the gate; on conflict, backoff, re-read, retry.
+func (s *ObjectStore) GuaranteedUpdate(ctx context.Context, key string, metaOut runtime.Object, ignoreNotFound bool,
+ preconditions *storage.Preconditions, tryUpdate storage.UpdateFunc, cachedExistingObject runtime.Object) error {
+ return s.guaranteedUpdate(ctx, key, metaOut, ignoreNotFound, preconditions, tryUpdate, cachedExistingObject, "", priorityHigh)
+}
+
+func (s *ObjectStore) guaranteedUpdate(ctx context.Context, key string, metaOut runtime.Object, ignoreNotFound bool,
+ preconditions *storage.Preconditions, tryUpdate storage.UpdateFunc, cachedExistingObject runtime.Object,
+ checksum string, priority writePriority) error {
+
+ v, err := conversion.EnforcePtr(metaOut)
+ if err != nil {
+ return fmt.Errorf("unable to convert output object to pointer: %v", err)
+ }
+ defer s.reservations.enter(ctx, key)()
+
+ conn, err := s.takeConn(ctx, "update", key)
+ if err != nil {
+ return err
+ }
+ defer s.pool.Put(conn)
+ h := &readHandle{store: s, conn: conn}
+
+ var origState *updateState
+ var origStateIsCurrent bool
+ if cachedExistingObject != nil {
+ rv, err := s.versioner.ObjectResourceVersion(cachedExistingObject)
+ if err != nil {
+ return fmt.Errorf("couldn't get resource version: %v", err)
+ }
+ origState = &updateState{obj: cachedExistingObject, rev: int64(rv), uid: string(cachedExistingObject.(metav1.Object).GetUID()), exists: rv != 0}
+ } else {
+ origState, err = s.readState(ctx, conn, key, ignoreNotFound, v)
+ if err != nil {
+ return err
+ }
+ origStateIsCurrent = true
+ }
+
+ if annotations := origState.obj.(metav1.Object).GetAnnotations(); annotations != nil && annotations[helpersv1.StatusMetadataKey] == helpersv1.TooLarge {
+ logger.L().Debug("GuaranteedUpdate - already too large object, skipping update", helpers.String("key", key))
+ v.Set(reflect.ValueOf(origState.obj).Elem())
+ return nil
+ }
+
+ var conflictAttempts int
+ for {
+ pw, err := s.prepareUpdate(ctx, h, key, ignoreNotFound, preconditions, tryUpdate, origState, origStateIsCurrent, checksum)
+ if errors.Is(err, errStaleState) {
+ origState, err = s.readState(ctx, conn, key, ignoreNotFound, v)
+ if err != nil {
+ return err
+ }
+ origStateIsCurrent = true
+ continue
+ }
+ if err != nil {
+ return err
+ }
+ if pw == nil {
+ v.Set(reflect.ValueOf(origState.obj).Elem())
+ return nil
+ }
+ if s.hooks.afterPrepare != nil {
+ s.hooks.afterPrepare(holdPathUpdate, key)
+ }
+
+ err = s.gate.run(ctx, priority, holdPathUpdate, ContainerProfileKindPlural, func(_ context.Context, conn *sqlite.Conn) error {
+ return s.execUpdate(conn, pw, s.stmtHook(conn, holdPathUpdate))
+ })
+ if errors.Is(err, errWriteConflict) {
+ conflictAttempts++
+ metrics.IncSingleWriterConflictRetry(resourceFromKey(key))
+ if backoffErr := singleWriterConflictBackoff(ctx, conflictAttempts); backoffErr != nil {
+ return newContentionTimeoutError("update", key, backoffErr)
+ }
+ origState, err = s.readState(ctx, conn, key, ignoreNotFound, v)
+ if err != nil {
+ return err
+ }
+ origStateIsCurrent = true
+ continue
+ }
+ if err != nil {
+ logger.L().Ctx(ctx).Error("GuaranteedUpdate - save object failed", helpers.Error(err), helpers.String("key", key))
+ return err
+ }
+ s.checkpointer.afterCommit()
+ fillOut(metaOut, pw.candidate)
+ s.watchDispatcher.Modified(key, pw.metaObj, pw.candidate)
+ return nil
+ }
+}
+
+// ---- Delete ----
+
+// Delete implements storage.Interface (§3.4 Delete): metadata + payloads +
+// time_series rows removed in one transaction; KeyNotFound when no row.
+func (s *ObjectStore) Delete(ctx context.Context, key string, metaOut runtime.Object, _ *storage.Preconditions, _ storage.ValidateObjectFunc, _ runtime.Object, _ storage.DeleteOptions) error {
+ return s.deleteKey(ctx, key, metaOut, nil, priorityHigh)
+}
+
+func (s *ObjectStore) deleteKey(ctx context.Context, key string, metaOut runtime.Object, expect *rowVersion, priority writePriority) error {
+ defer s.reservations.enter(ctx, key)()
+ var res deleteResult
+ err := s.gate.run(ctx, priority, holdPathDelete, ContainerProfileKindPlural, func(_ context.Context, conn *sqlite.Conn) error {
+ return s.execDelete(conn, key, expect, &res, s.stmtHook(conn, holdPathDelete))
+ })
+ if err != nil {
+ if !storage.IsNotFound(err) {
+ logger.L().Ctx(ctx).Error("Delete - delete failed", helpers.Error(err), helpers.String("key", key))
+ }
+ return err
+ }
+ s.checkpointer.afterCommit()
+ if metaOut != nil {
+ if err := json.Unmarshal(res.metadataJSON, metaOut); err != nil {
+ return fmt.Errorf("unmarshal deleted metadata: %w", err)
+ }
+ }
+ s.watchDispatcher.Deleted(key, metaOut)
+ return nil
+}
diff --git a/pkg/registry/file/sqliteobject_store_test.go b/pkg/registry/file/sqliteobject_store_test.go
new file mode 100644
index 000000000..ef8f56f59
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_store_test.go
@@ -0,0 +1,493 @@
+package file
+
+import (
+ "bytes"
+ "context"
+ "encoding/gob"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "testing"
+ "time"
+
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/apimachinery/pkg/util/uuid"
+ "k8s.io/apiserver/pkg/storage"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+func identityTryUpdate(input runtime.Object, _ storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ return input, nil, nil
+}
+
+func setLabel(k, v string) storage.UpdateFunc {
+ return func(input runtime.Object, _ storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ cp := input.(*softwarecomposition.ContainerProfile)
+ if cp.Labels == nil {
+ cp.Labels = map[string]string{}
+ }
+ cp.Labels[k] = v
+ return cp, nil, nil
+ }
+}
+
+func TestObjectStore_CreateGetUpdateDeleteRoundTrip(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ p := e.plain("rt")
+ p.TypeMeta = metav1.TypeMeta{APIVersion: StorageV1Beta1ApiVersion, Kind: "ContainerProfile"}
+ key := e.key("rt")
+
+ created := e.create(p)
+ assert.Equal(t, "1", created.ResourceVersion)
+ assert.NotEmpty(t, created.Annotations[helpersv1.SyncChecksumMetadataKey])
+ e.withConn(func(c *sqlite.Conn) { assertINV2(t, c, key) })
+
+ got := e.mustGet(key)
+ assert.Equal(t, created.ResourceVersion, got.ResourceVersion)
+ assert.Equal(t, created.UID, got.UID)
+ assert.Equal(t, p.TypeMeta, got.TypeMeta, "TypeMeta must survive the JSON body")
+ assert.Equal(t, canonicalCP(created).Spec, canonicalCP(got).Spec)
+ assert.Equal(t, created.Annotations, got.Annotations)
+
+ out := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, e.store.GuaranteedUpdate(e.ctx, key, out, false, nil, setLabel("a", "b"), nil))
+ assert.Equal(t, "2", out.ResourceVersion)
+ assert.Equal(t, "b", e.mustGet(key).Labels["a"])
+ e.withConn(func(c *sqlite.Conn) { assertINV2(t, c, key) })
+
+ // no-op update: no write, no RV bump (#315)
+ require.NoError(t, e.store.GuaranteedUpdate(e.ctx, key, out, false, nil, identityTryUpdate, nil))
+ assert.Equal(t, "2", out.ResourceVersion)
+ assert.Equal(t, "2", e.mustGet(key).ResourceVersion)
+
+ deleted := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, e.store.Delete(e.ctx, key, deleted, nil, nil, nil, storage.DeleteOptions{}))
+ assert.Equal(t, "2", deleted.ResourceVersion)
+ _, err := e.get(key)
+ assert.True(t, storage.IsNotFound(err), "%v", err)
+ row := e.inspect(key)
+ assert.False(t, row.metaExists)
+ assert.False(t, row.payloadExists)
+
+ err = e.store.Delete(e.ctx, key, &softwarecomposition.ContainerProfile{}, nil, nil, nil, storage.DeleteOptions{})
+ assert.True(t, storage.IsNotFound(err), "delete of an absent key: %v", err)
+}
+
+// TestObjectStore_CreateStates covers Create over (absent, present,
+// present-being-recreated): the second create of a key is KeyExists; a
+// delete+create restarts the key at rv=1 with a fresh UID.
+func TestObjectStore_CreateStates(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ key := e.key("cs")
+ first := e.create(e.plain("cs"))
+ assert.Equal(t, "1", first.ResourceVersion)
+
+ err := e.store.Create(e.ctx, key, e.plain("cs"), nil, 0)
+ assert.True(t, storage.IsExist(err), "%v", err)
+ assert.Equal(t, first.UID, e.mustGet(key).UID, "a refused create must not touch the row")
+
+ require.NoError(t, e.store.Delete(e.ctx, key, nil, nil, nil, nil, storage.DeleteOptions{}))
+ second := e.create(e.plain("cs"))
+ assert.Equal(t, "1", second.ResourceVersion)
+ assert.NotEqual(t, first.UID, second.UID)
+
+ err = e.store.Create(e.ctx, key, &softwarecomposition.ContainerProfile{ObjectMeta: metav1.ObjectMeta{Name: "cs", ResourceVersion: "7"}}, nil, 0)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "resourceVersion should not be set")
+}
+
+// TestObjectStore_CASMatrix drives the compare-and-swap statement directly over
+// (row state) × (expectation): the row is absent, present at (rv 1, uid A), or
+// recreated at (rv 1, uid B) after a delete; the expectation matches, is
+// stale, or names a different uid. Exactly the matching cases commit.
+func TestObjectStore_CASMatrix(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ uidA, uidB := "uid-a", "uid-b"
+ body, err := e.store.encodeBody(e.plain("cas"))
+ require.NoError(t, err)
+
+ type expectation struct {
+ name string
+ insert bool
+ expectRV int64
+ expectUID string
+ }
+ type rowState struct {
+ name string
+ seed func(conn *sqlite.Conn)
+ }
+ seedRow := func(uid string) func(conn *sqlite.Conn) {
+ return func(conn *sqlite.Conn) {
+ meta := fmt.Sprintf(`{"name":"cas","namespace":%q,"uid":%q,"resourceVersion":"1"}`, e.ns, uid)
+ require.NoError(t, sqlitex.Execute(conn, `INSERT INTO metadata (kind,namespace,name,metadata,rv,uid) VALUES ('containerprofile',?, 'cas', ?, 1, ?)`,
+ &sqlitex.ExecOptions{Args: []any{e.ns, meta, uid}}))
+ require.NoError(t, sqlitex.Execute(conn, `INSERT INTO payloads (kind,namespace,name,encoding,body) VALUES ('containerprofile',?, 'cas', ?, ?)`,
+ &sqlitex.ExecOptions{Args: []any{e.ns, PayloadEncodingJSONV1Beta1, body}}))
+ }
+ }
+ states := []rowState{
+ {"absent", func(*sqlite.Conn) {}},
+ {"present-rv1-uidA", seedRow(uidA)},
+ {"recreated-rv1-uidB", func(conn *sqlite.Conn) { seedRow(uidA)(conn); wipe(t, conn, e.ns, "cas"); seedRow(uidB)(conn) }},
+ }
+ expectations := []expectation{
+ {"insert-if-absent", true, 0, ""},
+ {"rv1-uidA", false, 1, uidA},
+ {"rv0-uidA (stale)", false, 0, uidA},
+ {"rv1-uidB", false, 1, uidB},
+ }
+ // which (state, expectation) pairs commit
+ commits := map[string]bool{
+ "absent/insert-if-absent": true,
+ "present-rv1-uidA/rv1-uidA": true,
+ "recreated-rv1-uidB/rv1-uidB": true,
+ }
+ for _, st := range states {
+ for _, ex := range expectations {
+ name := st.name + "/" + ex.name
+ t.Run(name, func(t *testing.T) {
+ e.withFixture(func(conn *sqlite.Conn) {
+ wipe(t, conn, e.ns, "cas")
+ st.seed(conn)
+ pw := &preparedWrite{key: e.key("cas"), kind: "containerprofile", namespace: e.ns, name: "cas",
+ metadataJSON: []byte(fmt.Sprintf(`{"name":"cas","namespace":%q,"uid":%q,"resourceVersion":"%d"}`, e.ns, "uid-new", ex.expectRV+1)),
+ body: body, rv: ex.expectRV + 1, uid: "uid-new", insert: ex.insert, expectRV: ex.expectRV, expectUID: ex.expectUID}
+ if ex.insert {
+ pw.rv, pw.uid = 1, "uid-new"
+ pw.metadataJSON = []byte(fmt.Sprintf(`{"name":"cas","namespace":%q,"uid":"uid-new","resourceVersion":"1"}`, e.ns))
+ }
+ endFn, err := sqlitex.ImmediateTransaction(conn)
+ require.NoError(t, err)
+ err = e.store.execUpdate(conn, pw, noHook)
+ endFn(&err)
+ if commits[name] {
+ require.NoError(t, err)
+ row := inspectRow(t, conn, e.key("cas"))
+ require.NotNil(t, row.rv)
+ assert.Equal(t, pw.rv, *row.rv)
+ assert.Equal(t, "uid-new", *row.uid)
+ } else {
+ assert.True(t, errors.Is(err, errWriteConflict), "expected conflict, got %v", err)
+ }
+ assertINV2(t, conn, e.key("cas"))
+ })
+ })
+ }
+ }
+}
+
+func wipe(t *testing.T, conn *sqlite.Conn, ns, name string) {
+ t.Helper()
+ require.NoError(t, sqlitex.Execute(conn, `DELETE FROM metadata WHERE kind='containerprofile' AND namespace=? AND name=?`, &sqlitex.ExecOptions{Args: []any{ns, name}}))
+ require.NoError(t, sqlitex.Execute(conn, `DELETE FROM payloads WHERE kind='containerprofile' AND namespace=? AND name=?`, &sqlitex.ExecOptions{Args: []any{ns, name}}))
+}
+
+// TestObjectStore_TSAdmissionInsideTransaction: the base's four states. The
+// authoritative check is the in-transaction SELECT, so the base is flipped
+// AFTER the prepare phase's PreSave passed (design "gap 1") and the Create
+// must still fail — with no TS object and no time_series row left behind.
+func TestObjectStore_TSAdmissionInsideTransaction(t *testing.T) {
+ cases := []struct {
+ name string
+ baseStatus string
+ baseComplete string
+ incoming string
+ wantErr error
+ wantAdmitted bool
+ }{
+ {"absent", "", "", helpersv1.Partial, nil, true},
+ {"learning", helpersv1.Learning, helpersv1.Partial, helpersv1.Partial, nil, true},
+ {"completed-partial+full-incoming", helpersv1.Completed, helpersv1.Partial, helpersv1.Full, nil, true},
+ {"completed-partial+partial-incoming", helpersv1.Completed, helpersv1.Partial, helpersv1.Partial, ObjectCompletedError, false},
+ {"completed-full", helpersv1.Completed, helpersv1.Full, helpersv1.Full, ObjectCompletedError, false},
+ {"too-large", helpersv1.TooLarge, helpersv1.Partial, helpersv1.Partial, ObjectTooLargeError, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ if tc.baseStatus != "" {
+ // Flip the base between prepare and commit: PreSave sees no base
+ // (or a Learning one) and admits; the transaction must not.
+ e.store.hooks.afterPrepare = func(path, key string) {
+ if path != holdPathCreate {
+ return
+ }
+ e.store.hooks.afterPrepare = nil
+ base := e.plain(e.baseNm)
+ base.Annotations[helpersv1.StatusMetadataKey] = tc.baseStatus
+ base.Annotations[helpersv1.CompletionMetadataKey] = tc.baseComplete
+ e.create(base)
+ }
+ }
+ ts := e.ts("r1", 1, helpersv1.Learning, tc.incoming)
+ err := e.store.Create(e.ctx, e.tsKey("r1"), ts, nil, 0)
+ row := e.inspect(e.tsKey("r1"))
+ base := e.inspect(e.baseKey)
+ if tc.wantAdmitted {
+ require.NoError(t, err)
+ assert.True(t, row.metaExists && row.payloadExists)
+ assert.Equal(t, 1, base.tsRows, "the time_series row joins the create")
+ } else {
+ require.ErrorIs(t, err, tc.wantErr)
+ assert.False(t, row.metaExists, "refused create must leave no metadata row")
+ assert.False(t, row.payloadExists, "refused create must leave no payload")
+ assert.Equal(t, 0, base.tsRows, "refused create must leave no time_series row")
+ }
+ })
+ }
+}
+
+// TestObjectStore_K6_PayloadRowMissingFailsLoudly: a metadata row without its
+// payloads row (INV-2 already violated by something else) makes the payloads
+// UPDATE change 0 rows; the transaction rolls back and the caller gets an
+// InternalError, instead of a metadata row silently advancing alone.
+func TestObjectStore_K6_PayloadRowMissingFailsLoudly(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ key := e.key("k6")
+ e.create(e.plain("k6"))
+ e.withFixture(func(conn *sqlite.Conn) {
+ require.NoError(t, sqlitex.Execute(conn, `DELETE FROM payloads WHERE name='k6'`, nil))
+ })
+ // The read needs a payload; feed the update the cached object so the
+ // prepare phase does not fail first.
+ cached := e.plain("k6")
+ cached.ResourceVersion = "1"
+ cached.UID = types.UID(e.inspect(key).uidOrEmpty())
+ err := e.store.GuaranteedUpdate(e.ctx, key, &softwarecomposition.ContainerProfile{}, false, nil, setLabel("x", "y"), cached)
+ require.Error(t, err)
+ assert.True(t, apierrors.IsInternalError(err), "%v", err)
+ assert.Contains(t, err.Error(), "INV-2")
+ row := e.inspect(key)
+ require.NotNil(t, row.rv)
+ assert.Equal(t, int64(1), *row.rv, "metadata UPDATE must have been rolled back")
+ assert.True(t, e.store.gate.conn.AutocommitEnabled(), "gate connection left in a transaction")
+}
+
+func (r dbRow) uidOrEmpty() string {
+ if r.uid == nil {
+ return ""
+ }
+ return *r.uid
+}
+
+// TestObjectStore_K3_AutocheckpointOffOnEveryConnection: wal_autocheckpoint is
+// a per-connection setting; PoolOptions.DisableAutoCheckpoint must reach every
+// connection of the pool, not only the gate's.
+func TestObjectStore_K3_AutocheckpointOffOnEveryConnection(t *testing.T) {
+ check := func(t *testing.T, disable bool, want int64) {
+ pool := NewPoolWithOptions(t.TempDir()+"/k3.sq3", PoolOptions{Size: 4, DisableAutoCheckpoint: disable})
+ var conns []*sqlite.Conn
+ defer func() {
+ for _, c := range conns {
+ pool.Put(c)
+ }
+ _ = pool.Close()
+ }()
+ for i := 0; i < 4; i++ {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ c, err := pool.Take(ctx)
+ require.NoError(t, err)
+ c.SetInterrupt(nil)
+ cancel()
+ conns = append(conns, c)
+ var v int64 = -1
+ require.NoError(t, sqlitex.ExecuteTransient(c, `PRAGMA wal_autocheckpoint`, &sqlitex.ExecOptions{ResultFunc: func(stmt *sqlite.Stmt) error { v = stmt.ColumnInt64(0); return nil }}))
+ assert.Equal(t, want, v, "connection %d", i)
+ }
+ }
+ t.Run("disabled", func(t *testing.T) { check(t, true, 0) })
+ t.Run("default", func(t *testing.T) { check(t, false, 1000) })
+}
+
+// TestObjectStore_K5_CloseReturnsGateConnection: the gate's connection is back
+// in the pool after Close, so every one of the pool's connections can be taken
+// and Pool.Close does not block. (The env's cleanup asserts Close returns.)
+func TestObjectStore_K5_CloseReturnsGateConnection(t *testing.T) {
+ e := newObjectStoreEnv(t, withPoolSize(3))
+ countTakeable := func() int {
+ var conns []*sqlite.Conn
+ defer func() {
+ for _, c := range conns {
+ e.pool.Put(c)
+ }
+ }()
+ for len(conns) < 10 {
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ c, err := e.pool.Take(ctx)
+ cancel()
+ if err != nil {
+ break
+ }
+ conns = append(conns, c)
+ }
+ return len(conns)
+ }
+ assert.Equal(t, 2, countTakeable(), "one of three connections is the gate's while the gate is open")
+ // The shared gate is closed by its owner after every store's Close (the
+ // apiserver's pre-shutdown hook); the store's own Close returns nothing.
+ require.NoError(t, e.store.Close())
+ assert.Equal(t, 2, countTakeable(), "the store does not own the gate's connection")
+ require.NoError(t, e.gate.Close())
+ assert.Equal(t, 3, countTakeable(), "the gate's Close must return its connection")
+ require.NoError(t, e.store.Close(), "Close is idempotent")
+ require.NoError(t, e.gate.Close(), "Close is idempotent")
+ err := e.store.Create(e.ctx, e.key("after-close"), e.plain("after-close"), nil, 0)
+ assert.ErrorIs(t, err, errGateClosed)
+}
+
+// TestObjectStore_CodecFidelity round-trips a user-authored multi-container
+// profile (the subtype-groups regression) and pins the ONE fidelity delta the
+// design lists: creationTimestamp is truncated to whole seconds.
+func TestObjectStore_CodecFidelity(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ p := groupedUserCP(e.ns, "grouped")
+ p.UID = uuid.NewUUID()
+ stamp := metav1.NewTime(time.Date(2026, 9, 8, 12, 0, 0, 123456789, time.UTC))
+ p.CreationTimestamp = stamp
+ key := e.key("grouped")
+ created := e.create(p)
+ got := e.mustGet(key)
+
+ assert.Equal(t, created.Spec.Containers, got.Spec.Containers)
+ assert.Equal(t, created.Spec.InitContainers, got.Spec.InitContainers)
+ assert.Equal(t, created.Spec.EphemeralContainers, got.Spec.EphemeralContainers)
+ assert.True(t, got.CreationTimestamp.Time.Equal(stamp.Truncate(time.Second)),
+ "creationTimestamp truncated to seconds (intended divergence): got %s", got.CreationTimestamp.Time)
+ assert.False(t, got.CreationTimestamp.Time.Equal(stamp.Time), "the sub-second part is gone")
+ // What the legacy store's GET returns is the gob round trip of the same
+ // persisted object (empty collections come back nil on both codecs).
+ var buf bytes.Buffer
+ require.NoError(t, gob.NewEncoder(&buf).Encode(created))
+ legacyView := &softwarecomposition.ContainerProfile{}
+ require.NoError(t, gob.NewDecoder(&buf).Decode(legacyView))
+ assert.True(t, legacyView.CreationTimestamp.Time.Equal(stamp.Time), "gob keeps the nanoseconds")
+ // Second codec delta, NOT in the design's list (reported): v1beta1 spec
+ // collections without omitempty come back as empty slices from JSON and
+ // as nil from gob (REST renders them as [] vs null).
+ assert.Nil(t, legacyView.Spec.Execs, "gob: empty Execs decodes to nil")
+ assert.NotNil(t, got.Spec.Execs, "json: empty Execs decodes to an empty slice")
+ assert.Empty(t, got.Spec.Execs)
+ assert.Equal(t, canonicalCP(legacyView), canonicalCP(got), "no field beyond creationTimestamp precision and nil-vs-empty collections may differ from the legacy GET")
+
+ // v1beta1 round trip of the body itself
+ var v1 map[string]any
+ e.withConn(func(conn *sqlite.Conn) {
+ require.NoError(t, sqlitex.Execute(conn, `SELECT body, encoding FROM payloads WHERE name='grouped'`, &sqlitex.ExecOptions{ResultFunc: func(stmt *sqlite.Stmt) error {
+ assert.Equal(t, PayloadEncodingJSONV1Beta1, stmt.ColumnText(1))
+ return json.Unmarshal([]byte(stmt.ColumnText(0)), &v1)
+ }}))
+ })
+ assert.NotNil(t, v1["spec"], "body is the v1beta1 wire form (lower-case json tags)")
+}
+
+// TestObjectStore_ListPagination: metadata LIST is the legacy statement; the
+// fullSpec LIST is a paginated join; an UPDATE mid-list keeps the rowid so a
+// paginating client sees each object exactly once.
+func TestObjectStore_ListPagination(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ for _, n := range []string{"a", "b", "c"} {
+ e.create(e.plain("pg-" + n))
+ }
+ listKey := testCPPrefix + e.ns
+ page := func(rv, cont string, limit int64) *softwarecomposition.ContainerProfileList {
+ out := &softwarecomposition.ContainerProfileList{}
+ opts := storage.ListOptions{ResourceVersion: rv, Predicate: storage.SelectionPredicate{Limit: limit, Continue: cont}, Recursive: true}
+ require.NoError(t, e.store.GetList(e.ctx, listKey, opts, out))
+ return out
+ }
+ names := func(l *softwarecomposition.ContainerProfileList) []string {
+ var out []string
+ for _, it := range l.Items {
+ out = append(out, it.Name)
+ }
+ return out
+ }
+ for _, rv := range []string{softwarecomposition.ResourceVersionMetadata, softwarecomposition.ResourceVersionFullSpec} {
+ p1 := page(rv, "", 2)
+ assert.Equal(t, []string{"pg-a", "pg-b"}, names(p1), rv)
+ require.NotEmpty(t, p1.Continue)
+ require.NoError(t, e.store.GuaranteedUpdate(e.ctx, e.key("pg-a"), &softwarecomposition.ContainerProfile{}, false, nil, setLabel("k", rv), nil))
+ p2 := page(rv, p1.Continue, 2)
+ assert.Equal(t, []string{"pg-c"}, names(p2), "%s: updated object must not reappear (rowid stable)", rv)
+ assert.Empty(t, p2.Continue)
+ }
+ full := page(softwarecomposition.ResourceVersionFullSpec, "", 0)
+ require.Len(t, full.Items, 3)
+ assert.NotEmpty(t, full.Items[0].Spec.Architectures, "fullSpec list carries the body")
+ meta := page(softwarecomposition.ResourceVersionMetadata, "", 0)
+ assert.Empty(t, meta.Items[0].Spec.Architectures, "metadata list carries no body")
+}
+
+// TestObjectStore_ListExcludesUnconsolidatedTSRows: an un-consolidated TS
+// profile create writes a real metadata+payloads row (see the ObjectStore
+// file comment), one per report, alongside its time_series bookkeeping row.
+// Those rows are internal to consolidation and must never surface through
+// the k8s List API -- a client that never asked about time-series internals
+// should see exactly the consolidated/base objects. This also is the fix for
+// Tier B's list-p95-ms regression (§ keyReserveWaitMax tuning history): more
+// writers proceeding unreserved past an in-flight consolidation retry left
+// more un-merged TS rows sitting in `metadata` before the tick could clear
+// them, and List had no way to skip them -- it decoded every one.
+func TestObjectStore_ListExcludesUnconsolidatedTSRows(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ base := e.create(e.plain("plain-object"))
+ for i := 1; i <= 3; i++ {
+ e.create(e.ts(fmt.Sprintf("s%d", i), i, helpersv1.Learning, helpersv1.Partial))
+ }
+ listKey := testCPPrefix + e.ns
+ for _, rv := range []string{softwarecomposition.ResourceVersionMetadata, softwarecomposition.ResourceVersionFullSpec} {
+ out := &softwarecomposition.ContainerProfileList{}
+ opts := storage.ListOptions{ResourceVersion: rv, Predicate: storage.SelectionPredicate{Limit: 0}, Recursive: true}
+ require.NoError(t, e.store.GetList(e.ctx, listKey, opts, out))
+ var names []string
+ for _, it := range out.Items {
+ names = append(names, it.Name)
+ }
+ assert.Equal(t, []string{base.Name}, names, "%s: only the consolidated/base object is listed, not the pending TS rows", rv)
+ }
+ // The TS rows are real rows, not silently dropped -- ConsolidateTimeSeries
+ // still finds and merges them normally into their own series (e.baseKey,
+ // unrelated to the plain object above); List's exclusion is read-side only.
+ e.tick()
+ assert.Equal(t, 0, e.pendingTSRows(e.baseKey), "consolidation still sees and merges the excluded TS rows")
+ assert.Equal(t, helpersv1.Learning, e.mustGet(e.baseKey).Annotations[helpersv1.StatusMetadataKey])
+}
+
+// TestObjectStore_ListFullSpec_MissingPayloadRowDoesNotHideLaterRows: a
+// metadata row with no payloads row (e.g. left by a migration path for an
+// undecodable legacy file) sorts before two valid rows. fetchListPage's
+// fullSpec query paginates metadata by rowid, LIMIT :limit, THEN inner-joins
+// payloads (PM-1: apply the predicate before the join) -- a page whose
+// metadata scan includes the missing-payload row used to return fewer
+// objects than :limit purely because the join dropped one, and GetList's
+// "count < remaining -> EOF" check took that as real end-of-data, clearing
+// the continuation token and silently hiding every row after the gap
+// forever (not just on this call: a fresh List from cursor "" hits the same
+// row first every time).
+func TestObjectStore_ListFullSpec_MissingPayloadRowDoesNotHideLaterRows(t *testing.T) {
+ e := newObjectStoreEnv(t)
+ e.withFixture(func(conn *sqlite.Conn) {
+ require.NoError(t, WriteJSON(conn, e.key("no-payload"), []byte(`{"name":"no-payload","namespace":"`+e.ns+`"}`)))
+ })
+ e.create(e.plain("valid-a"))
+ e.create(e.plain("valid-b"))
+
+ listKey := testCPPrefix + e.ns
+ out := &softwarecomposition.ContainerProfileList{}
+ opts := storage.ListOptions{ResourceVersion: softwarecomposition.ResourceVersionFullSpec, Predicate: storage.SelectionPredicate{Limit: 2}, Recursive: true}
+ require.NoError(t, e.store.GetList(e.ctx, listKey, opts, out))
+ var names []string
+ for _, it := range out.Items {
+ names = append(names, it.Name)
+ }
+ require.ElementsMatch(t, []string{"valid-a", "valid-b"}, names,
+ "both valid rows must be reachable; before the fix, valid-b was permanently hidden by the gap")
+}
diff --git a/pkg/registry/file/sqliteobject_testenv_test.go b/pkg/registry/file/sqliteobject_testenv_test.go
new file mode 100644
index 000000000..bdf2c9222
--- /dev/null
+++ b/pkg/registry/file/sqliteobject_testenv_test.go
@@ -0,0 +1,459 @@
+package file
+
+// Shared test environment for the ObjectStore (SQLite-native ContainerProfile
+// backend) tests: one pool, the ObjectStore, a legacy StorageImpl over the SAME
+// pool carrying the kind-ownership guard (the production wiring under
+// config.ContainerProfileSqliteBackend), a real ContainerProfileProcessor, and
+// a per-connection statement authorizer that records every table an
+// operation touched — the instrument INV-1 and INV-4 assert on.
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "reflect"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition/install"
+ "github.com/kubescape/storage/pkg/config"
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/util/uuid"
+ "k8s.io/apiserver/pkg/storage"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitemigration"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+const testCPPrefix = "/spdx.softwarecomposition.kubescape.io/containerprofile/"
+
+// tableAction is one authorizer observation: an SQLite action type on a table
+// (SQLITE_READ / INSERT / UPDATE / DELETE), recorded per connection.
+type tableAction struct {
+ op sqlite.OpType
+ table string
+ conn *sqlite.Conn
+ // txn is set for BEGIN/COMMIT/ROLLBACK observations (Operation()).
+ txn string
+}
+
+// tableRecorder is installed through PoolOptions.Authorizer on every
+// connection; it records every table-touching action from connection
+// creation on and is never windowed off: a statement first prepared outside
+// a recording window re-executes later through the connection's statement
+// cache with no authorizer call, so a windowed recorder would miss exactly
+// the statements prepared during setup. Tests take a mark and read what came
+// after it. Safe from any goroutine.
+type tableRecorder struct {
+ mu sync.Mutex
+ actions []tableAction
+}
+
+func (r *tableRecorder) authorizer(conn *sqlite.Conn) sqlite.Authorizer {
+ return sqlite.AuthorizeFunc(func(a sqlite.Action) sqlite.AuthResult {
+ switch a.Type() {
+ case sqlite.OpRead, sqlite.OpInsert, sqlite.OpUpdate, sqlite.OpDelete:
+ if t := a.Table(); t != "" {
+ r.mu.Lock()
+ r.actions = append(r.actions, tableAction{op: a.Type(), table: t, conn: conn})
+ r.mu.Unlock()
+ }
+ case sqlite.OpTransaction:
+ r.mu.Lock()
+ r.actions = append(r.actions, tableAction{op: a.Type(), conn: conn, txn: a.Operation()})
+ r.mu.Unlock()
+ }
+ return sqlite.AuthResultOK
+ })
+}
+
+// mark returns the current position; since returns everything recorded
+// after a mark.
+func (r *tableRecorder) mark() int {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return len(r.actions)
+}
+
+func (r *tableRecorder) since(mark int) []tableAction {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return append([]tableAction(nil), r.actions[mark:]...)
+}
+
+func isWriteOp(op sqlite.OpType) bool {
+ return op == sqlite.OpInsert || op == sqlite.OpUpdate || op == sqlite.OpDelete
+}
+
+type objectStoreEnv struct {
+ t *testing.T
+ ctx context.Context
+ dir string
+ dbPath string
+ pool *sqlitemigration.Pool
+ gate *writeGate
+ store *ObjectStore
+ cp *objectStoreCPStorage
+ legacy *StorageImpl
+ legacyFs afero.Fs
+ processor *ContainerProfileProcessor
+ wd *WatchDispatcher
+ scheme *runtime.Scheme
+ rec *tableRecorder
+ // fixture is the non-pool handle tests seed state through (CR-2b).
+ fixture *sqlite.Conn
+ tpl softwarecomposition.ContainerProfile
+ baseNm string
+ ns string
+ baseKey string
+ now time.Time
+}
+
+type envOption func(*envConfig)
+
+type envConfig struct {
+ poolSize int
+ checkpointBytes int64
+ checkpointEvery time.Duration
+ autoCheckpoint bool
+}
+
+func withPoolSize(n int) envOption { return func(c *envConfig) { c.poolSize = n } }
+func withCheckpoint(bytes int64, every time.Duration) envOption {
+ return func(c *envConfig) { c.checkpointBytes = bytes; c.checkpointEvery = every }
+}
+
+// newObjectStoreEnv builds the environment. singleWriterEnabled stays in its
+// default (true) position: the legacy instance is the flag-default reference.
+func newObjectStoreEnv(t *testing.T, opts ...envOption) *objectStoreEnv {
+ t.Helper()
+ cfg := envConfig{poolSize: DefaultPoolSize, checkpointEvery: time.Hour}
+ for _, o := range opts {
+ o(&cfg)
+ }
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "metadata.sq3")
+ rec := &tableRecorder{}
+ pool := NewPoolWithOptions(dbPath, PoolOptions{
+ Size: cfg.poolSize,
+ BusyTimeout: 5 * time.Second,
+ DisableAutoCheckpoint: !cfg.autoCheckpoint,
+ Authorizer: rec.authorizer,
+ })
+ // AC-G1, registered first so it runs after the store and pool closed.
+ armUngatedWriteCheck(t, pool)
+ sch := runtime.NewScheme()
+ install.Install(sch)
+ wd := NewWatchDispatcher()
+
+ legacyFs := afero.NewMemMapFs()
+ legacy := NewStorageImpl(legacyFs, DefaultStorageRoot, pool, wd, sch).(*StorageImpl)
+ legacy.SetForeignKinds(IsContainerProfileKind)
+
+ processor := NewContainerProfileProcessor(config.Config{DefaultNamespace: "kubescape", MaxContainerProfileSize: 40000}, nil)
+ processor.Interval = 0
+ processor.Workers = 1
+ processor.DeleteThreshold = 24 * time.Hour
+
+ gateCtx, gateCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer gateCancel()
+ gate, err := newWriteGate(gateCtx, pool)
+ require.NoError(t, err)
+ legacy.SetWriteGate(gate)
+ store, err := NewObjectStore(pool, dbPath, wd, sch, processor, legacy, gate, ObjectStoreOptions{
+ CheckpointThresholdBytes: cfg.checkpointBytes,
+ CheckpointInterval: cfg.checkpointEvery,
+ })
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ require.NoError(t, store.Close())
+ require.NoError(t, gate.Close())
+ closed := make(chan error, 1)
+ go func() { closed <- pool.Close() }()
+ select {
+ case err := <-closed:
+ require.NoError(t, err)
+ case <-time.After(10 * time.Second):
+ t.Errorf("pool.Close did not return: a connection was never returned (K-5)")
+ }
+ })
+
+ content, err := os.ReadFile("testdata/p1.json")
+ require.NoError(t, err)
+ var tpl softwarecomposition.ContainerProfile
+ require.NoError(t, json.Unmarshal(content, &tpl))
+ baseNm, _ := SplitProfileName(tpl.Name)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ t.Cleanup(cancel)
+ return &objectStoreEnv{
+ t: t, ctx: ctx, dir: dir, dbPath: dbPath, pool: pool, gate: gate, store: store,
+ cp: processor.ContainerProfileStorage.(*objectStoreCPStorage),
+ legacy: legacy, legacyFs: legacyFs, processor: processor, wd: wd, scheme: sch, rec: rec,
+ fixture: openFixtureConn(t, pool, dbPath, 5*time.Second),
+ tpl: tpl, baseNm: baseNm, ns: tpl.Namespace,
+ baseKey: testCPPrefix + tpl.Namespace + "/" + baseNm,
+ now: time.Now().Round(0),
+ }
+}
+
+func (e *objectStoreEnv) tsKey(suffix string) string { return e.baseKey + "-" + suffix }
+
+func (e *objectStoreEnv) reportTimestamp(n int) string {
+ return e.now.Add(time.Duration(n-10) * time.Minute).String()
+}
+
+// ts clones the template as report n of its series under suffix, chained to
+// report n-1 so consolidation sees one continuous series.
+func (e *objectStoreEnv) ts(suffix string, n int, status, completion string) *softwarecomposition.ContainerProfile {
+ p := e.tpl.DeepCopy()
+ p.Name = e.baseNm + "-" + suffix
+ p.ResourceVersion = ""
+ p.UID = uuid.NewUUID()
+ prev := "0001-01-01 00:00:00 +0000 UTC"
+ if n > 1 {
+ prev = e.reportTimestamp(n - 1)
+ }
+ p.Annotations[helpersv1.ReportTimestampMetadataKey] = e.reportTimestamp(n)
+ p.Annotations[helpersv1.PreviousReportTimestampMetadataKey] = prev
+ p.Annotations[helpersv1.StatusMetadataKey] = status
+ p.Annotations[helpersv1.CompletionMetadataKey] = completion
+ return p
+}
+
+// plain returns a non-TS profile named name in the template's namespace.
+func (e *objectStoreEnv) plain(name string) *softwarecomposition.ContainerProfile {
+ p := e.tpl.DeepCopy()
+ p.Name = name
+ p.ResourceVersion = ""
+ p.UID = uuid.NewUUID()
+ delete(p.Annotations, helpersv1.ReportSeriesIdMetadataKey)
+ return p
+}
+
+func (e *objectStoreEnv) key(name string) string { return testCPPrefix + e.ns + "/" + name }
+
+func (e *objectStoreEnv) create(p *softwarecomposition.ContainerProfile) *softwarecomposition.ContainerProfile {
+ e.t.Helper()
+ out := &softwarecomposition.ContainerProfile{}
+ require.NoError(e.t, e.store.Create(e.ctx, e.key(p.Name), p, out, 0))
+ return out
+}
+
+func (e *objectStoreEnv) get(key string) (*softwarecomposition.ContainerProfile, error) {
+ out := &softwarecomposition.ContainerProfile{}
+ err := e.store.Get(e.ctx, key, storage.GetOptions{}, out)
+ return out, err
+}
+
+func (e *objectStoreEnv) mustGet(key string) *softwarecomposition.ContainerProfile {
+ e.t.Helper()
+ out, err := e.get(key)
+ require.NoError(e.t, err)
+ return out
+}
+
+func (e *objectStoreEnv) tick() {
+ e.t.Helper()
+ require.NoError(e.t, e.processor.ConsolidateTimeSeries(e.ctx))
+}
+
+// withConn runs fn on a pool connection: reads only. A write here would be
+// an ungated write under AC-G1; seed state through withFixture instead.
+func (e *objectStoreEnv) withConn(fn func(conn *sqlite.Conn)) {
+ e.t.Helper()
+ conn, err := e.pool.Take(e.ctx)
+ require.NoError(e.t, err)
+ defer e.pool.Put(conn)
+ fn(conn)
+}
+
+// withFixture runs fn on the non-pool fixture handle (CR-2b).
+func (e *objectStoreEnv) withFixture(fn func(conn *sqlite.Conn)) {
+ e.t.Helper()
+ fn(e.fixture)
+}
+
+// dbRow is what the tables hold for one key, as seen from a fresh connection.
+type dbRow struct {
+ metaExists bool
+ payloadExists bool
+ rv *int64
+ uid *string
+ jsonRV string
+ jsonUID string
+ tsRows int
+}
+
+// inspect reads the INV-2 view of key from its own connection (a snapshot no
+// in-flight transaction can influence).
+func (e *objectStoreEnv) inspect(key string) dbRow {
+ e.t.Helper()
+ var row dbRow
+ e.withConn(func(conn *sqlite.Conn) {
+ row = inspectRow(e.t, conn, key)
+ })
+ return row
+}
+
+func inspectRow(t *testing.T, conn *sqlite.Conn, key string) dbRow {
+ t.Helper()
+ _, _, kind, _, ns, name := K8sPathToKeys(key)
+ var row dbRow
+ require.NoError(t, sqlitex.Execute(conn,
+ `SELECT rv, uid, json_extract(CAST(metadata AS TEXT), '$.resourceVersion'), json_extract(CAST(metadata AS TEXT), '$.uid')
+ FROM metadata WHERE kind = ? AND namespace = ? AND name = ?`,
+ &sqlitex.ExecOptions{Args: []any{kind, ns, name}, ResultFunc: func(stmt *sqlite.Stmt) error {
+ row.metaExists = true
+ if stmt.ColumnType(0) != sqlite.TypeNull {
+ v := stmt.ColumnInt64(0)
+ row.rv = &v
+ }
+ if stmt.ColumnType(1) != sqlite.TypeNull {
+ v := stmt.ColumnText(1)
+ row.uid = &v
+ }
+ row.jsonRV = stmt.ColumnText(2)
+ row.jsonUID = stmt.ColumnText(3)
+ return nil
+ }}))
+ require.NoError(t, sqlitex.Execute(conn,
+ `SELECT 1 FROM payloads WHERE kind = ? AND namespace = ? AND name = ?`,
+ &sqlitex.ExecOptions{Args: []any{kind, ns, name}, ResultFunc: func(*sqlite.Stmt) error { row.payloadExists = true; return nil }}))
+ require.NoError(t, sqlitex.Execute(conn,
+ `SELECT count(*) FROM time_series WHERE kind = ? AND namespace = ? AND name = ?`,
+ &sqlitex.ExecOptions{Args: []any{NormalizeContainerProfileKind(kind), ns, name}, ResultFunc: func(stmt *sqlite.Stmt) error { row.tsRows = int(stmt.ColumnInt64(0)); return nil }}))
+ return row
+}
+
+// assertINV2 asserts the design's INV-2 for key on a fresh connection:
+// metadata row ⇔ payloads row; rv == json resourceVersion; uid == json uid.
+func assertINV2(t *testing.T, conn *sqlite.Conn, key string) {
+ t.Helper()
+ row := inspectRow(t, conn, key)
+ require.Equal(t, row.metaExists, row.payloadExists, "INV-2: metadata row (%v) must exist iff payloads row (%v) for %s", row.metaExists, row.payloadExists, key)
+ if !row.metaExists {
+ return
+ }
+ require.NotNil(t, row.rv, "INV-2: rv column NULL for %s", key)
+ require.NotNil(t, row.uid, "INV-2: uid column NULL for %s", key)
+ require.Equal(t, strings.TrimSpace(row.jsonRV), strconv.FormatInt(*row.rv, 10), "INV-2: rv column != json resourceVersion for %s", key)
+ require.Equal(t, row.jsonUID, *row.uid, "INV-2: uid column != json uid for %s", key)
+}
+
+// allCPKeys lists every containerprofile key present in metadata or payloads.
+func allCPKeys(t *testing.T, conn *sqlite.Conn) []string {
+ t.Helper()
+ seen := map[string]bool{}
+ var keys []string
+ add := func(stmt *sqlite.Stmt) error {
+ k := testCPPrefix + stmt.ColumnText(0) + "/" + stmt.ColumnText(1)
+ if !seen[k] {
+ seen[k] = true
+ keys = append(keys, k)
+ }
+ return nil
+ }
+ require.NoError(t, sqlitex.Execute(conn, `SELECT namespace, name FROM metadata WHERE kind = 'containerprofile'`, &sqlitex.ExecOptions{ResultFunc: add}))
+ require.NoError(t, sqlitex.Execute(conn, `SELECT namespace, name FROM payloads WHERE kind = 'containerprofile'`, &sqlitex.ExecOptions{ResultFunc: add}))
+ return keys
+}
+
+// canonicalCP returns a copy of cp normalised for cross-codec comparison: the
+// two documented codec deltas are removed (creationTimestamp truncated to
+// seconds in UTC; empty collections nilified) plus raw header JSON compacted
+// (gob keeps the bytes verbatim, encoding/json compacts RawMessage on output).
+// Everything else must match exactly.
+func canonicalCP(cp *softwarecomposition.ContainerProfile) *softwarecomposition.ContainerProfile {
+ out := cp.DeepCopy()
+ if !out.CreationTimestamp.IsZero() {
+ out.CreationTimestamp = metav1.NewTime(out.CreationTimestamp.Time.Truncate(time.Second).UTC())
+ }
+ // Header values are emitted by the endpoint analyzer in map-iteration
+ // order (nondeterministic on either backend), so canonicalise them: parse,
+ // sort each header's values, re-marshal (sorted keys, compact).
+ compactHeaders := func(eps []softwarecomposition.HTTPEndpoint) {
+ for i := range eps {
+ if len(eps[i].Headers) == 0 {
+ continue
+ }
+ var hdr map[string][]string
+ if err := json.Unmarshal(eps[i].Headers, &hdr); err != nil {
+ var buf bytes.Buffer
+ if err := json.Compact(&buf, eps[i].Headers); err == nil {
+ eps[i].Headers = buf.Bytes()
+ }
+ continue
+ }
+ for k := range hdr {
+ sort.Strings(hdr[k])
+ }
+ if b, err := json.Marshal(hdr); err == nil {
+ eps[i].Headers = b
+ }
+ }
+ }
+ compactHeaders(out.Spec.Endpoints)
+ for i := range out.Spec.Containers {
+ compactHeaders(out.Spec.Containers[i].Endpoints)
+ }
+ for i := range out.Spec.InitContainers {
+ compactHeaders(out.Spec.InitContainers[i].Endpoints)
+ }
+ for i := range out.Spec.EphemeralContainers {
+ compactHeaders(out.Spec.EphemeralContainers[i].Endpoints)
+ }
+ nilifyEmpty(reflect.ValueOf(out).Elem())
+ return out
+}
+
+// nilifyEmpty sets every zero-length slice or map reachable from v to nil.
+func nilifyEmpty(v reflect.Value) {
+ switch v.Kind() {
+ case reflect.Pointer, reflect.Interface:
+ if !v.IsNil() {
+ nilifyEmpty(v.Elem())
+ }
+ case reflect.Struct:
+ for i := 0; i < v.NumField(); i++ {
+ if v.Field(i).CanSet() {
+ nilifyEmpty(v.Field(i))
+ }
+ }
+ case reflect.Slice:
+ if v.Len() == 0 {
+ if v.CanSet() {
+ v.Set(reflect.Zero(v.Type()))
+ }
+ return
+ }
+ for i := 0; i < v.Len(); i++ {
+ nilifyEmpty(v.Index(i))
+ }
+ case reflect.Map:
+ if v.Len() == 0 && v.CanSet() {
+ v.Set(reflect.Zero(v.Type()))
+ return
+ }
+ for _, k := range v.MapKeys() {
+ mv := v.MapIndex(k)
+ if mv.Kind() == reflect.Struct || mv.Kind() == reflect.Pointer {
+ cp := reflect.New(mv.Type()).Elem()
+ cp.Set(mv)
+ nilifyEmpty(cp)
+ v.SetMapIndex(k, cp)
+ }
+ }
+ }
+}
diff --git a/pkg/registry/file/storage.go b/pkg/registry/file/storage.go
index 4caee51f9..0d5ecf8a7 100644
--- a/pkg/registry/file/storage.go
+++ b/pkg/registry/file/storage.go
@@ -12,6 +12,7 @@ import (
"os/exec"
"path/filepath"
"reflect"
+ "strconv"
"strings"
"sync"
"time"
@@ -32,6 +33,7 @@ import (
"k8s.io/apimachinery/pkg/conversion"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/apiserver/pkg/storage"
"zombiezen.com/go/sqlite"
@@ -56,8 +58,47 @@ var (
// base ContainerProfile is already Completed/Full: nothing updates such a
// profile (softwarecomposition.IsCompletedFull).
ErrProfileFrozen = errors.New("profile is completed/full and cannot be updated")
+ // ErrGateRequiresSingleWriter is returned by Create/GuaranteedUpdate on a
+ // StorageImpl that shares the write gate while singleWriterEnabled is
+ // false: that combination would route every REST write through
+ // CreateWithConn/GuaranteedUpdateWithConn holding a pool connection while
+ // queued on the gate (write-gate-sharing §3.3). main.go refuses the
+ // configuration at startup; this catches a test flipping the package
+ // variable directly.
+ ErrGateRequiresSingleWriter = errors.New("storage: the shared write gate requires the single-writer path (singleWriterEnabled=false is refused)")
)
+// gatedWrite is the one helper every legacy write site runs its SQL through
+// (write-gate-sharing §3.1). With no gate it is today's code, byte for byte:
+// bare autocommit statements (txn=false, W3–W9) or the savepoint the site
+// has today (txn=true, W1/W2) on the caller's connection. With a gate, both
+// are BEGIN IMMEDIATE … COMMIT on the gate's connection and conn is ignored;
+// fn receives the gate-marked ctx and must thread it into anything it calls
+// (INV-1′: no lock, no pool take, no processor, no dispatch, no gob, no exec;
+// one same-directory rename of an already-written file is the one exemption).
+func gatedWrite(gate *writeGate, ctx context.Context, conn *sqlite.Conn, priority writePriority, path, kind string, txn bool, fn func(ctx context.Context, conn *sqlite.Conn) error) error {
+ if gate == nil {
+ if !txn {
+ return fn(ctx, conn)
+ }
+ release := sqlitex.Save(conn)
+ err := fn(ctx, conn)
+ release(&err)
+ return err
+ }
+ return gate.run(ctx, priority, path, kind, fn)
+}
+
+// SetWriteGate hands the StorageImpl the process's shared write gate (nil =
+// legacy behaviour, no gate). Called once at wiring time, before traffic.
+func (s *StorageImpl) SetWriteGate(gate *WriteGate) {
+ s.gate = gate
+}
+
+func (s *StorageImpl) write(ctx context.Context, conn *sqlite.Conn, priority writePriority, path, kind string, txn bool, fn func(ctx context.Context, conn *sqlite.Conn) error) error {
+ return gatedWrite(s.gate, ctx, conn, priority, path, kind, txn, fn)
+}
+
// lockTimeout is the hardcoded backstop for lock acquisition. It sits well under the
// ~60s outer apiserver request deadline so a contended request fails fast with a
// Retry-After signal instead of hanging to the full request timeout.
@@ -279,6 +320,56 @@ type StorageImpl struct {
// tests exercising unrelated paths) never spins up an unused goroutine.
writer *singleWriter
writerOnce sync.Once
+
+ // foreignKinds is the kind-ownership guard (design §5.6, INV-4): when set,
+ // every full-object operation on a key whose kind segment it reports as
+ // foreign is REFUSED with an InternalError instead of touching the shared
+ // metadata row, the payload file or the time_series table. Set from
+ // config.ContainerProfileSqliteBackend to IsContainerProfileKind, so a
+ // mis-wired legacy path is a loud failure, never a silent self-repair
+ // delete of a row the ObjectStore owns. Metadata-only reads (the shared
+ // row both backends agree on) are not refused. nil = no guard.
+ foreignKinds func(kind string) bool
+
+ // gate is the process's shared write gate (write-gate-sharing §3): when
+ // set, every write statement of this instance runs on the gate's
+ // connection through write(); nil = today's code on pool connections.
+ gate *writeGate
+}
+
+// SetForeignKinds installs the kind-ownership guard; nil removes it.
+func (s *StorageImpl) SetForeignKinds(f func(kind string) bool) {
+ s.foreignKinds = f
+}
+
+// kindOfKey returns the kind segment of a storage key ("/prefix/root/kind/…")
+// or of getListWithSpec's relative "apiVersion/kind/…" path.
+func kindOfKey(key string) string {
+ if strings.HasPrefix(key, "/") {
+ _, _, kind, _, _, _ := K8sPathToKeys(key)
+ return kind
+ }
+ parts := strings.Split(key, "/")
+ if len(parts) >= 2 {
+ return parts[1]
+ }
+ return ""
+}
+
+// refuseForeign returns the guard's InternalError when key's kind is foreign
+// to this instance, counting and logging the refusal.
+func (s *StorageImpl) refuseForeign(op, key string) error {
+ if s.foreignKinds == nil {
+ return nil
+ }
+ kind := kindOfKey(key)
+ if !s.foreignKinds(kind) {
+ return nil
+ }
+ metrics.IncCPOwnershipRefusal(op)
+ logger.L().Error("legacy StorageImpl refused an operation on a kind owned by another backend",
+ helpers.String("op", op), helpers.String("kind", kind), helpers.String("key", key))
+ return apierrors.NewInternalError(fmt.Errorf("storage: key %q has kind %q, which is owned by the ContainerProfile SQLite backend; the legacy file store refuses %s", key, kind, op))
}
func (s *StorageImpl) EnableResourceSizeEstimation(keysFunc storage.KeysFunc) error {
@@ -289,11 +380,7 @@ func (s *StorageImpl) EnableResourceSizeEstimation(keysFunc storage.KeysFunc) er
// and transparently retries without it when the filesystem returns an
// "unsupported" error (e.g. EINVAL on tmpfs/overlayfs).
func (s *StorageImpl) openPayloadFileWithFallback(path string, flag int, perm os.FileMode) (afero.File, error) {
- f, err := s.appFs.OpenFile(path, openFlagDirect|flag, perm)
- if err != nil && isDirectIOUnsupported(err) {
- f, err = s.appFs.OpenFile(path, flag, perm)
- }
- return f, err
+ return openPayloadFileWithFallbackFs(s.appFs, path, flag, perm)
}
func (s *StorageImpl) Stats(_ context.Context) (storage.Stats, error) {
@@ -380,6 +467,25 @@ func IsPayloadFile(path string) bool {
return strings.HasSuffix(path, GobExt)
}
+// keyFromPayloadPath recovers the storage key a payload file's path was
+// written to by makePayloadPath(root, key) (i.e. filepath.Join(root, key) +
+// GobExt), given only the file's actual on-disk path and the root it was
+// walked under. Reverses via filepath.Rel rather than slicing path at
+// len(root): root's exact textual form (whether "/data", "/data/", ".", or
+// "/") does not survive into path, which afero.Walk always reports as the
+// filepath.Join'd, cleaned form -- slicing by byte length silently
+// mis-derives the key whenever root's length doesn't match what Join
+// actually consumed (a trailing slash overcounts by one; root "/" or "."
+// each have their own off-by-one). Rel is exact for every root shape.
+func keyFromPayloadPath(root, path string) (string, error) {
+ rel, err := filepath.Rel(root, path)
+ if err != nil {
+ return "", fmt.Errorf("relativize %s under %s: %w", path, root, err)
+ }
+ rel = strings.TrimSuffix(rel, GobExt)
+ return "/" + filepath.ToSlash(rel), nil
+}
+
func poolContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), poolTimeout)
}
@@ -400,7 +506,12 @@ func (s *StorageImpl) keyFromPath(path string) string {
// for callers to use as the lightweight watch-event object: watchers created
// without ResourceVersionFullSpec receive this reduced object instead of the
// full one, to avoid bloating watch traffic with large Spec payloads.
-func (s *StorageImpl) saveObject(conn *sqlite.Conn, key string, obj runtime.Object, metaOut runtime.Object, checksum string) (runtime.Object, error) {
+//
+// The metadata row and the payload rename commit together through write()
+// (W2 of write-gate-sharing §3.2): today's savepoint on conn with no gate,
+// the gate's transaction with one. priority and path label the hold —
+// legacy_commit from the REST paths, migrate from the gob-migration rewrites.
+func (s *StorageImpl) saveObject(ctx context.Context, conn *sqlite.Conn, key string, obj runtime.Object, metaOut runtime.Object, checksum string, priority writePriority, path string) (runtime.Object, error) {
// increment resourceVersion
if version, err := s.versioner.ObjectResourceVersion(obj); err == nil {
if err := s.versioner.UpdateObject(obj, version+1); err != nil {
@@ -474,17 +585,18 @@ func (s *StorageImpl) saveObject(conn *sqlite.Conn, key string, obj runtime.Obje
if renamePayload == nil {
renamePayload = s.appFs.Rename
}
- release := sqlitex.Save(conn)
- err = func() error {
+ observeStmt("Save:saveObject")
+ err = s.write(ctx, conn, priority, path, resourceFromKey(key), true, func(_ context.Context, conn *sqlite.Conn) error {
if werr := writeMeta(conn, key, metadata); werr != nil {
return fmt.Errorf("write metadata: %w", werr)
}
+ renameStart := time.Now()
if rerr := renamePayload(tmpPayloadPath, finalPayloadPath); rerr != nil {
return fmt.Errorf("rename payload into place: %w", rerr)
}
+ metrics.ObserveSqliteWriteHoldStep(path, "rename", time.Since(renameStart))
return nil
- }()
- release(&err)
+ })
if err != nil {
_ = s.appFs.Remove(tmpPayloadPath)
return nil, err
@@ -519,6 +631,12 @@ func (s *StorageImpl) Create(ctx context.Context, key string, obj, metaOut runti
if singleWriterEnabled {
return s.createSingleWriter(ctx, key, obj, metaOut, priorityHigh)
}
+ if s.gate != nil {
+ if err := s.refuseForeign("create", key); err != nil {
+ return err
+ }
+ return ErrGateRequiresSingleWriter
+ }
poolCtx, cancel := poolContext()
defer cancel()
beforePool := time.Now()
@@ -533,6 +651,9 @@ func (s *StorageImpl) Create(ctx context.Context, key string, obj, metaOut runti
}
func (s *StorageImpl) CreateWithConn(ctx context.Context, conn *sqlite.Conn, key string, obj, metaOut runtime.Object, _ uint64) error {
+ if err := s.refuseForeign("create", key); err != nil {
+ return err
+ }
ctx, span := otel.Tracer("").Start(ctx, "StorageImpl.Create")
span.SetAttributes(attribute.String("key", key))
defer span.End()
@@ -556,6 +677,18 @@ func (s *StorageImpl) CreateWithConn(ctx context.Context, conn *sqlite.Conn, key
if _, err := s.appFs.Stat(makePayloadPath(filepath.Join(s.root, key))); err == nil {
return storage.NewKeyExistsError(key, 0)
}
+ // Flag-off objects can exist solely in SQLite. Check under the key lock
+ // before a legacy write can replace their metadata; the single-writer
+ // Create path performs its own commit-time database check.
+ visible, err := isFallbackVisible(conn, key)
+ if err != nil {
+ // Fail closed: never overwrite a key we could not check.
+ logger.L().Ctx(ctx).Error("Create - fallback existence check failed", helpers.Error(err), helpers.String("key", key))
+ return fmt.Errorf("create existence check: %w", err)
+ }
+ if visible {
+ return storage.NewKeyExistsError(key, 0)
+ }
// resourceVersion should not be set on create
if version, err := s.versioner.ObjectResourceVersion(obj); err == nil && version != 0 {
msg := "resourceVersion should not be set on objects to be created"
@@ -584,7 +717,7 @@ func (s *StorageImpl) CreateWithConn(ctx context.Context, conn *sqlite.Conn, key
}
}
// save object
- metaEvent, err := s.saveObject(conn, key, obj, metaOut, "")
+ metaEvent, err := s.saveObject(ctx, conn, key, obj, metaOut, "", priorityHigh, holdPathLegacyCommit)
if err != nil {
logger.L().Ctx(ctx).Error("Create - save object failed", helpers.Error(err), helpers.String("key", key))
return err
@@ -609,6 +742,18 @@ func (s *StorageImpl) CreateWithConn(ctx context.Context, conn *sqlite.Conn, key
// callers (none currently, but its signature/behavior is preserved as
// documented Phase 1 scope).
func (s *StorageImpl) Delete(ctx context.Context, key string, metaOut runtime.Object, _ *storage.Preconditions, _ storage.ValidateObjectFunc, _ runtime.Object, _ storage.DeleteOptions) error {
+ if s.gate != nil {
+ // The statements run on the gate's connection: the per-key lock only,
+ // no pool connection held while queued (write-gate-sharing §3.2/§3.3).
+ if err := s.lockKey(ctx, "delete", key, s.locks.Lock); err != nil {
+ return err
+ }
+ defer s.locks.Unlock(key)
+ ctx, span := otel.Tracer("").Start(ctx, "StorageImpl.Delete")
+ span.SetAttributes(attribute.String("key", key))
+ defer span.End()
+ return s.delete(ctx, nil, key, metaOut, nil, nil, nil, storage.DeleteOptions{})
+ }
conn, err := s.acquireLockedConn(ctx, "delete", key, s.locks.Lock, s.locks.Unlock)
if err != nil {
return err
@@ -622,6 +767,28 @@ func (s *StorageImpl) Delete(ctx context.Context, key string, metaOut runtime.Ob
return s.delete(ctx, conn, key, metaOut, nil, nil, nil, storage.DeleteOptions{})
}
+// lockKey is acquireLockedConn's lock step alone, for the gated paths that
+// need no pool connection: acquire under lockTimeout with the same metrics
+// and the same fail-fast error.
+func (s *StorageImpl) lockKey(ctx context.Context, op, key string, acquire func(context.Context, string) error) error {
+ _, spanLock := otel.Tracer("").Start(ctx, "waiting for lock")
+ beforeLock := time.Now()
+ lockCtx, lockCancel := context.WithTimeout(ctx, lockTimeout)
+ err := acquire(lockCtx, key)
+ lockCancel()
+ spanLock.End()
+ lockDuration := time.Since(beforeLock)
+ if err != nil {
+ metrics.ObserveLockWait(resourceFromKey(key), metrics.OutcomeTimeout, lockDuration)
+ return newContentionTimeoutError(op, key, err)
+ }
+ metrics.ObserveLockWait(resourceFromKey(key), metrics.OutcomeAcquired, lockDuration)
+ if lockDuration > time.Second {
+ logger.L().Debug(op, helpers.String("key", key), helpers.String("lockDuration", lockDuration.String()))
+ }
+ return nil
+}
+
func (s *StorageImpl) DeleteWithConn(ctx context.Context, conn *sqlite.Conn, key string, metaOut runtime.Object, _ *storage.Preconditions, _ storage.ValidateObjectFunc, _ runtime.Object, _ storage.DeleteOptions) error {
ctx, span := otel.Tracer("").Start(ctx, "StorageImpl.Delete")
span.SetAttributes(attribute.String("key", key))
@@ -651,6 +818,14 @@ func (s *StorageImpl) DeleteWithConn(ctx context.Context, conn *sqlite.Conn, key
// releasing whatever they hold (shard turn, connection, per-key lock).
func (s *StorageImpl) deleteLocked(ctx context.Context, conn *sqlite.Conn, key string, metaOut runtime.Object) error {
p := filepath.Join(s.root, key)
+ if s.gate != nil {
+ return s.deleteLockedGated(ctx, key, metaOut, p)
+ }
+ // Delete the SQL payload before metadata. On failure preserve the other
+ // state so the caller can retry the complete deletion.
+ if err := DeletePayloads(conn, key); err != nil {
+ return fmt.Errorf("delete payloads: %w", err)
+ }
// delete metadata in SQLite
err := DeleteMetadata(conn, key, metaOut)
if err != nil {
@@ -671,7 +846,48 @@ func (s *StorageImpl) deleteLocked(ctx context.Context, conn *sqlite.Conn, key s
return nil
}
+// deleteLockedGated is deleteLocked under the shared gate (W3): the row
+// delete (and the time_series delete for a containerprofile) in one gated
+// transaction with the deleted row's JSON captured raw; the payload Remove
+// and the decode into metaOut after release (INV-1′). conn is not used: the
+// caller may hold none (Delete) or one it keeps for reads (DeleteWithConn).
+func (s *StorageImpl) deleteLockedGated(ctx context.Context, key string, metaOut runtime.Object, p string) error {
+ _, _, kind, _, _, _ := K8sPathToKeys(key)
+ var raw []byte
+ err := s.write(ctx, nil, priorityHigh, holdPathLegacyDelete, resourceFromKey(key), false, func(_ context.Context, conn *sqlite.Conn) error {
+ var derr error
+ raw, derr = deleteMetadataRaw(conn, key)
+ if derr != nil {
+ return derr
+ }
+ if IsContainerProfileKind(kind) {
+ if terr := DeleteTimeSeriesContainerEntries(conn, key); terr != nil {
+ return fmt.Errorf("delete time series entries: %w", terr)
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ logger.L().Ctx(ctx).Error("Delete - delete metadata failed", helpers.Error(err), helpers.String("key", key))
+ }
+ if rerr := s.appFs.Remove(makePayloadPath(p)); rerr != nil {
+ logger.L().Ctx(ctx).Error("Delete - remove json file failed", helpers.Error(rerr), helpers.String("key", key))
+ }
+ if err != nil {
+ return err
+ }
+ if metaOut != nil && len(raw) > 0 {
+ if uerr := json.Unmarshal(raw, metaOut); uerr != nil {
+ return fmt.Errorf("delete metadata: %w", uerr)
+ }
+ }
+ return nil
+}
+
func (s *StorageImpl) delete(ctx context.Context, conn *sqlite.Conn, key string, metaOut runtime.Object, _ *storage.Preconditions, _ storage.ValidateObjectFunc, _ runtime.Object, _ storage.DeleteOptions) error {
+ if err := s.refuseForeign("delete", key); err != nil {
+ return err
+ }
if err := s.deleteLocked(ctx, conn, key, metaOut); err != nil {
return err
}
@@ -782,6 +998,13 @@ func (s *StorageImpl) get(ctx context.Context, conn *sqlite.Conn, key string, op
return json.Unmarshal(metadata, objPtr)
}
+ // The metadata-only branch above reads the shared row both backends agree
+ // on; everything below touches the payload file and the self-repair
+ // deletes, which a foreign kind's owner must never see (INV-4).
+ if err := s.refuseForeign("get", key); err != nil {
+ return err
+ }
+
// noLock callers perform unsynchronized file I/O by default. Acquire a
// temporary read lock so that a concurrent saveObject cannot truncate or
// overwrite the file while we are decoding it. The lock is released via
@@ -810,7 +1033,22 @@ func (s *StorageImpl) get(ctx context.Context, conn *sqlite.Conn, key string, op
// SQLite's write lock for the busy timeout, so a read of an absent
// key must not issue it.
if _, rerr := ReadMetadata(conn, key); rerr == nil {
- _ = DeleteMetadata(conn, key, nil)
+ outcome, inspectionErr := s.inspectPayloadsFallback(conn, key, objPtr)
+ if inspectionErr != nil {
+ // An unreadable object is not absent. In particular,
+ // IgnoreNotFound must not turn this into an empty object.
+ return fmt.Errorf("get payloads fallback: %w", inspectionErr)
+ }
+ switch outcome {
+ case fallbackServed:
+ return nil
+ case fallbackPrunable:
+ _ = s.repairDeleteWithPayloads(ctx, conn, key)
+ case fallbackIneligible:
+ _ = s.repairDelete(ctx, conn, key)
+ }
+ } else if !errors.Is(rerr, ErrMetadataNotFound) {
+ return fmt.Errorf("get fallback metadata: %w", rerr)
}
if opts.IgnoreNotFound {
return runtime.SetZeroValue(objPtr)
@@ -885,7 +1123,7 @@ func (s *StorageImpl) get(ctx context.Context, conn *sqlite.Conn, key string, op
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
// irrecoverable error, delete corresponding data
- _ = DeleteMetadata(conn, key, nil)
+ _ = s.repairDelete(ctx, conn, key)
_ = s.appFs.Remove(makePayloadPath(p))
logger.L().Ctx(ctx).Error("Get - gob error, treating as corrupted and removing files", helpers.Error(err), helpers.String("key", key))
if opts.IgnoreNotFound {
@@ -898,6 +1136,102 @@ func (s *StorageImpl) get(ctx context.Context, conn *sqlite.Conn, key string, op
return err
}
+// fallbackOutcome distinguishes a served object from the two read-repair
+// cases. Inspection failures are returned separately and prohibit mutation.
+type fallbackOutcome int
+
+const (
+ fallbackIneligible fallbackOutcome = iota // Preserve any recoverable payload.
+ fallbackServed
+ fallbackPrunable // A non-time-series legacy write superseded the payload.
+)
+
+// inspectPayloadsFallback is called only when the legacy file is missing.
+// It serves a joined metadata/payloads row with non-NULL rv and no time-series
+// marker. Query, decode, and conversion failures leave both rows untouched.
+func (s *StorageImpl) inspectPayloadsFallback(conn *sqlite.Conn, key string, objPtr runtime.Object) (fallbackOutcome, error) {
+ candidate, err := readFallbackCandidate(conn, key)
+ if err != nil {
+ return fallbackIneligible, err
+ }
+ if candidate == nil || candidate.isTimeSeries {
+ return fallbackIneligible, nil
+ }
+ // Every legacy write clears rv. Its old SQL payload must not be served
+ // after a lost file rename, and can be pruned at this missing-file site.
+ if candidate.rvNull {
+ return fallbackPrunable, nil
+ }
+ if !candidate.payloadsFound {
+ return fallbackIneligible, nil
+ }
+ if s.scheme == nil {
+ return fallbackIneligible, fmt.Errorf("no scheme to decode payloads body")
+ }
+ if err := decodePayloadBody(s.scheme, candidate.encoding, candidate.body, objPtr); err != nil {
+ return fallbackIneligible, fmt.Errorf("decode payloads body: %w", err)
+ }
+ accessor, err := meta.Accessor(objPtr)
+ if err != nil {
+ return fallbackIneligible, fmt.Errorf("access payload metadata: %w", err)
+ }
+ // Use the authoritative columns, as cpexport does.
+ accessor.SetResourceVersion(strconv.FormatInt(candidate.rv, 10))
+ if candidate.uid != "" {
+ accessor.SetUID(types.UID(candidate.uid))
+ }
+ logger.L().Debug("Get - payload file missing, served from the payloads row", helpers.String("key", key))
+ return fallbackServed, nil
+}
+
+// isFallbackVisible checks the database fallback predicate without decoding.
+// Even an eligible payload this binary cannot decode must block Create.
+// Query errors are returned so callers cannot overwrite uninspected state.
+func isFallbackVisible(conn *sqlite.Conn, key string) (bool, error) {
+ candidate, err := readFallbackCandidate(conn, key)
+ if err != nil {
+ return false, err
+ }
+ if candidate == nil {
+ return false, nil
+ }
+ return !candidate.rvNull && !candidate.isTimeSeries && candidate.payloadsFound, nil
+}
+
+// repairDelete is get()'s self-repair DELETE of a metadata row whose payload
+// is missing, empty or unmigratable (W4/W5/W6a/W7a): a low-priority write on
+// the repair path. The caller holds the key's read (or write) lock and its
+// pool connection while queued — accepted for these cold paths
+// (write-gate-sharing §3.3). The error is the caller's to swallow, as today.
+//
+// It deletes the metadata row and NOTHING else. In particular it must never
+// touch the key's payloads row: this function is shared by all four of get()'s
+// self-repair sites, three of which fire on an undecodable .g FILE (gob EOF,
+// and the two migration-tool failures) where a perfectly valid native payload
+// may sit next to the corrupt file. Those three sites are explicitly out of
+// scope for the rollback-safety guard and must keep leaving a recoverable
+// orphan payload behind, exactly as they did before it
+// (docs/features/containerprofile-sqlite-backend.md). Only get()'s
+// missing-file site prunes payloads, and only in the narrow case
+// repairDeleteWithPayloads below documents.
+func (s *StorageImpl) repairDelete(ctx context.Context, conn *sqlite.Conn, key string) error {
+ return s.write(ctx, conn, priorityLow, holdPathRepair, resourceFromKey(key), false, func(_ context.Context, conn *sqlite.Conn) error {
+ return DeleteMetadata(conn, key, nil)
+ })
+}
+
+// repairDeleteWithPayloads prunes a superseded SQL payload before metadata.
+// Only the missing-file branch calls it, after establishing rv IS NULL and
+// no time-series marker. Corrupt-file repairs must preserve recovery bytes.
+func (s *StorageImpl) repairDeleteWithPayloads(ctx context.Context, conn *sqlite.Conn, key string) error {
+ return s.write(ctx, conn, priorityLow, holdPathRepair, resourceFromKey(key), false, func(_ context.Context, conn *sqlite.Conn) error {
+ if err := DeletePayloads(conn, key); err != nil {
+ return err
+ }
+ return DeleteMetadata(conn, key, nil)
+ })
+}
+
// migrateObject runs the external migration tool and unmarshals the output into objPtr.
// It is used by get() to migrate objects that need external migration.
// The caller must hold the write lock for key before calling this.
@@ -923,7 +1257,7 @@ func (s *StorageImpl) migrateObject(ctx context.Context, conn *sqlite.Conn, path
migrationCtx, migrationCancel := context.WithTimeout(ctx, 30*time.Second)
defer migrationCancel()
- cmd := exec.CommandContext(migrationCtx, "/usr/bin/migration", "-file", makePayloadPath(path), "-type", typeName)
+ cmd := exec.CommandContext(migrationCtx, migrationBinaryPath, "-file", makePayloadPath(path), "-type", typeName)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
@@ -935,7 +1269,7 @@ func (s *StorageImpl) migrateObject(ctx context.Context, conn *sqlite.Conn, path
}
logger.L().Ctx(ctx).Error("Get - migration tool failed", helpers.Error(runErr), helpers.String("stderr", stderr.String()), helpers.String("key", key))
// If migration tool fails, treat as corrupted and delete
- _ = DeleteMetadata(conn, key, nil)
+ _ = s.repairDelete(ctx, conn, key)
_ = s.appFs.Remove(makePayloadPath(path))
if opts.IgnoreNotFound {
return runtime.SetZeroValue(objPtr)
@@ -952,7 +1286,7 @@ func (s *StorageImpl) migrateObject(ctx context.Context, conn *sqlite.Conn, path
logger.L().Ctx(ctx).Info("Get - external migration successful", helpers.String("key", key))
- if _, saveErr := s.saveObject(conn, key, objPtr, nil, ""); saveErr != nil {
+ if _, saveErr := s.saveObject(ctx, conn, key, objPtr, nil, "", priorityLow, holdPathMigrate); saveErr != nil {
logger.L().Ctx(ctx).Error("Get - failed to rewrite migrated object", helpers.Error(saveErr), helpers.String("key", key))
} else {
logger.L().Ctx(ctx).Info("Get - successfully migrated object to modern format", helpers.String("key", key))
@@ -980,11 +1314,11 @@ func (s *StorageImpl) tryDecodePayload(path string, objPtr runtime.Object) (bool
return true, nil
}
-// migrationBinaryPath is the external migration tool invoked by
-// execMigrationTool (the hasReadLock/noLock, unlocked-exec path only --
-// migrateObject's own, unchanged exec call for the hasWriteLock path keeps
-// its hardcoded path). Package-level var, not const, so tests can point it
-// at a fixture script instead of the real /usr/bin/migration binary.
+// migrationBinaryPath is the external migration tool invoked on every
+// gob-migration path: execMigrationTool (get()'s hasReadLock/noLock states),
+// migrateObject (hasWriteLock) and appendGobObjectFromFile (the list readers).
+// Package-level var, not const, so tests can point it at a fixture script
+// instead of the real /usr/bin/migration binary.
var migrationBinaryPath = "/usr/bin/migration"
// execMigrationTool runs the external migration binary against path and
@@ -1097,7 +1431,7 @@ func (s *StorageImpl) migrateObjectUnlocked(ctx context.Context, conn *sqlite.Co
return execErr
}
logger.L().Ctx(ctx).Error("Get - migration tool failed", helpers.Error(execErr), helpers.String("key", key))
- _ = DeleteMetadata(conn, key, nil)
+ _ = s.repairDelete(ctx, conn, key)
_ = s.appFs.Remove(makePayloadPath(path))
if opts.IgnoreNotFound {
return runtime.SetZeroValue(objPtr)
@@ -1109,7 +1443,7 @@ func (s *StorageImpl) migrateObjectUnlocked(ctx context.Context, conn *sqlite.Co
return unmarshalErr
}
logger.L().Ctx(ctx).Info("Get - external migration successful", helpers.String("key", key))
- if _, saveErr := s.saveObject(conn, key, objPtr, nil, ""); saveErr != nil {
+ if _, saveErr := s.saveObject(ctx, conn, key, objPtr, nil, "", priorityLow, holdPathMigrate); saveErr != nil {
logger.L().Ctx(ctx).Error("Get - failed to rewrite migrated object", helpers.Error(saveErr), helpers.String("key", key))
} else {
logger.L().Ctx(ctx).Info("Get - successfully migrated object to modern format", helpers.String("key", key))
@@ -1339,6 +1673,9 @@ func (s *StorageImpl) fetchListPage(ctx context.Context, conn *sqlite.Conn, key
var pageLast string
var err error
if isFullSpec {
+ if err := s.refuseForeign("list", key); err != nil {
+ return listPageResult{}, err
+ }
// get names from SQLite
entries, pageLast, err = listMetadataKeys(conn, key, cursor, remaining)
} else {
@@ -1393,6 +1730,9 @@ func setListContinue(listObj runtime.Object, pageLast string) error {
// getListWithSpec is the same as GetList, but it returns the full objects instead of just the metadata.
func (s *StorageImpl) getListWithSpec(ctx context.Context, key string, _ storage.ListOptions, listObj runtime.Object) error {
+ if err := s.refuseForeign("list", key); err != nil {
+ return err
+ }
ctx, span := otel.Tracer("").Start(ctx, "StorageImpl.getListWithSpec")
span.SetAttributes(attribute.String("key", key))
defer span.End()
@@ -1498,6 +1838,12 @@ func (s *StorageImpl) GuaranteedUpdate(
if singleWriterEnabled {
return s.guaranteedUpdateSingleWriter(ctx, key, metaOut, ignoreNotFound, preconditions, tryUpdate, cachedExistingObject, "", priorityHigh)
}
+ if s.gate != nil {
+ if err := s.refuseForeign("update", key); err != nil {
+ return err
+ }
+ return ErrGateRequiresSingleWriter
+ }
poolCtx, cancel := poolContext()
defer cancel()
beforePool := time.Now()
@@ -1514,6 +1860,9 @@ func (s *StorageImpl) GuaranteedUpdate(
func (s *StorageImpl) GuaranteedUpdateWithConn(
ctx context.Context, conn *sqlite.Conn, key string, metaOut runtime.Object, ignoreNotFound bool,
preconditions *storage.Preconditions, tryUpdate storage.UpdateFunc, cachedExistingObject runtime.Object, checksum string) error {
+ if err := s.refuseForeign("update", key); err != nil {
+ return err
+ }
ctx, span := otel.Tracer("").Start(ctx, "StorageImpl.GuaranteedUpdate")
span.SetAttributes(attribute.String("key", key))
defer span.End()
@@ -1680,7 +2029,7 @@ func (s *StorageImpl) GuaranteedUpdateWithConn(
}
// save to disk and fill into metaOut
- metaEvent, err := s.saveObject(conn, key, ret, metaOut, checksum)
+ metaEvent, err := s.saveObject(ctx, conn, key, ret, metaOut, checksum, priorityHigh, holdPathLegacyCommit)
if err != nil {
logger.L().Ctx(ctx).Error("GuaranteedUpdate - save object failed", helpers.Error(err), helpers.String("key", key))
return err
@@ -1738,6 +2087,9 @@ func (s *StorageImpl) GetByCluster(ctx context.Context, apiVersion, kind string,
// appendGobObjectFromFile unmarshalls a Gob file into a runtime.Object and appends it to the underlying list object.
func (s *StorageImpl) appendGobObjectFromFile(ctx context.Context, path string, v reflect.Value) error {
key := s.keyFromPath(path)
+ if err := s.refuseForeign("list", key); err != nil {
+ return err
+ }
lockCtx, lockCancel := context.WithTimeout(ctx, lockTimeout)
defer lockCancel()
err := s.locks.RLock(lockCtx, key)
@@ -1799,7 +2151,7 @@ func (s *StorageImpl) appendGobObjectFromFile(ctx context.Context, path string,
migrationCtx, migrationCancel := context.WithTimeout(ctx, 30*time.Second)
defer migrationCancel()
- cmd := exec.CommandContext(migrationCtx, "/usr/bin/migration", "-file", path, "-type", typeName)
+ cmd := exec.CommandContext(migrationCtx, migrationBinaryPath, "-file", path, "-type", typeName)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
@@ -1833,7 +2185,7 @@ func (s *StorageImpl) appendGobObjectFromFile(ctx context.Context, path string,
} else {
metrics.ObservePoolWait(resourceFromKey(key), metrics.OutcomeAcquired, time.Since(beforePool))
defer s.pool.Put(conn)
- if _, saveErr := s.saveObject(conn, key, obj, nil, ""); saveErr != nil {
+ if _, saveErr := s.saveObject(ctx, conn, key, obj, nil, "", priorityLow, holdPathMigrate); saveErr != nil {
logger.L().Ctx(ctx).Error("appendGobObjectFromFile - failed to rewrite migrated object", helpers.Error(saveErr), helpers.String("path", path))
} else {
logger.L().Ctx(ctx).Info("appendGobObjectFromFile - successfully migrated object to modern format", helpers.String("path", path))
diff --git a/pkg/registry/file/storage_test.go b/pkg/registry/file/storage_test.go
index bee064377..a696c624d 100644
--- a/pkg/registry/file/storage_test.go
+++ b/pkg/registry/file/storage_test.go
@@ -774,7 +774,7 @@ func BenchmarkWriteFiles(b *testing.B) {
metaOut := &v1beta1.SBOMSyft{}
conn, _ := s.pool.Take(context.Background())
for i := 0; i < b.N; i++ {
- _, _ = s.saveObject(conn, key, obj, metaOut, "")
+ _, _ = s.saveObject(context.Background(), conn, key, obj, metaOut, "", priorityLow, holdPathLegacyCommit)
}
s.pool.Put(conn)
b.ReportAllocs()
@@ -1078,7 +1078,7 @@ func TestStorageImpl_MigrateObjectUnlocked_ConcurrentWriteWins(t *testing.T) {
ObjectMeta: v1.ObjectMeta{Name: "toto"},
Spec: v1beta1.SBOMSyftSpec{Metadata: v1beta1.SPDXMeta{Tool: v1beta1.ToolMeta{Name: "concurrent-writer"}}},
}
- _, saveErr := s.saveObject(conn, key, newObj, nil, "")
+ _, saveErr := s.saveObject(context.Background(), conn, key, newObj, nil, "", priorityLow, holdPathLegacyCommit)
require.NoError(t, saveErr)
pool.Put(conn)
s.locks.Unlock(key)
@@ -1588,3 +1588,32 @@ func TestStorageImpl_GetList_ReleasesConnectionBetweenPages(t *testing.T) {
require.Len(t, list.Items, 1)
assert.Equal(t, "sbom-01", list.Items[0].Name)
}
+
+// TestKeyFromPayloadPath_RoundTripsForEveryRootShape: keyFromPayloadPath
+// recovers the exact key makePayloadPath(root, key) was written under,
+// regardless of root's textual form -- the bug this replaces (slicing a
+// Walk()-reported path at len(root)) silently mis-derived the key whenever
+// root's length didn't match what filepath.Join actually consumed: a
+// trailing slash overcounts by one, root "/" or "." each undercount or
+// overcount differently again. filepath.Rel is exact for all of them, which
+// this proves by reconstructing path the same way afero.Walk would report
+// it (filepath.Join(root, key) + GobExt) for every root shape a real
+// cpexport -root flag can be given, then recovering key from it.
+func TestKeyFromPayloadPath_RoundTripsForEveryRootShape(t *testing.T) {
+ keys := []string{
+ "/spdx.softwarecomposition.kubescape.io/containerprofile/kube-system/plain-00",
+ "/spdx.softwarecomposition.kubescape.io/containerprofile/default/app-with-dots.and-dashes",
+ }
+ roots := []string{"/data", "/data/", ".", "/", "/data/../data", "data"}
+ for _, root := range roots {
+ for _, key := range keys {
+ t.Run(fmt.Sprintf("root=%q/key=%s", root, key), func(t *testing.T) {
+ path := makePayloadPath(filepath.Join(root, key))
+ got, err := keyFromPayloadPath(root, path)
+ require.NoError(t, err)
+ assert.Equal(t, key, got,
+ "root=%q path=%q: recovered key must match the original, regardless of root's textual form", root, path)
+ })
+ }
+ }
+}
diff --git a/pkg/registry/file/testdata/perfab.thresholds.json b/pkg/registry/file/testdata/perfab.thresholds.json
new file mode 100644
index 000000000..c871d957b
--- /dev/null
+++ b/pkg/registry/file/testdata/perfab.thresholds.json
@@ -0,0 +1,34 @@
+{
+ "_comment": "Tier B pre-registered thresholds (A.13.4 B.5). Relative only; no absolute latency is checked in. ratio: regression when HEAD/BASE breaches threshold_pct in the bad direction AND either the paired t-test or Mann-Whitney U has p < alpha. count: regression when sum(HEAD) > sum(BASE) and significant. points: regression when the mean paired difference exceeds threshold_points and significant. hard: any non-zero value on HEAD in any round while BASE has none in every round, no statistics. info: reported with its statistics, never gates. REST GuaranteedUpdate is gated on p95, not p99: under the pinned production shape about 1% of updates hit acquireLockedConn's 250 ms connection-attempt cliff, so its p99 straddles that cliff and is bimodal round to round (~40 ms vs ~280 ms); the cliff itself is measured by pool-wait-timeouts and ops/s. REST List is gated on p95 for the same reason: the legacy fullSpec page opens one payload file per object and its p99 varies 2-3x round to round (probe CV 51%) while p95 is stable. tick-p50-ms is info (harness v4): once the updaters perform a real mutation, the legacy arm's consolidation pass is bimodal round to round (tick p50 785 ms to 6481 ms in one 12-pair run, 'database is locked' churn under contention) while the ObjectStore arm's stays within ~10% CV; the paired CV of the log-ratio is then a property of the legacy baseline, so gating on it cannot produce a PASS at any N. Consolidation stays gated on tick-p99-ms.",
+ "alpha": 0.05,
+ "power": 0.8,
+ "paired_cv_max_pct": 25,
+ "probe_cv_max_pct": 50,
+ "metrics": [
+ {"name": "get-p99-ms", "kind": "ratio", "direction": "lower_is_better", "threshold_pct": 20, "headline": true},
+ {"name": "create-p99-ms", "kind": "ratio", "direction": "lower_is_better", "threshold_pct": 20, "headline": true},
+ {"name": "update-p95-ms", "kind": "ratio", "direction": "lower_is_better", "threshold_pct": 20, "headline": true},
+ {"name": "update-p99-ms", "kind": "info", "direction": "lower_is_better"},
+ {"name": "list-p95-ms", "kind": "ratio", "direction": "lower_is_better", "threshold_pct": 20, "headline": true},
+ {"name": "list-p99-ms", "kind": "info", "direction": "lower_is_better"},
+ {"name": "list-p50-ms", "kind": "info", "direction": "lower_is_better"},
+ {"name": "list-meta-p95-ms", "kind": "info", "direction": "lower_is_better"},
+ {"name": "list-full-p95-ms", "kind": "info", "direction": "lower_is_better"},
+ {"name": "write-bytes", "kind": "info", "direction": "lower_is_better"},
+ {"name": "tick-p50-ms", "kind": "info", "direction": "lower_is_better"},
+ {"name": "tick-p99-ms", "kind": "ratio", "direction": "lower_is_better", "threshold_pct": 30, "headline": true},
+ {"name": "tick-total-s", "kind": "info", "direction": "lower_is_better"},
+ {"name": "ops-per-s", "kind": "ratio", "direction": "higher_is_better", "threshold_pct": 10, "headline": true},
+ {"name": "lock-wait-timeouts", "kind": "count", "direction": "lower_is_better"},
+ {"name": "pool-wait-timeouts", "kind": "count", "direction": "lower_is_better"},
+ {"name": "over-one-sec", "kind": "count", "direction": "lower_is_better"},
+ {"name": "commit-conflict-rate-pct", "kind": "points", "direction": "lower_is_better", "threshold_points": 5},
+ {"name": "legacy-create-p99-ms", "kind": "info", "direction": "lower_is_better"},
+ {"name": "legacy-update-p99-ms", "kind": "info", "direction": "lower_is_better"},
+ {"name": "legacy-delete-p99-ms", "kind": "info", "direction": "lower_is_better"},
+ {"name": "cleanup-tick-ms", "kind": "info", "direction": "lower_is_better"},
+ {"name": "legacy-over-one-sec", "kind": "count", "direction": "lower_is_better"},
+ {"name": "busy-wait-max-ms", "kind": "info", "direction": "lower_is_better"}
+ ],
+ "hard": ["err-other", "over-five-sec", "commit-panic", "legacy-err-other", "ungated-writes"]
+}
diff --git a/pkg/registry/file/testdata/workbudget.golden.json b/pkg/registry/file/testdata/workbudget.golden.json
new file mode 100644
index 000000000..0cdca653b
--- /dev/null
+++ b/pkg/registry/file/testdata/workbudget.golden.json
@@ -0,0 +1,203 @@
+{
+ "S1_learning_tick": {
+ "stmt": {
+ "DeleteMetadata": 3,
+ "DeletePayloads": 3,
+ "DeleteTimeSeriesContainerEntries": 3,
+ "ListTimeSeriesContainers": 1,
+ "ListTimeSeriesExpired": 1,
+ "ListTimeSeriesWithData": 1,
+ "ReadMetadata": 5,
+ "ReplaceTimeSeriesContainerEntries": 1,
+ "Save:saveObject": 1,
+ "Transaction": 1,
+ "WriteJSON": 1,
+ "WriteTimeSeriesEntry": 1
+ },
+ "lock": {
+ "rlock": 6,
+ "lock": 4,
+ "timeout": 0
+ },
+ "pool_take": 5,
+ "fs": {
+ "open": 8,
+ "rename": 1,
+ "remove": 3
+ },
+ "events": {
+ "Added": 0,
+ "Modified": 1,
+ "Deleted": 3
+ }
+ },
+ "S2_empty_tick": {
+ "stmt": {
+ "ListTimeSeriesExpired": 1,
+ "ListTimeSeriesWithData": 1
+ },
+ "lock": {
+ "rlock": 0,
+ "lock": 0,
+ "timeout": 0
+ },
+ "pool_take": 1,
+ "fs": {
+ "open": 0,
+ "rename": 0,
+ "remove": 0
+ },
+ "events": {
+ "Added": 0,
+ "Modified": 0,
+ "Deleted": 0
+ }
+ },
+ "S3_frozen_tick": {
+ "stmt": {
+ "DeleteMetadata": 2,
+ "DeletePayloads": 2,
+ "DeleteTimeSeriesContainerEntries": 2,
+ "ListTimeSeriesContainers": 1,
+ "ListTimeSeriesExpired": 1,
+ "ListTimeSeriesWithData": 1,
+ "ReadMetadata": 1,
+ "ReplaceTimeSeriesContainerEntries": 1,
+ "Transaction": 1
+ },
+ "lock": {
+ "rlock": 1,
+ "lock": 2,
+ "timeout": 0
+ },
+ "pool_take": 4,
+ "fs": {
+ "open": 1,
+ "rename": 0,
+ "remove": 2
+ },
+ "events": {
+ "Added": 0,
+ "Modified": 0,
+ "Deleted": 2
+ }
+ },
+ "S4_divergent_tick": {
+ "stmt": {
+ "DeleteMetadata": 2,
+ "DeletePayloads": 2,
+ "DeleteTimeSeriesContainerEntries": 2,
+ "ListTimeSeriesContainers": 1,
+ "ListTimeSeriesExpired": 1,
+ "ListTimeSeriesWithData": 1,
+ "ReadMetadata": 2,
+ "ReplaceTimeSeriesContainerEntries": 1,
+ "Save:saveObject": 1,
+ "Transaction": 1,
+ "WriteJSON": 1
+ },
+ "lock": {
+ "rlock": 1,
+ "lock": 3,
+ "timeout": 0
+ },
+ "pool_take": 4,
+ "fs": {
+ "open": 3,
+ "rename": 1,
+ "remove": 2
+ },
+ "events": {
+ "Added": 0,
+ "Modified": 1,
+ "Deleted": 2
+ }
+ },
+ "S5_rest_get": {
+ "stmt": {},
+ "lock": {
+ "rlock": 1,
+ "lock": 0,
+ "timeout": 0
+ },
+ "pool_take": 1,
+ "fs": {
+ "open": 1,
+ "rename": 0,
+ "remove": 0
+ },
+ "events": {
+ "Added": 0,
+ "Modified": 0,
+ "Deleted": 0
+ }
+ },
+ "S6_rest_create_ts": {
+ "stmt": {
+ "ReadMetadata": 2,
+ "Save:commit": 1,
+ "WriteJSON": 1,
+ "WriteTimeSeriesEntry": 1
+ },
+ "lock": {
+ "rlock": 0,
+ "lock": 1,
+ "timeout": 0
+ },
+ "pool_take": 3,
+ "fs": {
+ "open": 1,
+ "rename": 1,
+ "remove": 0
+ },
+ "events": {
+ "Added": 1,
+ "Modified": 0,
+ "Deleted": 0
+ }
+ },
+ "S7_noop_guaranteed_update": {
+ "stmt": {
+ "ReadMetadata": 4
+ },
+ "lock": {
+ "rlock": 3,
+ "lock": 0,
+ "timeout": 0
+ },
+ "pool_take": 2,
+ "fs": {
+ "open": 3,
+ "rename": 0,
+ "remove": 0
+ },
+ "events": {
+ "Added": 0,
+ "Modified": 0,
+ "Deleted": 0
+ }
+ },
+ "S8_delete_ts": {
+ "stmt": {
+ "DeleteMetadata": 1,
+ "DeletePayloads": 1,
+ "DeleteTimeSeriesContainerEntries": 1
+ },
+ "lock": {
+ "rlock": 0,
+ "lock": 1,
+ "timeout": 0
+ },
+ "pool_take": 2,
+ "fs": {
+ "open": 0,
+ "rename": 0,
+ "remove": 1
+ },
+ "events": {
+ "Added": 0,
+ "Modified": 0,
+ "Deleted": 1
+ }
+ }
+}
diff --git a/pkg/registry/file/utils.go b/pkg/registry/file/utils.go
index 993676ed7..1f8ef9c5c 100644
--- a/pkg/registry/file/utils.go
+++ b/pkg/registry/file/utils.go
@@ -2,6 +2,8 @@ package file
import (
"bytes"
+ "context"
+ "encoding/json"
"errors"
"fmt"
"path/filepath"
@@ -47,17 +49,47 @@ func NewKubernetesClient() (*kubernetes.Clientset, error) {
return kubernetes.NewForConfig(clusterConfig)
}
-func (h *ResourcesCleanupHandler) deleteMetadata(conn *sqlite.Conn, path string) (runtime.Object, error) {
+// deleteMetadata deletes the row (and the time_series rows of a
+// containerprofile) behind a reclaimed payload file (W9a of
+// write-gate-sharing §3.2): today's autocommit statements on the walk's
+// connection with no gate; one gated transaction with the row's JSON decoded
+// after release with one.
+func (h *ResourcesCleanupHandler) deleteMetadata(ctx context.Context, conn *sqlite.Conn, path string) (runtime.Object, error) {
key := payloadPathToKey(path)
metaOut := &PartialObjectMetadata{}
- err := DeleteMetadata(conn, key, metaOut)
+ _, _, kind, _, _, _ := K8sPathToKeys(key)
+ if h.gate == nil {
+ err := DeleteMetadata(conn, key, metaOut)
+ if err != nil {
+ return nil, fmt.Errorf("failed to delete metadata: %w", err)
+ }
+ if IsContainerProfileKind(kind) {
+ if err := DeleteTimeSeriesContainerEntries(conn, key); err != nil {
+ return nil, fmt.Errorf("failed to delete time series entries: %w", err)
+ }
+ }
+ return metaOut, nil
+ }
+ var raw []byte
+ err := h.write(ctx, conn, holdPathCleanup, resourceFromKey(key), func(_ context.Context, conn *sqlite.Conn) error {
+ var derr error
+ raw, derr = deleteMetadataRaw(conn, key)
+ if derr != nil {
+ return derr
+ }
+ if IsContainerProfileKind(kind) {
+ if terr := DeleteTimeSeriesContainerEntries(conn, key); terr != nil {
+ return fmt.Errorf("failed to delete time series entries: %w", terr)
+ }
+ }
+ return nil
+ })
if err != nil {
return nil, fmt.Errorf("failed to delete metadata: %w", err)
}
- _, _, kind, _, _, _ := K8sPathToKeys(key)
- if IsContainerProfileKind(kind) {
- if err := DeleteTimeSeriesContainerEntries(conn, key); err != nil {
- return nil, fmt.Errorf("failed to delete time series entries: %w", err)
+ if len(raw) > 0 {
+ if err := json.Unmarshal(raw, metaOut); err != nil {
+ return nil, fmt.Errorf("failed to delete metadata: %w", err)
}
}
return metaOut, nil
@@ -135,7 +167,7 @@ func payloadPathToKey(path string) string {
return path[len(DefaultStorageRoot) : len(path)-len(GobExt)]
}
-func (h *ResourcesCleanupHandler) readMetadata(conn *sqlite.Conn, payloadFilePath string) (*metav1.ObjectMeta, error) {
+func (h *ResourcesCleanupHandler) readMetadata(ctx context.Context, conn *sqlite.Conn, payloadFilePath string) (*metav1.ObjectMeta, error) {
key := payloadPathToKey(payloadFilePath)
metadataJSON, err := ReadMetadata(conn, key)
if err == nil {
@@ -153,8 +185,10 @@ func (h *ResourcesCleanupHandler) readMetadata(conn *sqlite.Conn, payloadFilePat
h.deleteFunc(h.appFs, payloadFilePath)
return nil, fmt.Errorf("failed to read metadata file: %w", err)
}
- // write to SQLite
- err = WriteJSON(conn, key, metadataJSON)
+ // write to SQLite (W9b: through the gate when there is one)
+ err = h.write(ctx, conn, holdPathCleanupMigrate, resourceFromKey(key), func(_ context.Context, conn *sqlite.Conn) error {
+ return WriteJSON(conn, key, metadataJSON)
+ })
if err != nil {
return nil, fmt.Errorf("failed to migrate metadata to SQLite: %w", err)
}
diff --git a/pkg/registry/file/workbudget.go b/pkg/registry/file/workbudget.go
new file mode 100644
index 000000000..e9fbc7507
--- /dev/null
+++ b/pkg/registry/file/workbudget.go
@@ -0,0 +1,29 @@
+package file
+
+import "sync/atomic"
+
+// stmtObserver is the Tier A work-budget hook (TestWorkBudget): every
+// sqlitex.Execute call site in this package and every transaction opener
+// reports its enclosing function's name through observeStmt. It is nil in
+// production, where observeStmt is one atomic load and a nil check and
+// allocates nothing. An atomic.Pointer rather than a bare func var so the
+// harness can install and clear it while a leaked shard or watch goroutine
+// from an earlier test is still executing statements, without a data race.
+var stmtObserver atomic.Pointer[func(string)]
+
+// observeStmt reports one statement (or transaction opener) execution at the
+// named site to the installed observer, if any.
+func observeStmt(site string) {
+ if f := stmtObserver.Load(); f != nil {
+ (*f)(site)
+ }
+}
+
+// setStmtObserver installs f as the statement observer; nil uninstalls it.
+func setStmtObserver(f func(string)) {
+ if f == nil {
+ stmtObserver.Store(nil)
+ return
+ }
+ stmtObserver.Store(&f)
+}
diff --git a/pkg/registry/file/workbudget_test.go b/pkg/registry/file/workbudget_test.go
new file mode 100644
index 000000000..2ac5fa9ba
--- /dev/null
+++ b/pkg/registry/file/workbudget_test.go
@@ -0,0 +1,681 @@
+package file
+
+// Tier A of the storage measurement harness: work budgets.
+//
+// TestWorkBudget runs eight single-goroutine scenarios over the hot paths of
+// this package and counts, per scenario, the SQL statements executed by Go
+// call site, the per-key lock acquisitions by mode, the connection-pool takes,
+// the payload-file operations and the watch events. The counts are compared
+// EXACTLY against testdata/workbudget.golden.json. Counts are machine
+// independent, so this runs on every `go test ./...` with no env gate.
+//
+// A golden change is a review item: the commit that changes it states each
+// delta and why, and the reviewer checks the attribution. Regenerate with
+//
+// go test ./pkg/registry/file -run TestWorkBudget -update
+//
+// Four invariants hold regardless of the golden (see checkFixedInvariants):
+// a no-op writes nothing (S2, S7); a frozen tick writes nothing to the base
+// (S3); one REST read is one read lock and one connection (S5); the
+// single-writer ROLLBACK recovery never fires on a passing path (all).
+//
+// Design: .omc/plans/raw-write-bypass-elimination.md, A.13.3.
+
+import (
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/kubescape/storage/pkg/utils"
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/watch"
+ "k8s.io/apiserver/pkg/storage"
+ "k8s.io/component-base/metrics/legacyregistry"
+ "zombiezen.com/go/sqlite"
+)
+
+var updateWorkBudget = flag.Bool("update", false, "rewrite testdata/workbudget.golden.json from this run's counts")
+
+const (
+ workBudgetGoldenPath = "testdata/workbudget.golden.json"
+ poolWaitMetricName = "storage_pool_wait_duration_seconds"
+ // budgetSettleWindow is how long the observers stay installed after a
+ // scenario returns: any observation arriving in it is contamination.
+ budgetSettleWindow = 50 * time.Millisecond
+ // budgetEventDrainQuiet / budgetEventDrainMax bound the watch drain.
+ budgetEventDrainQuiet = 50 * time.Millisecond
+ budgetEventDrainMax = 2 * time.Second
+)
+
+// workBudgetMu serialises budget scenarios: the two hooks and the metrics
+// registry are process-global.
+var workBudgetMu sync.Mutex
+
+// frozenTickInvariant enables fixed invariant 2 (a frozen tick writes nothing
+// to the base: no rename, no WriteJSON, no Modified). The property is the
+// frozen gate's (X-A, kubescape/storage#399); on a tree without it the tick
+// merges late reports into a Completed/Full base and rewrites it, which the
+// S3 golden row records. Flip to true when #399 lands.
+const frozenTickInvariant = false
+
+type lockBudget struct {
+ RLock int `json:"rlock"`
+ Lock int `json:"lock"`
+ Timeout int `json:"timeout"`
+}
+
+type fsBudget struct {
+ Open int `json:"open"`
+ Rename int `json:"rename"`
+ Remove int `json:"remove"`
+}
+
+type eventBudget struct {
+ Added int `json:"Added"`
+ Modified int `json:"Modified"`
+ Deleted int `json:"Deleted"`
+}
+
+// workBudget is one scenario's row in the golden. Stmt holds only non-zero
+// sites; an absent site is zero.
+type workBudget struct {
+ Stmt map[string]int `json:"stmt"`
+ Lock lockBudget `json:"lock"`
+ PoolTake int `json:"pool_take"`
+ Fs fsBudget `json:"fs"`
+ Events eventBudget `json:"events"`
+}
+
+// budgetRecorder is the sink behind every hook. It is mutex-guarded, never
+// touches testing.T, and once frozen records late arrivals by name instead of
+// counting them: a hook may fire from a shard goroutine, or from a goroutine
+// leaked by an earlier test that loaded the observer pointer before it was
+// cleared, after the scenario (or the test) has returned.
+type budgetRecorder struct {
+ mu sync.Mutex
+ frozen bool
+ stmt map[string]int
+ lock lockBudget
+ fs fsBudget
+ late []string
+}
+
+func newBudgetRecorder() *budgetRecorder {
+ return &budgetRecorder{stmt: map[string]int{}}
+}
+
+func (r *budgetRecorder) hit(site string, bump func()) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.frozen {
+ r.late = append(r.late, site)
+ return
+ }
+ bump()
+}
+
+func (r *budgetRecorder) observeStmt(site string) {
+ r.hit("stmt:"+site, func() { r.stmt[site]++ })
+}
+
+func (r *budgetRecorder) observeLock(mode, outcome string) {
+ r.hit("lock:"+mode+"/"+outcome, func() {
+ switch {
+ case outcome == utils.LockOutcomeTimeout:
+ r.lock.Timeout++
+ case mode == utils.LockModeRead:
+ r.lock.RLock++
+ default:
+ r.lock.Lock++
+ }
+ })
+}
+
+func (r *budgetRecorder) observeFs(op string) {
+ r.hit("fs:"+op, func() {
+ switch op {
+ case "open":
+ r.fs.Open++
+ case "rename":
+ r.fs.Rename++
+ case "remove":
+ r.fs.Remove++
+ }
+ })
+}
+
+// freeze stops counting and returns the counts so far.
+func (r *budgetRecorder) freeze() (map[string]int, lockBudget, fsBudget) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.frozen = true
+ stmt := make(map[string]int, len(r.stmt))
+ for k, v := range r.stmt {
+ stmt[k] = v
+ }
+ return stmt, r.lock, r.fs
+}
+
+func (r *budgetRecorder) lateArrivals() []string {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return append([]string(nil), r.late...)
+}
+
+// countingFs counts the payload-file operations StorageImpl issues through
+// its afero.Fs. One payload read is one open; one save is one open (the
+// staged temp file) plus one rename.
+type countingFs struct {
+ afero.Fs
+ rec *budgetRecorder
+}
+
+func (c *countingFs) Open(name string) (afero.File, error) {
+ c.rec.observeFs("open")
+ return c.Fs.Open(name)
+}
+
+func (c *countingFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
+ c.rec.observeFs("open")
+ return c.Fs.OpenFile(name, flag, perm)
+}
+
+func (c *countingFs) Rename(oldname, newname string) error {
+ c.rec.observeFs("rename")
+ return c.Fs.Rename(oldname, newname)
+}
+
+func (c *countingFs) Remove(name string) error {
+ c.rec.observeFs("remove")
+ return c.Fs.Remove(name)
+}
+
+// poolTakeSamples sums the sample count of storage_pool_wait_duration_seconds
+// over every label set. *sqlitemigration.Pool is concrete and cannot be
+// hooked, so pool takes are the delta of this histogram around a scenario;
+// "observed takes" are the sites that call metrics.ObservePoolWait.
+func poolTakeSamples(t *testing.T) int {
+ t.Helper()
+ families, err := legacyregistry.DefaultGatherer.Gather()
+ require.NoError(t, err)
+ total := 0
+ for _, mf := range families {
+ if mf.GetName() != poolWaitMetricName {
+ continue
+ }
+ for _, m := range mf.GetMetric() {
+ total += int(m.GetHistogram().GetSampleCount())
+ }
+ }
+ return total
+}
+
+// budgetEnv is one scenario's freshly built storage plus its fixture keys.
+type budgetEnv struct {
+ t *testing.T
+ ctx context.Context
+ s *StorageImpl
+ processor *ContainerProfileProcessor
+ rec *budgetRecorder
+
+ tpl softwarecomposition.ContainerProfile
+ baseNm string
+ ns string
+ baseKey string
+ now time.Time
+}
+
+const zeroReportTimestamp = "0001-01-01 00:00:00 +0000 UTC"
+
+// newBudgetEnv builds the scenario storage on newLoadStorage's production
+// shape (pool 10) with Workers 1 (single goroutine, deterministic), a positive
+// DeleteThreshold (so the tick's expired listing executes, as in production;
+// fixtures are stamped at run time so nothing is expired) and the counting
+// filesystem installed before anything is written.
+func newBudgetEnv(t *testing.T, rec *budgetRecorder) *budgetEnv {
+ t.Helper()
+ s, processor, pool := newLoadStorage(t, DefaultPoolSize)
+ t.Cleanup(func() { _ = pool.Close() })
+ s.appFs = &countingFs{Fs: s.appFs, rec: rec}
+ processor.Workers = 1
+ processor.DeleteThreshold = 24 * time.Hour
+
+ content, err := os.ReadFile("testdata/p1.json")
+ require.NoError(t, err)
+ var tpl softwarecomposition.ContainerProfile
+ require.NoError(t, json.Unmarshal(content, &tpl))
+ baseNm, _ := SplitProfileName(tpl.Name)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ t.Cleanup(cancel)
+ env := &budgetEnv{
+ t: t,
+ ctx: ctx,
+ s: s,
+ processor: processor,
+ rec: rec,
+ tpl: tpl,
+ baseNm: baseNm,
+ ns: tpl.Namespace,
+ baseKey: "/spdx.softwarecomposition.kubescape.io/containerprofile/" + tpl.Namespace + "/" + baseNm,
+ now: time.Now().Round(0),
+ }
+ // Warm the CollapseConfiguration cache (10 s TTL) so no scenario pays the
+ // CR lookup: production steady state, and independent of scenario order.
+ processor.CollapseSettings()
+ return env
+}
+
+func (e *budgetEnv) tsKey(suffix string) string { return e.baseKey + "-" + suffix }
+
+// reportTimestamp stamps report n of the series in the same format
+// ListTimeSeriesExpired compares against (time.Time.String, local zone), so
+// the lexical expiry check is consistent and nothing seeded here is expired.
+func (e *budgetEnv) reportTimestamp(n int) string {
+ return e.now.Add(time.Duration(n-10) * time.Minute).String()
+}
+
+// ts clones the template as report n of its series under suffix. Reports
+// chain (previous = report n-1) so consolidation sees one continuous series.
+func (e *budgetEnv) ts(suffix string, n int, status, completion string) *softwarecomposition.ContainerProfile {
+ p := e.tpl.DeepCopy()
+ p.Name = e.baseNm + "-" + suffix
+ p.ResourceVersion = ""
+ prev := zeroReportTimestamp
+ if n > 1 {
+ prev = e.reportTimestamp(n - 1)
+ }
+ p.Annotations[helpersv1.ReportTimestampMetadataKey] = e.reportTimestamp(n)
+ p.Annotations[helpersv1.PreviousReportTimestampMetadataKey] = prev
+ p.Annotations[helpersv1.StatusMetadataKey] = status
+ p.Annotations[helpersv1.CompletionMetadataKey] = completion
+ return p
+}
+
+// restCreate is the node-agent write: StorageImpl.Create of a TS profile.
+func (e *budgetEnv) restCreate(p *softwarecomposition.ContainerProfile) {
+ e.t.Helper()
+ key := "/spdx.softwarecomposition.kubescape.io/containerprofile/" + p.Namespace + "/" + p.Name
+ require.NoError(e.t, e.s.Create(e.ctx, key, p, nil, 0))
+}
+
+// seedTSDirect writes a TS profile and its time_series row without PreSave's
+// gate -- the shape a Create that raced ahead of consolidation leaves behind
+// (a late row under a Completed/Full base cannot be created through REST).
+func (e *budgetEnv) seedTSDirect(p *softwarecomposition.ContainerProfile) {
+ e.t.Helper()
+ conn, err := e.s.pool.Take(e.ctx)
+ require.NoError(e.t, err)
+ defer e.s.pool.Put(conn)
+ _, suffix := SplitProfileName(p.Name)
+ key := e.tsKey(suffix)
+ _, err = e.s.saveObject(context.Background(), conn, key, p, nil, "", priorityLow, holdPathLegacyCommit)
+ require.NoError(e.t, err)
+ require.NoError(e.t, WriteTimeSeriesEntry(conn, ContainerProfileKind, p.Namespace, e.baseNm,
+ p.Annotations[helpersv1.ReportSeriesIdMetadataKey], suffix,
+ p.Annotations[helpersv1.ReportTimestampMetadataKey],
+ p.Annotations[helpersv1.StatusMetadataKey],
+ p.Annotations[helpersv1.CompletionMetadataKey],
+ p.Annotations[helpersv1.PreviousReportTimestampMetadataKey], true))
+}
+
+func (e *budgetEnv) tick() {
+ e.t.Helper()
+ require.NoError(e.t, e.processor.ConsolidateTimeSeries(e.ctx))
+}
+
+// seedLearningBase creates the base profile the common tick operates on:
+// report 1 of the series, consolidated once, leaving a Learning base.
+func (e *budgetEnv) seedLearningBase() {
+ e.t.Helper()
+ e.restCreate(e.ts("r1", 1, helpersv1.Learning, helpersv1.Partial))
+ e.tick()
+}
+
+// seedCompletedFullBase consolidates a Completed/Full report 1, leaving a
+// Completed/Full base with no time_series rows.
+func (e *budgetEnv) seedCompletedFullBase() {
+ e.t.Helper()
+ e.restCreate(e.ts("r1", 1, helpersv1.Completed, helpersv1.Full))
+ e.tick()
+}
+
+func (e *budgetEnv) getBase() softwarecomposition.ContainerProfile {
+ e.t.Helper()
+ var out softwarecomposition.ContainerProfile
+ require.NoError(e.t, e.s.Get(e.ctx, e.baseKey, storage.GetOptions{}, &out))
+ return out
+}
+
+func (e *budgetEnv) withConn(fn func(conn *sqlite.Conn)) {
+ e.t.Helper()
+ conn, err := e.s.pool.Take(e.ctx)
+ require.NoError(e.t, err)
+ defer e.s.pool.Put(conn)
+ fn(conn)
+}
+
+// budgetScenario is one golden row: setup runs unobserved, run is measured.
+type budgetScenario struct {
+ name string
+ setup func(e *budgetEnv)
+ run func(e *budgetEnv)
+}
+
+func identityUpdate(input runtime.Object, _ storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ return input, nil, nil
+}
+
+var budgetScenarios = []budgetScenario{
+ {
+ // The common tick and the per-visited-key floor: a Learning base,
+ // three new reports of its one series, all with data.
+ name: "S1_learning_tick",
+ setup: func(e *budgetEnv) {
+ e.seedLearningBase()
+ for n := 2; n <= 4; n++ {
+ e.restCreate(e.ts(fmt.Sprintf("r%d", n), n, helpersv1.Learning, helpersv1.Partial))
+ }
+ },
+ run: func(e *budgetEnv) { e.tick() },
+ },
+ {
+ // The tick's own floor: nothing pending, so no key is visited.
+ name: "S2_empty_tick",
+ setup: func(e *budgetEnv) {},
+ run: func(e *budgetEnv) { e.tick() },
+ },
+ {
+ // A Completed/Full base with two late reports that carry data.
+ name: "S3_frozen_tick",
+ setup: func(e *budgetEnv) {
+ e.seedCompletedFullBase()
+ e.seedTSDirect(e.ts("r2", 2, helpersv1.Learning, helpersv1.Partial))
+ e.seedTSDirect(e.ts("r3", 3, helpersv1.Learning, helpersv1.Partial))
+ },
+ run: func(e *budgetEnv) { e.tick() },
+ },
+ {
+ // Divergent base: payload Completed/Full at RV n+1, metadata row
+ // Learning at RV n, plus S3's two late reports.
+ name: "S4_divergent_tick",
+ setup: func(e *budgetEnv) {
+ e.seedLearningBase()
+ var rowAtN []byte
+ e.withConn(func(conn *sqlite.Conn) {
+ var err error
+ rowAtN, err = ReadMetadata(conn, e.baseKey)
+ require.NoError(e.t, err)
+ })
+ require.NoError(e.t, e.s.GuaranteedUpdate(e.ctx, e.baseKey, &softwarecomposition.ContainerProfile{}, false, nil,
+ func(input runtime.Object, _ storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ p := input.(*softwarecomposition.ContainerProfile)
+ p.Annotations[helpersv1.StatusMetadataKey] = helpersv1.Completed
+ p.Annotations[helpersv1.CompletionMetadataKey] = helpersv1.Full
+ return p, nil, nil
+ }, nil))
+ e.withConn(func(conn *sqlite.Conn) {
+ require.NoError(e.t, WriteJSON(conn, e.baseKey, rowAtN))
+ })
+ e.seedTSDirect(e.ts("r2", 2, helpersv1.Learning, helpersv1.Partial))
+ e.seedTSDirect(e.ts("r3", 3, helpersv1.Learning, helpersv1.Partial))
+ },
+ run: func(e *budgetEnv) { e.tick() },
+ },
+ {
+ name: "S5_rest_get",
+ setup: func(e *budgetEnv) { e.seedLearningBase() },
+ run: func(e *budgetEnv) { e.getBase() },
+ },
+ {
+ name: "S6_rest_create_ts",
+ setup: func(e *budgetEnv) { e.seedLearningBase() },
+ run: func(e *budgetEnv) {
+ e.restCreate(e.ts("r2", 2, helpersv1.Learning, helpersv1.Partial))
+ },
+ },
+ {
+ name: "S7_noop_guaranteed_update",
+ setup: func(e *budgetEnv) { e.seedLearningBase() },
+ run: func(e *budgetEnv) {
+ require.NoError(e.t, e.s.GuaranteedUpdate(e.ctx, e.baseKey, &softwarecomposition.ContainerProfile{}, true, nil, identityUpdate, nil))
+ },
+ },
+ {
+ // One processed TS profile deleted the way the consolidation pass
+ // deletes it, under the worker's connection.
+ name: "S8_delete_ts",
+ setup: func(e *budgetEnv) {
+ e.seedLearningBase()
+ e.restCreate(e.ts("r2", 2, helpersv1.Learning, helpersv1.Partial))
+ },
+ run: func(e *budgetEnv) {
+ ctx, cleanup, err := e.processor.ContainerProfileStorage.WithConnection(e.ctx)
+ require.NoError(e.t, err)
+ defer cleanup()
+ require.NoError(e.t, e.processor.deleteProcessedTimeSeries(ctx, []string{e.tsKey("r2")}))
+ },
+ },
+}
+
+// runBudgetScenario builds a fresh storage, runs setup unobserved, installs
+// the observers, runs the scenario, then settles: drains the watch with a
+// deadline, keeps the observers installed for budgetSettleWindow, clears
+// them, and takes one final locked read. Any observation after the scenario
+// returned -- statement, lock, file op or pool take -- fails the test as
+// `contaminated: ` rather than surfacing as a golden mismatch.
+func runBudgetScenario(t *testing.T, sc budgetScenario) workBudget {
+ t.Helper()
+ workBudgetMu.Lock()
+ defer workBudgetMu.Unlock()
+
+ // Other tests flip this package var with a deferred restore; under false
+ // S6/S8 take a different path entirely, which must fail by name.
+ require.True(t, singleWriterEnabled, "%s: singleWriterEnabled must be true", sc.name)
+
+ rec := newBudgetRecorder()
+ env := newBudgetEnv(t, rec)
+ sc.setup(env)
+
+ w, err := env.s.Watch(env.ctx, "/", storage.ListOptions{})
+ require.NoError(t, err)
+ defer w.Stop()
+
+ poolBefore := poolTakeSamples(t)
+ setStmtObserver(rec.observeStmt)
+ utils.SetLockObserver(rec.observeLock)
+ // The counting fs was installed at construction so setup's writes go
+ // through it too; counting starts when the recorder is unfrozen, which it
+ // is from construction -- so reset what setup accumulated.
+ rec.mu.Lock()
+ rec.stmt = map[string]int{}
+ rec.lock = lockBudget{}
+ rec.fs = fsBudget{}
+ rec.mu.Unlock()
+
+ sc.run(env)
+
+ stmt, lock, fs := rec.freeze()
+ poolAfter := poolTakeSamples(t)
+ events := drainWatchEvents(w)
+
+ time.Sleep(budgetSettleWindow)
+ setStmtObserver(nil)
+ utils.SetLockObserver(nil)
+ if late := rec.lateArrivals(); len(late) > 0 {
+ t.Fatalf("%s: contaminated: %s (%d late observations after the scenario returned)", sc.name, late[0], len(late))
+ }
+ if settled := poolTakeSamples(t); settled != poolAfter {
+ t.Fatalf("%s: contaminated: pool_take (moved %d -> %d after the scenario returned)", sc.name, poolAfter, settled)
+ }
+
+ for site, n := range stmt {
+ if n == 0 {
+ delete(stmt, site)
+ }
+ }
+ return workBudget{
+ Stmt: stmt,
+ Lock: lock,
+ PoolTake: poolAfter - poolBefore,
+ Fs: fs,
+ Events: events,
+ }
+}
+
+// drainWatchEvents reads the watcher until it has been quiet for
+// budgetEventDrainQuiet, or budgetEventDrainMax in total. The dispatcher's
+// send is synchronous to the watcher's inCh; the receive from outCh is not.
+func drainWatchEvents(w watch.Interface) eventBudget {
+ var ev eventBudget
+ deadline := time.NewTimer(budgetEventDrainMax)
+ defer deadline.Stop()
+ quiet := time.NewTimer(budgetEventDrainQuiet)
+ defer quiet.Stop()
+ for {
+ select {
+ case e, ok := <-w.ResultChan():
+ if !ok {
+ return ev
+ }
+ switch e.Type {
+ case watch.Added:
+ ev.Added++
+ case watch.Modified:
+ ev.Modified++
+ case watch.Deleted:
+ ev.Deleted++
+ }
+ if !quiet.Stop() {
+ <-quiet.C
+ }
+ quiet.Reset(budgetEventDrainQuiet)
+ case <-quiet.C:
+ return ev
+ case <-deadline.C:
+ return ev
+ }
+ }
+}
+
+// checkFixedInvariants are the floors no golden update can lower.
+func checkFixedInvariants(t *testing.T, name string, b workBudget) {
+ t.Helper()
+ // 4: the dirty-connection ROLLBACK recovery never fires on a passing path.
+ require.Zero(t, b.Stmt["ROLLBACK"], "%s: invariant 4: ROLLBACK must be 0 in every scenario", name)
+ switch name {
+ case "S2_empty_tick", "S7_noop_guaranteed_update":
+ // 1: a no-op writes nothing.
+ require.Zero(t, b.Fs.Rename, "%s: invariant 1: a no-op renames nothing", name)
+ require.Zero(t, b.Stmt["WriteJSON"], "%s: invariant 1: a no-op writes no metadata", name)
+ require.Equal(t, eventBudget{}, b.Events, "%s: invariant 1: a no-op emits no events", name)
+ case "S3_frozen_tick":
+ // 2: a frozen tick writes nothing to the base.
+ if frozenTickInvariant {
+ require.Zero(t, b.Fs.Rename, "%s: invariant 2: a frozen tick renames nothing", name)
+ require.Zero(t, b.Stmt["WriteJSON"], "%s: invariant 2: a frozen tick writes no metadata", name)
+ require.Zero(t, b.Events.Modified, "%s: invariant 2: a frozen tick modifies nothing", name)
+ }
+ case "S5_rest_get":
+ // 3: one REST read is one read lock and one connection.
+ require.Equal(t, 1, b.Lock.RLock, "%s: invariant 3: one REST read is one read lock", name)
+ require.Zero(t, b.Lock.Lock, "%s: invariant 3: a REST read takes no write lock", name)
+ require.Equal(t, 1, b.PoolTake, "%s: invariant 3: one REST read is one connection", name)
+ }
+}
+
+func TestWorkBudget(t *testing.T) {
+ got := make(map[string]workBudget, len(budgetScenarios))
+ for _, sc := range budgetScenarios {
+ got[sc.name] = runBudgetScenario(t, sc)
+ checkFixedInvariants(t, sc.name, got[sc.name])
+ }
+
+ if *updateWorkBudget {
+ data, err := json.MarshalIndent(got, "", " ")
+ require.NoError(t, err)
+ require.NoError(t, os.MkdirAll(filepath.Dir(workBudgetGoldenPath), 0o755))
+ require.NoError(t, os.WriteFile(workBudgetGoldenPath, append(data, '\n'), 0o644))
+ t.Logf("wrote %s", workBudgetGoldenPath)
+ return
+ }
+
+ data, err := os.ReadFile(workBudgetGoldenPath)
+ require.NoError(t, err, "missing golden; run with -update to record it")
+ var want map[string]workBudget
+ require.NoError(t, json.Unmarshal(data, &want))
+ if diff := diffWorkBudgets(want, got); diff != "" {
+ t.Fatalf("work budget differs from %s (golden -> got); a golden change is a review item, state each delta and why:\n%s", workBudgetGoldenPath, diff)
+ }
+}
+
+// diffWorkBudgets lists every field that differs, one line each, in a stable
+// order. Empty means equal.
+func diffWorkBudgets(want, got map[string]workBudget) string {
+ var lines []string
+ names := map[string]struct{}{}
+ for n := range want {
+ names[n] = struct{}{}
+ }
+ for n := range got {
+ names[n] = struct{}{}
+ }
+ sorted := make([]string, 0, len(names))
+ for n := range names {
+ sorted = append(sorted, n)
+ }
+ sort.Strings(sorted)
+ for _, n := range sorted {
+ w, wok := want[n]
+ g, gok := got[n]
+ if !wok || !gok {
+ lines = append(lines, fmt.Sprintf("%s: in golden=%v in run=%v", n, wok, gok))
+ continue
+ }
+ sites := map[string]struct{}{}
+ for s := range w.Stmt {
+ sites[s] = struct{}{}
+ }
+ for s := range g.Stmt {
+ sites[s] = struct{}{}
+ }
+ siteList := make([]string, 0, len(sites))
+ for s := range sites {
+ siteList = append(siteList, s)
+ }
+ sort.Strings(siteList)
+ for _, s := range siteList {
+ if w.Stmt[s] != g.Stmt[s] {
+ lines = append(lines, fmt.Sprintf("%s.stmt.%s: %d -> %d", n, s, w.Stmt[s], g.Stmt[s]))
+ }
+ }
+ cmp := func(field string, a, b int) {
+ if a != b {
+ lines = append(lines, fmt.Sprintf("%s.%s: %d -> %d", n, field, a, b))
+ }
+ }
+ cmp("lock.rlock", w.Lock.RLock, g.Lock.RLock)
+ cmp("lock.lock", w.Lock.Lock, g.Lock.Lock)
+ cmp("lock.timeout", w.Lock.Timeout, g.Lock.Timeout)
+ cmp("pool_take", w.PoolTake, g.PoolTake)
+ cmp("fs.open", w.Fs.Open, g.Fs.Open)
+ cmp("fs.rename", w.Fs.Rename, g.Fs.Rename)
+ cmp("fs.remove", w.Fs.Remove, g.Fs.Remove)
+ cmp("events.Added", w.Events.Added, g.Events.Added)
+ cmp("events.Modified", w.Events.Modified, g.Events.Modified)
+ cmp("events.Deleted", w.Events.Deleted, g.Events.Deleted)
+ }
+ return strings.Join(lines, "\n")
+}
diff --git a/pkg/registry/file/writegate_acg2_on_test.go b/pkg/registry/file/writegate_acg2_on_test.go
new file mode 100644
index 000000000..582556ea9
--- /dev/null
+++ b/pkg/registry/file/writegate_acg2_on_test.go
@@ -0,0 +1,159 @@
+package file
+
+// AC-G2(on): the flag-on topology (config.ContainerProfileSqliteBackend) —
+// the ObjectStore and the legacy StorageImpl over one pool, the legacy
+// instance carrying the kind-ownership guard, one write gate. The lock
+// holder is the gate itself (CR-3). Repair readers must queue behind an
+// explicitly held gate and remain pending until release. Non-repair readers
+// must complete before release. The armed AC-G1 check rejects ungated writes.
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/armosec/armoapi-go/armotypes"
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/k8s-interface/names"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime"
+ "zombiezen.com/go/sqlite"
+)
+
+// newACG2OnEnv builds the flag-on environment: pool size ≥ 2 (R-9: the
+// reader holds its pool connection while queued on the gate; the gate's
+// dedicated connection is the other), the legacy instance with the guard,
+// the ObjectStore, the cleanup handler, the fixture handle, and AC-G1 armed.
+func newACG2OnEnv(t *testing.T) *acg2Env {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "acg2.sq3")
+ rec := &tableRecorder{}
+ pool := NewPoolWithOptions(dbPath, PoolOptions{Size: 4, BusyTimeout: acg2BusyTimeout, DisableAutoCheckpoint: true, Authorizer: rec.authorizer})
+ armUngatedWriteCheck(t, pool)
+ e := newACG2Base(t, pool, dbPath)
+ t.Cleanup(e.closePool)
+ e.rec = rec
+ e.legacy.SetForeignKinds(IsContainerProfileKind)
+ gate, err := newWriteGate(e.ctx, pool)
+ require.NoError(t, err)
+ e.gate = gate
+ e.legacy.SetWriteGate(gate)
+ e.cleanup.SetWriteGate(gate)
+ store, err := NewObjectStore(pool, dbPath, e.legacy.watchDispatcher, e.legacy.scheme, e.processor, e.legacy, gate, ObjectStoreOptions{CheckpointInterval: time.Hour})
+ require.NoError(t, err)
+ e.store = store
+ e.closeStore = func() {
+ require.NoError(t, store.Close())
+ require.NoError(t, gate.Close())
+ }
+ e.hold = func() func() {
+ held := make(chan struct{})
+ release := make(chan struct{})
+ done := make(chan error, 1)
+ go func() {
+ done <- e.gate.run(e.ctx, priorityHigh, "acg2-holder", "test", func(context.Context, *sqlite.Conn) error {
+ close(held)
+ select {
+ case <-release:
+ return nil
+ case <-e.ctx.Done():
+ return e.ctx.Err()
+ }
+ })
+ }()
+ select {
+ case <-held:
+ case <-time.After(acg2Deadline):
+ close(release)
+ t.Fatal("gate holder did not acquire the gate")
+ }
+ return sync.OnceFunc(func() {
+ close(release)
+ select {
+ case err := <-done:
+ require.NoError(t, err)
+ case <-time.After(acg2Deadline):
+ t.Fatal("gate holder did not finish")
+ }
+ })
+ }
+
+ return e
+}
+
+var acg2On = acg2Topology{
+ name: "on",
+ on: true,
+ build: newACG2OnEnv,
+}
+
+// acg2BaseProfile is testdata/p1.json as a base (consolidated) profile: the
+// object PreSave runs the SBOM lookup for.
+func acg2BaseProfile(t *testing.T) *softwarecomposition.ContainerProfile {
+ t.Helper()
+ content, err := os.ReadFile("testdata/p1.json")
+ require.NoError(t, err)
+ var p softwarecomposition.ContainerProfile
+ require.NoError(t, json.Unmarshal(content, &p))
+ p.Name, _ = SplitProfileName(p.Name)
+ delete(p.Annotations, helpersv1.ReportSeriesIdMetadataKey)
+ return &p
+}
+
+// acg2SbomKeyOf derives the sbomsyft key PreSave reads for p, exactly as
+// ContainerProfileProcessor.PreSave does.
+func acg2SbomKeyOf(e *acg2Env, p *softwarecomposition.ContainerProfile) string {
+ e.t.Helper()
+ slug, err := names.ImageInfoToSlug(p.Spec.ImageTag, p.Spec.ImageID)
+ require.NoError(e.t, err)
+ id := armotypes.ProfileIdentifier{
+ ProfileScope: armotypes.ProfileScope{
+ HostType: e.processor.HostType,
+ Cluster: p.Annotations[helpersv1.ClusterMetadataKey],
+ Namespace: e.processor.DefaultNamespace,
+ CloudAccountIdentifier: p.Annotations[helpersv1.CloudAccountIdentifierMetadataKey],
+ Region: p.Annotations[helpersv1.RegionMetadataKey],
+ HostID: p.Annotations[helpersv1.HostIDMetadataKey],
+ },
+ Name: slug,
+ }
+ return BuildContainerProfileKey(id, "sbomsyft")
+}
+
+// acg2OnReaders are the flag-on-only entry points: PreSave for a base CP,
+// the path bug 2 sat on (PreSave → GetSbom → legacy GetWithConn → get()).
+var acg2OnReaders = []acg2Reader{
+ {name: "PreSave(base-CP)", class: acg2GetReader, onOnly: true,
+ key: func(e *acg2Env, cell string) string {
+ p := acg2BaseProfile(e.t)
+ // Include the state marker in the image-derived SBOM path so the
+ // migration fixture distinguishes tool failure from success.
+ p.Spec.ImageTag += "-" + cell
+ e.preSaveProfile = p
+ return acg2SbomKeyOf(e, p)
+ },
+ obj: newSBOM,
+ prepare: func(e *acg2Env, key string) func() error {
+ p := e.preSaveProfile
+ return func() error {
+ ctx, cleanup, err := e.processor.ContainerProfileStorage.WithConnection(e.ctx)
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+ return e.processor.PreSave(ctx, p)
+ }
+ }},
+}
+
+// TestACG2_FlagOn is AC-G2(on).
+func TestACG2_FlagOn(t *testing.T) {
+ runACG2Matrix(t, acg2On, append(append([]acg2Reader(nil), acg2Readers...), acg2OnReaders...))
+}
+
+var _ runtime.Object
diff --git a/pkg/registry/file/writegate_acg2_test.go b/pkg/registry/file/writegate_acg2_test.go
new file mode 100644
index 000000000..9abbd43b6
--- /dev/null
+++ b/pkg/registry/file/writegate_acg2_test.go
@@ -0,0 +1,549 @@
+package file
+
+// AC-G2 of .omc/plans/write-gate-sharing.md: every read entry point returns
+// promptly while SQLite's write lock is held — the generalisation of the two
+// tests #401 added (TestGet_AbsentKeyDoesNotWaitOnWriter and the collapse
+// provider's). A table over (read entry point) × (key state), run once per
+// topology:
+//
+// - AC-G2(off) — no gate; a pool connection holds BEGIN IMMEDIATE until
+// the reader returns. Repair cells must attempt a write without changing
+// metadata; other synchronous readers must not attempt writes.
+// - AC-G2(on) — see writegate_acg2_on_test.go.
+//
+// Key states cover both found bugs' statement on every door it has: absent
+// (from #401), present, orphan (row, no file: W4), corrupt gob (row,
+// truncated file: W5), wrong-type with a failing migration tool (W6a/W7a, no
+// write on the list readers), wrong-type with a succeeding tool (the W6b/
+// W7b/W8 rewrites); and for the cleanup tick, referenced (pure read),
+// unreferenced (W9a) and file-without-row (W9b).
+
+import (
+ "bytes"
+ "context"
+ "encoding/gob"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ mapset "github.com/deckarep/golang-set/v2"
+ "github.com/goradd/maps"
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition/install"
+ "github.com/kubescape/storage/pkg/config"
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/storage"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitemigration"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+const (
+ // acg2BusyTimeout lets flag-off repairs finish while the writer stays held.
+ acg2BusyTimeout = time.Second
+ // acg2Prompt remains the bound used by the independent gate-refusal test.
+ acg2Prompt = 500 * time.Millisecond
+ // This is a deadlock guard, not a read-latency acceptance threshold.
+ acg2Deadline = 30 * time.Second
+
+ acg2Kind = "sbomsyft"
+ acg2Group = "spdx.softwarecomposition.kubescape.io"
+ acg2DefaultNS = "kubescape" // the SBOM namespace (ContainerProfileProcessor.DefaultNamespace)
+ acg2LiveImage = "sha256:live"
+ acg2DeadImage = "sha256:dead"
+)
+
+// acg2Topology is what differs between AC-G2(off) and AC-G2(on).
+type acg2Topology struct {
+ name string
+ on bool
+ // build constructs a fresh environment for one cell.
+ build func(t *testing.T) *acg2Env
+}
+
+type acg2Env struct {
+ t *testing.T
+ ctx context.Context
+ dbPath string
+ fs afero.Fs
+ pool *sqlitemigration.Pool
+ legacy *StorageImpl
+ fixture *sqlite.Conn
+ processor *ContainerProfileProcessor
+ preSaveProfile *softwarecomposition.ContainerProfile
+ cleanup *ResourcesCleanupHandler
+ fetcher *acg2Fetcher
+ // hold acquires SQLite's write lock the topology's way and returns the
+ // release; flag-on repairs are released after they queue.
+ hold func() (release func())
+ // closeStore runs before pool.Close (nil under flag-off).
+ closeStore func()
+ // The statement recorder observes both topologies; store and gate are flag-on only.
+ store *ObjectStore
+ gate *writeGate
+ rec *tableRecorder
+}
+
+// acg2Fetcher is the cleanup tick's ResourcesFetcher: one namespace that
+// lists the live image ids, plus the default namespace the SBOMs live in.
+type acg2Fetcher struct {
+ live mapset.Set[string]
+}
+
+func (f *acg2Fetcher) ListNamespaces(*sqlite.Conn) ([]string, error) {
+ return []string{"other", acg2DefaultNS}, nil
+}
+
+func (f *acg2Fetcher) FetchResources(string) (ResourceMaps, error) {
+ return ResourceMaps{
+ RunningContainerImageIds: f.live,
+ RunningInstanceIds: mapset.NewSet[string](),
+ RunningTemplateHash: mapset.NewSet[string](),
+ RunningWlidsToContainerNames: new(maps.SafeMap[string, mapset.Set[string]]),
+ }, nil
+}
+
+// newACG2Base builds the parts both topologies share: a pool with
+// acg2BusyTimeout on every connection, the legacy StorageImpl over an
+// in-memory filesystem, the ContainerProfileProcessor (its storage is set by
+// the topology), the cleanup handler and the fixture handle.
+func newACG2Base(t *testing.T, pool *sqlitemigration.Pool, dbPath string) *acg2Env {
+ t.Helper()
+ sch := runtime.NewScheme()
+ install.Install(sch)
+ wd := NewWatchDispatcher()
+ fs := afero.NewMemMapFs()
+ legacy := NewStorageImpl(fs, DefaultStorageRoot, pool, wd, sch).(*StorageImpl)
+ processor := NewContainerProfileProcessor(acg2ProcessorConfig(), nil)
+ processor.Interval = 0
+ processor.Workers = 1
+ fetcher := &acg2Fetcher{live: mapset.NewSet(acg2LiveImage)}
+ cleanup := NewResourcesCleanupHandler(fs, DefaultStorageRoot, pool, wd, 0, acg2DefaultNS, fetcher, false)
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ t.Cleanup(cancel)
+ return &acg2Env{
+ t: t, ctx: ctx, dbPath: dbPath, fs: fs, pool: pool, legacy: legacy,
+ fixture: openFixtureConn(t, pool, dbPath, acg2BusyTimeout),
+ processor: processor, cleanup: cleanup, fetcher: fetcher,
+ }
+}
+
+// closePool is the env's last cleanup: the store first (K-5), then the pool,
+// which must return promptly once every connection is back.
+func (e *acg2Env) closePool() {
+ if e.closeStore != nil {
+ e.closeStore()
+ }
+ done := make(chan error, 1)
+ go func() { done <- e.pool.Close() }()
+ select {
+ case err := <-done:
+ require.NoError(e.t, err)
+ case <-time.After(30 * time.Second):
+ e.t.Errorf("pool.Close did not return: a connection was never returned")
+ }
+}
+
+// acg2Off is AC-G2(off): today's production topology, no gate.
+var acg2Off = acg2Topology{
+ name: "off",
+ build: func(t *testing.T) *acg2Env {
+ dbPath := filepath.Join(t.TempDir(), "acg2.sq3")
+ rec := &tableRecorder{}
+ pool := NewPoolWithOptions(dbPath, PoolOptions{Size: 4, BusyTimeout: acg2BusyTimeout, Authorizer: rec.authorizer})
+ e := newACG2Base(t, pool, dbPath)
+ e.rec = rec
+ t.Cleanup(e.closePool)
+ e.processor.SetStorage(NewContainerProfileStorageImpl(e.legacy, pool))
+ // The holder: a pool connection in an open BEGIN IMMEDIATE, the state
+ // a legacy writer is in for the length of its transaction.
+ e.hold = func() func() {
+ conn, err := pool.Take(e.ctx)
+ require.NoError(t, err)
+ conn.SetInterrupt(nil)
+ endFn, err := sqlitex.ImmediateTransaction(conn)
+ require.NoError(t, err)
+ return sync.OnceFunc(func() {
+ var txErr error
+ endFn(&txErr)
+ require.NoError(t, txErr)
+ pool.Put(conn)
+ })
+ }
+ return e
+ },
+}
+
+// ---- key states ----
+
+func (e *acg2Env) payloadPath(key string) string {
+ return makePayloadPath(filepath.Join(DefaultStorageRoot, key))
+}
+
+// seedRow writes the metadata row exactly as writeMetadata does, through the
+// fixture handle.
+func (e *acg2Env) seedRow(key string, obj runtime.Object) {
+ e.t.Helper()
+ raw, err := json.Marshal(extractFields(obj, []string{"ObjectMeta", "SchemaVersion"}))
+ require.NoError(e.t, err)
+ require.NoError(e.t, WriteJSON(e.fixture, key, raw))
+}
+
+func gobBytes(t *testing.T, obj runtime.Object) []byte {
+ t.Helper()
+ var buf bytes.Buffer
+ require.NoError(t, gob.NewEncoder(&buf).Encode(obj))
+ return buf.Bytes()
+}
+
+func (e *acg2Env) seedPayload(key string, payload []byte) {
+ e.t.Helper()
+ require.NoError(e.t, afero.WriteFile(e.fs, e.payloadPath(key), payload, 0644))
+}
+
+func (e *acg2Env) seedPresent(key string, obj runtime.Object) {
+ e.seedRow(key, obj)
+ e.seedPayload(key, gobBytes(e.t, obj))
+}
+
+type acg2KeyState struct {
+ name string
+ seed func(e *acg2Env, key string, obj runtime.Object)
+ // cleanupOnly states exist for the cleanup tick's referencedness axis.
+ cleanupOnly bool
+}
+
+var acg2States = []acg2KeyState{
+ {name: "absent", seed: func(*acg2Env, string, runtime.Object) {}},
+ {name: "present", seed: func(e *acg2Env, key string, obj runtime.Object) { e.seedPresent(key, obj) }},
+ {name: "orphan", seed: func(e *acg2Env, key string, obj runtime.Object) {
+ e.seedPresent(key, obj)
+ require.NoError(e.t, e.fs.Remove(e.payloadPath(key)))
+ }},
+ {name: "corrupt", seed: func(e *acg2Env, key string, obj runtime.Object) {
+ // An empty payload (created, never written: a crash before the first
+ // O_DIRECT block) decodes to io.EOF, the branch get() treats as
+ // corrupt (W5). A partially written gob fails with gob's own "extra
+ // data"/"type" errors, which get() returns without repairing.
+ e.seedRow(key, obj)
+ e.seedPayload(key, nil)
+ }},
+ {name: "wrongtype-toolfails", seed: func(e *acg2Env, key string, obj runtime.Object) {
+ e.seedRow(key, obj)
+ e.seedPayload(key, gobPayloadNeedingMigration(e.t))
+ }},
+ {name: "wrongtype-toolsucceeds", seed: func(e *acg2Env, key string, obj runtime.Object) {
+ e.seedRow(key, obj)
+ e.seedPayload(key, gobPayloadNeedingMigration(e.t))
+ }},
+ {name: "present-referenced", cleanupOnly: true, seed: func(e *acg2Env, key string, obj runtime.Object) {
+ obj.(metav1.Object).SetAnnotations(map[string]string{helpersv1.ImageIDMetadataKey: acg2LiveImage})
+ e.seedPresent(key, obj)
+ }},
+ {name: "present-unreferenced", cleanupOnly: true, seed: func(e *acg2Env, key string, obj runtime.Object) {
+ obj.(metav1.Object).SetAnnotations(map[string]string{helpersv1.ImageIDMetadataKey: acg2DeadImage})
+ e.seedPresent(key, obj)
+ }},
+ {name: "file-without-row", cleanupOnly: true, seed: func(e *acg2Env, key string, obj runtime.Object) {
+ obj.(metav1.Object).SetAnnotations(map[string]string{helpersv1.ImageIDMetadataKey: acg2LiveImage})
+ e.seedPayload(key, gobBytes(e.t, obj))
+ raw, err := json.Marshal(extractFields(obj, []string{"ObjectMeta", "SchemaVersion"}))
+ require.NoError(e.t, err)
+ sidecar := strings.TrimSuffix(e.payloadPath(key), GobExt) + MetadataExt
+ require.NoError(e.t, afero.WriteFile(e.fs, sidecar, raw, 0644))
+ }},
+}
+
+// installACG2MigrationTool points migrationBinaryPath at one script for the
+// whole matrix: it fails for a payload whose path names the tool-fails state
+// and prints a valid object otherwise, so cells need no per-cell package
+// state and can run in parallel.
+func installACG2MigrationTool(t *testing.T) {
+ t.Helper()
+ scriptPath := filepath.Join(t.TempDir(), "acg2-migration.sh")
+ script := "#!/bin/sh\ncase \"$2\" in *toolfails*) echo 'acg2: tool fails' >&2; exit 1;; esac\n" +
+ "printf '%s' '{\"metadata\":{\"name\":\"migrated\",\"namespace\":\"" + acg2DefaultNS + "\"}}'\n"
+ require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0755))
+ old := migrationBinaryPath
+ migrationBinaryPath = scriptPath
+ t.Cleanup(func() { migrationBinaryPath = old })
+}
+
+// ---- read entry points ----
+
+type acg2ReaderClass int
+
+const (
+ // acg2GetReader reaches get(): repairs orphan/corrupt/wrong-type keys.
+ acg2GetReader acg2ReaderClass = iota
+ // acg2ListReader walks payload files (appendGobObjectFromFile): the only
+ // write is the W8 rewrite after a succeeding migration tool.
+ acg2ListReader
+ // acg2MetaReader reads the metadata row only: never writes.
+ acg2MetaReader
+ // acg2CleanupReader is one cleanup tick: writes on the unreferenced and
+ // file-without-row states.
+ acg2CleanupReader
+)
+
+type acg2Reader struct {
+ name string
+ class acg2ReaderClass
+ onOnly bool
+ // key returns the key whose state the cell seeds; obj a fresh object of
+ // its kind.
+ key func(e *acg2Env, cell string) string
+ obj func() runtime.Object
+ // prepare, when set, runs before the key is seeded and before the lock is
+ // held, and returns the measured read (for readers whose construction
+ // itself reads, like the collapse provider's prime).
+ prepare func(e *acg2Env, key string) func() error
+ run func(e *acg2Env, key string) error
+}
+
+func (r acg2Reader) repairs(st acg2KeyState) bool {
+ switch r.class {
+ case acg2GetReader:
+ return st.name == "orphan" || st.name == "corrupt" || strings.HasPrefix(st.name, "wrongtype")
+ case acg2ListReader:
+ return st.name == "wrongtype-toolsucceeds"
+ case acg2CleanupReader:
+ return st.name == "present-unreferenced" || st.name == "file-without-row"
+ }
+ return false
+}
+
+func sbomKey(e *acg2Env, cell string) string {
+ return K8sKeysToPath("", acg2Group, acg2Kind, "", acg2DefaultNS, cell)
+}
+
+func newSBOM() runtime.Object {
+ return &softwarecomposition.SBOMSyft{ObjectMeta: metav1.ObjectMeta{Namespace: acg2DefaultNS}}
+}
+
+func sbomPrefix() string { return "/" + acg2Group + "/" + acg2Kind + "/" + acg2DefaultNS }
+
+// acg2Readers are the entry points common to both topologies; the flag-on
+// file appends its own.
+var acg2Readers = []acg2Reader{
+ {name: "Get", class: acg2GetReader, key: sbomKey, obj: newSBOM, run: func(e *acg2Env, key string) error {
+ return e.legacy.Get(e.ctx, key, storage.GetOptions{}, &softwarecomposition.SBOMSyft{})
+ }},
+ {name: "Get(metadata)", class: acg2MetaReader, key: sbomKey, obj: newSBOM, run: func(e *acg2Env, key string) error {
+ return e.legacy.Get(e.ctx, key, storage.GetOptions{ResourceVersion: softwarecomposition.ResourceVersionMetadata}, &softwarecomposition.SBOMSyft{})
+ }},
+ {name: "GetWithConn", class: acg2GetReader, key: sbomKey, obj: newSBOM, run: func(e *acg2Env, key string) error {
+ conn, err := e.pool.Take(e.ctx)
+ if err != nil {
+ return err
+ }
+ defer e.pool.Put(conn)
+ return e.legacy.GetWithConn(e.ctx, conn, key, storage.GetOptions{}, &softwarecomposition.SBOMSyft{})
+ }},
+ {name: "get(noLock)", class: acg2GetReader, key: sbomKey, obj: newSBOM, run: func(e *acg2Env, key string) error {
+ conn, err := e.pool.Take(e.ctx)
+ if err != nil {
+ return err
+ }
+ defer e.pool.Put(conn)
+ return e.legacy.get(e.ctx, conn, key, storage.GetOptions{}, &softwarecomposition.SBOMSyft{}, noLock)
+ }},
+ {name: "GetList(metadata)", class: acg2MetaReader, key: sbomKey, obj: newSBOM, run: func(e *acg2Env, key string) error {
+ return e.legacy.GetList(e.ctx, sbomPrefix(), storage.ListOptions{ResourceVersion: softwarecomposition.ResourceVersionMetadata, Recursive: true}, &softwarecomposition.SBOMSyftList{})
+ }},
+ {name: "GetList(fullSpec)", class: acg2GetReader, key: sbomKey, obj: newSBOM, run: func(e *acg2Env, key string) error {
+ return e.legacy.GetList(e.ctx, sbomPrefix(), storage.ListOptions{ResourceVersion: softwarecomposition.ResourceVersionFullSpec, Recursive: true}, &softwarecomposition.SBOMSyftList{})
+ }},
+ {name: "GetByNamespace", class: acg2ListReader, key: sbomKey, obj: newSBOM, run: func(e *acg2Env, key string) error {
+ return e.legacy.GetByNamespace(e.ctx, acg2Group, acg2Kind, acg2DefaultNS, &softwarecomposition.SBOMSyftList{})
+ }},
+ {name: "GetByCluster", class: acg2ListReader, key: sbomKey, obj: newSBOM, run: func(e *acg2Env, key string) error {
+ return e.legacy.GetByCluster(e.ctx, acg2Group, acg2Kind, &softwarecomposition.SBOMSyftList{})
+ }},
+ {name: "Count", class: acg2MetaReader, key: sbomKey, obj: newSBOM, run: func(e *acg2Env, key string) error {
+ _, err := e.legacy.Count(sbomPrefix())
+ return err
+ }},
+ {name: "Watch", class: acg2MetaReader, key: sbomKey, obj: newSBOM, run: func(e *acg2Env, key string) error {
+ w, err := e.legacy.Watch(e.ctx, key, storage.ListOptions{})
+ if err == nil {
+ w.Stop()
+ }
+ return err
+ }},
+ {name: "GetSbom", class: acg2GetReader, key: sbomKey, obj: newSBOM, run: func(e *acg2Env, key string) error {
+ ctx, cleanup, err := e.processor.ContainerProfileStorage.WithConnection(e.ctx)
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+ _, err = e.processor.ContainerProfileStorage.GetSbom(ctx, key)
+ return err
+ }},
+ {name: "collapse-provider", class: acg2MetaReader,
+ key: func(*acg2Env, string) string { return collapseConfigurationKey(DefaultCollapseConfigurationName) },
+ obj: func() runtime.Object {
+ return &softwarecomposition.CollapseConfiguration{ObjectMeta: metav1.ObjectMeta{Name: DefaultCollapseConfigurationName}}
+ },
+ prepare: func(e *acg2Env, key string) func() error {
+ // The provider primes synchronously at construction (on the absent
+ // key, as in #401's test); the key state is seeded after that, so it
+ // is the background refresh that meets it. The refresh is off the
+ // caller's goroutine (#401): the closure is prompt in every key
+ // state, and its Get is what AC-G1 watches. Both Gets (prime +
+ // refresh) are joined after the measurement, before the env closes.
+ cs := &countingGetStorage{Interface: e.legacy}
+ provider := NewCRDCollapseSettingsProvider(cs)
+ e.t.Cleanup(func() { joinRefresh(e.t, cs) })
+ return func() error { provider(); return nil }
+ }},
+ {name: "cleanup-tick", class: acg2CleanupReader, key: sbomKey, obj: newSBOM, run: func(e *acg2Env, key string) error {
+ return e.cleanup.CleanupTask(e.ctx, map[string][]TypeCleanupHandlerFunc{acg2Kind: {deleteByImageId}})
+ }},
+}
+
+func acg2ReaderStates(r acg2Reader) []acg2KeyState {
+ var out []acg2KeyState
+ for _, st := range acg2States {
+ if st.cleanupOnly == (r.class == acg2CleanupReader) {
+ out = append(out, st)
+ }
+ }
+ return out
+}
+
+// runACG2Matrix runs every (reader × key state) cell of topo, each in a fresh
+// environment, in parallel. Package state the cells share (the migration
+// tool, the collapse TTL) is set once here and restored after the last cell.
+func runACG2Matrix(t *testing.T, topo acg2Topology, readers []acg2Reader) {
+ t.Helper()
+ installACG2MigrationTool(t)
+ oldTTL := collapseSettingsTTL
+ collapseSettingsTTL = time.Nanosecond
+ t.Cleanup(func() { collapseSettingsTTL = oldTTL })
+
+ for _, r := range readers {
+ if r.onOnly && !topo.on {
+ continue
+ }
+ for _, st := range acg2ReaderStates(r) {
+ r, st := r, st
+ t.Run(r.name+"/"+st.name, func(t *testing.T) {
+ t.Parallel()
+ e := topo.build(t)
+ cell := strings.NewReplacer("(", "-", ")", "", "/", "-").Replace(r.name + "-" + st.name)
+ key := r.key(e, cell)
+ obj := r.obj()
+ if m, ok := obj.(metav1.Object); ok && m.GetName() == "" {
+ _, _, _, _, _, name := K8sPathToKeys(key)
+ m.SetName(name)
+ }
+ run := func() error { return r.run(e, key) }
+ if r.prepare != nil {
+ run = r.prepare(e, key)
+ }
+ st.seed(e, key, obj)
+
+ repair := r.repairs(st)
+ before, beforeErr := ReadMetadata(e.fixture, key)
+ if beforeErr != nil {
+ require.ErrorIs(t, beforeErr, ErrMetadataNotFound)
+ }
+ release := e.hold()
+ // Release before registered cleanups even when an assertion fails.
+ defer release()
+ mark := e.rec.mark()
+ start := time.Now()
+ done := make(chan error, 1)
+ stopped := make(chan struct{})
+ go func() {
+ defer close(stopped)
+ done <- run()
+ }()
+ defer func() {
+ release()
+ select {
+ case <-stopped:
+ case <-time.After(acg2Deadline):
+ t.Error("reader did not stop after release")
+ }
+ }()
+ if topo.on && repair {
+ require.Eventually(t, func() bool {
+ high, low := e.gate.queued()
+ if high+low > 0 {
+ return true
+ }
+ select {
+ case <-stopped:
+ return true
+ default:
+ return false
+ }
+ }, acg2Deadline, time.Millisecond, "repair must queue behind the held gate")
+ select {
+ case err := <-done:
+ t.Fatalf("repair returned before the gate was released: %v", err)
+ default:
+ }
+ release()
+ }
+ var err error
+ select {
+ case err = <-done:
+ case <-time.After(acg2Deadline):
+ t.Fatal("reader did not complete")
+ }
+ elapsed := time.Since(start)
+ // The collapse provider returns cached settings and starts a separate
+ // refresh, which may repair. Its contract here is caller completion
+ // while held; prepare registers a cleanup to join that refresh.
+ if r.name != "collapse-provider" {
+ wrote := false
+ for _, action := range e.rec.since(mark) {
+ wrote = wrote || isWriteOp(action.op)
+ }
+ assert.Equal(t, repair, wrote, "repair cells must attempt writes; pure reads must not")
+ }
+ if topo.on && repair && (st.name == "orphan" || st.name == "corrupt" || st.name == "wrongtype-toolfails" || st.name == "present-unreferenced") {
+ _, afterErr := ReadMetadata(e.fixture, key)
+ assert.ErrorIs(t, afterErr, ErrMetadataNotFound, "queued repair must delete the metadata after release")
+ }
+ if !topo.on && repair {
+ after, afterErr := ReadMetadata(e.fixture, key)
+ assert.Equal(t, before, after, "repair cannot change metadata while SQLite's writer is held")
+ assert.Equal(t, beforeErr, afterErr)
+ }
+ release()
+
+ kind := "read"
+ if repair {
+ kind = "repair"
+ }
+ t.Logf("AC-G2(%s) %s × %s: %s cell, %s (err=%v)", topo.name, r.name, st.name, kind, elapsed, err)
+ if st.name == "present" && r.class != acg2MetaReader {
+ assert.NoError(t, err, "a present key must read")
+ }
+ })
+ }
+ }
+}
+
+// TestACG2_FlagOff is AC-G2(off): the legacy topology's residual, pinned.
+func TestACG2_FlagOff(t *testing.T) {
+ runACG2Matrix(t, acg2Off, acg2Readers)
+}
+
+// acg2ProcessorConfig is the processor config both topologies use.
+func acg2ProcessorConfig() config.Config {
+ return config.Config{DefaultNamespace: acg2DefaultNS, MaxContainerProfileSize: 40000}
+}
diff --git a/pkg/registry/file/writegate_fixture_test.go b/pkg/registry/file/writegate_fixture_test.go
new file mode 100644
index 000000000..e0f5db7cf
--- /dev/null
+++ b/pkg/registry/file/writegate_fixture_test.go
@@ -0,0 +1,38 @@
+package file
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitemigration"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+// openFixtureConn opens a connection to pool's database file that is NOT a
+// pool connection: the one handle tests may seed state through. A fixture
+// write on a pool connection would prepare (and cache) a write statement
+// there, and the code under test re-executing the same statement on that
+// connection would then never re-invoke the authorizer the write-gate
+// invariant (AC-G1) is built on — so fixtures never touch the pool.
+//
+// The pool's schema migration runs on the first Take; the handle is opened
+// only after that, or fixture statements race the migration and fail on a
+// missing table. Autocheckpoint is off on the handle so a fixture COMMIT does
+// not checkpoint under the tests that measure the background checkpointer.
+func openFixtureConn(t *testing.T, pool *sqlitemigration.Pool, dbPath string, busyTimeout time.Duration) *sqlite.Conn {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ c, err := pool.Take(ctx)
+ require.NoError(t, err, "pool did not become ready")
+ pool.Put(c)
+ conn, err := sqlite.OpenConn(dbPath, sqlite.OpenReadWrite|sqlite.OpenWAL)
+ require.NoError(t, err)
+ conn.SetBusyTimeout(busyTimeout)
+ require.NoError(t, sqlitex.ExecuteTransient(conn, `PRAGMA wal_autocheckpoint=0`, nil))
+ t.Cleanup(func() { _ = conn.Close() })
+ return conn
+}
diff --git a/pkg/registry/file/writegate_registry.go b/pkg/registry/file/writegate_registry.go
new file mode 100644
index 000000000..a8d5cb66d
--- /dev/null
+++ b/pkg/registry/file/writegate_registry.go
@@ -0,0 +1,114 @@
+package file
+
+// One write gate per pool, and the instrument that proves it is the only
+// writer (design: .omc/plans/write-gate-sharing.md §3.6 R-7, §7 AC-G1).
+//
+// SQLite has one write lock per database. Every write that goes through the
+// gate is queued FIFO in Go; every write that does not acquires the same lock
+// through SQLite's busy handler, which polls every 1…100 ms, is not
+// ctx-bounded, and loses systematically against a gate that commits
+// continuously — a stall of the whole busy timeout (60 s in production) that
+// the gate's own instruments cannot see. So:
+//
+// - a pool may carry at most one live gate (two gates on two dedicated
+// connections would each gate their own writes while busy-waiting
+// against each other — the class, visible from neither side);
+// - every INSERT/UPDATE/DELETE prepared on a connection the pool's gate has
+// never owned is counted (storage_sqlite_ungated_write_total, the
+// production canary) and handed to the test observer that turns it into
+// a failure with the stack of the site that prepared it.
+//
+// *sqlitemigration.Pool is a third-party type with nothing to register on,
+// so the registry is a package-level map keyed by the pool pointer.
+
+import (
+ "errors"
+ "strings"
+ "sync"
+ "sync/atomic"
+
+ "github.com/kubescape/storage/pkg/metrics"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitemigration"
+)
+
+// errGateExists is returned by newWriteGate for a pool that already has a
+// live gate.
+var errGateExists = errors.New("write gate: the pool already has a live write gate")
+
+var gateRegistry = struct {
+ mu sync.Mutex
+ gates map[*sqlitemigration.Pool]*writeGate
+}{gates: map[*sqlitemigration.Pool]*writeGate{}}
+
+func registerWriteGate(pool *sqlitemigration.Pool, g *writeGate) error {
+ gateRegistry.mu.Lock()
+ defer gateRegistry.mu.Unlock()
+ if _, exists := gateRegistry.gates[pool]; exists {
+ return errGateExists
+ }
+ gateRegistry.gates[pool] = g
+ return nil
+}
+
+func unregisterWriteGate(pool *sqlitemigration.Pool, g *writeGate) {
+ gateRegistry.mu.Lock()
+ defer gateRegistry.mu.Unlock()
+ if gateRegistry.gates[pool] == g {
+ delete(gateRegistry.gates, pool)
+ }
+}
+
+// gateForPool returns the pool's live gate, or nil.
+func gateForPool(pool *sqlitemigration.Pool) *writeGate {
+ gateRegistry.mu.Lock()
+ defer gateRegistry.mu.Unlock()
+ return gateRegistry.gates[pool]
+}
+
+// writeStatement is one INSERT/UPDATE/DELETE prepared on a pool connection.
+// seq orders it against the gate's Close (writeGate.owns).
+type writeStatement struct {
+ pool *sqlitemigration.Pool
+ conn *sqlite.Conn
+ op sqlite.OpType
+ table string
+ seq uint64
+}
+
+var writeStmtSeq atomic.Uint64
+
+// writeStmtObserver receives every write statement prepared on any pool
+// connection; nil in production. Tests install the AC-G1 ledger here.
+var writeStmtObserver atomic.Pointer[func(writeStatement)]
+
+// writeGateObserver receives every gate at construction; nil in production.
+var writeGateObserver atomic.Pointer[func(*writeGate)]
+
+// noteWriteStatement is the write authorizer's sink (sqlite.go).
+func noteWriteStatement(pool *sqlitemigration.Pool, conn *sqlite.Conn, op sqlite.OpType, table string) {
+ if strings.HasPrefix(table, "sqlite_") {
+ // Schema migration bookkeeping (CREATE/ALTER TABLE write sqlite_master)
+ // runs on the migration connection at pool open, before any traffic.
+ return
+ }
+ rec := writeStatement{pool: pool, conn: conn, op: op, table: table, seq: writeStmtSeq.Add(1)}
+ if g := gateForPool(pool); g != nil && !g.owns(conn, rec.seq) {
+ metrics.IncSqliteUngatedWrite(writeOpLabel(op), table)
+ }
+ if obs := writeStmtObserver.Load(); obs != nil {
+ (*obs)(rec)
+ }
+}
+
+func writeOpLabel(op sqlite.OpType) string {
+ switch op {
+ case sqlite.OpInsert:
+ return "insert"
+ case sqlite.OpUpdate:
+ return "update"
+ case sqlite.OpDelete:
+ return "delete"
+ }
+ return "other"
+}
diff --git a/pkg/registry/file/writegate_tg1_test.go b/pkg/registry/file/writegate_tg1_test.go
new file mode 100644
index 000000000..117959fde
--- /dev/null
+++ b/pkg/registry/file/writegate_tg1_test.go
@@ -0,0 +1,188 @@
+package file
+
+// T-G1 of .omc/plans/write-gate-sharing.md: one table-driven test per
+// enumerated write site outside the gate (§1, W1–W9b), run under the flag-on
+// topology with the statement recorder on every pool connection and state
+// seeded only through the fixture handle. Each site asserts (i) zero
+// INSERT/UPDATE/DELETE on any connection the gate never owned, (ii) the
+// site's own statement on a gate-owned connection, and (iii) — once, for the
+// statements themselves — that the delete/read use the primary key.
+//
+// On the prototype topology before gate sharing (R0) every site is red at
+// (i): that failing run is the evidence the bug class exists there. After R1
+// every site is green.
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/storage"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+type tg1Write struct {
+ op sqlite.OpType
+ table string
+}
+
+type tg1Site struct {
+ name string
+ state string // an acg2States name, "" for nothing seeded
+ run func(e *acg2Env, key string) error
+ // want is the statement the site must run on a gate-owned connection.
+ want tg1Write
+}
+
+func acg2StateByName(t *testing.T, name string) acg2KeyState {
+ t.Helper()
+ for _, st := range acg2States {
+ if st.name == name {
+ return st
+ }
+ }
+ t.Fatalf("no key state %q", name)
+ return acg2KeyState{}
+}
+
+func tg1Update(input runtime.Object, _ storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ m := input.(metav1.Object)
+ labels := m.GetLabels()
+ if labels == nil {
+ labels = map[string]string{}
+ }
+ labels["tg1"] = "updated"
+ m.SetLabels(labels)
+ return input, nil, nil
+}
+
+var tg1Sites = []tg1Site{
+ {name: "W1-create", run: func(e *acg2Env, key string) error {
+ obj := newSBOM().(*softwarecomposition.SBOMSyft)
+ _, _, _, _, _, obj.Name = K8sPathToKeys(key)
+ return e.legacy.Create(e.ctx, key, obj, nil, 0)
+ }, want: tg1Write{sqlite.OpInsert, "metadata"}},
+ {name: "W1-update", state: "present", run: func(e *acg2Env, key string) error {
+ return e.legacy.GuaranteedUpdate(e.ctx, key, &softwarecomposition.SBOMSyft{}, false, nil, tg1Update, nil)
+ }, want: tg1Write{sqlite.OpInsert, "metadata"}},
+ {name: "W3-Delete", state: "present", run: func(e *acg2Env, key string) error {
+ return e.legacy.Delete(e.ctx, key, &softwarecomposition.SBOMSyft{}, nil, nil, nil, storage.DeleteOptions{})
+ }, want: tg1Write{sqlite.OpDelete, "metadata"}},
+ {name: "W3-DeleteWithConn", state: "present", run: func(e *acg2Env, key string) error {
+ conn, err := e.pool.Take(e.ctx)
+ if err != nil {
+ return err
+ }
+ defer e.pool.Put(conn)
+ return e.legacy.DeleteWithConn(e.ctx, conn, key, &softwarecomposition.SBOMSyft{}, nil, nil, nil, storage.DeleteOptions{})
+ }, want: tg1Write{sqlite.OpDelete, "metadata"}},
+ {name: "W4-orphan-prune", state: "orphan", run: func(e *acg2Env, key string) error {
+ return e.legacy.Get(e.ctx, key, storage.GetOptions{}, &softwarecomposition.SBOMSyft{})
+ }, want: tg1Write{sqlite.OpDelete, "metadata"}},
+ {name: "W5-corrupt-delete", state: "corrupt", run: func(e *acg2Env, key string) error {
+ return e.legacy.Get(e.ctx, key, storage.GetOptions{}, &softwarecomposition.SBOMSyft{})
+ }, want: tg1Write{sqlite.OpDelete, "metadata"}},
+ {name: "W6a-migrate-toolfails", state: "wrongtype-toolfails", run: tg1GetHoldingWriteLock, want: tg1Write{sqlite.OpDelete, "metadata"}},
+ {name: "W6b-migrate-toolsucceeds", state: "wrongtype-toolsucceeds", run: tg1GetHoldingWriteLock, want: tg1Write{sqlite.OpInsert, "metadata"}},
+ {name: "W7a-migrateUnlocked-toolfails", state: "wrongtype-toolfails", run: func(e *acg2Env, key string) error {
+ return e.legacy.Get(e.ctx, key, storage.GetOptions{}, &softwarecomposition.SBOMSyft{})
+ }, want: tg1Write{sqlite.OpDelete, "metadata"}},
+ {name: "W7b-migrateUnlocked-toolsucceeds", state: "wrongtype-toolsucceeds", run: func(e *acg2Env, key string) error {
+ return e.legacy.Get(e.ctx, key, storage.GetOptions{}, &softwarecomposition.SBOMSyft{})
+ }, want: tg1Write{sqlite.OpInsert, "metadata"}},
+ {name: "W8-list-rewrite-toolsucceeds", state: "wrongtype-toolsucceeds", run: func(e *acg2Env, key string) error {
+ return e.legacy.GetByNamespace(e.ctx, acg2Group, acg2Kind, acg2DefaultNS, &softwarecomposition.SBOMSyftList{})
+ }, want: tg1Write{sqlite.OpInsert, "metadata"}},
+ {name: "W9a-cleanup-delete", state: "present-unreferenced", run: tg1CleanupTick, want: tg1Write{sqlite.OpDelete, "metadata"}},
+ {name: "W9b-cleanup-migrate", state: "file-without-row", run: tg1CleanupTick, want: tg1Write{sqlite.OpInsert, "metadata"}},
+}
+
+// tg1GetHoldingWriteLock reaches get()'s hasWriteLock state (migrateObject,
+// W6) the way GuaranteedUpdateWithConn does: under Lock(key).
+func tg1GetHoldingWriteLock(e *acg2Env, key string) error {
+ conn, err := e.pool.Take(e.ctx)
+ if err != nil {
+ return err
+ }
+ defer e.pool.Put(conn)
+ if err := e.legacy.locks.Lock(e.ctx, key); err != nil {
+ return err
+ }
+ defer e.legacy.locks.Unlock(key)
+ return e.legacy.get(e.ctx, conn, key, storage.GetOptions{}, &softwarecomposition.SBOMSyft{}, hasWriteLock)
+}
+
+func tg1CleanupTick(e *acg2Env, _ string) error {
+ return e.cleanup.CleanupTask(e.ctx, map[string][]TypeCleanupHandlerFunc{acg2Kind: {deleteByImageId}})
+}
+
+func TestTG1_EveryLegacyWriteSiteIsGated(t *testing.T) {
+ installACG2MigrationTool(t)
+ for _, site := range tg1Sites {
+ site := site
+ t.Run(site.name, func(t *testing.T) {
+ t.Parallel()
+ e := newACG2OnEnv(t)
+ key := sbomKey(e, strings.ToLower(site.name))
+ if site.state != "" {
+ obj := newSBOM()
+ _, _, _, _, _, name := K8sPathToKeys(key)
+ obj.(metav1.Object).SetName(name)
+ acg2StateByName(t, site.state).seed(e, key, obj)
+ }
+ mark := e.rec.mark()
+ err := site.run(e, key)
+ actions := e.rec.since(mark)
+ t.Logf("%s: err=%v", site.name, err)
+
+ now := writeStmtSeq.Load()
+ var ungated []string
+ var gated []tg1Write
+ for _, a := range actions {
+ if !isWriteOp(a.op) || a.table == "" {
+ continue
+ }
+ if e.gate.owns(a.conn, now) {
+ gated = append(gated, tg1Write{a.op, a.table})
+ } else {
+ ungated = append(ungated, writeOpLabel(a.op)+" "+a.table)
+ }
+ }
+ assert.Empty(t, ungated, "%s: write statements prepared on a connection the gate does not own (the bug class)", site.name)
+ assert.Contains(t, gated, site.want, "%s: expected %s on %s on the gate's connection; gated writes: %v", site.name, writeOpLabel(site.want.op), site.want.table, gated)
+ })
+ }
+}
+
+// TestTG1_WriteStatementsUsePrimaryKey (iii): the legacy delete and the
+// CAS read both resolve the row through the metadata primary key, so a hold
+// is one index seek (PM-G1's index case).
+func TestTG1_WriteStatementsUsePrimaryKey(t *testing.T) {
+ e := newACG2OnEnv(t)
+ plan := func(sql string) string {
+ var details []string
+ require.NoError(t, sqlitex.ExecuteTransient(e.fixture, "EXPLAIN QUERY PLAN "+sql, &sqlitex.ExecOptions{
+ Args: []any{"k", "n", "x"},
+ ResultFunc: func(stmt *sqlite.Stmt) error {
+ details = append(details, stmt.ColumnText(3))
+ return nil
+ },
+ }))
+ return strings.Join(details, "; ")
+ }
+ for _, sql := range []string{
+ `DELETE FROM metadata WHERE kind = ? AND namespace = ? AND name = ? RETURNING metadata`,
+ `SELECT metadata FROM metadata WHERE kind = ? AND namespace = ? AND name = ?`,
+ } {
+ p := plan(sql)
+ assert.True(t, strings.Contains(p, "sqlite_autoindex_metadata_1") || strings.Contains(p, "PRIMARY KEY"), "%s: plan %q does not use the primary key", sql, p)
+ }
+}
+
+var _ = context.Background
diff --git a/pkg/registry/file/writegate_tg2_test.go b/pkg/registry/file/writegate_tg2_test.go
new file mode 100644
index 000000000..0b7c7f19a
--- /dev/null
+++ b/pkg/registry/file/writegate_tg2_test.go
@@ -0,0 +1,319 @@
+package file
+
+// T-G2, T-G5 and T-G6 of .omc/plans/write-gate-sharing.md: the gate's
+// re-entrancy detectors, the cold-path pool bound and shutdown ordering.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "sync"
+ "testing"
+ "time"
+
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/storage"
+ "k8s.io/component-base/metrics/legacyregistry"
+ "zombiezen.com/go/sqlite"
+ "zombiezen.com/go/sqlite/sqlitex"
+)
+
+func seedOrphanSBOM(e *acg2Env, key string) {
+ obj := newSBOM()
+ _, _, _, _, _, name := K8sPathToKeys(key)
+ obj.(metav1.Object).SetName(name)
+ acg2StateByName(e.t, "orphan").seed(e, key, obj)
+}
+
+// TestTG2_ReentrantAcquireThroughThreadedCtxFailsFast: a gated fn that
+// re-enters the store through the ctx it was handed (a repair on an orphan
+// key, W4) is refused at O(1) on the ctx marker — under the test binary as a
+// panic the outer transaction recovers into an error — instead of queuing
+// behind its own holder for the whole request deadline.
+func TestTG2_ReentrantAcquireThroughThreadedCtxFailsFast(t *testing.T) {
+ e := newACG2OnEnv(t)
+ key := sbomKey(e, "tg2-threaded")
+ seedOrphanSBOM(e, key)
+
+ start := time.Now()
+ err := e.legacy.write(e.ctx, nil, priorityLow, "test", "test", false, func(ctx context.Context, _ *sqlite.Conn) error {
+ return e.legacy.Get(ctx, key, storage.GetOptions{}, &softwarecomposition.SBOMSyft{})
+ })
+ elapsed := time.Since(start)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "re-entrant", "the nested acquire must be refused on the marker, got: %v", err)
+ assert.Less(t, elapsed, acg2Prompt, "the refusal is O(1), not a queue wait")
+ assert.False(t, e.gate.held(), "the outer transaction released the gate")
+ assert.Zero(t, e.gate.watchdogFired.Load())
+}
+
+// TestTG2_ReentrantAcquireThroughCapturedCtxIsCaughtByWatchdog: the marker
+// cannot see a closure that captured the pre-ticket ctx; that nested acquire
+// queues behind its own holder until the ctx expires, and the watchdog is
+// what names it — the hold outlives the threshold and the holder is logged
+// with every goroutine's stack.
+func TestTG2_ReentrantAcquireThroughCapturedCtxIsCaughtByWatchdog(t *testing.T) {
+ oldInterval, oldThreshold := gateWatchdogInterval, gateWatchdogThreshold
+ gateWatchdogInterval, gateWatchdogThreshold = 10*time.Millisecond, 100*time.Millisecond
+ t.Cleanup(func() { gateWatchdogInterval, gateWatchdogThreshold = oldInterval, oldThreshold })
+
+ e := newACG2OnEnv(t)
+ key := sbomKey(e, "tg2-captured")
+ seedOrphanSBOM(e, key)
+
+ const bound = 700 * time.Millisecond
+ outer, cancel := context.WithTimeout(e.ctx, bound)
+ defer cancel()
+ start := time.Now()
+ err := e.legacy.write(outer, nil, priorityLow, "test", "test", false, func(context.Context, *sqlite.Conn) error {
+ // The captured ctx carries no marker: the repair queues behind us.
+ return e.legacy.Get(outer, key, storage.GetOptions{}, &softwarecomposition.SBOMSyft{})
+ })
+ elapsed := time.Since(start)
+ // The queued repair gives up when the ctx expires; get() swallows that
+ // and reports the orphan as not found, which the outer fn returns.
+ assert.True(t, storage.IsNotFound(err), "got %v", err)
+ assert.GreaterOrEqual(t, elapsed, bound, "the nested acquire hung until the ctx bound")
+ assert.GreaterOrEqual(t, e.gate.watchdogFired.Load(), int64(1), "the watchdog must have logged the hold")
+ assert.False(t, e.gate.held())
+}
+
+// TestTG5_ColdRepairsUnderSaturatedGateDoNotExhaustThePool (PM-G3): twenty
+// readers each GET a distinct orphaned key while the gate is saturated by
+// ContainerProfile creates. Every repair queues holding its pool connection
+// (§3.3's relaxation); the bound is that this never turns into pool
+// exhaustion — zero pool-wait timeouts — and every GET returns inside one
+// low-lane turn (≤ highBurstLimit high commits) plus the read.
+func TestTG5_ColdRepairsUnderSaturatedGateDoNotExhaustThePool(t *testing.T) {
+ e := newObjectStoreEnv(t) // pool DefaultPoolSize, busy 5 s, legacy guarded
+ const readers = 20
+ keys := make([]string, readers)
+ for i := range keys {
+ keys[i] = K8sKeysToPath("", acg2Group, acg2Kind, "", "kubescape", fmt.Sprintf("tg5-%d", i))
+ raw, err := json.Marshal(extractFields(&softwarecomposition.SBOMSyft{ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("tg5-%d", i), Namespace: "kubescape"}}, []string{"ObjectMeta", "SchemaVersion"}))
+ require.NoError(t, err)
+ require.NoError(t, WriteJSON(e.fixture, keys[i], raw))
+ }
+
+ timeoutsBefore := poolWaitTimeouts(t)
+ stop := make(chan struct{})
+ var creator sync.WaitGroup
+ creator.Add(1)
+ go func() {
+ defer creator.Done()
+ for n := 0; ; n++ {
+ select {
+ case <-stop:
+ return
+ default:
+ }
+ p := e.plain(fmt.Sprintf("sat-%d", n))
+ p.UID = ""
+ _ = e.store.Create(e.ctx, e.key(p.Name), p, nil, 0)
+ }
+ }()
+ // Let the creator saturate the gate before the readers arrive.
+ require.Eventually(t, func() bool { return e.gate.held() }, 5*time.Second, time.Millisecond)
+
+ var wg sync.WaitGroup
+ durations := make([]time.Duration, readers)
+ errs := make([]error, readers)
+ for i := 0; i < readers; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ t0 := time.Now()
+ errs[i] = e.legacy.Get(e.ctx, keys[i], storage.GetOptions{}, &softwarecomposition.SBOMSyft{})
+ durations[i] = time.Since(t0)
+ }(i)
+ }
+ wg.Wait()
+ close(stop)
+ creator.Wait()
+
+ assert.Equal(t, timeoutsBefore, poolWaitTimeouts(t), "pool-wait timeouts must stay at zero with the cold paths exercised")
+ for i := range keys {
+ assert.True(t, storage.IsNotFound(errs[i]), "reader %d: %v", i, errs[i])
+ assert.Less(t, durations[i], 2*time.Second, "reader %d took %s: a queued repair, not a busy-wait", i, durations[i])
+ _, err := ReadMetadata(e.fixture, keys[i])
+ assert.ErrorIs(t, err, ErrMetadataNotFound, "reader %d: the orphan row was repaired", i)
+ }
+ var maxD time.Duration
+ for _, d := range durations {
+ if d > maxD {
+ maxD = d
+ }
+ }
+ t.Logf("T-G5: %d readers under a saturated gate, slowest GET %s, pool-wait timeouts %d", readers, maxD, poolWaitTimeouts(t)-timeoutsBefore)
+}
+
+// poolWaitTimeouts sums storage_pool_wait_duration_seconds{outcome="timeout"}
+// over every kind.
+func poolWaitTimeouts(t *testing.T) uint64 {
+ t.Helper()
+ families, err := legacyregistry.DefaultGatherer.Gather()
+ require.NoError(t, err)
+ var n uint64
+ for _, mf := range families {
+ if mf.GetName() != "storage_pool_wait_duration_seconds" {
+ continue
+ }
+ for _, m := range mf.GetMetric() {
+ for _, l := range m.GetLabel() {
+ if l.GetName() == "outcome" && l.GetValue() == "timeout" {
+ n += m.GetHistogram().GetSampleCount()
+ }
+ }
+ }
+ }
+ return n
+}
+
+// TestTG6_CloseWithQueuedWritersAndCleanupMidWalk (PM-G7): a legacy commit
+// queued on the high lane and a cleanup tick queued on the low lane when the
+// gate closes both fail with errGateClosed, the holder finishes, Close
+// returns, and the pool closes (the env's cleanup asserts Pool.Close
+// returns: every connection is back).
+func TestTG6_CloseWithQueuedWritersAndCleanupMidWalk(t *testing.T) {
+ e := newACG2OnEnv(t)
+ // The rows live in a non-default namespace: CleanupTask drops the default
+ // namespace's error (`err = h.cleanupNamespace(...); return nil`,
+ // cleanup.go), a pre-existing flag-off behaviour this change leaves alone.
+ for _, n := range []string{"tg6-a", "tg6-b"} {
+ key := K8sKeysToPath("", acg2Group, acg2Kind, "", "other", n)
+ obj := &softwarecomposition.SBOMSyft{ObjectMeta: metav1.ObjectMeta{Name: n, Namespace: "other"}}
+ acg2StateByName(t, "present-unreferenced").seed(e, key, obj)
+ }
+
+ held := make(chan struct{})
+ releaseHolder := make(chan struct{})
+ holderDone := make(chan error, 1)
+ go func() {
+ holderDone <- e.gate.run(e.ctx, priorityHigh, "tg6-holder", "test", func(context.Context, *sqlite.Conn) error {
+ close(held)
+ <-releaseHolder
+ return nil
+ })
+ }()
+ <-held
+
+ createErr := make(chan error, 1)
+ go func() {
+ obj := newSBOM().(*softwarecomposition.SBOMSyft)
+ obj.Name = "tg6-create"
+ createErr <- e.legacy.Create(e.ctx, sbomKey(e, obj.Name), obj, nil, 0)
+ }()
+ cleanupErr := make(chan error, 1)
+ go func() {
+ cleanupErr <- e.cleanup.CleanupTask(e.ctx, map[string][]TypeCleanupHandlerFunc{acg2Kind: {deleteByImageId}})
+ }()
+ require.Eventually(t, func() bool { h, l := e.gate.queued(); return h == 1 && l == 1 }, 5*time.Second, time.Millisecond, "one writer queued in each lane")
+
+ closed := make(chan struct{})
+ go func() { _ = e.gate.Close(); close(closed) }()
+ select {
+ case <-closed:
+ t.Fatal("Close returned while the holder was still inside its transaction")
+ case <-time.After(50 * time.Millisecond):
+ }
+ cErr, clErr := <-createErr, <-cleanupErr
+ t.Logf("queued create: %v; queued cleanup: %v", cErr, clErr)
+ assert.ErrorIs(t, cErr, errGateClosed, "the queued legacy commit")
+ assert.ErrorIs(t, clErr, errGateClosed, "the mid-walk cleanup tick")
+ close(releaseHolder)
+ require.NoError(t, <-holderDone)
+ select {
+ case <-closed:
+ case <-time.After(5 * time.Second):
+ t.Fatal("Close did not return after the holder released")
+ }
+ // Reads survive a closed gate. The aborted tick removed the first file
+ // before its row delete was refused (cleanup's file-then-row order, kept
+ // as today), so that row is an orphan the next tick repairs.
+ assert.NoError(t, e.legacy.Get(e.ctx, K8sKeysToPath("", acg2Group, acg2Kind, "", "other", "tg6-b"), storage.GetOptions{ResourceVersion: softwarecomposition.ResourceVersionMetadata}, &softwarecomposition.SBOMSyft{}))
+}
+
+// TestStorageImpl_GateRequiresSingleWriter (RC-6): a StorageImpl sharing the
+// gate refuses Create/GuaranteedUpdate while the single-writer path is off
+// with a sentinel (errors.Is, not string matching), and keeps serving reads
+// and deletes — the signal a test that flipped the package variable gets.
+func TestStorageImpl_GateRequiresSingleWriter(t *testing.T) {
+ e := newACG2OnEnv(t)
+ key := sbomKey(e, "rc6")
+ obj := newSBOM()
+ obj.(metav1.Object).SetName("rc6")
+ acg2StateByName(t, "present").seed(e, key, obj)
+
+ old := singleWriterEnabled
+ singleWriterEnabled = false
+ t.Cleanup(func() { singleWriterEnabled = old })
+
+ err := e.legacy.Create(e.ctx, sbomKey(e, "rc6-new"), &softwarecomposition.SBOMSyft{ObjectMeta: metav1.ObjectMeta{Name: "rc6-new", Namespace: acg2DefaultNS}}, nil, 0)
+ assert.ErrorIs(t, err, ErrGateRequiresSingleWriter)
+ err = e.legacy.GuaranteedUpdate(e.ctx, key, &softwarecomposition.SBOMSyft{}, false, nil,
+ func(input runtime.Object, _ storage.ResponseMeta) (runtime.Object, *uint64, error) {
+ return input, nil, nil
+ }, nil)
+ assert.ErrorIs(t, err, ErrGateRequiresSingleWriter)
+ assert.NoError(t, e.legacy.Get(e.ctx, key, storage.GetOptions{}, &softwarecomposition.SBOMSyft{}))
+ assert.NoError(t, e.legacy.Delete(e.ctx, key, &softwarecomposition.SBOMSyft{}, nil, nil, nil, storage.DeleteOptions{}))
+}
+
+// TestWriteGate_OwnsIsBoundedByTenure: a write recorded on a connection
+// BEFORE the gate took it from the pool (a pool taker's write, when it was
+// still a pool connection) is not the gate's — owns() is bounded below by
+// the sequence at which the gate took the connection, and the ledger judges
+// that record ungated.
+func TestWriteGate_OwnsIsBoundedByTenure(t *testing.T) {
+ pool := NewPoolWithOptions(t.TempDir()+"/tenure.sq3", PoolOptions{Size: 1, DisableAutoCheckpoint: true})
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ conn, err := pool.Take(ctx)
+ require.NoError(t, err)
+ require.NoError(t, sqlitex.ExecuteTransient(conn, `INSERT INTO metadata (kind,namespace,name,metadata) VALUES ('k','n','pre-tenure','{}')`, nil))
+ preSeq := writeStmtSeq.Load()
+ pool.Put(conn)
+
+ // Pool size 1: the gate takes the very same connection.
+ g, err := newWriteGate(ctx, pool)
+ require.NoError(t, err)
+ require.Same(t, conn, g.conn)
+ assert.False(t, g.owns(conn, preSeq), "a write before the gate's tenure is not the gate's")
+ require.NoError(t, g.run(ctx, priorityHigh, "tenure", "test", func(_ context.Context, c *sqlite.Conn) error {
+ return sqlitex.ExecuteTransient(c, `INSERT INTO metadata (kind,namespace,name,metadata) VALUES ('k','n','in-tenure','{}')`, nil)
+ }))
+ assert.True(t, g.owns(conn, writeStmtSeq.Load()), "a write inside the tenure is the gate's")
+
+ // The ledger sees exactly the pre-tenure INSERT as ungated (judging it
+ // here keeps TestMain's sweep clean).
+ vs := acg1Ledger.violations(pool)
+ require.Len(t, vs, 1)
+ assert.Equal(t, preSeq, vs[0].seq)
+ assert.Equal(t, sqlite.OpInsert, vs[0].op)
+ require.NoError(t, g.Close())
+ require.NoError(t, pool.Close())
+}
+
+// TestContainerProfileStorageImpl_RefusesGatedStorageImpl (W11/W12): the
+// legacy ContainerProfile storage's long pool-connection transactions must
+// never run over a StorageImpl that shares the write gate — its saveObject
+// would queue on the gate behind the lock its own connection holds.
+func TestContainerProfileStorageImpl_RefusesGatedStorageImpl(t *testing.T) {
+ e := newACG2OnEnv(t)
+ cps := NewContainerProfileStorageImpl(e.legacy, e.pool)
+ ctx, cleanup, err := cps.WithConnection(e.ctx)
+ require.NoError(t, err)
+ defer cleanup()
+ _, err = cps.BeginTransaction(ctx)
+ assert.ErrorIs(t, err, errCPStorageGated)
+ assert.ErrorIs(t, cps.HealDivergence(ctx, sbomKey(e, "w12")), errCPStorageGated)
+}
+
+var _ = errors.Is
+var _ = helpersv1.ImageIDMetadataKey
diff --git a/pkg/registry/softwarecomposition/containerprofile/backend_differential_test.go b/pkg/registry/softwarecomposition/containerprofile/backend_differential_test.go
new file mode 100644
index 000000000..2a5f78a3a
--- /dev/null
+++ b/pkg/registry/softwarecomposition/containerprofile/backend_differential_test.go
@@ -0,0 +1,311 @@
+package containerprofile
+
+// Backend differential suite (the Phase 4 pattern one level down): the SAME
+// hand-written CustomREST (genericrest.Store) over the legacy row+gob-file
+// StorageImpl and over the SQLite-native ObjectStore
+// (pkg/registry/file/sqliteobject_*.go), driven through identical REST
+// Create / Get / Update / Delete / List / Watch sequences. Externally
+// observable results must be identical except the two codec deltas the
+// storage-level suite pins (file.TestDifferential_*): GET creationTimestamp is
+// truncated to whole seconds on the new store, and v1beta1 spec collections
+// without omitempty come back as [] instead of null.
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+ "time"
+
+ helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
+ "github.com/kubescape/storage/pkg/apis/softwarecomposition"
+ "github.com/kubescape/storage/pkg/config"
+ "github.com/kubescape/storage/pkg/registry/file"
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/watch"
+ "k8s.io/apiserver/pkg/registry/rest"
+ "k8s.io/apiserver/pkg/storage"
+)
+
+// newObjectStoreTestStorage mirrors apiserver.go's wiring under
+// ContainerProfileSqliteBackend: the legacy default StorageImpl carries the
+// kind-ownership guard and serves GetSbom; the ObjectStore serves the
+// containerprofile kind.
+func newObjectStoreTestStorage(t *testing.T) storage.Interface {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "metadata.sq3")
+ pool := file.NewPoolWithOptions(dbPath, file.PoolOptions{DisableAutoCheckpoint: true, BusyTimeout: 5 * time.Second})
+ sch := newTestScheme(t)
+ legacy := file.NewStorageImpl(afero.NewMemMapFs(), file.DefaultStorageRoot, pool, nil, sch)
+ legacy.(*file.StorageImpl).SetForeignKinds(file.IsContainerProfileKind)
+ processor := file.NewContainerProfileProcessor(config.Config{DefaultNamespace: "kubescape", MaxContainerProfileSize: 40000}, nil)
+ processor.Interval = 0
+ gateCtx, gateCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer gateCancel()
+ gate, err := file.NewWriteGate(gateCtx, pool)
+ require.NoError(t, err)
+ legacy.(*file.StorageImpl).SetWriteGate(gate)
+ store, err := file.NewObjectStore(pool, dbPath, nil, sch, processor, legacy, gate, file.ObjectStoreOptions{CheckpointInterval: time.Hour})
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ require.NoError(t, store.Close())
+ require.NoError(t, gate.Close())
+ require.NoError(t, pool.Close())
+ })
+ return store
+}
+
+// backendHarness: the same CustomREST over each backend.
+type backendHarness struct {
+ oldREST rest.StandardStorage // CustomREST over the legacy StorageImpl
+ newREST rest.StandardStorage // CustomREST over the ObjectStore
+}
+
+func newBackendHarness(t *testing.T) *backendHarness {
+ t.Helper()
+ sch := newTestScheme(t)
+ optsGetter := newOptsGetter()
+ oldStorage, _ := newTestStorage(t)
+ oldREST, err := NewCustomREST(sch, oldStorage, optsGetter)
+ require.NoError(t, err)
+ newREST, err := NewCustomREST(sch, newObjectStoreTestStorage(t), optsGetter)
+ require.NoError(t, err)
+ return &backendHarness{oldREST: oldREST, newREST: newREST}
+}
+
+// canonical strips instance-specific fields (UID, creationTimestamp, checksum)
+// and the nil-vs-empty codec delta so the two backends' objects can be
+// compared field by field.
+func canonical(t *testing.T, obj runtime.Object) *softwarecomposition.ContainerProfile {
+ t.Helper()
+ out := normalize(t, obj)
+ nilifyEmptyCollections(out)
+ return out
+}
+
+func nilifyEmptyCollections(cp *softwarecomposition.ContainerProfile) {
+ if len(cp.Spec.Architectures) == 0 {
+ cp.Spec.Architectures = nil
+ }
+ if len(cp.Spec.Capabilities) == 0 {
+ cp.Spec.Capabilities = nil
+ }
+ if len(cp.Spec.Execs) == 0 {
+ cp.Spec.Execs = nil
+ }
+ if len(cp.Spec.Opens) == 0 {
+ cp.Spec.Opens = nil
+ }
+ if len(cp.Spec.Syscalls) == 0 {
+ cp.Spec.Syscalls = nil
+ }
+ if len(cp.Spec.Endpoints) == 0 {
+ cp.Spec.Endpoints = nil
+ }
+ if len(cp.Spec.PolicyByRuleId) == 0 {
+ cp.Spec.PolicyByRuleId = nil
+ }
+ if len(cp.Spec.IdentifiedCallStacks) == 0 {
+ cp.Spec.IdentifiedCallStacks = nil
+ }
+ if len(cp.Spec.Ingress) == 0 {
+ cp.Spec.Ingress = nil
+ }
+ if len(cp.Spec.Egress) == 0 {
+ cp.Spec.Egress = nil
+ }
+ if len(cp.Spec.MatchLabels) == 0 {
+ cp.Spec.MatchLabels = nil
+ }
+ if len(cp.Spec.MatchExpressions) == 0 {
+ cp.Spec.MatchExpressions = nil
+ }
+}
+
+func rvOf(t *testing.T, obj runtime.Object) string {
+ t.Helper()
+ m, err := meta.Accessor(obj)
+ require.NoError(t, err)
+ return m.GetResourceVersion()
+}
+
+func profileWithSpec(name string) *softwarecomposition.ContainerProfile {
+ return &softwarecomposition.ContainerProfile{
+ ObjectMeta: metav1.ObjectMeta{Name: name, Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Learning}},
+ Spec: softwarecomposition.ContainerProfileSpec{
+ Architectures: []string{"amd64"},
+ // Empty and non-nil on purpose: PreSave keeps it that way, gob drops
+ // it (GET → nil) and JSON keeps it (GET → []): the codec delta.
+ Capabilities: []string{},
+ Execs: []softwarecomposition.ExecCalls{{Path: "/bin/sh", Args: []string{"/bin/sh"}}},
+ ImageTag: "img:1",
+ },
+ }
+}
+
+func TestBackendDifferential_CreateGetUpdateDelete(t *testing.T) {
+ h := newBackendHarness(t)
+ ctx := testContext("ns1")
+ cp := profileWithSpec("cp-a")
+
+ oldOut := mustCreate(t, ctx, h.oldREST, cp)
+ newOut := mustCreate(t, ctx, h.newREST, cp)
+ assert.Equal(t, canonical(t, oldOut), canonical(t, newOut))
+ assert.Equal(t, rvOf(t, oldOut), rvOf(t, newOut))
+
+ _, err := h.oldREST.Create(ctx, cp.DeepCopy(), rest.ValidateAllObjectFunc, &metav1.CreateOptions{})
+ assert.True(t, apierrors.IsAlreadyExists(err), "old: %v", err)
+ _, err = h.newREST.Create(ctx, cp.DeepCopy(), rest.ValidateAllObjectFunc, &metav1.CreateOptions{})
+ assert.True(t, apierrors.IsAlreadyExists(err), "new: %v", err)
+
+ oldGot, err := h.oldREST.Get(ctx, "cp-a", &metav1.GetOptions{})
+ require.NoError(t, err)
+ newGot, err := h.newREST.Get(ctx, "cp-a", &metav1.GetOptions{})
+ require.NoError(t, err)
+ assert.Equal(t, canonical(t, oldGot), canonical(t, newGot))
+ // The codec delta, pinned rather than hidden: the store never persisted
+ // empty Capabilities, yet GET returns nil on the old store and [] on new.
+ assert.Nil(t, oldGot.(*softwarecomposition.ContainerProfile).Spec.Capabilities)
+ assert.NotNil(t, newGot.(*softwarecomposition.ContainerProfile).Spec.Capabilities)
+ assert.Equal(t, 0, newGot.(*softwarecomposition.ContainerProfile).CreationTimestamp.Nanosecond())
+
+ update := func(r rest.StandardStorage, mutate func(*softwarecomposition.ContainerProfile)) (runtime.Object, bool, error) {
+ return r.Update(ctx, "cp-a", rest.DefaultUpdatedObjectInfo(nil, func(_ context.Context, newObj, oldObj runtime.Object) (runtime.Object, error) {
+ out := oldObj.(*softwarecomposition.ContainerProfile).DeepCopy()
+ mutate(out)
+ return out, nil
+ }), rest.ValidateAllObjectFunc, rest.ValidateAllObjectUpdateFunc, false, &metav1.UpdateOptions{})
+ }
+ addLabel := func(c *softwarecomposition.ContainerProfile) {
+ if c.Labels == nil {
+ c.Labels = map[string]string{}
+ }
+ c.Labels["k"] = "v"
+ }
+ oldUpd, oldCreated, err := update(h.oldREST, addLabel)
+ require.NoError(t, err)
+ newUpd, newCreated, err := update(h.newREST, addLabel)
+ require.NoError(t, err)
+ assert.Equal(t, oldCreated, newCreated)
+ assert.Equal(t, canonical(t, oldUpd), canonical(t, newUpd))
+ assert.Equal(t, rvOf(t, oldUpd), rvOf(t, newUpd))
+ assert.Equal(t, "2", rvOf(t, newUpd))
+
+ // no-op update: same RV on both (#315)
+ oldNoop, _, err := update(h.oldREST, func(*softwarecomposition.ContainerProfile) {})
+ require.NoError(t, err)
+ newNoop, _, err := update(h.newREST, func(*softwarecomposition.ContainerProfile) {})
+ require.NoError(t, err)
+ assert.Equal(t, rvOf(t, oldNoop), rvOf(t, newNoop))
+ assert.Equal(t, "2", rvOf(t, newNoop))
+
+ // stale resourceVersion → Conflict on both
+ staleUpdate := func(r rest.StandardStorage) error {
+ _, _, err := r.Update(ctx, "cp-a", rest.DefaultUpdatedObjectInfo(nil, func(_ context.Context, newObj, oldObj runtime.Object) (runtime.Object, error) {
+ out := oldObj.(*softwarecomposition.ContainerProfile).DeepCopy()
+ out.ResourceVersion = "1"
+ out.Labels["k"] = "stale"
+ return out, nil
+ }), rest.ValidateAllObjectFunc, rest.ValidateAllObjectUpdateFunc, false, &metav1.UpdateOptions{})
+ return err
+ }
+ assert.True(t, apierrors.IsConflict(staleUpdate(h.oldREST)))
+ assert.True(t, apierrors.IsConflict(staleUpdate(h.newREST)))
+
+ oldDel, oldImmediate, err := h.oldREST.Delete(ctx, "cp-a", rest.ValidateAllObjectFunc, &metav1.DeleteOptions{})
+ require.NoError(t, err)
+ newDel, newImmediate, err := h.newREST.Delete(ctx, "cp-a", rest.ValidateAllObjectFunc, &metav1.DeleteOptions{})
+ require.NoError(t, err)
+ assert.Equal(t, oldImmediate, newImmediate)
+ assertEqualDeleteStatus(t, oldDel, newDel, "cp-a")
+
+ _, err = h.oldREST.Get(ctx, "cp-a", &metav1.GetOptions{})
+ assert.True(t, apierrors.IsNotFound(err))
+ _, err = h.newREST.Get(ctx, "cp-a", &metav1.GetOptions{})
+ assert.True(t, apierrors.IsNotFound(err))
+ _, _, err = h.oldREST.Delete(ctx, "cp-a", rest.ValidateAllObjectFunc, &metav1.DeleteOptions{})
+ assert.True(t, apierrors.IsNotFound(err))
+ _, _, err = h.newREST.Delete(ctx, "cp-a", rest.ValidateAllObjectFunc, &metav1.DeleteOptions{})
+ assert.True(t, apierrors.IsNotFound(err))
+}
+
+func TestBackendDifferential_ListAndContinue(t *testing.T) {
+ h := newBackendHarness(t)
+ ctx := testContext("ns1")
+ for _, n := range []string{"l-a", "l-b", "l-c"} {
+ mustCreate(t, ctx, h.oldREST, profileWithSpec(n))
+ mustCreate(t, ctx, h.newREST, profileWithSpec(n))
+ }
+ names := func(obj runtime.Object) []string {
+ l := obj.(*softwarecomposition.ContainerProfileList)
+ var out []string
+ for _, it := range l.Items {
+ out = append(out, it.Name)
+ }
+ return out
+ }
+ for _, r := range []rest.StandardStorage{h.oldREST, h.newREST} {
+ p1, err := r.List(ctx, &metainternalversion.ListOptions{Limit: 2})
+ require.NoError(t, err)
+ assert.Equal(t, []string{"l-a", "l-b"}, names(p1))
+ cont := p1.(*softwarecomposition.ContainerProfileList).Continue
+ require.NotEmpty(t, cont)
+ p2, err := r.List(ctx, &metainternalversion.ListOptions{Limit: 2, Continue: cont})
+ require.NoError(t, err)
+ assert.Equal(t, []string{"l-c"}, names(p2))
+ full, err := r.List(ctx, &metainternalversion.ListOptions{ResourceVersion: softwarecomposition.ResourceVersionFullSpec})
+ require.NoError(t, err)
+ require.Len(t, full.(*softwarecomposition.ContainerProfileList).Items, 3)
+ assert.Equal(t, "img:1", full.(*softwarecomposition.ContainerProfileList).Items[0].Spec.ImageTag, "fullSpec list carries the spec")
+ }
+ oldL, _ := h.oldREST.List(ctx, &metainternalversion.ListOptions{})
+ newL, _ := h.newREST.List(ctx, &metainternalversion.ListOptions{})
+ require.Len(t, oldL.(*softwarecomposition.ContainerProfileList).Items, 3)
+ for i := range oldL.(*softwarecomposition.ContainerProfileList).Items {
+ o := &oldL.(*softwarecomposition.ContainerProfileList).Items[i]
+ n := &newL.(*softwarecomposition.ContainerProfileList).Items[i]
+ assert.Equal(t, canonical(t, o), canonical(t, n))
+ }
+}
+
+func TestBackendDifferential_Watch(t *testing.T) {
+ h := newBackendHarness(t)
+ ctx := testContext("ns1")
+ events := func(r rest.StandardStorage) []string {
+ w, err := r.Watch(ctx, &metainternalversion.ListOptions{ResourceVersion: softwarecomposition.ResourceVersionFullSpec})
+ require.NoError(t, err)
+ defer w.Stop()
+ mustCreate(t, ctx, r, profileWithSpec("w-a"))
+ _, _, err = r.Update(ctx, "w-a", rest.DefaultUpdatedObjectInfo(nil, func(_ context.Context, _, oldObj runtime.Object) (runtime.Object, error) {
+ out := oldObj.(*softwarecomposition.ContainerProfile).DeepCopy()
+ out.Spec.ImageTag = "img:2"
+ return out, nil
+ }), rest.ValidateAllObjectFunc, rest.ValidateAllObjectUpdateFunc, false, &metav1.UpdateOptions{})
+ require.NoError(t, err)
+ _, _, err = r.Delete(ctx, "w-a", rest.ValidateAllObjectFunc, &metav1.DeleteOptions{})
+ require.NoError(t, err)
+ var out []string
+ for len(out) < 3 {
+ select {
+ case ev := <-w.ResultChan():
+ cp := ev.Object.(*softwarecomposition.ContainerProfile)
+ out = append(out, string(ev.Type)+" "+cp.Name+" rv="+cp.ResourceVersion+" img="+cp.Spec.ImageTag)
+ case <-time.After(2 * time.Second):
+ t.Fatalf("watch events missing after %v", out)
+ }
+ }
+ return out
+ }
+ oldEv := events(h.oldREST)
+ newEv := events(h.newREST)
+ assert.Equal(t, oldEv, newEv)
+ assert.Equal(t, string(watch.Added)+" w-a rv=1 img=img:1", newEv[0])
+ assert.Equal(t, string(watch.Modified)+" w-a rv=2 img=img:2", newEv[1])
+ assert.Equal(t, string(watch.Deleted)+" w-a rv=2 img=", newEv[2], "Deleted carries the metadata-only object on both")
+}
diff --git a/pkg/utils/mutex.go b/pkg/utils/mutex.go
index 2279576af..96484852e 100644
--- a/pkg/utils/mutex.go
+++ b/pkg/utils/mutex.go
@@ -4,10 +4,50 @@ import (
"context"
"errors"
"sync"
+ "sync/atomic"
)
var ContextNilError = errors.New("context is nil")
+// Lock observer labels: the mode of a MapMutex acquisition and its outcome.
+const (
+ LockModeWrite = "lock"
+ LockModeRead = "rlock"
+ LockOutcomeAcquired = "acquired"
+ LockOutcomeTimeout = "timeout"
+)
+
+// lockObserver is a nil-in-production hook reporting every Lock/RLock
+// attempt's mode and outcome. It exists for the storage work-budget test
+// (pkg/registry/file, TestWorkBudget), which needs a read/write axis the
+// storage_lock_wait_duration_seconds histogram does not have; production
+// pays one atomic load and a nil check per acquisition and allocates nothing.
+var lockObserver atomic.Pointer[func(mode, outcome string)]
+
+// SetLockObserver installs f as the process-wide lock observer; nil uninstalls
+// it. The observer is called after the MapMutex's internal mutex is released,
+// never under it, and may be called from any goroutine.
+func SetLockObserver(f func(mode, outcome string)) {
+ if f == nil {
+ lockObserver.Store(nil)
+ return
+ }
+ lockObserver.Store(&f)
+}
+
+func observeLock(mode, outcome string) {
+ if f := lockObserver.Load(); f != nil {
+ (*f)(mode, outcome)
+ }
+}
+
+func lockOutcome(err error) string {
+ if err != nil {
+ return LockOutcomeTimeout
+ }
+ return LockOutcomeAcquired
+}
+
type keyState struct {
cond *sync.Cond
readers int
@@ -109,14 +149,17 @@ func (m *MapMutex[T]) Lock(ctx context.Context, key T) error {
if !s.writer && s.readers == 0 && s.pendingWriters == 0 {
s.writer = true
m.mu.Unlock()
+ observeLock(LockModeWrite, LockOutcomeAcquired)
return nil
}
s.pendingWriters++
- return m.lockSlow(ctx, key, s,
+ err := m.lockSlow(ctx, key, s,
func() bool { return !s.writer && s.readers == 0 },
func() { s.pendingWriters--; s.writer = true },
func() { s.pendingWriters-- },
)
+ observeLock(LockModeWrite, lockOutcome(err))
+ return err
}
func (m *MapMutex[T]) RLock(ctx context.Context, key T) error {
@@ -128,13 +171,16 @@ func (m *MapMutex[T]) RLock(ctx context.Context, key T) error {
if !s.writer && s.pendingWriters == 0 {
s.readers++
m.mu.Unlock()
+ observeLock(LockModeRead, LockOutcomeAcquired)
return nil
}
- return m.lockSlow(ctx, key, s,
+ err := m.lockSlow(ctx, key, s,
func() bool { return !s.writer && s.pendingWriters == 0 },
func() { s.readers++ },
nil,
)
+ observeLock(LockModeRead, lockOutcome(err))
+ return err
}
func (m *MapMutex[T]) Unlock(key T) {