Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Order cache eviction by written files, not directory mtimes

e6f0d8b947d985c8615e1172aef06cee8fd3729e · Keenan Nemetz · 20h ago · parent 973fb809

Order cache eviction by written files, not directory mtimes

Trim sorted namespaces by the newest mtime in the tree, directories included.
A directory's mtime moves when a tree is unpacked, so two namespaces created in
the same breath compared equal and sort.Slice broke the tie arbitrarily. The
runner's verify run on 01M6YSX585GD5BDT45H0FE65AE removed the newer namespace
and left the older one, failing TestTrimDropsTheOldestNamespaceWhole on a
machine where it had passed on ext4.

measure now dates a namespace by its files, falling back to the directory's own
mtime when there is no file, and Trim breaks an exact tie by path so the same
namespace goes on every pass.

TestTrimOrdersByContentNotDirectories fails on the previous implementation with
the same message the runner printed.

ci/cache/cache_test.go +92

@@ -131,6 +131,98 @@ func TestTrimDropsTheOldestNamespaceWhole(t *testing.T) {
131 131 }
132 132 }
133 133
134 // A namespace is aged by the work it holds, not by when its tree was unpacked.
135 // Directories are created when a cache is extracted, so counting their mtimes let a
136 // two-hour-old cache outlive a one-hour-old one. That is the shape of the failure
137 // that made this test pass on ext4 and fail on a runner.
138 func TestTrimOrdersByContentNotDirectories(t *testing.T) {
139 t.Parallel()
140 root := t.TempDir()
141
142 mk := func(repo, image string, content, dirs time.Time) string {
143 ns := Namespace(root, repo, "direct", image)
144 if err := os.MkdirAll(ns, 0o755); err != nil {
145 t.Fatal(err)
146 }
147 p := filepath.Join(ns, "a")
148 if err := os.WriteFile(p, make([]byte, 1000), 0o644); err != nil {
149 t.Fatal(err)
150 }
151 if err := os.Chtimes(p, content, content); err != nil {
152 t.Fatal(err)
153 }
154 if err := os.Chtimes(ns, dirs, dirs); err != nil {
155 t.Fatal(err)
156 }
157 return ns
158 }
159
160 now := time.Now()
161 old := mk("old", "ref-a", now.Add(-2*time.Hour), now)
162 fresh := mk("fresh", "ref-b", now.Add(-time.Hour), now.Add(-time.Hour))
163
164 if removed, err := Trim(root, 1500); err != nil || removed != 1000 {
165 t.Fatalf("trim = %d, %v; want 1000 bytes", removed, err)
166 }
167 if _, err := os.Stat(old); !os.IsNotExist(err) {
168 t.Fatalf("the older content survived because its directory was touched later: %v", err)
169 }
170 if _, err := os.Stat(fresh); err != nil {
171 t.Fatalf("the newer content was removed: %v", err)
172 }
173 }
174
175 // Two namespaces written at the same instant must not be ordered by chance: the same
176 // one goes on every pass, so a runner sharing a directory does not evict a different
177 // cache depending on which way the sort fell.
178 func TestTrimBreaksATieDeterministically(t *testing.T) {
179 t.Parallel()
180 when := time.Now().Add(-time.Hour).Truncate(time.Second)
181 victim := ""
182 for run := 0; run < 20; run++ {
183 root := t.TempDir()
184 var built []string
185 for _, image := range []string{"ref-a", "ref-b"} {
186 ns := Namespace(root, "demo", "direct", image)
187 if err := os.MkdirAll(ns, 0o755); err != nil {
188 t.Fatal(err)
189 }
190 p := filepath.Join(ns, "a")
191 if err := os.WriteFile(p, make([]byte, 1000), 0o644); err != nil {
192 t.Fatal(err)
193 }
194 for _, path := range []string{p, ns} {
195 if err := os.Chtimes(path, when, when); err != nil {
196 t.Fatal(err)
197 }
198 }
199 built = append(built, ns)
200 }
201 if removed, err := Trim(root, 1500); err != nil || removed != 1000 {
202 t.Fatalf("run %d: trim = %d, %v; want 1000 bytes", run, removed, err)
203 }
204 gone := ""
205 for _, ns := range built {
206 if _, err := os.Stat(ns); os.IsNotExist(err) {
207 gone = ns
208 }
209 }
210 if gone == "" {
211 t.Fatalf("run %d: nothing was removed", run)
212 }
213 if victim == "" {
214 victim = gone
215 continue
216 }
217 // t.TempDir makes a new root per run, so compare the namespace that went,
218 // not where it was: the same name must be evicted every time.
219 if filepath.Base(gone) != filepath.Base(victim) {
220 t.Fatalf("run %d evicted %s, the first run evicted %s",
221 run, filepath.Base(gone), filepath.Base(victim))
222 }
223 }
224 }
225
134 226 // Clear is what --no-cache uses: the directory stays mounted, but nothing survives.
135 227 func TestClearRemovesEverything(t *testing.T) {
136 228 t.Parallel()