Files
kandev/apps/backend/internal/system/disk/usage.go
T

218 lines
7.1 KiB
Go

// Package disk computes a lazy, cached breakdown of the on-disk kandev data
// footprint. Service exposes Get (returns cached value, kicks async refresh
// when missing or stale) and Refresh (forces a recompute). The walk runs as
// a tracked job so the WebSocket gateway streams progress to clients.
package disk
import (
"context"
"sync"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/system/jobs"
)
// cacheTTL controls how long a cached breakdown is considered fresh. After
// it expires, the next Get returns the stale value AND kicks a background
// refresh.
const cacheTTL = 2 * time.Hour
// jobKind is the Kind string used when tracking disk-walk jobs.
const jobKind = "disk-walk"
// Breakdown is the per-subdir disk footprint plus aggregated total and any
// warnings collected from permission errors during the walk. ComputedAt is
// the wall-clock time the walk finished.
type Breakdown struct {
DataDir int64 `json:"data_dir"`
Worktrees int64 `json:"worktrees"`
Repos int64 `json:"repos"`
Sessions int64 `json:"sessions"`
Tasks int64 `json:"tasks"`
QuickChat int64 `json:"quick_chat"`
Backups int64 `json:"backups"`
Total int64 `json:"total"`
Warnings []string `json:"warnings,omitempty"`
ComputedAt time.Time `json:"computed_at"`
}
// GetResult is what Service.Get returns: the cached breakdown (or nil when
// none has been computed yet) plus a Computing flag that signals "a
// background walk is in flight, expect a fresh value soon". HomeDir is the
// absolute path the breakdown was computed against, so the UI can show
// "where" the data lives.
type GetResult struct {
Data *Breakdown `json:"data"`
Computing bool `json:"computing"`
HomeDir string `json:"home_dir"`
}
// Service owns the in-memory cache and coordinates lazy refresh through the
// shared jobs.Tracker. It is safe for concurrent use.
type Service struct {
homeDir string
jobs *jobs.Tracker
log *logger.Logger
mu sync.Mutex
value *Breakdown
computing bool
activeJobID string
}
// NewService constructs a Service for the given home directory. The home
// directory should be cfg.ResolvedHomeDir().
func NewService(homeDir string, tracker *jobs.Tracker, log *logger.Logger) *Service {
return &Service{
homeDir: homeDir,
jobs: tracker,
log: log,
}
}
// Get returns the current cached breakdown and may kick off an async walk:
// - cold (no value, not computing) -> start walk, return {nil, true}
// - cold (no value, computing) -> return {nil, true}
// - fresh (value, < TTL) -> return {value, false}
// - stale (value, >= TTL) -> start refresh, return {value, true}
func (s *Service) Get(ctx context.Context) GetResult {
s.mu.Lock()
value := s.value
computing := s.computing
stale := value != nil && time.Since(value.ComputedAt) >= cacheTTL
shouldKick := !computing && (value == nil || stale)
if shouldKick {
s.computing = true
// Dispatch inside the lock so activeJobID is published before any
// concurrent Refresh can observe (computing==true, activeJobID=="")
// and start a duplicate walk.
s.startWalk(ctx)
}
s.mu.Unlock()
if value == nil {
return GetResult{Data: nil, Computing: true, HomeDir: s.homeDir}
}
return GetResult{Data: value, Computing: computing || stale, HomeDir: s.homeDir}
}
// HomeDir returns the absolute path the disk-usage service walks. Exposed so
// the "open folder" handler can reveal it in the host OS file explorer.
func (s *Service) HomeDir() string {
return s.homeDir
}
// Refresh kicks off a walk and returns the job ID. If a walk is already in
// flight the in-flight job ID is returned and no new walk is started — a
// concurrent recompute would race on the cache and waste IO.
//
// Single-flight is preserved across the brief window between
// `computing = true` and `activeJobID = <id>` by holding the mutex through
// the kickJob call: a second Refresh that arrives while we are dispatching
// blocks here, then observes computing==true on the next lock and coalesces
// onto our job ID.
func (s *Service) Refresh(ctx context.Context) string {
s.mu.Lock()
defer s.mu.Unlock()
if s.computing && s.activeJobID != "" {
return s.activeJobID
}
s.computing = true
return s.kickJobLocked(ctx)
}
// startWalk schedules an async walk; intended for Get's lazy path where we
// only want the side-effect (no job ID returned). Caller must hold s.mu.
func (s *Service) startWalk(ctx context.Context) {
_ = s.kickJobLocked(ctx)
}
// kickJobLocked hands the walk off to the jobs.Tracker so progress flows
// through the standard system.job.update channel. The fn updates the cache
// before returning so observers reading Service state after the job
// completes see the fresh value. The active job ID is tracked so concurrent
// Refresh callers can be coalesced onto the in-flight walk.
//
// Locked-variant: caller must already hold s.mu so the activeJobID
// assignment happens before the lock is released. This closes the race
// where a second Refresh could observe (computing==true, activeJobID=="")
// and start a duplicate walk.
func (s *Service) kickJobLocked(ctx context.Context) string {
id := s.jobs.Start(ctx, jobKind, func(jobCtx context.Context) (map[string]interface{}, error) {
breakdown := s.computeBreakdown(jobCtx)
s.storeBreakdown(breakdown)
return map[string]interface{}{"total_bytes": breakdown.Total}, nil
})
s.activeJobID = id
return id
}
// computeBreakdown walks every configured subdir, aggregating sizes and
// warnings into a fresh Breakdown stamped with the wall-clock completion
// time.
func (s *Service) computeBreakdown(_ context.Context) *Breakdown {
b := &Breakdown{}
for _, sd := range subdirsFor(s.homeDir) {
res := walkSubdir(sd.path)
assignSubdir(b, sd.name, res.bytes)
b.Total += res.bytes
if len(res.warnings) > 0 {
b.Warnings = append(b.Warnings, res.warnings...)
}
}
b.ComputedAt = time.Now().UTC()
if s.log != nil {
s.log.Debug("disk walk completed",
zap.Int64("total_bytes", b.Total),
zap.Int("warnings", len(b.Warnings)),
)
}
return b
}
// assignSubdir routes the per-subdir size into the matching Breakdown
// field. Kept as a small switch so adding a new subdir is a one-liner here
// plus one line in subdirsFor.
func assignSubdir(b *Breakdown, name string, size int64) {
switch name {
case "data_dir":
b.DataDir = size
case "worktrees":
b.Worktrees = size
case "repos":
b.Repos = size
case "sessions":
b.Sessions = size
case "tasks":
b.Tasks = size
case "quick_chat":
b.QuickChat = size
case "backups":
b.Backups = size
}
}
// storeBreakdown publishes a freshly computed breakdown into the cache and
// clears the computing flag.
func (s *Service) storeBreakdown(b *Breakdown) {
s.mu.Lock()
defer s.mu.Unlock()
s.value = b
s.computing = false
s.activeJobID = ""
}
// setComputedAt is a test-only helper that rewinds the cached value's
// ComputedAt so tests can observe the TTL-expired branch of Get without
// sleeping for two hours.
func (s *Service) setComputedAt(t time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
if s.value != nil {
s.value.ComputedAt = t
}
}