The Go module moves from backend/ to the root (go.mod, go.sum, .golangci.yml, cmd/, internal/), the page's source to web/, and nginx's two files to configs/. backend/ is gone: its LICENSE and .dockerignore are deleted, and its .gitignore entries, README and scripts are folded into the root ones. script/build builds the server binary after the frontend; script/run and script/clean move up from backend/script/; make test, lint, fmt and fmt-check do what the backend's did. Vite's root is web/ and it still builds into dist/. go.mod ignores node_modules/, whose npm packages can ship .go files. script/bootstrap no longer installs gcc, which only the host Go tests needed. Model: opus-5-5
This commit was merged in pull request #127.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
package reportbuf
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// ErrDataDirOutsideVolume is returned by PrepareDataDir for a DATA_DIR
|
||||
// that is not the volume or a path below it, written in full.
|
||||
var ErrDataDirOutsideVolume = errors.New(
|
||||
"must be /data or a path below it, with no '.', '..' or extra '/'")
|
||||
|
||||
// PrepareDataDir gets dir, the server's DATA_DIR, ready for owner, the
|
||||
// user the server runs as, so that a host directory mounted at volume,
|
||||
// /data in the image, needs no preparing: it creates dir, gives volume
|
||||
// and everything in it to owner, and gives volume and dir the mode the
|
||||
// server gives a directory it creates. dir must be volume or a path
|
||||
// below it, with no '.', '..', empty part or '/' at the end.
|
||||
//
|
||||
// bin/entrypoint.sh runs this as root, which would follow a symbolic
|
||||
// link anywhere, so every step goes through an os.Root opened on
|
||||
// volume: it follows a link only when it is written as a relative
|
||||
// path that stays inside volume, and refuses any other. A process on
|
||||
// the host can swap a link onto a path in volume at any moment while
|
||||
// this runs. Even then, the os.Root checks each link as it reaches
|
||||
// it. MkdirAll creates each directory inside a parent it already has
|
||||
// open, never following a link at the name it creates, and follows a
|
||||
// link on the path only as the os.Root allows, so a relative link
|
||||
// inside volume can lead it to create directories elsewhere inside
|
||||
// volume. Lchown never changes what a link points to, and the modes
|
||||
// are set on directories already opened (see chmodDir), so the most
|
||||
// that process can do is make a step fail or wait, or act on
|
||||
// something else inside volume.
|
||||
func PrepareDataDir(volume, dir string, owner *user.User) error {
|
||||
// rel is dir as a path from volume; IsLocal is false for one that
|
||||
// leads out of it.
|
||||
rel, err := filepath.Rel(volume, dir)
|
||||
if err != nil || dir != filepath.Clean(dir) || !filepath.IsLocal(rel) {
|
||||
return ErrDataDirOutsideVolume
|
||||
}
|
||||
|
||||
uid, err := strconv.Atoi(owner.Uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gid, err := strconv.Atoi(owner.Gid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
root, err := os.OpenRoot(volume)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = root.Close() }()
|
||||
|
||||
err = root.MkdirAll(rel, dirPerms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = lchownAll(root, ".", uid, gid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = chmodDir(root, ".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return chmodDir(root, rel)
|
||||
}
|
||||
|
||||
// lchownAll gives name, a directory inside root, and everything in it
|
||||
// to uid and gid. It reads each directory opened through root, not
|
||||
// through root.FS(), which refuses a name that is not valid UTF-8, and
|
||||
// calls Lchown on every entry, which gives a symbolic link itself to
|
||||
// them, not what it points to. It goes into an entry only when the
|
||||
// read found a directory there, so it follows no link it finds; one
|
||||
// swapped in for that directory afterwards is followed only as the
|
||||
// os.Root allows.
|
||||
func lchownAll(root *os.Root, name string, uid, gid int) error {
|
||||
err := root.Lchown(name, uid, gid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir, err := root.Open(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := dir.ReadDir(-1)
|
||||
_ = dir.Close()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
entryName := filepath.Join(name, entry.Name())
|
||||
if entry.IsDir() {
|
||||
err = lchownAll(root, entryName, uid, gid)
|
||||
} else {
|
||||
err = root.Lchown(entryName, uid, gid)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// chmodDir gives name, a directory inside root, the mode the server
|
||||
// gives a directory it creates. Root.Chmod would not hold: on Linux it
|
||||
// checks that name is not a symbolic link, then sets the mode by name,
|
||||
// following a link swapped in between. So chmodDir opens name through
|
||||
// root and sets the mode on the open directory. It refuses anything
|
||||
// but a directory: a directory has no second name (hard link), so the
|
||||
// one opened is inside root, where any other file could be a hard link
|
||||
// to one outside.
|
||||
func chmodDir(root *os.Root, name string) error {
|
||||
dir, err := root.Open(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = dir.Close() }()
|
||||
|
||||
info, err := dir.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return &fs.PathError{Op: "chmod", Path: name, Err: syscall.ENOTDIR}
|
||||
}
|
||||
|
||||
return dir.Chmod(dirPerms)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package reportbuf_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
)
|
||||
|
||||
// reports is the last part of DATA_DIR in these tests, as in the
|
||||
// image's /data/reports.
|
||||
const reports = "reports"
|
||||
|
||||
// currentUser is the user the test runs as, the only owner a test not
|
||||
// run as root can give files to.
|
||||
func currentUser() *user.User {
|
||||
return &user.User{
|
||||
Uid: strconv.Itoa(os.Getuid()),
|
||||
Gid: strconv.Itoa(os.Getgid()),
|
||||
}
|
||||
}
|
||||
|
||||
// tempDirMode700 is a new directory in a t.TempDir with mode 0700, so
|
||||
// a test can tell that PrepareDataDir left its mode alone.
|
||||
func tempDirMode700(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir := filepath.Join(t.TempDir(), "d")
|
||||
|
||||
err := os.Mkdir(dir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func requireMode(t *testing.T, path string, want fs.FileMode) {
|
||||
t.Helper()
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.Mode() != want {
|
||||
t.Errorf("%s: mode %v, want %v", path, info.Mode(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func requireMissing(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := os.Lstat(path)
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("%s: created, or Lstat failed: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireOwner(t *testing.T, path string, uid, gid uint32) {
|
||||
t.Helper()
|
||||
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stat, _ := info.Sys().(*syscall.Stat_t)
|
||||
if stat.Uid != uid || stat.Gid != gid {
|
||||
t.Errorf("%s: owner %d:%d, want %d:%d", path, stat.Uid, stat.Gid,
|
||||
uid, gid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDataDirCreatesDataDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := t.TempDir()
|
||||
dir := filepath.Join(volume, "a", reports)
|
||||
|
||||
err := reportbuf.PrepareDataDir(volume, dir, currentUser())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
requireMode(t, volume, fs.ModeDir|0o750)
|
||||
requireMode(t, dir, fs.ModeDir|0o750)
|
||||
}
|
||||
|
||||
// TestPrepareDataDirSetsModeOfExistingDataDir: a DATA_DIR already on
|
||||
// the host with another mode gets the mode too, not only a new one.
|
||||
func TestPrepareDataDirSetsModeOfExistingDataDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := t.TempDir()
|
||||
dir := filepath.Join(volume, reports)
|
||||
|
||||
err := os.Mkdir(dir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = reportbuf.PrepareDataDir(volume, dir, currentUser())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
requireMode(t, dir, fs.ModeDir|0o750)
|
||||
}
|
||||
|
||||
func TestPrepareDataDirTakesTheVolumeItself(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := t.TempDir()
|
||||
|
||||
err := reportbuf.PrepareDataDir(volume, volume, currentUser())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
requireMode(t, volume, fs.ModeDir|0o750)
|
||||
}
|
||||
|
||||
// TestPrepareDataDirRefusesDataDirOutsideVolume covers a DATA_DIR that
|
||||
// is relative, outside the volume, or not written in full.
|
||||
func TestPrepareDataDirRefusesDataDirOutsideVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := tempDirMode700(t)
|
||||
for _, dir := range []string{
|
||||
reports, volume + "/../new", volume + "/", volume + "//" + reports,
|
||||
volume + "/./" + reports, volume + "/" + reports + "/..", volume + "x",
|
||||
"/etc",
|
||||
} {
|
||||
err := reportbuf.PrepareDataDir(volume, dir, currentUser())
|
||||
if !errors.Is(err, reportbuf.ErrDataDirOutsideVolume) {
|
||||
t.Errorf("%q: error = %v, want ErrDataDirOutsideVolume", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
requireMissing(t, filepath.Join(filepath.Dir(volume), "new"))
|
||||
requireMode(t, volume, fs.ModeDir|0o700)
|
||||
}
|
||||
|
||||
// TestPrepareDataDirRefusesLinkOutOfVolume puts a symbolic link to a
|
||||
// directory outside the volume on the path to DATA_DIR, written as a
|
||||
// full path and as one that climbs out with '..', and as DATA_DIR
|
||||
// itself, where the mode would be set through it.
|
||||
func TestPrepareDataDirRefusesLinkOutOfVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
climbsOut bool
|
||||
link, dir string
|
||||
}{
|
||||
{"full path", false, "x", "x/reports"},
|
||||
{"climbs out", true, "x", "x/reports"},
|
||||
{"DATA_DIR itself", false, reports, reports},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
outside := tempDirMode700(t)
|
||||
volume := t.TempDir()
|
||||
|
||||
climbOut, err := filepath.Rel(volume, outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
target := outside
|
||||
if tc.climbsOut {
|
||||
target = climbOut
|
||||
}
|
||||
|
||||
err = os.Symlink(target, filepath.Join(volume, tc.link))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = reportbuf.PrepareDataDir(volume,
|
||||
filepath.Join(volume, tc.dir), currentUser())
|
||||
if err == nil {
|
||||
t.Error("no error")
|
||||
}
|
||||
|
||||
requireMissing(t, filepath.Join(outside, reports))
|
||||
requireMode(t, outside, fs.ModeDir|0o700)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareDataDirRefusesDanglingLink: DATA_DIR is a symbolic link
|
||||
// to a name in the volume that does not exist, which is not created.
|
||||
func TestPrepareDataDirRefusesDanglingLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := t.TempDir()
|
||||
|
||||
err := os.Symlink("missing", filepath.Join(volume, reports))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
|
||||
currentUser())
|
||||
if err == nil {
|
||||
t.Error("no error")
|
||||
}
|
||||
|
||||
requireMissing(t, filepath.Join(volume, "missing"))
|
||||
}
|
||||
|
||||
// TestPrepareDataDirTakesDirectoryNamedInLatin1: a host directory can
|
||||
// hold names that are not valid UTF-8, here "café" written in Latin-1.
|
||||
// A test not run as root can only check that PrepareDataDir goes into
|
||||
// such a directory and gives it, and what it holds, to the current
|
||||
// user.
|
||||
func TestPrepareDataDirTakesDirectoryNamedInLatin1(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := t.TempDir()
|
||||
latin1 := filepath.Join(volume, "caf\xe9")
|
||||
|
||||
err := os.Mkdir(latin1, 0o700)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(filepath.Join(latin1, "f"), nil, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
owner := currentUser()
|
||||
|
||||
err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
|
||||
owner)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
uid, _ := strconv.ParseUint(owner.Uid, 10, 32)
|
||||
gid, _ := strconv.ParseUint(owner.Gid, 10, 32)
|
||||
|
||||
requireOwner(t, latin1, uint32(uid), uint32(gid))
|
||||
requireOwner(t, filepath.Join(latin1, "f"), uint32(uid), uint32(gid))
|
||||
}
|
||||
|
||||
// TestPrepareDataDirGivesVolumeToOwner gives everything in the volume
|
||||
// to a uid and gid that own nothing, which only root can do. A
|
||||
// symbolic link in the volume to a directory outside it is given to
|
||||
// them itself; what it points to is left as it was.
|
||||
func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
t.Skip("only root can give files to another uid")
|
||||
}
|
||||
|
||||
outside := t.TempDir()
|
||||
volume := t.TempDir()
|
||||
old := filepath.Join(volume, "old")
|
||||
|
||||
err := os.WriteFile(filepath.Join(outside, "f"), nil, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.Mkdir(old, 0o700)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(filepath.Join(old, "f"), nil, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.Symlink(outside, filepath.Join(old, "link"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
|
||||
&user.User{Uid: "4242", Gid: "4343"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
volume, filepath.Join(volume, reports), old,
|
||||
filepath.Join(old, "f"), filepath.Join(old, "link"),
|
||||
} {
|
||||
requireOwner(t, path, 4242, 4343)
|
||||
}
|
||||
|
||||
requireOwner(t, outside, 0, 0)
|
||||
requireOwner(t, filepath.Join(outside, "f"), 0, 0)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package reportbuf
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FlushSizeThreshold exposes the buffer size at which Append starts
|
||||
// writing a report file to the external tests.
|
||||
const FlushSizeThreshold = flushSizeThreshold
|
||||
|
||||
// Flush writes the buffered reports to a file now, as the periodic
|
||||
// flush does, so tests need not wait a minute for it.
|
||||
func (b *Buffer) Flush() error {
|
||||
return b.flushLocked()
|
||||
}
|
||||
|
||||
// StopClock makes every report file the buffer writes from now on
|
||||
// carry the timestamp at, as if all were written in one millisecond.
|
||||
func (b *Buffer) StopClock(at time.Time) {
|
||||
b.now = func() time.Time { return at }
|
||||
}
|
||||
|
||||
// OnFileCreated makes the buffer call fn with each report file it
|
||||
// writes from now on, once the file is created and before anything is
|
||||
// written to it.
|
||||
func (b *Buffer) OnFileCreated(fn func(f *os.File)) {
|
||||
b.fileCreated = fn
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
// Package reportbuf accumulates telemetry reports in memory
|
||||
// and periodically flushes them to zstd-compressed JSONL files,
|
||||
// deleting the oldest files to keep them under a size cap.
|
||||
package reportbuf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/config"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
const (
|
||||
flushSizeThreshold = 10 << 20 // 10 MiB
|
||||
flushInterval = 1 * time.Minute
|
||||
defaultDataDir = "./data/reports"
|
||||
dirPerms fs.FileMode = 0o750
|
||||
filePerms fs.FileMode = 0o640
|
||||
|
||||
// Report files are named filePrefix + timestamp + "-" + number +
|
||||
// fileSuffix; see writeFile.
|
||||
filePrefix = "reports-"
|
||||
fileSuffix = ".jsonl.zst"
|
||||
)
|
||||
|
||||
// ErrFull is returned by Append when the reports waiting to be
|
||||
// written leave no room for the report under the configured maximum
|
||||
// size, however many report files are deleted.
|
||||
var ErrFull = errors.New("reports waiting to be written fill the size cap")
|
||||
|
||||
// Params defines the dependencies for Buffer.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// reportFile is a report file that may be deleted to make room, with
|
||||
// the size it counts for in usedBytes.
|
||||
type reportFile struct {
|
||||
name string
|
||||
size int64
|
||||
}
|
||||
|
||||
// Buffer accumulates JSON lines in memory and flushes them
|
||||
// to zstd-compressed files on disk.
|
||||
type Buffer struct {
|
||||
buf bytes.Buffer
|
||||
dataDir string
|
||||
done chan struct{}
|
||||
// fileCreated is called with each report file once it is
|
||||
// created, before anything is written to it: it does nothing,
|
||||
// except in tests that hold the write open or make it fail.
|
||||
fileCreated func(f *os.File)
|
||||
// files are the report files that may be deleted to make room,
|
||||
// in name order, which is oldest first: those in dataDir at
|
||||
// start, and each one this buffer writes, put in at its place by
|
||||
// name once it is complete, even when an older file's write
|
||||
// completes after a newer one's. A file still being written is
|
||||
// not among them. filesBytes is their total size.
|
||||
files []reportFile
|
||||
filesBytes int64
|
||||
log *slog.Logger
|
||||
maxBytes int64
|
||||
mu sync.Mutex
|
||||
// now is the clock report files are named by: time.Now, except
|
||||
// in tests that need two flushes to share a timestamp.
|
||||
now func() time.Time
|
||||
// seq numbers the report files, so that two named in the same
|
||||
// millisecond still get different names.
|
||||
seq atomic.Uint64
|
||||
stopOnce sync.Once
|
||||
// usedBytes is what Append checks against maxBytes: the size
|
||||
// of the report files in dataDir, plus the reports waiting to
|
||||
// be written to one at their uncompressed size.
|
||||
usedBytes int64
|
||||
}
|
||||
|
||||
// New creates a Buffer and registers lifecycle hooks to
|
||||
// manage the data directory and flush goroutine.
|
||||
func New(
|
||||
lc fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Buffer, error) {
|
||||
dir := params.Config.DataDir
|
||||
if dir == "" {
|
||||
dir = defaultDataDir
|
||||
}
|
||||
|
||||
b := &Buffer{
|
||||
dataDir: dir,
|
||||
done: make(chan struct{}),
|
||||
fileCreated: func(*os.File) {},
|
||||
log: params.Logger.Get(),
|
||||
maxBytes: params.Config.DataDirMaxBytes,
|
||||
now: time.Now,
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
err := os.MkdirAll(b.dataDir, dirPerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
|
||||
// Report files left by earlier runs count too, and are
|
||||
// the first to be deleted to make room.
|
||||
files, err := reportFiles(b.dataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
|
||||
b.files = files
|
||||
for _, f := range files {
|
||||
b.filesBytes += f.size
|
||||
}
|
||||
|
||||
b.usedBytes = b.filesBytes
|
||||
|
||||
// The files may be past the cap, if it was lowered since
|
||||
// the last run.
|
||||
b.deleteOldestFiles(0)
|
||||
|
||||
b.mu.Unlock()
|
||||
|
||||
go b.flushLoop()
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
// stopOnce makes OnStop idempotent: a second
|
||||
// invocation must not close an already-closed channel
|
||||
// (which would panic) or flush again.
|
||||
var err error
|
||||
|
||||
b.stopOnce.Do(func() {
|
||||
close(b.done)
|
||||
err = b.flushLocked()
|
||||
})
|
||||
|
||||
// A failed final flush fails the stop, so the process
|
||||
// exits non-zero.
|
||||
return err
|
||||
},
|
||||
})
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Append marshals v as a single JSON line and appends it to
|
||||
// the buffer. If the line would take usedBytes past maxBytes, the
|
||||
// oldest report files are deleted to make room; it stores nothing
|
||||
// and returns ErrFull if that cannot make room. If the buffer
|
||||
// reaches the size threshold, it is drained and written to disk
|
||||
// asynchronously.
|
||||
func (b *Buffer) Append(v any) error {
|
||||
line, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal report: %w", err)
|
||||
}
|
||||
|
||||
lineBytes := int64(len(line)) + 1 // with its newline
|
||||
|
||||
b.mu.Lock()
|
||||
|
||||
b.deleteOldestFiles(lineBytes)
|
||||
|
||||
if b.usedBytes+lineBytes > b.maxBytes {
|
||||
b.mu.Unlock()
|
||||
|
||||
return ErrFull
|
||||
}
|
||||
|
||||
b.usedBytes += lineBytes
|
||||
b.buf.Write(line)
|
||||
b.buf.WriteByte('\n')
|
||||
|
||||
if b.buf.Len() >= flushSizeThreshold {
|
||||
data := b.drainBuf()
|
||||
b.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
writeErr := b.writeFile(data)
|
||||
if writeErr != nil {
|
||||
b.log.Error("flush reports failed", "error", writeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteOldestFiles deletes report files, oldest first, until n more
|
||||
// bytes fit under maxBytes. It deletes none when the reports waiting
|
||||
// to be written leave no room for n even with every file gone, since
|
||||
// that would lose the files for nothing. The caller must hold b.mu.
|
||||
func (b *Buffer) deleteOldestFiles(n int64) {
|
||||
for b.usedBytes+n > b.maxBytes && len(b.files) > 0 {
|
||||
if b.usedBytes-b.filesBytes+n > b.maxBytes {
|
||||
return
|
||||
}
|
||||
|
||||
f := b.files[0]
|
||||
b.files = b.files[1:]
|
||||
b.filesBytes -= f.size
|
||||
|
||||
// A file already gone, deleted by hand, has freed its room too.
|
||||
err := os.Remove(filepath.Join(b.dataDir, f.name))
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
// The file is still there, so it still counts. It is
|
||||
// not tried again until the next start.
|
||||
b.log.Error("delete report file failed",
|
||||
"file", f.name, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
b.usedBytes -= f.size
|
||||
b.log.Info("deleted report file to make room",
|
||||
"file", f.name, "bytes", f.size)
|
||||
}
|
||||
}
|
||||
|
||||
// flushLoop runs a ticker that periodically flushes buffered
|
||||
// data to disk until the done channel is closed.
|
||||
func (b *Buffer) flushLoop() {
|
||||
ticker := time.NewTicker(flushInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
err := b.flushLocked()
|
||||
if err != nil {
|
||||
b.log.Error("flush reports failed", "error", err)
|
||||
}
|
||||
case <-b.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flushLocked acquires the lock, drains the buffer, and
|
||||
// writes the data to a compressed file.
|
||||
func (b *Buffer) flushLocked() error {
|
||||
b.mu.Lock()
|
||||
|
||||
if b.buf.Len() == 0 {
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
data := b.drainBuf()
|
||||
b.mu.Unlock()
|
||||
|
||||
return b.writeFile(data)
|
||||
}
|
||||
|
||||
// drainBuf copies the buffer contents and resets it.
|
||||
// The caller must hold b.mu.
|
||||
func (b *Buffer) drainBuf() []byte {
|
||||
data := make([]byte, b.buf.Len())
|
||||
copy(data, b.buf.Bytes())
|
||||
b.buf.Reset()
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// writeFile writes data, reports drained from the buffer, to a new
|
||||
// timestamped zstd-compressed JSONL file in the data directory.
|
||||
func (b *Buffer) writeFile(data []byte) error {
|
||||
// The timestamp comes first, so the names sort by time; the number
|
||||
// after it tells apart files named in the same millisecond.
|
||||
ts := b.now().UTC().Format("2006-01-02T15-04-05.000Z")
|
||||
name := fmt.Sprintf("%s%s-%d%s", filePrefix, ts, b.seq.Add(1), fileSuffix)
|
||||
|
||||
size, err := b.createFile(filepath.Join(b.dataDir, name), data)
|
||||
|
||||
// The reports no longer wait to be written, so they stop counting
|
||||
// at their uncompressed size. If the write failed they are lost;
|
||||
// otherwise they count as the file, which from here on may be
|
||||
// deleted to make room.
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
b.usedBytes -= int64(len(data))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.usedBytes += size
|
||||
b.filesBytes += size
|
||||
|
||||
// At its place by name, not at the end: another write, of a newer
|
||||
// file, may have completed while this one was being written.
|
||||
i, _ := slices.BinarySearchFunc(b.files, name,
|
||||
func(f reportFile, target string) int {
|
||||
return strings.Compare(f.name, target)
|
||||
})
|
||||
b.files = slices.Insert(b.files, i, reportFile{name: name, size: size})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createFile creates the file at path holding data compressed with
|
||||
// zstd, and returns its size. If the write fails once the file is
|
||||
// created, it removes the file, so that a failed write leaves nothing
|
||||
// behind to take room.
|
||||
func (b *Buffer) createFile(path string, data []byte) (int64, error) {
|
||||
// path is built from the operator-supplied dataDir plus a
|
||||
// generated timestamp and number, so it carries no external input.
|
||||
f, err := os.OpenFile( //nolint:gosec // see comment above
|
||||
path,
|
||||
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
||||
filePerms,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create report file: %w", err)
|
||||
}
|
||||
|
||||
b.fileCreated(f)
|
||||
|
||||
size, err := writeCompressed(f, data)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
|
||||
return 0, errors.Join(err, os.Remove(path))
|
||||
}
|
||||
|
||||
err = f.Close()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("close report file: %w", err)
|
||||
|
||||
return 0, errors.Join(err, os.Remove(path))
|
||||
}
|
||||
|
||||
return size, nil
|
||||
}
|
||||
|
||||
// writeCompressed writes data to f compressed with zstd, and returns
|
||||
// the size of f.
|
||||
func writeCompressed(f *os.File, data []byte) (int64, error) {
|
||||
enc, err := zstd.NewWriter(f)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create zstd encoder: %w", err)
|
||||
}
|
||||
|
||||
_, err = enc.Write(data)
|
||||
if err != nil {
|
||||
_ = enc.Close()
|
||||
|
||||
return 0, fmt.Errorf("write compressed data: %w", err)
|
||||
}
|
||||
|
||||
err = enc.Close()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("close zstd encoder: %w", err)
|
||||
}
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("stat report file: %w", err)
|
||||
}
|
||||
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
// reportFiles returns the report files in dir, oldest first:
|
||||
// os.ReadDir sorts them by name, and the names sort by time.
|
||||
func reportFiles(dir string) ([]reportFile, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read data dir: %w", err)
|
||||
}
|
||||
|
||||
files := make([]reportFile, 0, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !strings.HasPrefix(name, filePrefix) ||
|
||||
!strings.HasSuffix(name, fileSuffix) {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat report file: %w", err)
|
||||
}
|
||||
|
||||
files = append(files, reportFile{name: name, size: info.Size()})
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
@@ -0,0 +1,919 @@
|
||||
package reportbuf_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/config"
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
)
|
||||
|
||||
// TestFlushOnShutdown proves the flush-on-shutdown path: a
|
||||
// report appended after start but before the periodic flush
|
||||
// window must reach disk when the fx lifecycle stops. This is
|
||||
// the exact case that silent data loss on restart used to
|
||||
// destroy.
|
||||
func TestFlushOnShutdown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
|
||||
var buf *reportbuf.Buffer
|
||||
|
||||
app := fxtest.New(t,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
config.New,
|
||||
reportbuf.New,
|
||||
),
|
||||
fx.Populate(&buf),
|
||||
)
|
||||
|
||||
app.RequireStart()
|
||||
|
||||
err := buf.Append(map[string]string{"probe": "shutdown"})
|
||||
if err != nil {
|
||||
t.Fatalf("append report: %v", err)
|
||||
}
|
||||
|
||||
// RequireStop runs the reportbuf OnStop hook, which is the
|
||||
// only code path that flushes buffered reports on shutdown.
|
||||
app.RequireStop()
|
||||
|
||||
if !hasReportFile(t, dir) {
|
||||
t.Fatal("no report file on disk after shutdown; " +
|
||||
"the buffered report was lost")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedFinalFlushFailsStop proves a final flush that cannot
|
||||
// write its file makes the stop fail, which makes the process
|
||||
// exit non-zero instead of dropping the buffered reports silently.
|
||||
func TestFailedFinalFlushFailsStop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
|
||||
var buf *reportbuf.Buffer
|
||||
|
||||
app := fxtest.New(t,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
config.New,
|
||||
reportbuf.New,
|
||||
),
|
||||
fx.Populate(&buf),
|
||||
)
|
||||
|
||||
app.RequireStart()
|
||||
|
||||
err := buf.Append(map[string]string{"probe": "shutdown"})
|
||||
if err != nil {
|
||||
t.Fatalf("append report: %v", err)
|
||||
}
|
||||
|
||||
// Removing the data directory leaves the final flush nowhere to
|
||||
// write. A read-only directory would not do: tests run as root
|
||||
// in the backend image, and root ignores the read-only bit.
|
||||
err = os.RemoveAll(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("remove data dir: %v", err)
|
||||
}
|
||||
|
||||
err = app.Stop(t.Context())
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("stop error = %v, want the final flush's error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// startBuffer starts a Buffer through fx, as main does, with the
|
||||
// DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set.
|
||||
func startBuffer(t *testing.T) *reportbuf.Buffer {
|
||||
t.Helper()
|
||||
|
||||
var buf *reportbuf.Buffer
|
||||
|
||||
app := fxtest.New(t,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
config.New,
|
||||
reportbuf.New,
|
||||
),
|
||||
fx.Populate(&buf),
|
||||
)
|
||||
|
||||
app.RequireStart()
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
// lineBytes is what one report takes in the buffer: its JSON and a
|
||||
// newline.
|
||||
func lineBytes(t *testing.T, report any) int {
|
||||
t.Helper()
|
||||
|
||||
line, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal report: %v", err)
|
||||
}
|
||||
|
||||
return len(line) + 1
|
||||
}
|
||||
|
||||
// TestAppendPastCapIsRefused fills the cap with a report not yet
|
||||
// written. The next report is refused, and the report file already in
|
||||
// DATA_DIR is kept: it is smaller than a report, so deleting it could
|
||||
// not make room.
|
||||
func TestAppendPastCapIsRefused(t *testing.T) {
|
||||
report := map[string]string{"id": "cap"}
|
||||
dir := t.TempDir()
|
||||
earlier := reportFilePath(dir, 1)
|
||||
|
||||
writeBytes(t, earlier, 1)
|
||||
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(1+lineBytes(t, report)))
|
||||
|
||||
buf := startBuffer(t)
|
||||
|
||||
err := buf.Append(report)
|
||||
if err != nil {
|
||||
t.Fatalf("report that fills the cap exactly: %v", err)
|
||||
}
|
||||
|
||||
err = buf.Append(report)
|
||||
if !errors.Is(err, reportbuf.ErrFull) {
|
||||
t.Fatalf("report past the cap: error = %v, want ErrFull", err)
|
||||
}
|
||||
|
||||
if !exists(t, earlier) {
|
||||
t.Fatal("report file deleted, though that could not make room")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOldestReportFileDeletedFirst starts on a data directory holding
|
||||
// report files from an earlier run, and a file that is not a report,
|
||||
// which neither counts nor is ever deleted. Nothing is deleted while
|
||||
// there is room; then only the oldest report file is.
|
||||
func TestOldestReportFileDeletedFirst(t *testing.T) {
|
||||
const fileBytes = 100
|
||||
|
||||
report := map[string]string{"id": "oldest"}
|
||||
dir := t.TempDir()
|
||||
oldest := reportFilePath(dir, 1)
|
||||
kept := []string{
|
||||
reportFilePath(dir, 2),
|
||||
reportFilePath(dir, 3),
|
||||
filepath.Join(dir, "notes.txt"),
|
||||
}
|
||||
|
||||
writeBytes(t, oldest, fileBytes)
|
||||
|
||||
for _, path := range kept {
|
||||
writeBytes(t, path, fileBytes)
|
||||
}
|
||||
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
// Room for the three report files and one report.
|
||||
t.Setenv("DATA_DIR_MAX_BYTES",
|
||||
strconv.Itoa(3*fileBytes+lineBytes(t, report)))
|
||||
|
||||
buf := startBuffer(t)
|
||||
|
||||
err := buf.Append(report)
|
||||
if err != nil {
|
||||
t.Fatalf("report that fills the cap exactly: %v", err)
|
||||
}
|
||||
|
||||
if !exists(t, oldest) {
|
||||
t.Fatal("oldest report file deleted while there was room")
|
||||
}
|
||||
|
||||
err = buf.Append(report)
|
||||
if err != nil {
|
||||
t.Fatalf("report past the cap: %v", err)
|
||||
}
|
||||
|
||||
if exists(t, oldest) {
|
||||
t.Fatal("oldest report file kept when room was needed")
|
||||
}
|
||||
|
||||
for _, path := range kept {
|
||||
if !exists(t, path) {
|
||||
t.Fatalf("%s deleted; only the oldest report file should be", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartDeletesFilesPastCap starts on report files past the cap,
|
||||
// as after the cap is lowered: the oldest are deleted until the rest
|
||||
// fit.
|
||||
func TestStartDeletesFilesPastCap(t *testing.T) {
|
||||
const fileBytes = 100
|
||||
|
||||
dir := t.TempDir()
|
||||
oldest := reportFilePath(dir, 1)
|
||||
kept := []string{reportFilePath(dir, 2), reportFilePath(dir, 3)}
|
||||
|
||||
writeBytes(t, oldest, fileBytes)
|
||||
|
||||
for _, path := range kept {
|
||||
writeBytes(t, path, fileBytes)
|
||||
}
|
||||
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(len(kept)*fileBytes))
|
||||
|
||||
startBuffer(t)
|
||||
|
||||
if exists(t, oldest) {
|
||||
t.Fatal("oldest report file kept, though the files were past the cap")
|
||||
}
|
||||
|
||||
for _, path := range kept {
|
||||
if !exists(t, path) {
|
||||
t.Fatalf("%s deleted, though the rest fit without it", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrittenReportsCountAtFileSize checks that once reports are
|
||||
// written, they count as their compressed file, not their
|
||||
// uncompressed size, which frees room under the cap.
|
||||
func TestWrittenReportsCountAtFileSize(t *testing.T) {
|
||||
// Repetitive, so its file is far smaller than its JSON.
|
||||
report := map[string]string{"id": strings.Repeat("a", 1000)}
|
||||
size := lineBytes(t, report)
|
||||
dir := t.TempDir()
|
||||
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
// Room for the report twice over only if the first one counts
|
||||
// at its file's size by the time the second arrives.
|
||||
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(2*size-1))
|
||||
|
||||
buf := startBuffer(t)
|
||||
|
||||
err := buf.Append(report)
|
||||
if err != nil {
|
||||
t.Fatalf("first report: %v", err)
|
||||
}
|
||||
|
||||
err = buf.Flush()
|
||||
if err != nil {
|
||||
t.Fatalf("flush: %v", err)
|
||||
}
|
||||
|
||||
err = buf.Append(report)
|
||||
if err != nil {
|
||||
t.Fatalf("second report, after the first was written: %v", err)
|
||||
}
|
||||
|
||||
if !hasReportFile(t, dir) {
|
||||
t.Fatal("first report's file deleted, though the second fit beside it")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrittenFilesDeletedToMakeRoom writes one report file after
|
||||
// another under a small cap. Every report must be taken; files are
|
||||
// deleted only when the report would not fit beside them, and the
|
||||
// files kept leave room for it.
|
||||
func TestWrittenFilesDeletedToMakeRoom(t *testing.T) {
|
||||
const (
|
||||
maxBytes = 200
|
||||
// One file each, which take far more than maxBytes together.
|
||||
reports = 50
|
||||
)
|
||||
|
||||
report := map[string]string{"id": "written"}
|
||||
size := int64(lineBytes(t, report))
|
||||
dir := t.TempDir()
|
||||
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(maxBytes))
|
||||
|
||||
buf := startBuffer(t)
|
||||
|
||||
for range reports {
|
||||
before := reportFilesBytes(t, dir)
|
||||
|
||||
err := buf.Append(report)
|
||||
if err != nil {
|
||||
t.Fatalf("with %d bytes of report files: %v", before, err)
|
||||
}
|
||||
|
||||
after := reportFilesBytes(t, dir)
|
||||
|
||||
if before+size <= maxBytes && after != before {
|
||||
t.Fatalf("files deleted, though the report fit beside "+
|
||||
"their %d bytes", before)
|
||||
}
|
||||
|
||||
if after+size > maxBytes {
|
||||
t.Fatalf("%d bytes of report files kept, leaving no room "+
|
||||
"for the report", after)
|
||||
}
|
||||
|
||||
err = buf.Flush()
|
||||
if err != nil {
|
||||
t.Fatalf("flush: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedDeletionStillCounts makes deleting the oldest report file
|
||||
// fail. It is still there, so it still takes room, and the next oldest
|
||||
// is deleted in its place.
|
||||
func TestFailedDeletionStillCounts(t *testing.T) {
|
||||
const fileBytes = 100
|
||||
|
||||
report := map[string]string{"id": "stuck"}
|
||||
dir := t.TempDir()
|
||||
stuck := reportFilePath(dir, 1)
|
||||
next := reportFilePath(dir, 2)
|
||||
newest := reportFilePath(dir, 3)
|
||||
|
||||
for _, path := range []string{stuck, next, newest} {
|
||||
writeBytes(t, path, fileBytes)
|
||||
}
|
||||
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(3*fileBytes))
|
||||
|
||||
buf := startBuffer(t)
|
||||
|
||||
// A directory that is not empty cannot be deleted, even by root,
|
||||
// which the tests run as in the backend image.
|
||||
err := os.Remove(stuck)
|
||||
if err != nil {
|
||||
t.Fatalf("remove %s: %v", stuck, err)
|
||||
}
|
||||
|
||||
err = os.Mkdir(stuck, 0o750)
|
||||
if err != nil {
|
||||
t.Fatalf("make directory %s: %v", stuck, err)
|
||||
}
|
||||
|
||||
writeBytes(t, filepath.Join(stuck, "file"), 1)
|
||||
|
||||
err = buf.Append(report)
|
||||
if err != nil {
|
||||
t.Fatalf("report past the cap: %v", err)
|
||||
}
|
||||
|
||||
if exists(t, next) {
|
||||
t.Fatal("next oldest report file kept: the failed deletion " +
|
||||
"counted as making room")
|
||||
}
|
||||
|
||||
if !exists(t, newest) {
|
||||
t.Fatal("newest report file deleted, though deleting one made room")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileDeletedByHandFreesRoom deletes the oldest report file by
|
||||
// hand after start. When room is needed, its room counts as freed, so
|
||||
// no other file is deleted.
|
||||
func TestFileDeletedByHandFreesRoom(t *testing.T) {
|
||||
const fileBytes = 100
|
||||
|
||||
report := map[string]string{"id": "by-hand"}
|
||||
dir := t.TempDir()
|
||||
gone := reportFilePath(dir, 1)
|
||||
kept := reportFilePath(dir, 2)
|
||||
|
||||
writeBytes(t, gone, fileBytes)
|
||||
writeBytes(t, kept, fileBytes)
|
||||
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(2*fileBytes))
|
||||
|
||||
buf := startBuffer(t)
|
||||
|
||||
err := os.Remove(gone)
|
||||
if err != nil {
|
||||
t.Fatalf("remove %s: %v", gone, err)
|
||||
}
|
||||
|
||||
err = buf.Append(report)
|
||||
if err != nil {
|
||||
t.Fatalf("report past the cap: %v", err)
|
||||
}
|
||||
|
||||
if !exists(t, kept) {
|
||||
t.Fatal("report file deleted, though the one deleted by hand " +
|
||||
"had made room")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileBeingWrittenIsNeverDeleted holds the write of one report file
|
||||
// open while a second write completes, then sends a report that needs
|
||||
// room. Deleting either file would make it, and the one being written is
|
||||
// the older, but only the complete one may be deleted. Once the first
|
||||
// write is complete, its file is deleted when room is needed.
|
||||
func TestFileBeingWrittenIsNeverDeleted(t *testing.T) {
|
||||
report := map[string]string{"id": "writing"}
|
||||
|
||||
t.Setenv("DATA_DIR", t.TempDir())
|
||||
// Room for two reports waiting to be written, but not for two
|
||||
// beside a report file.
|
||||
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(2*lineBytes(t, report)))
|
||||
|
||||
buf := startBuffer(t)
|
||||
|
||||
created := make(chan string)
|
||||
release := make(chan struct{})
|
||||
|
||||
buf.OnFileCreated(func(f *os.File) {
|
||||
created <- f.Name()
|
||||
|
||||
<-release
|
||||
})
|
||||
|
||||
err := buf.Append(report)
|
||||
if err != nil {
|
||||
t.Fatalf("first report: %v", err)
|
||||
}
|
||||
|
||||
flushed := make(chan error)
|
||||
|
||||
go func() { flushed <- buf.Flush() }()
|
||||
|
||||
writing := <-created
|
||||
|
||||
// Only the first write is held; the second goes through, and
|
||||
// so do the writes after it, the final one at stop included.
|
||||
var complete string
|
||||
|
||||
buf.OnFileCreated(func(f *os.File) { complete = f.Name() })
|
||||
|
||||
// Errorf, not Fatalf, until the first write is released, so that a
|
||||
// failure here does not leave it held.
|
||||
err = buf.Append(report)
|
||||
if err != nil {
|
||||
t.Errorf("second report: %v", err)
|
||||
}
|
||||
|
||||
err = buf.Flush()
|
||||
if err != nil {
|
||||
t.Errorf("flush of the second report: %v", err)
|
||||
}
|
||||
|
||||
err = buf.Append(report)
|
||||
if err != nil {
|
||||
t.Errorf("report that needs room: %v", err)
|
||||
}
|
||||
|
||||
if !exists(t, writing) {
|
||||
t.Error("report file deleted while it was being written")
|
||||
}
|
||||
|
||||
if exists(t, complete) {
|
||||
t.Error("complete report file kept, though room was needed")
|
||||
}
|
||||
|
||||
close(release)
|
||||
|
||||
err = <-flushed
|
||||
if err != nil {
|
||||
t.Fatalf("flush of the first report: %v", err)
|
||||
}
|
||||
|
||||
err = buf.Append(report)
|
||||
if err != nil {
|
||||
t.Fatalf("report after the first write was complete: %v", err)
|
||||
}
|
||||
|
||||
if exists(t, writing) {
|
||||
t.Fatal("complete report file kept when room was needed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedWriteStopsCounting makes a write fail once its file is
|
||||
// created. Its reports are lost, so they stop counting, and the part of
|
||||
// the file written is removed, so it takes no room.
|
||||
func TestFailedWriteStopsCounting(t *testing.T) {
|
||||
report := map[string]string{"id": "failed"}
|
||||
|
||||
t.Setenv("DATA_DIR", t.TempDir())
|
||||
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(lineBytes(t, report)))
|
||||
|
||||
buf := startBuffer(t)
|
||||
|
||||
var failed string
|
||||
|
||||
// Closing the file under the write makes the write fail.
|
||||
buf.OnFileCreated(func(f *os.File) {
|
||||
failed = f.Name()
|
||||
|
||||
_ = f.Close()
|
||||
})
|
||||
|
||||
err := buf.Append(report)
|
||||
if err != nil {
|
||||
t.Fatalf("report that fills the cap exactly: %v", err)
|
||||
}
|
||||
|
||||
err = buf.Flush()
|
||||
if err == nil {
|
||||
t.Fatal("flush succeeded, though its file was closed under it")
|
||||
}
|
||||
|
||||
if exists(t, failed) {
|
||||
t.Fatal("file of the failed write kept")
|
||||
}
|
||||
|
||||
// Writes from here on, the final one at stop included, succeed.
|
||||
buf.OnFileCreated(func(*os.File) {})
|
||||
|
||||
err = buf.Append(report)
|
||||
if err != nil {
|
||||
t.Fatalf("report after the failed write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentAppendsStopAtCap appends from many goroutines at once
|
||||
// with room for exactly roomFor reports: exactly that many must be
|
||||
// taken, which holds only if Append checks and counts each report
|
||||
// under one lock.
|
||||
func TestConcurrentAppendsStopAtCap(t *testing.T) {
|
||||
const (
|
||||
roomFor = 5
|
||||
senders = 50
|
||||
)
|
||||
|
||||
// Large, so each Append takes long enough for the senders to
|
||||
// overlap while the cap is reached.
|
||||
report := map[string]string{"id": strings.Repeat("a", 1_000_000)}
|
||||
|
||||
t.Setenv("DATA_DIR", t.TempDir())
|
||||
t.Setenv("DATA_DIR_MAX_BYTES",
|
||||
strconv.Itoa(roomFor*lineBytes(t, report)))
|
||||
|
||||
buf := startBuffer(t)
|
||||
|
||||
var (
|
||||
taken atomic.Int64
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
start := make(chan struct{})
|
||||
|
||||
for range senders {
|
||||
wg.Go(func() {
|
||||
<-start
|
||||
|
||||
err := buf.Append(report)
|
||||
if err == nil {
|
||||
taken.Add(1)
|
||||
} else if !errors.Is(err, reportbuf.ErrFull) {
|
||||
t.Errorf("append: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if got := taken.Load(); got != roomFor {
|
||||
t.Fatalf("%d reports taken, want %d", got, roomFor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTwoFlushesInOneMillisecond flushes twice within one millisecond,
|
||||
// as a flush for size and the final flush at shutdown can: each flush
|
||||
// must write a file of its own, and the files must hold every report.
|
||||
func TestTwoFlushesInOneMillisecond(t *testing.T) {
|
||||
const flushes = 2
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
|
||||
buf := startBuffer(t)
|
||||
buf.StopClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
for id := 1; id <= flushes; id++ {
|
||||
err := buf.Append(map[string]int{"id": id})
|
||||
if err != nil {
|
||||
t.Fatalf("append report %d: %v", id, err)
|
||||
}
|
||||
|
||||
err = buf.Flush()
|
||||
if err != nil {
|
||||
t.Fatalf("flush %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
files, err := readReportFiles(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read report files: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != flushes {
|
||||
t.Fatalf("%d report files after %d flushes", len(files), flushes)
|
||||
}
|
||||
|
||||
for id := 1; id <= flushes; id++ {
|
||||
want := fmt.Sprintf(`{"id":%d}`+"\n", id)
|
||||
if !slices.Contains(files, want) {
|
||||
t.Fatalf("no report file holds report %d alone", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportFileHoldsTheLinesAppended flushes three reports and reads
|
||||
// their file back: it must decompress to exactly their JSON lines, in
|
||||
// the order they were appended.
|
||||
func TestReportFileHoldsTheLinesAppended(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
|
||||
buf := startBuffer(t)
|
||||
|
||||
for _, id := range []int{1, 2, 3} {
|
||||
err := buf.Append(map[string]int{"id": id})
|
||||
if err != nil {
|
||||
t.Fatalf("append report %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
err := buf.Flush()
|
||||
if err != nil {
|
||||
t.Fatalf("flush: %v", err)
|
||||
}
|
||||
|
||||
files, err := readReportFiles(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read report files: %v", err)
|
||||
}
|
||||
|
||||
want := `{"id":1}` + "\n" + `{"id":2}` + "\n" + `{"id":3}` + "\n"
|
||||
if len(files) != 1 || files[0] != want {
|
||||
t.Fatalf("report files = %q, want one holding %q", files, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlushAtSizeThreshold appends reports until the buffer holds
|
||||
// FlushSizeThreshold bytes. The append that gets it there must write
|
||||
// them all to one report file, with no call to Flush and the periodic
|
||||
// flush a minute away, and no earlier append may write one.
|
||||
func TestFlushAtSizeThreshold(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
|
||||
buf := startBuffer(t)
|
||||
|
||||
// Large reports, so the threshold takes a few hundred appends.
|
||||
pad := strings.Repeat("a", 64<<10)
|
||||
|
||||
var appended strings.Builder
|
||||
|
||||
for id := 0; appended.Len() < reportbuf.FlushSizeThreshold; id++ {
|
||||
report := map[string]any{"id": id, "pad": pad}
|
||||
|
||||
err := buf.Append(report)
|
||||
if err != nil {
|
||||
t.Fatalf("append report %d: %v", id, err)
|
||||
}
|
||||
|
||||
line, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal report %d: %v", id, err)
|
||||
}
|
||||
|
||||
appended.Write(line)
|
||||
appended.WriteByte('\n')
|
||||
}
|
||||
|
||||
// Append writes the file in the background, so wait for it.
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
|
||||
for {
|
||||
files, err := readReportFiles(dir)
|
||||
if err == nil && len(files) == 1 && files[0] == appended.String() {
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%d report files (error: %v), want one holding the "+
|
||||
"%d bytes appended", len(files), err, appended.Len())
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// reportFilesBytes returns the total size of the report files in dir.
|
||||
func reportFilesBytes(t *testing.T, dir string) int64 {
|
||||
t.Helper()
|
||||
|
||||
paths, err := filepath.Glob(filepath.Join(dir, "reports-*.jsonl.zst"))
|
||||
if err != nil {
|
||||
t.Fatalf("list report files: %v", err)
|
||||
}
|
||||
|
||||
var total int64
|
||||
|
||||
for _, path := range paths {
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
t.Fatalf("stat %s: %v", path, statErr)
|
||||
}
|
||||
|
||||
total += info.Size()
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// readReportFiles returns the decompressed contents of each report
|
||||
// file in dir. A file still being written does not decompress, so it
|
||||
// gives an error.
|
||||
func readReportFiles(dir string) ([]string, error) {
|
||||
files := os.DirFS(dir)
|
||||
|
||||
names, err := fs.Glob(files, "reports-*.jsonl.zst")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list report files: %w", err)
|
||||
}
|
||||
|
||||
dec, err := zstd.NewReader(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create zstd decoder: %w", err)
|
||||
}
|
||||
defer dec.Close()
|
||||
|
||||
contents := make([]string, 0, len(names))
|
||||
|
||||
for _, name := range names {
|
||||
compressed, readErr := fs.ReadFile(files, name)
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", name, readErr)
|
||||
}
|
||||
|
||||
data, decErr := dec.DecodeAll(compressed, nil)
|
||||
if decErr != nil {
|
||||
return nil, fmt.Errorf("decompress %s: %w", name, decErr)
|
||||
}
|
||||
|
||||
contents = append(contents, string(data))
|
||||
}
|
||||
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
// reportFilePath returns the path in dir of a report file named as
|
||||
// written on the given day of January 2026, so that a lower day sorts
|
||||
// as older.
|
||||
func reportFilePath(dir string, day int) string {
|
||||
return filepath.Join(dir,
|
||||
fmt.Sprintf("reports-2026-01-%02dT00-00-00.000Z-1.jsonl.zst", day))
|
||||
}
|
||||
|
||||
func writeBytes(t *testing.T, path string, n int) {
|
||||
t.Helper()
|
||||
|
||||
err := os.WriteFile(path, make([]byte, n), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func exists(t *testing.T, path string) bool {
|
||||
t.Helper()
|
||||
|
||||
_, err := os.Stat(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return false
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("stat %s: %v", path, err)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func hasReportFile(t *testing.T, dir string) bool {
|
||||
t.Helper()
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read data dir: %v", err)
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".jsonl.zst") {
|
||||
info, statErr := e.Info()
|
||||
if statErr != nil {
|
||||
t.Fatalf("stat %s: %v", e.Name(), statErr)
|
||||
}
|
||||
|
||||
if info.Size() > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// TestFilesDeletedOldestFirstWhenWritesOverlap holds the write of an
|
||||
// older report file open until a newer one's write completes, then
|
||||
// releases it. When room is needed, the older file is deleted first,
|
||||
// though its write was the last to complete.
|
||||
func TestFilesDeletedOldestFirstWhenWritesOverlap(t *testing.T) {
|
||||
const maxBytes = 1000
|
||||
|
||||
report := map[string]string{"id": "overlap"}
|
||||
|
||||
t.Setenv("DATA_DIR", t.TempDir())
|
||||
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(maxBytes))
|
||||
|
||||
buf := startBuffer(t)
|
||||
|
||||
created := make(chan string)
|
||||
release := make(chan struct{})
|
||||
|
||||
buf.OnFileCreated(func(f *os.File) {
|
||||
created <- f.Name()
|
||||
|
||||
<-release
|
||||
})
|
||||
|
||||
err := buf.Append(report)
|
||||
if err != nil {
|
||||
t.Fatalf("older report: %v", err)
|
||||
}
|
||||
|
||||
flushed := make(chan error)
|
||||
|
||||
go func() { flushed <- buf.Flush() }()
|
||||
|
||||
older := <-created
|
||||
|
||||
// Only the older write is held; the newer one goes through, and
|
||||
// so do the writes after it, the final one at stop included.
|
||||
var newer string
|
||||
|
||||
buf.OnFileCreated(func(f *os.File) { newer = f.Name() })
|
||||
|
||||
// Errorf, not Fatalf, until the older write is released, so that a
|
||||
// failure here does not leave it held.
|
||||
err = buf.Append(report)
|
||||
if err != nil {
|
||||
t.Errorf("newer report: %v", err)
|
||||
}
|
||||
|
||||
err = buf.Flush()
|
||||
if err != nil {
|
||||
t.Errorf("flush of the newer report: %v", err)
|
||||
}
|
||||
|
||||
close(release)
|
||||
|
||||
err = <-flushed
|
||||
if err != nil {
|
||||
t.Fatalf("flush of the older report: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(newer)
|
||||
if err != nil {
|
||||
t.Fatalf("stat %s: %v", newer, err)
|
||||
}
|
||||
|
||||
// A report that fits beside the newer file alone, so deleting the
|
||||
// older one makes exactly the room it needs.
|
||||
pad := maxBytes - int(info.Size()) - lineBytes(t, map[string]string{"id": ""})
|
||||
|
||||
err = buf.Append(map[string]string{"id": strings.Repeat("a", pad)})
|
||||
if err != nil {
|
||||
t.Fatalf("report that needs room: %v", err)
|
||||
}
|
||||
|
||||
if exists(t, older) {
|
||||
t.Fatal("older report file kept when room was needed")
|
||||
}
|
||||
|
||||
if !exists(t, newer) {
|
||||
t.Fatal("newer report file deleted before the older one")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user