Skip to content

Commit 580424a

Browse files
committed
test(img): gc prune refusal contracts — grace, dangling, race guard, errno, mount loss
1 parent 54050d5 commit 580424a

1 file changed

Lines changed: 140 additions & 0 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
defmodule Hyper.Img.Db.Gc.PruneTest do
2+
@moduledoc """
3+
Safety contracts of the GC delete step against real Postgres and the real
4+
layer store (the laws are stated in `Prune`'s moduledoc): prune only what
5+
is missing AND unreferenced AND out of grace AND on a healthy medium; the
6+
DELETE re-checks references; `presence/1` maps only a true `:enoent` to
7+
`:missing`. Every test creates uniquely-named rows/files and removes them
8+
on exit.
9+
10+
Hits Postgres + the configured layer store: excluded from the default
11+
run; CI runs it in the integration job (`mix test --include external`).
12+
"""
13+
use ExUnit.Case, async: false
14+
15+
@moduletag :external
16+
17+
import Ecto.Query
18+
19+
alias Hyper.Cfg
20+
alias Hyper.Cfg.Toml
21+
alias Hyper.Img.Db.{Blob, Image, ImageLayer, Repo}
22+
alias Hyper.Img.Db.Gc.Prune
23+
24+
defp config(overrides \\ []) do
25+
struct!(
26+
%Cfg.Gc{
27+
batch_size: 100,
28+
batch_pause: Unit.Time.ms(1),
29+
sweep_interval: Unit.Time.s(60),
30+
acquire_interval: Unit.Time.s(5),
31+
retry: Unit.Time.s(60),
32+
timeout: Unit.Time.s(5),
33+
grace_period: Unit.Time.s(0)
34+
},
35+
overrides
36+
)
37+
end
38+
39+
defp unique_id, do: Base.encode16(:crypto.strong_rand_bytes(16), case: :lower)
40+
41+
defp insert_blob!(id, opts) do
42+
age_s = Keyword.get(opts, :age_s, 3600)
43+
44+
Repo.insert!(%Blob{
45+
id: id,
46+
kind: :delta,
47+
size: Keyword.get(opts, :size, 1000),
48+
inserted_at: DateTime.add(DateTime.utc_now(), -age_s, :second)
49+
})
50+
end
51+
52+
defp blob!(opts \\ []) do
53+
id = unique_id()
54+
insert_blob!(id, opts)
55+
on_exit(fn -> Repo.delete_all(from b in Blob, where: b.id == ^id) end)
56+
id
57+
end
58+
59+
# A blob referenced by an image_layer row (ON DELETE RESTRICT on blob_id).
60+
defp referenced_blob!(opts \\ []) do
61+
blob_id = unique_id()
62+
img_id = unique_id()
63+
insert_blob!(blob_id, opts)
64+
Repo.insert!(%Image{id: img_id, label: "gc-prune-test"})
65+
Repo.insert!(%ImageLayer{image_id: img_id, blob_id: blob_id, position: 0})
66+
67+
on_exit(fn ->
68+
Repo.delete_all(from il in ImageLayer, where: il.image_id == ^img_id)
69+
Repo.delete_all(from i in Image, where: i.id == ^img_id)
70+
Repo.delete_all(from b in Blob, where: b.id == ^blob_id)
71+
end)
72+
73+
blob_id
74+
end
75+
76+
test "a missing, unreferenced, out-of-grace blob is pruned and its bytes counted" do
77+
id = blob!(size: 2048, age_s: 3600)
78+
79+
assert Prune.execute(config(), [{id, 2048}]) == {1, 2048, 0}
80+
assert Repo.get(Blob, id) == nil
81+
end
82+
83+
test "a missing blob still referenced by an image is dangling: reported, never deleted" do
84+
id = referenced_blob!(size: 512, age_s: 3600)
85+
86+
assert Prune.execute(config(), [{id, 512}]) == {0, 0, 1}
87+
assert Repo.get(Blob, id)
88+
end
89+
90+
test "rows inside the grace period are never deleted, even when missing and unreferenced" do
91+
id = blob!(age_s: 0)
92+
93+
assert Prune.execute(config(grace_period: Unit.Time.s(3600)), [{id, 1000}]) == {0, 0, 0}
94+
assert Repo.get(Blob, id)
95+
end
96+
97+
test "the DELETE re-checks references: a row referenced after the snapshot is skipped" do
98+
# Simulate the race by handing prune_rows an id that IS referenced: the
99+
# NOT EXISTS clause, not the caller's earlier snapshot, must protect it.
100+
id = referenced_blob!(age_s: 3600)
101+
future_cutoff = DateTime.add(DateTime.utc_now(), 3600, :second)
102+
103+
assert Prune.prune_rows(config(), [id], future_cutoff) == {0, 0}
104+
assert Repo.get(Blob, id)
105+
end
106+
107+
test "a medium that vanished between probe and delete skips the page's deletions" do
108+
id = blob!(age_s: 3600)
109+
110+
# Point the layer store at a nonexistent dir only for the duration of the
111+
# call: test_system must fail and Prune must refuse every delete. Safe to
112+
# do globally-briefly: unknown/system-error never deletes, by design.
113+
result =
114+
try do
115+
Toml.put_cache(%{"img" => %{"store" => "/nonexistent-hyper-gc-prune-test"}})
116+
Prune.execute(config(), [{id, 1000}])
117+
after
118+
Toml.reload()
119+
end
120+
121+
assert result == {0, 0, 0}
122+
assert Repo.get(Blob, id)
123+
end
124+
125+
test "presence/1: real file :present, true absence :missing, any other errno :unknown" do
126+
store = Hyper.Cfg.Dirs.layer_dir()
127+
id = unique_id()
128+
file = Path.join(store, "layer_#{id}.img")
129+
File.write!(file, "x")
130+
on_exit(fn -> File.rm(file) end)
131+
132+
assert Prune.presence(id) == :present
133+
assert Prune.presence(unique_id()) == :missing
134+
135+
# Probe a path that traverses THROUGH the regular file: stat fails with
136+
# :enotdir — not :enoent — and must classify :unknown (never prunable),
137+
# standing in for ESTALE/EIO on a wobbling NFS mount.
138+
assert Prune.presence("#{id}.img/nested") == :unknown
139+
end
140+
end

0 commit comments

Comments
 (0)