Files
s3/weed/s3api/s3lifecycle/reader/composition_test.go
zooqueen dd74d3e6d6 Replace AGPL MinIO fork with Apache-2.0 SeaweedFS fork; rebrand CLI weed->s3
hanzoai/s3 was an AGPL-3.0 MinIO fork whose network-copyleft blocks embedding
and reselling as a managed service. Replace it wholesale with a fork of
SeaweedFS (github.com/seaweedfs/seaweedfs, Apache-2.0, Go) which ships a
built-in S3 gateway + Filer.

- License: AGPL-3.0 -> Apache-2.0 (upstream LICENSE kept verbatim, incl.
  "Copyright 2025 Chris Lu"); NOTICE added crediting SeaweedFS. No AGPL/BUSL/
  SSPL/GPL or enterprise ("ee") files remain.
- Module: go.mod path set to github.com/hanzoai/s3 with a self-replace
  (replace github.com/seaweedfs/seaweedfs => ./) so the ~1800 internal Go files
  keep their upstream import paths with zero churn. Internal identifiers and
  import paths are intentionally NOT renamed.
- CLI rebrand: the binary is built as `s3` (was `weed`); root usage, per-command
  help/usage/flag strings, shell autocomplete program name, and the product name
  in CLI output now read "Hanzo S3" / `s3`. Users never see `weed`.
  (e.g. `s3 server`, `s3 filer`, `s3 master`, `s3 volume`, `s3 s3`, `s3 mount`.)
- Build: `make` (or `go build -o s3 ./weed`) builds the `s3` binary green;
  `cmd/weed-db` and `cmd/weed-sql` also build; `go vet ./weed/command/` clean.
- README/LLM.md rewritten for Hanzo S3; AGENTS.md/CLAUDE.md symlinks preserved.
- Dropped a 33MB prebuilt Mach-O `stress` binary upstream had committed; `.gitignore`
  updated to ignore the `s3` build output.
2026-06-18 23:41:25 -07:00

163 lines
5.9 KiB
Go

package reader
import (
"context"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Layer 2 contracts the daily-replay drain relies on. These augment
// reader_test.go (extract + dispatch) and cursor_test.go (Advance +
// Freeze) by pinning the cursor composition surface that the daily-run
// drain depends on for resume-point selection, checkpoint persistence,
// and Run-time input validation.
func TestCursorMinTsNsWithFrozenKeysIncluded(t *testing.T) {
// MinTsNs is the resume point Reader.Run feeds to SubscribeMetadata
// when StartTsNs is zero. A freeze pins a key at its current TsNs;
// the min must still see that pinned position so the subscription
// doesn't rewind past it. Mixing frozen + non-frozen keys exercises
// the path where the smallest position belongs to a frozen key.
c := NewCursor()
frozenKey := key("a", s3lifecycle.ActionKindExpirationDays)
movingKey := key("b", s3lifecycle.ActionKindNoncurrentDays)
otherKey := key("c", s3lifecycle.ActionKindAbortMPU)
c.Advance(frozenKey, 100)
c.Advance(movingKey, 500)
c.Advance(otherKey, 300)
c.Freeze(frozenKey, 100)
// Frozen key holds at 100; Advance must not move it.
c.Advance(frozenKey, 9_999)
require.Equal(t, int64(100), c.Get(frozenKey))
// MinTsNs reflects the frozen key because it is still the smallest.
assert.Equal(t, int64(100), c.MinTsNs())
// Non-frozen keys keep advancing; min stays anchored to the freeze.
c.Advance(movingKey, 700)
c.Advance(otherKey, 600)
assert.Equal(t, int64(100), c.MinTsNs())
}
func TestCursorSnapshotIsDeepCopy(t *testing.T) {
// Snapshot is persisted via the persister; callers must be able to
// hold the returned map without observing live cursor mutations,
// and the cursor must not observe caller-side edits to the map.
c := NewCursor()
a := key("a", s3lifecycle.ActionKindExpirationDays)
b := key("b", s3lifecycle.ActionKindNoncurrentDays)
c.Advance(a, 100)
c.Advance(b, 200)
snap := c.Snapshot()
require.Equal(t, int64(100), snap[a])
require.Equal(t, int64(200), snap[b])
// Mutating the returned map must not bleed into the cursor.
snap[a] = 9_999
delete(snap, b)
snap[key("z", s3lifecycle.ActionKindAbortMPU)] = 42
assert.Equal(t, int64(100), c.Get(a), "cursor must be insulated from snapshot writes")
assert.Equal(t, int64(200), c.Get(b), "cursor must retain key the caller deleted from snapshot")
assert.Equal(t, int64(0), c.Get(key("z", s3lifecycle.ActionKindAbortMPU)), "cursor must not see keys added to snapshot")
// Mutating the cursor after Snapshot must not bleed into the map
// the caller is still holding (e.g., between Snapshot and Save).
snap2 := c.Snapshot()
c.Advance(a, 1_000)
c.Advance(key("d", s3lifecycle.ActionKindAbortMPU), 50)
assert.Equal(t, int64(100), snap2[a], "snapshot must not see post-snapshot Advance")
_, hasNew := snap2[key("d", s3lifecycle.ActionKindAbortMPU)]
assert.False(t, hasNew, "snapshot must not see post-snapshot key insertions")
}
func TestCursorRestoreReplacesNotMerges(t *testing.T) {
// On startup the persister loads the last checkpoint and Restores
// the cursor. A subsequent Restore (e.g., after a re-bootstrap)
// must fully replace the in-memory map: keys present before but
// absent in the new map must vanish, otherwise stale resume
// points would survive across restores.
c := NewCursor()
a := key("a", s3lifecycle.ActionKindExpirationDays)
b := key("b", s3lifecycle.ActionKindNoncurrentDays)
cc := key("c", s3lifecycle.ActionKindAbortMPU)
c.Restore(map[s3lifecycle.ActionKey]int64{a: 100, b: 200, cc: 300})
require.Equal(t, int64(100), c.Get(a))
require.Equal(t, int64(200), c.Get(b))
require.Equal(t, int64(300), c.Get(cc))
// Freeze a key before second Restore; Restore must clear frozen state
// alongside the value map so a stale freeze doesn't survive a reload.
c.Freeze(a, 100)
require.True(t, c.IsFrozen(a))
// Second Restore drops keys b and c entirely; a is replaced.
c.Restore(map[s3lifecycle.ActionKey]int64{a: 50})
assert.Equal(t, int64(50), c.Get(a))
assert.False(t, c.IsFrozen(a), "Restore must clear frozen state")
assert.Equal(t, int64(0), c.Get(b), "key absent from second Restore must be removed")
assert.Equal(t, int64(0), c.Get(cc), "key absent from second Restore must be removed")
// MinTsNs reflects only the surviving key.
assert.Equal(t, int64(50), c.MinTsNs())
// Restoring an empty map clears all state.
c.Restore(map[s3lifecycle.ActionKey]int64{})
assert.Equal(t, int64(0), c.Get(a))
assert.Equal(t, int64(0), c.MinTsNs())
}
func TestReaderRunValidatesInputsBeforeSubscribing(t *testing.T) {
// Run guards: out-of-range ShardID (no predicate), nil Events
// channel, empty BucketsPath. Each error path must fire before any
// client call, so a nil client suffices to drive the assertions.
out := make(chan *Event, 1)
cases := []struct {
name string
reader *Reader
wantSub string
}{
{
name: "shard id below zero",
reader: &Reader{ShardID: -1, BucketsPath: "/buckets", Events: out},
wantSub: "shard_id",
},
{
name: "shard id at or above ShardCount",
reader: &Reader{ShardID: s3lifecycle.ShardCount, BucketsPath: "/buckets", Events: out},
wantSub: "shard_id",
},
{
name: "nil events channel",
reader: &Reader{ShardID: 0, BucketsPath: "/buckets", Events: nil},
wantSub: "nil Events",
},
{
name: "empty buckets path",
reader: &Reader{ShardID: 0, BucketsPath: "", Events: out},
wantSub: "empty BucketsPath",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Bound the call so a regression that lets Run reach the
// nil client's blocking subscribe surfaces as a test failure
// instead of hanging the suite.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := tc.reader.Run(ctx, nil, "test", 0)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantSub)
})
}
}