mirror of
https://github.com/hanzoai/s3.git
synced 2026-11-02 13:11:24 +00:00
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.
277 lines
11 KiB
Go
277 lines
11 KiB
Go
package dailyrun
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/engine"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/reader"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/router"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// readerEventAlias keeps the test free of the reader import collision
|
|
// in the recovery test file when we need a typed nil channel.
|
|
type readerEventAlias = reader.Event
|
|
|
|
// TestWalkerDue covers the throttle decision matrix in isolation.
|
|
func TestWalkerDue(t *testing.T) {
|
|
runNow := time.Unix(1_700_000_000, 0).UTC()
|
|
cases := []struct {
|
|
name string
|
|
lastWalkedNs int64
|
|
interval time.Duration
|
|
want bool
|
|
}{
|
|
// interval=0 keeps the prior "fire every pass" semantics — the
|
|
// in-repo integration tests and the s3tests fast driver rely on
|
|
// this so the rule-change-then-walk behavior surfaces within a
|
|
// single test runtime. Within-pass double-fire suppression
|
|
// lives in runShard's walkedThisPass local; walkerDue answers
|
|
// the persisted-state question only.
|
|
{"interval zero always due", runNow.UnixNano(), 0, true},
|
|
// LastWalkedNs=0 marks "never walked steady-state" — the post-
|
|
// upgrade cold-start case for cursor files that predate the
|
|
// LastWalkedNs field. Fire so the throttle anchor gets seeded.
|
|
{"never walked is due", 0, time.Hour, true},
|
|
// Throttle on: interval elapsed → due.
|
|
{"interval elapsed", runNow.Add(-2 * time.Hour).UnixNano(), time.Hour, true},
|
|
{"interval exactly elapsed", runNow.Add(-time.Hour).UnixNano(), time.Hour, true},
|
|
// Throttle on: not yet → skip.
|
|
{"interval not yet elapsed", runNow.Add(-30 * time.Minute).UnixNano(), time.Hour, false},
|
|
// Future LastWalkedNs (clock skew or replay of an older runNow
|
|
// against a newer cursor) must not panic and must not fire —
|
|
// treat as "throttled" because the cursor claims a future walk.
|
|
{"future lastWalked", runNow.Add(time.Hour).UnixNano(), time.Hour, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
assert.Equal(t, tc.want, walkerDue(tc.lastWalkedNs, runNow, tc.interval))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRunShard_WalkerThrottle confirms two back-to-back runShard
|
|
// invocations only fire the steady-state walker once when the second
|
|
// pass starts inside the WalkerInterval window. The interval-zero
|
|
// control case keeps firing on every pass — same shape, different
|
|
// expectation — pinning the prior behavior so a default-zero config
|
|
// (tests, in-repo integration) doesn't regress.
|
|
func TestRunShard_WalkerThrottle(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
interval time.Duration
|
|
secondPassAfter time.Duration
|
|
wantTotalCalls int
|
|
wantSecondAdvNs bool // did LastWalkedNs change between pass 1 and pass 2?
|
|
}{
|
|
{"interval=0 fires every pass", 0, 30 * time.Second, 2, true},
|
|
{"throttled: second pass within interval", time.Hour, 30 * time.Second, 1, false},
|
|
{"throttled: second pass past interval", time.Hour, 2 * time.Hour, 2, true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
snap := snapshotWithRule(t, 30) // replay-eligible only; steady-state walker would normally skip
|
|
// Force a walker-only partition so the steady-state branch
|
|
// has something to walk: set retentionWindow=0 in cfg, which
|
|
// makes promoted non-empty and walkView non-empty.
|
|
p := newMemPersister()
|
|
calls := 0
|
|
cfg := Config{
|
|
Persister: p,
|
|
Walker: func(_ context.Context, _ *engine.Snapshot, _ int) error {
|
|
calls++
|
|
return nil
|
|
},
|
|
RetentionWindow: -1, // negative sentinel: falls back to maxTTL — leaves walkView empty
|
|
WalkerInterval: tc.interval,
|
|
}
|
|
// Pre-seed cursor matching snap's hashes so cold-start /
|
|
// recovery branches don't fire and we measure ONLY the
|
|
// steady-state walker.
|
|
rsh := engine.ReplayContentHash(snap)
|
|
promoted := engine.PromotedHash(snap, engine.MaxEffectiveTTL(snap))
|
|
runNow := time.Unix(1_700_000_000, 0).UTC()
|
|
require.NoError(t, p.Save(context.Background(), 0, Cursor{
|
|
TsNs: runNow.UnixNano(),
|
|
RuleSetHash: rsh,
|
|
PromotedHash: promoted,
|
|
}))
|
|
|
|
// With the snapshot's only rule being replay-eligible, the
|
|
// steady-state walkView is empty and the walker won't fire
|
|
// regardless of throttle. Use a snapshot that has BOTH a
|
|
// replay rule AND a scan-only walker rule by forcing
|
|
// retentionWindow to a very small value via cfg.
|
|
cfg.RetentionWindow = time.Nanosecond
|
|
// Recompute promoted with the test retention so persisted
|
|
// hashes match what runShard sees on each pass.
|
|
snapPromoted := engine.PromotedHash(snap, time.Nanosecond)
|
|
require.NoError(t, p.Save(context.Background(), 0, Cursor{
|
|
TsNs: runNow.UnixNano(),
|
|
RuleSetHash: rsh,
|
|
PromotedHash: snapPromoted,
|
|
}))
|
|
|
|
// runShard reaches drainShardEvents after the steady-state
|
|
// walker fires (replay-eligible rules present). A nil events
|
|
// channel would block forever; a closed one returns
|
|
// immediately so drain exits cleanly and the cursor save
|
|
// still records LastWalkedNs.
|
|
closedEvents := make(chan *readerEventAlias)
|
|
close(closedEvents)
|
|
|
|
// Pass 1.
|
|
require.NoError(t, runShard(context.Background(), cfg, snap, runNow, 0, closedEvents))
|
|
afterPass1, _, _ := p.Load(context.Background(), 0)
|
|
require.Equal(t, 1, calls, "pass 1 must fire the walker (cold-start anchor)")
|
|
require.Equal(t, runNow.UnixNano(), afterPass1.LastWalkedNs)
|
|
|
|
// Pass 2.
|
|
runNow2 := runNow.Add(tc.secondPassAfter)
|
|
closedEvents2 := make(chan *readerEventAlias)
|
|
close(closedEvents2)
|
|
require.NoError(t, runShard(context.Background(), cfg, snap, runNow2, 0, closedEvents2))
|
|
afterPass2, _, _ := p.Load(context.Background(), 0)
|
|
assert.Equal(t, tc.wantTotalCalls, calls, "walker calls after 2 passes")
|
|
if tc.wantSecondAdvNs {
|
|
assert.Equal(t, runNow2.UnixNano(), afterPass2.LastWalkedNs, "throttle allowed the second walk")
|
|
} else {
|
|
assert.Equal(t, runNow.UnixNano(), afterPass2.LastWalkedNs, "throttle suppressed the second walk; anchor unchanged")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidate_RejectsNegativeWalkerInterval pins the loud-failure
|
|
// contract for an embedder or test that constructs dailyrun.Config
|
|
// directly with a negative WalkerInterval. The admin-config parser
|
|
// clamps negative input to zero (worker/tasks/s3_lifecycle/config.go),
|
|
// but a caller bypassing that parser would otherwise get the silent
|
|
// fall-through-to-walk-every-pass behavior the throttle is trying to
|
|
// prevent.
|
|
func TestValidate_RejectsNegativeWalkerInterval(t *testing.T) {
|
|
// validate only checks for nil fields, not behavior — type-assert
|
|
// each interface to its zero value via a stub. The stubs never run.
|
|
cfg := validatableConfig()
|
|
cfg.WalkerInterval = -time.Hour
|
|
err := validate(cfg)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "WalkerInterval")
|
|
// And the zero sentinel still passes.
|
|
cfg.WalkerInterval = 0
|
|
require.NoError(t, validate(cfg))
|
|
}
|
|
|
|
// validatableConfig builds a minimal dailyrun.Config that passes the
|
|
// nil checks in validate() so individual fields can be poked. The
|
|
// stubs intentionally don't implement any meaningful behavior — they
|
|
// only need to be non-nil interface values.
|
|
func validatableConfig() Config {
|
|
return Config{
|
|
Engine: engine.New(),
|
|
FilerClient: stubFilerClient{},
|
|
Client: stubLifecycleClient{},
|
|
Persister: newMemPersister(),
|
|
Lister: stubSiblingLister{},
|
|
BucketsPath: "/buckets",
|
|
}
|
|
}
|
|
|
|
type stubFilerClient struct{ filer_pb.SeaweedFilerClient }
|
|
type stubLifecycleClient struct{}
|
|
|
|
func (stubLifecycleClient) LifecycleDelete(_ context.Context, _ *s3_lifecycle_pb.LifecycleDeleteRequest) (*s3_lifecycle_pb.LifecycleDeleteResponse, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
type stubSiblingLister struct{}
|
|
|
|
func (stubSiblingLister) Survivors(_ context.Context, _, _ string) (router.Survivors, error) {
|
|
return router.Survivors{}, nil
|
|
}
|
|
func (stubSiblingLister) LookupVersion(_ context.Context, _, _, _ string) (*filer_pb.Entry, error) {
|
|
return nil, nil
|
|
}
|
|
func (stubSiblingLister) ListVersions(_ context.Context, _, _ string) ([]*filer_pb.Entry, error) {
|
|
return nil, nil
|
|
}
|
|
func (stubSiblingLister) LookupNullVersion(_ context.Context, _, _ string) (*filer_pb.Entry, bool, error) {
|
|
return nil, false, nil
|
|
}
|
|
|
|
// TestRunShard_ColdStartDoesNotDoubleWalk pins the within-pass guard
|
|
// in runShard's walkedThisPass local. Before the guard, a cold-start
|
|
// pass with WalkerInterval=0 fired the walker twice in one pass:
|
|
// once via the mustWalkColdStart branch (with RecoveryView) and again
|
|
// immediately via the steady-state branch (with the per-shard walk
|
|
// view, which is a subset of RecoveryView). The second walk added no
|
|
// coverage and burned a full bucket scan. Guard against regression.
|
|
func TestRunShard_ColdStartDoesNotDoubleWalk(t *testing.T) {
|
|
snap := snapshotWithRule(t, 30)
|
|
p := newMemPersister()
|
|
// No persisted cursor → mustWalkColdStart=true. WalkerInterval=0
|
|
// would, before the fix, allow the steady-state branch to fire
|
|
// again with no time elapsed.
|
|
calls := 0
|
|
cfg := Config{
|
|
Persister: p,
|
|
Walker: func(_ context.Context, _ *engine.Snapshot, _ int) error {
|
|
calls++
|
|
return nil
|
|
},
|
|
WalkerInterval: 0,
|
|
}
|
|
runNow := time.Unix(1_700_000_000, 0).UTC()
|
|
|
|
closedEvents := make(chan *readerEventAlias)
|
|
close(closedEvents)
|
|
require.NoError(t, runShard(context.Background(), cfg, snap, runNow, 0, closedEvents))
|
|
|
|
assert.Equal(t, 1, calls, "cold-start walker must fire exactly once per pass even with interval=0")
|
|
}
|
|
|
|
// TestRunShard_RecoveryWalkerSetsLastWalkedAnchor pins that the
|
|
// unconditional recovery-branch walker fire still updates the
|
|
// LastWalkedNs anchor so the NEXT pass's throttle starts counting
|
|
// from this walk rather than re-firing immediately.
|
|
func TestRunShard_RecoveryWalkerSetsLastWalkedAnchor(t *testing.T) {
|
|
snap := snapshotWithRule(t, 30)
|
|
p := newMemPersister()
|
|
var stale [32]byte
|
|
for i := range stale {
|
|
stale[i] = 0xAA
|
|
}
|
|
require.NoError(t, p.Save(context.Background(), 5, Cursor{
|
|
TsNs: 1234,
|
|
RuleSetHash: stale,
|
|
LastWalkedNs: 0, // never walked anchor
|
|
}))
|
|
|
|
calls := 0
|
|
cfg := Config{
|
|
Persister: p,
|
|
Walker: func(_ context.Context, _ *engine.Snapshot, _ int) error {
|
|
calls++
|
|
return nil
|
|
},
|
|
WalkerInterval: time.Hour,
|
|
}
|
|
runNow := time.Unix(1_700_000_000, 0).UTC()
|
|
require.NoError(t, runShard(context.Background(), cfg, snap, runNow, 5, nil))
|
|
|
|
require.Equal(t, 1, calls, "recovery branch fires the walker unconditionally")
|
|
got, ok, _ := p.Load(context.Background(), 5)
|
|
require.True(t, ok)
|
|
assert.Equal(t, runNow.UnixNano(), got.LastWalkedNs,
|
|
"recovery walk must update LastWalkedNs so the steady-state pass after it can throttle")
|
|
}
|
|
|
|
// Compile-time check that the documented sentinel passes the type system.
|
|
var _ = s3lifecycle.ShardCount
|