Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions doc/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4137,6 +4137,12 @@ components:
Size of the tree. Absent when it could not be walked, which keeps
"unreadable" distinguishable from "empty". The SQLite entry
includes the -wal and -shm sidecars.
partial:
type: boolean
description: |
True when used_bytes undercounts because some entries inside the
tree were unreadable and skipped. Absent means the sum is
complete.
fs_total_bytes: { type: integer, format: int64 }
fs_free_bytes: { type: integer, format: int64 }

Expand Down
1,384 changes: 695 additions & 689 deletions server/internal/httpapi/openapi/openapi.gen.go

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions server/internal/maintenance/dirsize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package maintenance

import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
)

func TestDirSizeBytes_SumsRegularFiles(t *testing.T) {
dir := t.TempDir()
writeFileOfSize(t, filepath.Join(dir, "a"), 100)
writeFileOfSize(t, filepath.Join(dir, "sub", "b"), 50)

n, ok := DirSizeBytes(context.Background(), dir)
if !ok {
t.Fatal("ok = false on a readable tree")
}
if n != 150 {
t.Errorf("total = %d, want 150", n)
}
}

func TestDirSizeBytes_MissingRoot_ReportsNotOK(t *testing.T) {
n, ok := DirSizeBytes(context.Background(), filepath.Join(t.TempDir(), "nope"))
if ok {
t.Error("ok = true on a missing directory — 'unreadable' and 'empty' must stay distinguishable")
}
if n != 0 {
t.Errorf("total = %d, want 0", n)
}
}

func TestDirSizeBytes_UnreadableSubtree_ReturnsPartial(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("root ignores directory permissions")
}
dir := t.TempDir()
writeFileOfSize(t, filepath.Join(dir, "a"), 100)
locked := filepath.Join(dir, "locked")
writeFileOfSize(t, filepath.Join(locked, "hidden"), 999)
if err := os.Chmod(locked, 0o000); err != nil {
t.Fatalf("chmod: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })

n, skipped, ok := dirSizeDetail(context.Background(), dir)
if !ok {
t.Fatal("ok = false — one unreadable subtree must not throw the whole number away")
}
if n != 100 {
t.Errorf("total = %d, want 100 (the readable part)", n)
}
if skipped == 0 {
t.Error("skipped = 0 — the undercount must be visible so DiskUsage can flag it as partial")
}
}

func TestDirSizeBytes_CancelledContext_ReportsNotOK(t *testing.T) {
dir := t.TempDir()
// Enough entries to guarantee the every-512-entries context check fires.
for i := range 600 {
writeFileOfSize(t, filepath.Join(dir, fmt.Sprintf("f%04d", i)), 1)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, ok := DirSizeBytes(ctx, dir); ok {
t.Error("ok = true on a cancelled context, want false so callers omit the number")
}
}

func writeFileOfSize(t *testing.T, path string, size int) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, make([]byte, size), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
34 changes: 26 additions & 8 deletions server/internal/maintenance/maintenance.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,15 @@ type Analysis struct {
Warnings []string `json:"warnings,omitempty"`
}

// DirSizeBytes walks dir and sums regular-file sizes. Returns (0,false) on any
// error (missing dir, permission, cancelled context) so callers can omit the
// number rather than report a misleading 0.
// DirSizeBytes walks dir and sums regular-file sizes. An unreadable entry
// inside the tree is skipped and the rest still counts — a partial number
// beats no number on a tree of hundreds of thousands of git objects, where a
// single bad directory used to make the whole "Cloned repositories" row
// vanish. Returns (partial, false) only when nothing trustworthy could be
// produced: the root itself is missing/unreadable (so "unreadable" and
// "empty" stay distinguishable) or the context was cancelled mid-walk.
// Callers that need to tell a complete sum from an undercount use
// dirSizeDetail, which also reports how many entries were skipped.
//
// The context is checked every so many entries: on a vector store that is one
// file per document these walks visit hundreds of thousands of entries, and a
Expand All @@ -229,11 +235,23 @@ type Analysis struct {
// Lives here rather than in httpapi because both the resource endpoints and
// the project-detail card need it and there must be exactly one copy.
func DirSizeBytes(ctx context.Context, dir string) (int64, bool) {
var total int64
n, _, ok := dirSizeDetail(ctx, dir)
return n, ok
}

func dirSizeDetail(ctx context.Context, dir string) (total int64, skipped int, ok bool) {
var seen int
walkErr := filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
// Root failure means there is nothing to report; anything
// deeper is one bad subtree — count it as skipped and keep
// walking. (WalkDir already skips the children of a directory
// it could not read.)
if path == dir {
return err
}
skipped++
return nil
}
// Checking every entry would make ctx.Err() a meaningful share of the
// walk's cost; every 512 keeps cancellation prompt for free.
Expand All @@ -253,9 +271,9 @@ func DirSizeBytes(ctx context.Context, dir string) (int64, bool) {
return nil
})
if walkErr != nil {
return 0, false
return total, skipped, false
}
return total, true
return total, skipped, true
}

// dirSizeOrZero is the convenience form for places that already know the
Expand Down
24 changes: 17 additions & 7 deletions server/internal/maintenance/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ type DiskUsage struct {
Exists bool `json:"exists"`
// UsedBytes is omitted rather than zeroed when the tree could not be
// walked, so "unreadable" and "empty" stay distinguishable.
UsedBytes *int64 `json:"used_bytes,omitempty"`
UsedBytes *int64 `json:"used_bytes,omitempty"`
// Partial marks a UsedBytes that undercounts: some entries could not be
// read and were skipped. Without this flag a single root-owned checkout
// makes the row show a confident wrong number — exactly what an
// operator chasing disk growth must not rule out.
Partial bool `json:"partial,omitempty"`
FSTotalBytes *int64 `json:"fs_total_bytes,omitempty"`
FSFreeBytes *int64 `json:"fs_free_bytes,omitempty"`
}
Expand Down Expand Up @@ -125,27 +130,32 @@ func (s *Service) computeUsage(ctx context.Context) Usage {
// pre-migration gob files are reported (and reclaimed) through the
// abandoned-namespace category instead.
if cfg.VectorsDir != "" {
out.Disks = append(out.Disks, walkedDisk(ctx, DiskChroma, "Vector store", cfg.VectorsDir))
out.Disks = append(out.Disks, s.walkedDisk(ctx, DiskChroma, "Vector store", cfg.VectorsDir))
} else if cfg.ChromaPersistDir != "" {
out.Disks = append(out.Disks, walkedDisk(ctx, DiskChroma, "Vector store", cfg.ChromaPersistDir))
out.Disks = append(out.Disks, s.walkedDisk(ctx, DiskChroma, "Vector store", cfg.ChromaPersistDir))
}
if root := s.reposRoot(); root != "" {
out.Disks = append(out.Disks, walkedDisk(ctx, DiskRepos, "Cloned repositories", root))
out.Disks = append(out.Disks, s.walkedDisk(ctx, DiskRepos, "Cloned repositories", root))
}
if dir := s.activeGGUFCacheDir(); dir != "" {
out.Disks = append(out.Disks, walkedDisk(ctx, DiskGGUF, "Model cache", dir))
out.Disks = append(out.Disks, s.walkedDisk(ctx, DiskGGUF, "Model cache", dir))
}

return out
}

func walkedDisk(ctx context.Context, id, label, path string) DiskUsage {
func (s *Service) walkedDisk(ctx context.Context, id, label, path string) DiskUsage {
d := DiskUsage{ID: id, Label: label, Path: path}
if _, err := os.Stat(path); err == nil {
d.Exists = true
if !ctxDone(ctx) {
if n, ok := DirSizeBytes(ctx, path); ok {
if n, skipped, ok := dirSizeDetail(ctx, path); ok {
d.UsedBytes = &n
if skipped > 0 {
d.Partial = true
s.d.Logger.Warn("maintenance: disk usage undercounts — entries were unreadable",
"disk", id, "path", path, "skipped_entries", skipped)
}
}
}
}
Expand Down
Loading