Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Build artifacts and packaging output — large, and never needed inside the
# image. Without these exclusions the build context carries hundreds of MB of
# release tarballs and packaging trees on a developer machine that has them.
zarf-package-*.tar.zst
uds/
*.tar.zst

# Local runtime data — gitignored, and on a developer machine this can be tens
# of GB of parquet. Copying it into the build context exhausts the builder's
# disk long before the binary is compiled.
data/
*.parquet
*.duckdb

# Version control and local tooling
.git/
.github/
.claude/

# Test/scratch output
scripts/memtest/
*.test
coverage.out

# Docs and deployment manifests — not needed to compile or run the binary
docs/
deploy/
helm/
*.md
14 changes: 14 additions & 0 deletions RELEASE_NOTES_2026.09.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ Reachable only when RBAC is enabled (the multi-tenant authorization boundary); n

## Bug fixes

### Compaction subprocesses no longer drop the S3 prefix ([#560](https://github.com/Basekick-Labs/arc/issues/560))

Compaction runs in a forked subprocess for DuckDB memory isolation, and the child rebuilds its storage backend from the parent's `Type()` and `ConfigJSON()`. `S3Backend.ConfigJSON()` emitted `prefix`, but the subprocess's parse struct had no matching field, so `json.Unmarshal` silently discarded it and the rebuilt backend was left with an empty prefix.

Because the prefix is applied to every key the S3 backend touches — read, write, delete, and list — a compaction subprocess on a deployment with `storage.s3_prefix` set operated against the **bucket root** instead of the configured prefix, with no error raised. Only deployments that set a non-empty prefix were affected; it defaults to empty, which is why "prefixed" and "unprefixed" were the same string everywhere the code was exercised.

The prefix is now parsed and forwarded. A round-trip test drives the real reconstruction path and compares the rebuilt backend's configuration against the parent's, so any field added to `ConfigJSON()` in future without a matching parse field fails immediately rather than silently.

### Removed the unused `ResilientBackend` storage wrapper ([#320](https://github.com/Basekick-Labs/arc/issues/320))

`internal/storage/resilient.go` provided a retry + circuit-breaker wrapper around a storage backend, but nothing ever constructed one: no call sites in `cmd/` or `internal/`, no tests, no type assertions. It dates to the original Go migration and was never wired in. Having drifted behind the `Backend` interface — missing `ReadToAt`, `StatFile`, `Type`, and `ConfigJSON` — it no longer satisfied the interface it was written against, which is what surfaced it.

The file is removed rather than completed. Retry and circuit-breaking around cloud storage remain worth having, but an unused, untested wrapper would simply drift again with the next interface change; a future implementation should be written against the interface as it stands then, and actually wired in. No behavior change — the code was unreachable.

### Auth `last_used_at` updates no longer outlive shutdown ([#325](https://github.com/Basekick-Labs/arc/issues/325))

Every token verification that missed the auth cache spawned a fire-and-forget goroutine to run `UPDATE api_tokens SET last_used_at = ?`. Nothing tracked those goroutines, so `AuthManager.Close()` could close the database out from under one still in flight; it then failed with `sql: database is closed` and logged at **Error** level, making an otherwise clean shutdown look like a failure. The update itself was also lost.
Expand Down
7 changes: 7 additions & 0 deletions internal/compaction/subprocess.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,14 @@ func createStorageBackendFromConfig(config *SubprocessJobConfig, logger zerolog.
return storage.NewLocalBackend(localConfig.BasePath, logger)

case "s3":
// Every field S3Backend.ConfigJSON emits must be parsed and forwarded
// here. Prefix in particular: it is applied to every key the backend
// touches (prefixedKey), so dropping it silently reroots the subprocess
// at the bucket root and compaction reads and writes the wrong location.
// It defaults to empty, which is why the omission went unnoticed.
var s3Config struct {
Bucket string `json:"bucket"`
Prefix string `json:"prefix"`
Region string `json:"region"`
Endpoint string `json:"endpoint"`
PathStyle bool `json:"path_style"`
Expand All @@ -329,6 +335,7 @@ func createStorageBackendFromConfig(config *SubprocessJobConfig, logger zerolog.
}
return storage.NewS3Backend(&storage.S3Config{
Bucket: s3Config.Bucket,
Prefix: s3Config.Prefix,
Region: s3Config.Region,
Endpoint: s3Config.Endpoint,
PathStyle: s3Config.PathStyle,
Expand Down
88 changes: 88 additions & 0 deletions internal/compaction/subprocess_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package compaction

import (
"testing"

"github.com/basekick-labs/arc/internal/storage"
"github.com/rs/zerolog"
)

// The compaction subprocess rebuilds its storage backend from the parent's
// Type()+ConfigJSON(). Any field ConfigJSON emits but createStorageBackendFromConfig
// fails to parse is silently dropped, and the subprocess then operates against a
// different location than the parent — with no error.
//
// The round-trip below is the real guard: it drives the actual production parse
// function, so a field added to ConfigJSON without a matching field in the
// subprocess parse struct changes the rebuilt config and fails here.
//
// Prefix in particular defaults to empty, which is exactly why dropping it went
// unnoticed — every test and most deployments leave it unset.
func TestCreateStorageBackendFromConfig_PreservesS3Prefix(t *testing.T) {
logger := zerolog.Nop()

parent, err := storage.NewS3Backend(&storage.S3Config{
Bucket: "arc-data",
Prefix: "instances/abc123/",
Region: "us-west-2",
Endpoint: "http://localhost:9000",
PathStyle: true,
}, logger)
if err != nil {
t.Skipf("cannot construct S3 backend in this environment: %v", err)
}

cfg := &SubprocessJobConfig{
StorageType: parent.Type(),
StorageConfig: parent.ConfigJSON(),
}

rebuilt, err := createStorageBackendFromConfig(cfg, logger)
if err != nil {
t.Fatalf("createStorageBackendFromConfig: %v", err)
}
defer rebuilt.Close()

// The rebuilt backend must serialize back to the same configuration —
// if the prefix were dropped, this comparison shows it.
if got, want := rebuilt.ConfigJSON(), parent.ConfigJSON(); got != want {
t.Errorf("rebuilt backend config differs from parent:\n parent: %s\n rebuilt: %s", want, got)
}
}

// Same round-trip contract for the local backend, which is the default and so
// the one most deployments actually exercise.
func TestCreateStorageBackendFromConfig_PreservesLocalConfig(t *testing.T) {
logger := zerolog.Nop()

parent, err := storage.NewLocalBackend(t.TempDir(), logger)
if err != nil {
t.Fatalf("NewLocalBackend: %v", err)
}

rebuilt, err := createStorageBackendFromConfig(&SubprocessJobConfig{
StorageType: parent.Type(),
StorageConfig: parent.ConfigJSON(),
}, logger)
if err != nil {
t.Fatalf("createStorageBackendFromConfig: %v", err)
}
defer rebuilt.Close()

if got, want := rebuilt.ConfigJSON(), parent.ConfigJSON(); got != want {
t.Errorf("rebuilt backend config differs from parent:\n parent: %s\n rebuilt: %s", want, got)
}
}

// An unrecognized storage type must fail loudly rather than fall back to some
// default backend — a subprocess silently compacting against the wrong storage
// is worse than one that refuses to start.
func TestCreateStorageBackendFromConfig_RejectsUnknownType(t *testing.T) {
_, err := createStorageBackendFromConfig(&SubprocessJobConfig{
StorageType: "resilient",
StorageConfig: "{}",
}, zerolog.Nop())
if err == nil {
t.Fatal("expected an error for an unknown storage type")
}
}
Loading