Skip to content

Commit 894337a

Browse files
test: coverage for codec/transport/otel/grpc/lease/gc/vm (#69)
1 parent 52bd002 commit 894337a

11 files changed

Lines changed: 881 additions & 124 deletions

File tree

coveralls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"skip_files": [
3+
"lib/hyper/firecracker/api/codec.ex",
34
"lib/hyper/firecracker/api/operations/",
45
"lib/hyper/firecracker/api/schemas/",
56
"lib/mix/"

lib/hyper/img/db/gc.ex

Lines changed: 10 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,18 @@ defmodule Hyper.Img.Db.Gc do
2424
**Publish contract:** write a layer's file to the medium before inserting its
2525
`blobs` row (ideally write-temp then atomic rename); the grace period is
2626
insurance, not a substitute.
27+
28+
The delete step itself lives in `Hyper.Img.Db.Gc.Prune`.
2729
"""
2830

2931
use GenServer
3032
use OpenTelemetryDecorator
3133
require Logger
32-
import Ecto.Query
3334

3435
alias Hyper.Cfg.Gc, as: Config
3536
alias Hyper.Cluster.Routing
36-
alias Hyper.Img.Db.{Blob, ImageLayer, Repo}
37+
alias Hyper.Img.Db.Blob
38+
alias Hyper.Img.Db.Gc.Prune
3739
alias Hyper.Img.Db.Gc.Sweep
3840
alias Hyper.Node.Layer.Repo, as: LayerRepo
3941

@@ -116,8 +118,6 @@ defmodule Hyper.Img.Db.Gc do
116118
# role, monitor noise, etc.) rather than crashing an in-flight sweep.
117119
def handle_info(_msg, state), do: {:noreply, state}
118120

119-
## Internals
120-
121121
@spec acquire(t()) :: t()
122122
defp acquire(state) do
123123
case Horde.Registry.register(Routing.name(), @singleton_key, nil) do
@@ -136,10 +136,13 @@ defmodule Hyper.Img.Db.Gc do
136136
@decorate with_span("Hyper.Img.Db.Gc.scan_one_batch")
137137
defp scan_one_batch(%__MODULE__{sweep: sweep} = state) do
138138
limit = state.config.batch_size
139-
batch = with_low_priority(state, fn -> Blob.present_after(sweep.cursor, limit) end)
140-
{sweep, missing} = Sweep.absorb(sweep, batch, &presence/1)
141139

142-
{pruned, pruned_bytes, dangling} = maybe_prune(state, missing)
140+
batch =
141+
Prune.with_low_priority(state.config, fn -> Blob.present_after(sweep.cursor, limit) end)
142+
143+
{sweep, missing} = Sweep.absorb(sweep, batch, &Prune.presence/1)
144+
145+
{pruned, pruned_bytes, dangling} = Prune.execute(state.config, missing)
143146
sweep = Sweep.record_prune(sweep, pruned, pruned_bytes, dangling)
144147

145148
if Sweep.continue?(batch, limit) do
@@ -156,120 +159,4 @@ defmodule Hyper.Img.Db.Gc do
156159
%{state | sweep: nil, last_sweep: sweep}
157160
end
158161
end
159-
160-
# Re-check the medium is still mounted before acting on a page's "missing"
161-
# set. If the whole mount vanished mid-page, every probe read as gone - skip
162-
# the deletions rather than wipe a page of live rows.
163-
@spec maybe_prune(t(), [Sweep.blob()]) ::
164-
{non_neg_integer(), non_neg_integer(), non_neg_integer()}
165-
defp maybe_prune(_state, []), do: {0, 0, 0}
166-
167-
defp maybe_prune(state, missing) do
168-
case LayerRepo.test_system() do
169-
:ok ->
170-
prune_missing(state, missing)
171-
172-
{:error, reason} ->
173-
Logger.warning(
174-
"layer gc: medium became unavailable before pruning (#{inspect(reason)}); " <>
175-
"skipping #{length(missing)} deletion(s) this page"
176-
)
177-
178-
{0, 0, 0}
179-
end
180-
end
181-
182-
# Prune the missing blobs that no image references; report the rest as dangling.
183-
@spec prune_missing(t(), [Sweep.blob()]) ::
184-
{non_neg_integer(), non_neg_integer(), non_neg_integer()}
185-
defp prune_missing(state, missing) do
186-
ids = Enum.map(missing, fn {id, _size} -> id end)
187-
referenced = referenced_ids(state, ids)
188-
189-
{dangling, prunable} =
190-
Enum.split_with(missing, fn {id, _size} -> MapSet.member?(referenced, id) end)
191-
192-
report_dangling(dangling)
193-
194-
cutoff =
195-
DateTime.add(DateTime.utc_now(), -Unit.Time.as_ms(state.config.grace_period), :millisecond)
196-
197-
prunable_ids = Enum.map(prunable, fn {id, _size} -> id end)
198-
{pruned, pruned_bytes} = prune_rows(state, prunable_ids, cutoff)
199-
200-
{pruned, pruned_bytes, length(dangling)}
201-
end
202-
203-
@spec prune_rows(t(), [String.t()], DateTime.t()) :: {non_neg_integer(), non_neg_integer()}
204-
defp prune_rows(_state, [], _cutoff), do: {0, 0}
205-
206-
defp prune_rows(state, ids, cutoff) do
207-
# `RETURNING size` gives the exact deleted set's count and bytes. The grace
208-
# cutoff protects freshly-published rows. NOT EXISTS double-guards the FK:
209-
# even if a reference appeared since we snapshotted `referenced`, that row is
210-
# skipped rather than raising.
211-
query =
212-
from b in Blob,
213-
as: :b,
214-
where:
215-
b.id in ^ids and b.state == :present and b.inserted_at < ^cutoff and
216-
not exists(from il in ImageLayer, where: il.blob_id == parent_as(:b).id),
217-
select: b.size
218-
219-
{count, sizes} = with_low_priority(state, fn -> Repo.delete_all(query) end)
220-
{count, Enum.sum(sizes)}
221-
end
222-
223-
# Report dangling blobs (file gone, still referenced = data loss) once per page,
224-
# aggregated, so a broken mount cannot flood the logs one line per blob.
225-
@spec report_dangling([Sweep.blob()]) :: :ok
226-
defp report_dangling([]), do: :ok
227-
228-
defp report_dangling(dangling) do
229-
count = length(dangling)
230-
bytes = dangling |> Enum.map(fn {_id, size} -> size end) |> Enum.sum()
231-
sample = dangling |> Enum.take(10) |> Enum.map(fn {id, _size} -> id end)
232-
233-
Logger.error(
234-
"layer gc: #{count} blob(s) missing from medium but still referenced by an image " <>
235-
"(#{bytes} bytes total); leaving in place. sample=#{inspect(sample)}"
236-
)
237-
end
238-
239-
@spec referenced_ids(t(), [String.t()]) :: MapSet.t(String.t())
240-
defp referenced_ids(state, ids) do
241-
query = from il in ImageLayer, where: il.blob_id in ^ids, distinct: true, select: il.blob_id
242-
state |> with_low_priority(fn -> Repo.all(query) end) |> MapSet.new()
243-
end
244-
245-
# Run a DB operation at low priority: in a transaction with a capped per-statement
246-
# timeout, so it can never pin a backend and yields under contention.
247-
@spec with_low_priority(t(), (-> result)) :: result when result: var
248-
defp with_low_priority(state, fun) do
249-
timeout = Unit.Time.as_ms(state.config.timeout)
250-
251-
{:ok, result} =
252-
Repo.transaction(fn ->
253-
_ =
254-
Repo.query!("SELECT set_config('statement_timeout', $1, true)", [
255-
Integer.to_string(timeout)
256-
])
257-
258-
fun.()
259-
end)
260-
261-
result
262-
end
263-
264-
# Shared-medium presence probe injected into the pure Sweep core. Distinguishes
265-
# a genuine absence (`:enoent` -> prunable) from an I/O error (`:unknown` ->
266-
# never pruned), so a transient NFS hiccup can never drive a delete.
267-
@spec presence(String.t()) :: Sweep.presence()
268-
defp presence(id) do
269-
case LayerRepo.find_layer(id) do
270-
{:ok, _path} -> :present
271-
{:error, :enoent} -> :missing
272-
{:error, _posix} -> :unknown
273-
end
274-
end
275162
end

lib/hyper/img/db/gc/prune.ex

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
defmodule Hyper.Img.Db.Gc.Prune do
2+
@moduledoc """
3+
The delete step of the layer GC, factored out of the `Gc` server so its
4+
safety contracts are testable against a real database without the
5+
cluster-singleton GenServer around them:
6+
7+
* a missing blob still referenced by an image is *dangling* — reported,
8+
never deleted;
9+
* rows younger than `grace_period` are never deleted;
10+
* the `DELETE` re-checks `NOT EXISTS (image_layers)`, so a reference
11+
that appeared after the caller's snapshot is skipped, not violated;
12+
* the shared medium is re-probed before any delete, so a mount that
13+
vanished mid-page skips the page instead of mass-deleting;
14+
* `presence/1` maps only a true `:enoent` to `:missing`; any other I/O
15+
error is `:unknown` and never drives a delete.
16+
17+
Every query runs at low priority: in a transaction with a capped
18+
per-statement timeout, so the GC can never pin a backend.
19+
"""
20+
21+
require Logger
22+
import Ecto.Query
23+
24+
alias Hyper.Cfg.Gc, as: Config
25+
alias Hyper.Img.Db.{Blob, ImageLayer, Repo}
26+
alias Hyper.Img.Db.Gc.Sweep
27+
alias Hyper.Node.Layer.Repo, as: LayerRepo
28+
29+
@doc """
30+
Act on one page's missing set: re-check the medium is still mounted, split
31+
dangling (still referenced) from prunable, delete what is out of grace.
32+
Returns `{pruned, pruned_bytes, dangling}`.
33+
"""
34+
@spec execute(Config.t(), [Sweep.blob()]) ::
35+
{non_neg_integer(), non_neg_integer(), non_neg_integer()}
36+
def execute(_config, []), do: {0, 0, 0}
37+
38+
def execute(config, missing) do
39+
case LayerRepo.test_system() do
40+
:ok ->
41+
prune_missing(config, missing)
42+
43+
{:error, reason} ->
44+
Logger.warning(
45+
"layer gc: medium became unavailable before pruning (#{inspect(reason)}); " <>
46+
"skipping #{length(missing)} deletion(s) this page"
47+
)
48+
49+
{0, 0, 0}
50+
end
51+
end
52+
53+
@doc """
54+
Shared-medium presence probe injected into the pure Sweep core.
55+
Distinguishes a genuine absence (`:enoent` -> prunable) from an I/O error
56+
(`:unknown` -> never pruned), so a transient NFS hiccup can never drive a
57+
delete.
58+
"""
59+
@spec presence(String.t()) :: Sweep.presence()
60+
def presence(id) do
61+
case LayerRepo.find_layer(id) do
62+
{:ok, _path} -> :present
63+
{:error, :enoent} -> :missing
64+
{:error, _posix} -> :unknown
65+
end
66+
end
67+
68+
@doc """
69+
Run a DB operation at low priority: in a transaction with a capped
70+
per-statement timeout, so it can never pin a backend and yields under
71+
contention.
72+
"""
73+
@spec with_low_priority(Config.t(), (-> result)) :: result when result: var
74+
def with_low_priority(config, fun) do
75+
timeout = Unit.Time.as_ms(config.timeout)
76+
77+
{:ok, result} =
78+
Repo.transaction(fn ->
79+
_ =
80+
Repo.query!("SELECT set_config('statement_timeout', $1, true)", [
81+
Integer.to_string(timeout)
82+
])
83+
84+
fun.()
85+
end)
86+
87+
result
88+
end
89+
90+
@doc false
91+
# Public only for its safety test: the NOT EXISTS re-check guards a race (a
92+
# reference appearing after the caller snapshotted `referenced_ids`) that
93+
# cannot be reached deterministically through `execute/2`.
94+
@spec prune_rows(Config.t(), [String.t()], DateTime.t()) ::
95+
{non_neg_integer(), non_neg_integer()}
96+
def prune_rows(_config, [], _cutoff), do: {0, 0}
97+
98+
def prune_rows(config, ids, cutoff) do
99+
# `RETURNING size` gives the exact deleted set's count and bytes. The grace
100+
# cutoff protects freshly-published rows. NOT EXISTS double-guards the FK:
101+
# even if a reference appeared since we snapshotted `referenced`, that row
102+
# is skipped rather than raising.
103+
query =
104+
from b in Blob,
105+
as: :b,
106+
where:
107+
b.id in ^ids and b.state == :present and b.inserted_at < ^cutoff and
108+
not exists(from il in ImageLayer, where: il.blob_id == parent_as(:b).id),
109+
select: b.size
110+
111+
{count, sizes} = with_low_priority(config, fn -> Repo.delete_all(query) end)
112+
{count, Enum.sum(sizes)}
113+
end
114+
115+
# Prune the missing blobs that no image references; report the rest as dangling.
116+
@spec prune_missing(Config.t(), [Sweep.blob()]) ::
117+
{non_neg_integer(), non_neg_integer(), non_neg_integer()}
118+
defp prune_missing(config, missing) do
119+
ids = Enum.map(missing, fn {id, _size} -> id end)
120+
referenced = referenced_ids(config, ids)
121+
122+
{dangling, prunable} =
123+
Enum.split_with(missing, fn {id, _size} -> MapSet.member?(referenced, id) end)
124+
125+
report_dangling(dangling)
126+
127+
cutoff =
128+
DateTime.add(DateTime.utc_now(), -Unit.Time.as_ms(config.grace_period), :millisecond)
129+
130+
prunable_ids = Enum.map(prunable, fn {id, _size} -> id end)
131+
{pruned, pruned_bytes} = prune_rows(config, prunable_ids, cutoff)
132+
133+
{pruned, pruned_bytes, length(dangling)}
134+
end
135+
136+
# Report dangling blobs (file gone, still referenced = data loss) once per
137+
# page, aggregated, so a broken mount cannot flood the logs one line per blob.
138+
@spec report_dangling([Sweep.blob()]) :: :ok
139+
defp report_dangling([]), do: :ok
140+
141+
defp report_dangling(dangling) do
142+
count = length(dangling)
143+
bytes = dangling |> Enum.map(fn {_id, size} -> size end) |> Enum.sum()
144+
sample = dangling |> Enum.take(10) |> Enum.map(fn {id, _size} -> id end)
145+
146+
Logger.error(
147+
"layer gc: #{count} blob(s) missing from medium but still referenced by an image " <>
148+
"(#{bytes} bytes total); leaving in place. sample=#{inspect(sample)}"
149+
)
150+
end
151+
152+
@spec referenced_ids(Config.t(), [String.t()]) :: MapSet.t(String.t())
153+
defp referenced_ids(config, ids) do
154+
query = from il in ImageLayer, where: il.blob_id in ^ids, distinct: true, select: il.blob_id
155+
config |> with_low_priority(fn -> Repo.all(query) end) |> MapSet.new()
156+
end
157+
end

0 commit comments

Comments
 (0)