Commit a166de255d

a166de255da625e3f7ee1bfd38667d6d786d4d79

parent: 690a0542e1

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 21:41 UTC

backuplock: flock between a full backup and repository moves

Ref #259

Layout: unified · split

internal/backuplock/backuplock.go added +59
@@ -0,0 +1,59 @@
1// Package backuplock keeps repository deletes, renames and transfers
2// out of a full backup's way (#259). The backup runs in its own process
3// (gitbayd admin backup) and a delete in the daemon's, so the lock is
4// flock(2) on a file under server.root: the backup holds it exclusively
5// from its database snapshot until the last repository is archived, and
6// each delete or move holds it shared while it runs.
7package backuplock
8
9import (
10 "errors"
11 "os"
12 "path/filepath"
13 "syscall"
14)
15
16// Name is the lock file under server.root. Backups skip it.
17const Name = "backup.lock"
18
19// ErrBusy is TryShared's answer while a backup holds the lock.
20var ErrBusy = errors.New("a backup is running; repositories cannot be deleted, renamed or moved until it finishes, usually within minutes")
21
22// open opens the lock file read-only, which is all flock needs, so the
23// daemon's user can lock a file a root-run backup created. O_NOFOLLOW
24// refuses a symlink planted at Name instead of following it, since the
25// path is inside server.root where only the daemon's own user writes.
26func open(root string) (*os.File, error) {
27 return os.OpenFile(filepath.Join(root, Name), os.O_RDONLY|os.O_CREATE|syscall.O_NOFOLLOW, 0o644)
28}
29
30// Hold takes the lock exclusively, waiting for deletes and moves under
31// way to finish. Closing the file releases it.
32func Hold(root string) (func(), error) {
33 f, err := open(root)
34 if err != nil {
35 return nil, err
36 }
37 if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
38 f.Close()
39 return nil, err
40 }
41 return func() { f.Close() }, nil
42}
43
44// TryShared takes the lock shared without waiting: ErrBusy while a
45// backup holds it.
46func TryShared(root string) (func(), error) {
47 f, err := open(root)
48 if err != nil {
49 return nil, err
50 }
51 if err := syscall.Flock(int(f.Fd()), syscall.LOCK_SH|syscall.LOCK_NB); err != nil {
52 f.Close()
53 if errors.Is(err, syscall.EWOULDBLOCK) {
54 return nil, ErrBusy
55 }
56 return nil, err
57 }
58 return func() { f.Close() }, nil
59}
internal/backuplock/backuplock_test.go added +69
@@ -0,0 +1,69 @@
1package backuplock
2
3import (
4 "errors"
5 "testing"
6 "time"
7)
8
9func TestTrySharedRefusedWhileHeld(t *testing.T) {
10 root := t.TempDir()
11 release, err := Hold(root)
12 if err != nil {
13 t.Fatal(err)
14 }
15 if _, err := TryShared(root); !errors.Is(err, ErrBusy) {
16 t.Fatalf("TryShared during a backup: %v", err)
17 }
18 release()
19 r, err := TryShared(root)
20 if err != nil {
21 t.Fatalf("TryShared after the backup: %v", err)
22 }
23 r()
24}
25
26func TestSharedHoldersCoexist(t *testing.T) {
27 root := t.TempDir()
28 a, err := TryShared(root)
29 if err != nil {
30 t.Fatal(err)
31 }
32 defer a()
33 b, err := TryShared(root)
34 if err != nil {
35 t.Fatalf("second shared holder: %v", err)
36 }
37 b()
38}
39
40// A backup waits for a delete already under way.
41func TestHoldWaitsForSharedHolder(t *testing.T) {
42 root := t.TempDir()
43 shared, err := TryShared(root)
44 if err != nil {
45 t.Fatal(err)
46 }
47 got := make(chan struct{})
48 go func() {
49 release, err := Hold(root)
50 if err != nil {
51 t.Error(err)
52 close(got)
53 return
54 }
55 close(got)
56 release()
57 }()
58 select {
59 case <-got:
60 t.Fatal("Hold returned while a shared holder was in")
61 case <-time.After(100 * time.Millisecond):
62 }
63 shared()
64 select {
65 case <-got:
66 case <-time.After(5 * time.Second):
67 t.Fatal("Hold never returned after the shared holder left")
68 }
69}