| new file mode 100644 |
| index 000000000..a07deb79f |
| --- /dev/null |
| +++ b/internal/console/scandrain_test.go |
| @@ -0,0 +1,651 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// drainFixture is a console whose release store is a PLAIN DirStore. |
| +// |
| +// The shared fixture wraps its store in a struct that embeds release.Store as |
| +// an INTERFACE, which hides the two optional interfaces this whole file is |
| +// about: PageLister, which is how the drain caps one app's turn (§7.3), and |
| +// ModLister, which is how expired result objects are collected. A test on the |
| +// wrapper would silently exercise the degraded fallback in both cases and |
| +// prove nothing about the path a deployment actually runs. |
| +// |
| +// It returns the bucket root because two tests have to AGE a file, and mtime |
| +// is what DirStore's ListMod reads. |
| +func drainFixture(t *testing.T) (*consoleFixture, *objstore.MemScanner, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + dir := t.TempDir() |
| + rel := release.NewDirStore(dir) |
| + fx.rel = rel |
| + fx.srv.cfg.Releases = rel |
| + sc := objstore.NewMemScanner() |
| + fx.srv.cfg.StoreScanner = sc |
| + return fx, sc, dir |
| +} |
| + |
| +func drainBucket(acct, app string) string { return "carlos-" + acct + "-" + app } |
| + |
| +// enqueueFor puts one item in the bucket AND in the server's index, which is |
| +// what the door does. |
| +func enqueueFor(t *testing.T, fx *consoleFixture, acct, app, key string, at time.Time) { |
| + t.Helper() |
| + if _, err := objstore.Enqueue(context.Background(), fx.rel, fx.srv.scanQueue, acct, app, |
| + &objstore.QueueItem{Key: key, By: "test"}, at); err != nil { |
| + t.Fatalf("enqueue %s: %v", key, err) |
| + } |
| +} |
| + |
| +// enqueueStranded is the crash §7.1 describes: the item object is written and |
| +// the process dies before the index is bumped. Modelled by bumping a THROWAWAY |
| +// index, so the bucket has the item and this console has never heard of it. |
| +func enqueueStranded(t *testing.T, fx *consoleFixture, acct, app, key string, at time.Time) { |
| + t.Helper() |
| + if _, err := objstore.Enqueue(context.Background(), fx.rel, objstore.NewQueueIndex(), acct, app, |
| + &objstore.QueueItem{Key: key, By: "test"}, at); err != nil { |
| + t.Fatalf("enqueue %s: %v", key, err) |
| + } |
| +} |
| + |
| +func queueLen(t *testing.T, fx *consoleFixture, acct, app string) int { |
| + t.Helper() |
| + n, err := objstore.QueueDepth(context.Background(), fx.rel, acct, app) |
| + if err != nil { |
| + t.Fatalf("queue depth: %v", err) |
| + } |
| + return n |
| +} |
| + |
| +func resultKeys(t *testing.T, fx *consoleFixture, acct, app string) []string { |
| + t.Helper() |
| + keys, err := fx.rel.List(context.Background(), objstore.ScanResultsPrefix(acct, app)) |
| + if err != nil { |
| + t.Fatalf("list results: %v", err) |
| + } |
| + return keys |
| +} |
| + |
| +// countingStore counts listings, which is the only way to see the difference |
| +// between "the drain refused" and "the drain did the work and objstore |
| +// refused". |
| +// |
| +// It embeds the CONCRETE *release.DirStore rather than the interface, so |
| +// ListMod and ListDirs are still promoted — embedding the interface is exactly |
| +// the trap drainFixture's comment describes. |
| +type countingStore struct { |
| + *release.DirStore |
| + lists atomic.Int64 |
| +} |
| + |
| +func (c *countingStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + c.lists.Add(1) |
| + return c.DirStore.List(ctx, prefix) |
| +} |
| + |
| +func (c *countingStore) ListPage(ctx context.Context, prefix, startAfter string, limit int) ([]string, error) { |
| + c.lists.Add(1) |
| + return c.DirStore.ListPage(ctx, prefix, startAfter, limit) |
| +} |
| + |
| +// setDeploymentScanning flips the deployment switch either way. |
| +// |
| +// enableDeploymentScanning is CREATE-ONLY — it returns early when a record |
| +// already exists, which is right for a fixture arming a fresh deployment and |
| +// useless for re-enabling one that was turned off. |
| +func setDeploymentScanning(t *testing.T, fx *consoleFixture, on bool) { |
| + t.Helper() |
| + ctx := context.Background() |
| + av, etag, err := release.GetAntivirus(ctx, fx.rel) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if av == nil { |
| + av = &release.AntivirusSettings{Engine: "clamav"} |
| + } |
| + av.Enabled = on |
| + av.At = time.Now().UTC() |
| + av.By = "test" |
| + if _, err := release.PutAntivirus(ctx, fx.rel, av, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// revokeScanning is the operator taking the per-store grant away AFTER the |
| +// record exists, which enableStore cannot do: it writes with an empty etag and |
| +// so conflicts with anything already there. |
| +func revokeScanning(t *testing.T, fx *consoleFixture, acct, app string) { |
| + t.Helper() |
| + ctx := context.Background() |
| + rec, etag, err := objstore.Get(ctx, fx.rel, acct, app) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec == nil { |
| + t.Fatalf("no record for %s/%s to revoke", acct, app) |
| + } |
| + rec.Scanning = nil |
| + if _, err := objstore.Put(ctx, fx.rel, acct, app, rec, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// TestBootRebuildFindsStrandedItem is the whole argument for the rebuild |
| +// existing, and it is written so that the rebuild is the ONLY difference |
| +// between the two halves. |
| +// |
| +// A tick runs first and must do nothing: the drain only visits apps the index |
| +// names, and nothing named this one. Then the rebuild runs, and the same tick |
| +// scans. Without the "before" half this would pass against a drain that walked |
| +// every app on the deployment every 30s — which is the design the index exists |
| +// to avoid. |
| +func TestBootRebuildFindsStrandedItem(t *testing.T) { |
| + ctx := context.Background() |
| + fx, sc, _ := drainFixture(t) |
| + enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| + sc.PutObject(drainBucket(fixtureAcct, fixtureApp), "uploads/x.pdf", "etag-1", time.Now(), 10) |
| + enqueueStranded(t, fx, fixtureAcct, fixtureApp, "uploads/x.pdf", time.Now()) |
| + |
| + if d := fx.srv.ScanQueueDepth(); d != 0 { |
| + t.Fatalf("index depth %d before the rebuild — the item was not stranded, so this test proves nothing", d) |
| + } |
| + if err := fx.srv.scanDrainTick(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := sc.Scans("uploads/x.pdf"); n != 0 { |
| + t.Fatalf("a tick scanned %d time(s) for an app the index never named", n) |
| + } |
| + |
| + if err := fx.srv.rebuildScanQueue(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if d := fx.srv.ScanQueueDepth(); d != 1 { |
| + t.Fatalf("index depth %d after the rebuild, want 1 — nothing would ever list that prefix again", d) |
| + } |
| + if err := fx.srv.scanDrainTick(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := sc.Scans("uploads/x.pdf"); n != 1 { |
| + t.Fatalf("scanned %d times after the rebuild, want 1", n) |
| + } |
| + if n := queueLen(t, fx, fixtureAcct, fixtureApp); n != 0 { |
| + t.Errorf("%d item(s) left after a successful drain", n) |
| + } |
| +} |
| + |
| +// TestBootRebuildCollectsExpiredItemsAndResults covers BOTH prefixes on the |
| +// same 24h TTL, because they expire for the same reason: after a sweep cycle |
| +// the ordinary walk has had its chance at the object, and a stale on-demand |
| +// answer is worth less than none. |
| +// |
| +// The result object matters more than the item. A stranded item is drained on |
| +// the next tick; a stranded result is found by nothing, blocks the app-name |
| +// claim gate, and would sit in the bucket forever. |
| +func TestBootRebuildCollectsExpiredItemsAndResults(t *testing.T) { |
| + ctx := context.Background() |
| + fx, _, dir := drainFixture(t) |
| + enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| + |
| + old := time.Now().Add(-objstore.QueueTTL - time.Hour) |
| + enqueueStranded(t, fx, fixtureAcct, fixtureApp, "uploads/stale.pdf", old) |
| + if err := objstore.WriteScanResult(ctx, fx.rel, fixtureAcct, fixtureApp, &objstore.ScanResult{ |
| + Key: "uploads/untaggable.pdf", Verdict: "clean", Version: "etag-9@x", |
| + Engine: "clamav", Signature: "sig", At: old.UTC(), |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + // ListMod reads MTIME, not the At field inside the JSON, so the file has |
| + // to actually be old. |
| + resKey := objstore.ScanResultKey(fixtureAcct, fixtureApp, "uploads/untaggable.pdf") |
| + if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(resKey)), old, old); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if n := queueLen(t, fx, fixtureAcct, fixtureApp); n != 1 { |
| + t.Fatalf("%d item(s) staged, want 1", n) |
| + } |
| + if got := resultKeys(t, fx, fixtureAcct, fixtureApp); len(got) != 1 { |
| + t.Fatalf("%d result(s) staged, want 1", len(got)) |
| + } |
| + |
| + if err := fx.srv.rebuildScanQueue(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := queueLen(t, fx, fixtureAcct, fixtureApp); n != 0 { |
| + t.Errorf("%d expired item(s) survived the rebuild", n) |
| + } |
| + if got := resultKeys(t, fx, fixtureAcct, fixtureApp); len(got) != 0 { |
| + t.Errorf("expired result(s) survived the rebuild: %v", got) |
| + } |
| + // And nothing is left pointing at an app with no work. |
| + if d := fx.srv.ScanQueueDepth(); d != 0 { |
| + t.Errorf("index depth %d after collecting everything, want 0", d) |
| + } |
| +} |
| + |
| +// TestRebuildKeepsAnItemInsideItsTTL is the other half of the test above, and |
| +// it is here because "collects expired items" passes trivially against a |
| +// rebuild that deletes everything it finds. |
| +func TestRebuildKeepsAnItemInsideItsTTL(t *testing.T) { |
| + ctx := context.Background() |
| + fx, _, _ := drainFixture(t) |
| + enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| + enqueueStranded(t, fx, fixtureAcct, fixtureApp, "uploads/fresh.pdf", time.Now().Add(-time.Hour)) |
| + |
| + if err := fx.srv.rebuildScanQueue(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := queueLen(t, fx, fixtureAcct, fixtureApp); n != 1 { |
| + t.Fatalf("%d item(s) after the rebuild, want the one that is an hour old", n) |
| + } |
| + if d := fx.srv.ScanQueueDepth(); d != 1 { |
| + t.Errorf("index depth %d, want 1 — a kept item with no index entry is stranded again", d) |
| + } |
| +} |
| + |
| +// TestDrainRunsWhileSweepIsBlocked is §7.2 stated as something observable. |
| +// |
| +// "TestDrainIsItsOwnGoroutine" as a name would pass against anything at all — |
| +// against a queue phase inside scanPass, even. So the sweep is PARKED, inside |
| +// the lister, for the whole duration of a drain, and the drain has to complete |
| +// anyway. That is the only formulation that fails if the two are ever folded |
| +// together, which is what draft 1 proposed and what would cost this path the |
| +// seconds of latency it exists for. |
| +func TestDrainRunsWhileSweepIsBlocked(t *testing.T) { |
| + ctx := context.Background() |
| + fx, sc, _ := drainFixture(t) |
| + enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| + sc.PutObject(drainBucket(fixtureAcct, fixtureApp), "uploads/urgent.pdf", "etag-u", time.Now(), 10) |
| + enqueueFor(t, fx, fixtureAcct, fixtureApp, "uploads/urgent.pdf", time.Now()) |
| + |
| + entered := make(chan struct{}, 1) |
| + release := make(chan struct{}) |
| + fx.srv.storeListerFor = func(context.Context, *objstore.Provisioned) (objstore.ObjectLister, error) { |
| + select { |
| + case entered <- struct{}{}: |
| + default: |
| + } |
| + <-release |
| + return &countingLister{}, nil |
| + } |
| + swept := make(chan struct{}) |
| + go func() { |
| + _ = fx.srv.scanPass(ctx) |
| + close(swept) |
| + }() |
| + |
| + select { |
| + case <-entered: |
| + case <-time.After(5 * time.Second): |
| + close(release) |
| + t.Fatal("the sweep never reached the lister — the test never parked it") |
| + } |
| + |
| + // The sweep is now stopped dead inside scanOneApp. The drain must not be. |
| + if err := fx.srv.scanDrainTick(ctx); err != nil { |
| + t.Fatalf("drain tick while the sweep is parked: %v", err) |
| + } |
| + if n := sc.Scans("uploads/urgent.pdf"); n != 1 { |
| + t.Fatalf("scanned %d times while the sweep held its budget, want 1", n) |
| + } |
| + |
| + close(release) |
| + <-swept |
| +} |
| + |
| +// TestDrainRoundRobinFairness: one app's flood must not delay another app's |
| +// single item beyond one tick. |
| +// |
| +// FIFO across the deployment is the design this refuses. The flooder is given |
| +// more items than the per-app cap on purpose, so that a drain which emptied |
| +// one app before moving on would still be inside the flooder when the tick |
| +// ended, and the other app's item would go unanswered. |
| +func TestDrainRoundRobinFairness(t *testing.T) { |
| + ctx := context.Background() |
| + fx, sc, _ := drainFixture(t) |
| + enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| + enableStore(t, fx, fixtureAcct, "flooder", true) |
| + |
| + flood := scanDrainPerApp + 10 |
| + for i := 0; i < flood; i++ { |
| + key := fmt.Sprintf("uploads/flood-%03d.bin", i) |
| + sc.PutObject(drainBucket(fixtureAcct, "flooder"), key, "etag-f", time.Now(), 10) |
| + enqueueFor(t, fx, fixtureAcct, "flooder", key, time.Now()) |
| + } |
| + sc.PutObject(drainBucket(fixtureAcct, fixtureApp), "uploads/urgent.pdf", "etag-u", time.Now(), 10) |
| + enqueueFor(t, fx, fixtureAcct, fixtureApp, "uploads/urgent.pdf", time.Now()) |
| + |
| + if err := fx.srv.scanDrainTick(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := sc.Scans("uploads/urgent.pdf"); n != 1 { |
| + t.Fatalf("the urgent item scanned %d times in the tick that also met a flood of %d, want 1", n, flood) |
| + } |
| + // And the cap held: the flooder did not get the whole queue in one turn. |
| + left := queueLen(t, fx, fixtureAcct, "flooder") |
| + if left != flood-scanDrainPerApp { |
| + t.Errorf("flooder has %d item(s) left, want %d — the per-app cap is not being applied", |
| + left, flood-scanDrainPerApp) |
| + } |
| + // A SECOND tick, because the release rule needs one: DrainApp reports a |
| + // consumed page as "not empty" — a full page may have more behind it — so |
| + // an app's entry goes on the first tick that lists nothing, not on the |
| + // tick that emptied it. After tick two the urgent app is released and the |
| + // flooder, which still had a partial page, is not. |
| + if err := fx.srv.scanDrainTick(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := queueLen(t, fx, fixtureAcct, "flooder"); n != 0 { |
| + t.Errorf("flooder has %d item(s) after two ticks, want 0", n) |
| + } |
| + if d := fx.srv.ScanQueueDepth(); d != 1 { |
| + t.Errorf("index depth %d after two ticks, want 1 — the drained app should have been released and the flooder kept", d) |
| + } |
| +} |
| + |
| +// TestDrainAgeSweepsResultObjects: per VISITED app, so a console that never |
| +// restarts does not accumulate them. |
| +// |
| +// Nothing else deletes these. The drain removes a result only when a later |
| +// scan records a real verdict on the same key, and for an object that is |
| +// permanently untaggable — the ten-tag case — that never happens. |
| +func TestDrainAgeSweepsResultObjects(t *testing.T) { |
| + ctx := context.Background() |
| + fx, sc, dir := drainFixture(t) |
| + enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| + sc.PutObject(drainBucket(fixtureAcct, fixtureApp), "uploads/live.pdf", "etag-l", time.Now(), 10) |
| + enqueueFor(t, fx, fixtureAcct, fixtureApp, "uploads/live.pdf", time.Now()) |
| + |
| + old := time.Now().Add(-objstore.QueueTTL - time.Hour) |
| + for _, k := range []string{"uploads/old.pdf", "uploads/recent.pdf"} { |
| + if err := objstore.WriteScanResult(ctx, fx.rel, fixtureAcct, fixtureApp, &objstore.ScanResult{ |
| + Key: k, Verdict: "clean", Version: "etag-x@y", Engine: "clamav", Signature: "sig", |
| + At: time.Now().UTC(), |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + oldPath := filepath.Join(dir, filepath.FromSlash( |
| + objstore.ScanResultKey(fixtureAcct, fixtureApp, "uploads/old.pdf"))) |
| + if err := os.Chtimes(oldPath, old, old); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if err := fx.srv.scanDrainTick(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := resultKeys(t, fx, fixtureAcct, fixtureApp) |
| + if len(got) != 1 { |
| + t.Fatalf("results after the tick = %v, want only the recent one", got) |
| + } |
| + // The one inside its TTL survives — a collector that dropped both would |
| + // pass a test that only counted the expired one. |
| + if want := objstore.ScanResultKey(fixtureAcct, fixtureApp, "uploads/recent.pdf"); got[0] != want { |
| + t.Errorf("survivor = %q, want %q", got[0], want) |
| + } |
| +} |
| + |
| +// TestQueueDepthReported: §4.4 wants it deployment-wide and §10 wants it per |
| +// app, and they are different numbers measured in different ways. |
| +func TestQueueDepthReported(t *testing.T) { |
| + ctx := context.Background() |
| + fx, _, _ := drainFixture(t) |
| + enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| + enableStore(t, fx, fixtureAcct, "second", true) |
| + |
| + if d := fx.srv.ScanQueueDepth(); d != 0 { |
| + t.Fatalf("depth %d on an idle deployment, want 0", d) |
| + } |
| + enqueueFor(t, fx, fixtureAcct, fixtureApp, "a.pdf", time.Now()) |
| + enqueueFor(t, fx, fixtureAcct, fixtureApp, "b.pdf", time.Now()) |
| + enqueueFor(t, fx, fixtureAcct, "second", "c.pdf", time.Now()) |
| + |
| + // Deployment-wide counts APPS with work, not items: two apps, three items. |
| + if d := fx.srv.ScanQueueDepth(); d != 2 { |
| + t.Errorf("deployment depth = %d, want 2 apps with work", d) |
| + } |
| + // Per app counts ITEMS. |
| + if n := queueLen(t, fx, fixtureAcct, fixtureApp); n != 2 { |
| + t.Errorf("per-app depth = %d, want 2 items", n) |
| + } |
| + if n := queueLen(t, fx, fixtureAcct, "second"); n != 1 { |
| + t.Errorf("per-app depth = %d, want 1 item", n) |
| + } |
| + // An app that has never enqueued has no prefix, and that is depth zero |
| + // rather than an error. |
| + if n := queueLen(t, fx, fixtureAcct, "never-used"); n != 0 { |
| + t.Errorf("depth %d for an app with no queue prefix, want 0", n) |
| + } |
| + _ = ctx |
| +} |
| + |
| +// TestDrainDoesNothingWhileTheDeploymentIsDisabled, and specifically KEEPS the |
| +// items: the deployment switch is the one reversible refusal, and an operator |
| +// running `enable` should find the queue still there. The TTL is what clears a |
| +// deployment left off for a day, not the tick. |
| +func TestDrainDoesNothingWhileTheDeploymentIsDisabled(t *testing.T) { |
| + ctx := context.Background() |
| + fx, sc, dir := drainFixture(t) |
| + counting := &countingStore{DirStore: release.NewDirStore(dir)} |
| + fx.rel = counting |
| + fx.srv.cfg.Releases = counting |
| + enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| + sc.PutObject(drainBucket(fixtureAcct, fixtureApp), "uploads/x.pdf", "etag-1", time.Now(), 10) |
| + enqueueFor(t, fx, fixtureAcct, fixtureApp, "uploads/x.pdf", time.Now()) |
| + setDeploymentScanning(t, fx, false) |
| + |
| + // The tick's own switch check is not what keeps the bytes safe — DrainApp |
| + // re-reads consent and refuses on its own, so removing the tick's check |
| + // changes no verdict and no item. What it changes is WORK: with it, a |
| + // disabled deployment costs one record read; without it, every app with a |
| + // queue entry is listed every 30s forever. Listings are therefore what |
| + // this asserts, because they are the only observable difference. |
| + counting.lists.Store(0) |
| + if err := fx.srv.scanDrainTick(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := counting.lists.Load(); n != 0 { |
| + t.Errorf("%d listing(s) on a disabled deployment — the tick is doing per-app work it can never use", n) |
| + } |
| + if n := sc.Scans("uploads/x.pdf"); n != 0 { |
| + t.Errorf("scanned %d times with the deployment switch off", n) |
| + } |
| + if n := queueLen(t, fx, fixtureAcct, fixtureApp); n != 1 { |
| + t.Fatalf("%d item(s) left, want the item KEPT — this refusal is reversible", n) |
| + } |
| + |
| + // And it drains once the operator turns it back on, without a restart. |
| + setDeploymentScanning(t, fx, true) |
| + if err := fx.srv.scanDrainTick(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := sc.Scans("uploads/x.pdf"); n != 1 { |
| + t.Errorf("scanned %d times after re-enabling, want 1", n) |
| + } |
| +} |
| + |
| +// TestDrainDropsTheQueueWhenConsentIsGone is §7.5's other side: the permanent |
| +// refusals delete, because none of them can change back in a way that makes |
| +// the item useful, and an item that outlives consent is a request to read |
| +// bytes nobody may read. |
| +func TestDrainDropsTheQueueWhenConsentIsGone(t *testing.T) { |
| + ctx := context.Background() |
| + fx, sc, _ := drainFixture(t) |
| + enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| + enqueueFor(t, fx, fixtureAcct, fixtureApp, "uploads/x.pdf", time.Now()) |
| + // The operator revokes the per-store grant between enqueue and drain. |
| + revokeScanning(t, fx, fixtureAcct, fixtureApp) |
| + |
| + if err := fx.srv.scanDrainTick(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := sc.Scans("uploads/x.pdf"); n != 0 { |
| + t.Errorf("read bytes %d time(s) from a store that may not be scanned", n) |
| + } |
| + if n := queueLen(t, fx, fixtureAcct, fixtureApp); n != 0 { |
| + t.Errorf("%d item(s) survived a permanent refusal", n) |
| + } |
| +} |
| + |
| +// TestScanNotifyWakesAWaiter is the long-poll's half of the drain (§5.2). The |
| +// door lands in task 13; the wake it selects on is delivered here, so it has |
| +// something to be written against. |
| +func TestScanNotifyWakesAWaiter(t *testing.T) { |
| + ctx := context.Background() |
| + fx, sc, _ := drainFixture(t) |
| + enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| + sc.PutObject(drainBucket(fixtureAcct, fixtureApp), "uploads/x.pdf", "etag-1", time.Now(), 10) |
| + enqueueFor(t, fx, fixtureAcct, fixtureApp, "uploads/x.pdf", time.Now()) |
| + |
| + ref := objstore.AppRef{Account: fixtureAcct, App: fixtureApp} |
| + woken, cancel := fx.srv.scanWait(ref, "uploads/x.pdf") |
| + defer cancel() |
| + // A waiter on a DIFFERENT key must not be woken by this drain. |
| + other, cancelOther := fx.srv.scanWait(ref, "uploads/other.pdf") |
| + defer cancelOther() |
| + |
| + if err := fx.srv.scanDrainTick(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + select { |
| + case <-woken: |
| + default: |
| + t.Error("the drain scanned the object and nothing woke the waiter") |
| + } |
| + select { |
| + case <-other: |
| + t.Error("a waiter on another key was woken") |
| + default: |
| + } |
| + |
| + // Cancelling removes the registration rather than leaking one entry per |
| + // key ever waited on. |
| + cancel() |
| + fx.srv.scanWaits.mu.Lock() |
| + n := len(fx.srv.scanWaits.m[scanWaitKey{ref: ref, key: "uploads/x.pdf"}]) |
| + fx.srv.scanWaits.mu.Unlock() |
| + if n != 0 { |
| + t.Errorf("%d waiter(s) still registered after cancel", n) |
| + } |
| +} |
| + |
| +// TestDrainNotifiesEvenWhenALaterItemFails: DrainApp returns what it managed |
| +// before it stopped, and a waiter whose object WAS scanned must be woken even |
| +// though the app's next item failed. Notifying after the error check would |
| +// make a long-poll wait out its whole window for an answer that already |
| +// exists on the object. |
| +// |
| +// Reaching that state takes some care with the fake. The FIRST object is |
| +// pre-tagged at its current version, so Status answers from the tag and Scan |
| +// is never called for it — which leaves the one-shot Scan failure below for |
| +// the SECOND object. done therefore carries the first while err carries the |
| +// second, which is exactly the shape being asserted. |
| +func TestDrainNotifiesEvenWhenALaterItemFails(t *testing.T) { |
| + ctx := context.Background() |
| + fx, sc, _ := drainFixture(t) |
| + enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| + bucket := drainBucket(fixtureAcct, fixtureApp) |
| + |
| + mod := time.Now().Truncate(time.Second) |
| + first := objstore.ScanObject{Key: "uploads/first.pdf", ETag: "etag-1", Modified: mod, Size: 10} |
| + sc.PutObject(bucket, first.Key, first.ETag, mod, first.Size) |
| + sc.Put(bucket, first.Key, first.Version(), objstore.VerdictClean) |
| + sc.PutObject(bucket, "uploads/second.pdf", "etag-2", mod, 10) |
| + |
| + // Ids sort by time, so the first item is drained first. |
| + enqueueFor(t, fx, fixtureAcct, fixtureApp, first.Key, time.Now()) |
| + enqueueFor(t, fx, fixtureAcct, fixtureApp, "uploads/second.pdf", time.Now().Add(2*time.Millisecond)) |
| + |
| + ref := objstore.AppRef{Account: fixtureAcct, App: fixtureApp} |
| + woken, cancel := fx.srv.scanWait(ref, first.Key) |
| + defer cancel() |
| + |
| + sc.Fail("Scan", errors.New("engine had a bad moment")) |
| + |
| + if err := fx.srv.scanDrainTick(ctx); err != nil { |
| + t.Fatal(err) // the tick logs per-app failures rather than returning them |
| + } |
| + if sc.Scans(first.Key) != 0 { |
| + t.Fatal("the pre-tagged object was rescanned, so the Scan failure did not reach the second item") |
| + } |
| + select { |
| + case <-woken: |
| + default: |
| + t.Error("the first object was answered and its waiter was not woken, because a later item failed") |
| + } |
| + // The failed item is KEPT — an engine failure is about the engine, not |
| + // about whether this request was legitimate. |
| + if n := queueLen(t, fx, fixtureAcct, fixtureApp); n != 1 { |
| + t.Errorf("%d item(s) left, want the failed one kept for the next tick", n) |
| + } |
| +} |
| + |
| +// TestStartScanDrainRebuildsBeforeItsFirstTick covers the goroutine itself, |
| +// which the tick-level tests above do not: they call rebuildScanQueue and |
| +// scanDrainTick by hand, so a StartScanDrain that never ran the rebuild would |
| +// pass every one of them and strand every item across every restart. |
| +// |
| +// It is also the only test of "the first tick is immediate". A loop that |
| +// waited a full interval before its first pass would make every restart cost |
| +// 30s of latency on work that was already queued. |
| +func TestStartScanDrainRebuildsBeforeItsFirstTick(t *testing.T) { |
| + fx, sc, _ := drainFixture(t) |
| + enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| + sc.PutObject(drainBucket(fixtureAcct, fixtureApp), "uploads/x.pdf", "etag-1", time.Now(), 10) |
| + enqueueStranded(t, fx, fixtureAcct, fixtureApp, "uploads/x.pdf", time.Now()) |
| + |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + stopped := make(chan struct{}) |
| + go func() { |
| + fx.srv.StartScanDrain(ctx) |
| + close(stopped) |
| + }() |
| + |
| + deadline := time.Now().Add(5 * time.Second) |
| + for sc.Scans("uploads/x.pdf") == 0 { |
| + if time.Now().After(deadline) { |
| + cancel() |
| + <-stopped |
| + t.Fatal("the goroutine did not rebuild and drain a stranded item within 5s — well inside its first tick") |
| + } |
| + time.Sleep(2 * time.Millisecond) |
| + } |
| + cancel() |
| + select { |
| + case <-stopped: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("StartScanDrain did not return when its context was cancelled") |
| + } |
| +} |
| + |
| +// TestStartScanDrainIsInertWithoutAnEngine: scanning is opt-in, and the |
| +// ordinary deployment has no clamd. The goroutine must return rather than tick |
| +// forever over a deployment that can never drain anything. |
| +func TestStartScanDrainIsInertWithoutAnEngine(t *testing.T) { |
| + fx, _, _ := drainFixture(t) |
| + fx.srv.cfg.StoreScanner = nil |
| + |
| + stopped := make(chan struct{}) |
| + go func() { |
| + fx.srv.StartScanDrain(context.Background()) |
| + close(stopped) |
| + }() |
| + select { |
| + case <-stopped: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("StartScanDrain kept ticking on a deployment with no engine") |
| + } |
| +} |