Skip to content

Commit c6d7429

Browse files
authored
Merge pull request #161 from shelltime/feat/daemon-cleanup-timer-service
feat(daemon): add daily cleanup timer service for large log files
2 parents dc03563 + af725dd commit c6d7429

9 files changed

Lines changed: 426 additions & 52 deletions

File tree

cmd/daemon/main.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,18 @@ func main() {
8888
defer syncCircuitBreakerService.Stop()
8989
}
9090

91+
// Start cleanup timer service if enabled (enabled by default)
92+
if cfg.LogCleanup != nil && cfg.LogCleanup.Enabled != nil && *cfg.LogCleanup.Enabled {
93+
cleanupTimerService := daemon.NewCleanupTimerService(cfg)
94+
if err := cleanupTimerService.Start(ctx); err != nil {
95+
slog.Error("Failed to start cleanup timer service", slog.Any("err", err))
96+
} else {
97+
slog.Info("Cleanup timer service started",
98+
slog.Int64("thresholdMB", cfg.LogCleanup.ThresholdMB))
99+
defer cleanupTimerService.Stop()
100+
}
101+
}
102+
91103
go daemon.SocketTopicProcessor(msg)
92104

93105
// Start CCUsage service if enabled (v1 - ccusage CLI based)

commands/gc.go

Lines changed: 9 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ import (
1313
"go.opentelemetry.io/otel/trace"
1414
)
1515

16-
const logFileSizeThreshold int64 = 50 * 1024 * 1024 // 50 MB
17-
1816
var GCCommand *cli.Command = &cli.Command{
1917
Name: "gc",
2018
Usage: "clean internal storage",
@@ -34,52 +32,6 @@ var GCCommand *cli.Command = &cli.Command{
3432
Action: commandGC,
3533
}
3634

37-
// cleanLogFile removes a log file if it exceeds the threshold or if force is true.
38-
// Returns the size of the deleted file (0 if not deleted or file doesn't exist).
39-
func cleanLogFile(filePath string, threshold int64, force bool) (int64, error) {
40-
info, err := os.Stat(filePath)
41-
if os.IsNotExist(err) {
42-
return 0, nil
43-
}
44-
if err != nil {
45-
return 0, fmt.Errorf("failed to stat file %s: %w", filePath, err)
46-
}
47-
48-
fileSize := info.Size()
49-
if !force && fileSize < threshold {
50-
return 0, nil
51-
}
52-
53-
if err := os.Remove(filePath); err != nil {
54-
return 0, fmt.Errorf("failed to remove file %s: %w", filePath, err)
55-
}
56-
57-
slog.Info("cleaned log file", slog.String("file", filePath), slog.Int64("size_bytes", fileSize))
58-
return fileSize, nil
59-
}
60-
61-
// cleanLargeLogFiles checks all log files and removes those exceeding the size threshold.
62-
// If force is true, removes all log files regardless of size.
63-
func cleanLargeLogFiles(force bool) (int64, error) {
64-
logFiles := []string{
65-
model.GetLogFilePath(),
66-
model.GetHeartbeatLogFilePath(),
67-
model.GetSyncPendingFilePath(),
68-
}
69-
70-
var totalFreed int64
71-
for _, filePath := range logFiles {
72-
freed, err := cleanLogFile(filePath, logFileSizeThreshold, force)
73-
if err != nil {
74-
slog.Warn("failed to clean log file", slog.String("file", filePath), slog.Any("err", err))
75-
continue
76-
}
77-
totalFreed += freed
78-
}
79-
80-
return totalFreed, nil
81-
}
82-
8335
// backupAndWriteFile backs up the existing file and writes new content.
8436
func backupAndWriteFile(filePath string, content []byte) error {
8537
backupFile := filePath + ".bak"
@@ -231,9 +183,17 @@ func commandGC(c *cli.Context) error {
231183
return nil
232184
}
233185

186+
// Get config for threshold
187+
cfg, err := configService.ReadConfigFile(ctx)
188+
if err != nil {
189+
slog.Warn("failed to read config, using default threshold", slog.Any("err", err))
190+
cfg.LogCleanup = &model.LogCleanup{ThresholdMB: 100}
191+
}
192+
thresholdBytes := cfg.LogCleanup.ThresholdMB * 1024 * 1024
193+
234194
// Clean log files: force clean if --withLog, otherwise only clean large files
235195
forceCleanLogs := c.Bool("withLog")
236-
freedBytes, err := cleanLargeLogFiles(forceCleanLogs)
196+
freedBytes, err := model.CleanLargeLogFiles(thresholdBytes, forceCleanLogs)
237197
if err != nil {
238198
slog.Warn("error during log cleanup", slog.Any("err", err))
239199
}

commands/track_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,16 @@ func (s *trackTestSuite) TestTrackWithSendData() {
122122
}))
123123
defer server.Close()
124124
cs := model.NewMockConfigService(s.T())
125+
truthy := true
125126
mockedConfig := model.ShellTimeConfig{
126127
Token: "TOKEN001",
127128
APIEndpoint: server.URL,
128129
FlushCount: 7,
129130
GCTime: 8,
131+
LogCleanup: &model.LogCleanup{
132+
Enabled: &truthy,
133+
ThresholdMB: 100,
134+
},
130135
}
131136
cs.On("ReadConfigFile", mock.Anything).Return(mockedConfig, nil)
132137
model.UserShellTimeConfig = mockedConfig

daemon/cleanup_timer.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package daemon
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"sync"
7+
"time"
8+
9+
"github.com/malamtime/cli/model"
10+
)
11+
12+
const (
13+
// CleanupInterval is the interval for log cleanup (24 hours)
14+
CleanupInterval = 24 * time.Hour
15+
)
16+
17+
// CleanupTimerService handles periodic cleanup of large log files
18+
type CleanupTimerService struct {
19+
config model.ShellTimeConfig
20+
ticker *time.Ticker
21+
stopChan chan struct{}
22+
wg sync.WaitGroup
23+
}
24+
25+
// NewCleanupTimerService creates a new cleanup timer service
26+
func NewCleanupTimerService(config model.ShellTimeConfig) *CleanupTimerService {
27+
return &CleanupTimerService{
28+
config: config,
29+
stopChan: make(chan struct{}),
30+
}
31+
}
32+
33+
// Start begins the periodic cleanup job
34+
func (s *CleanupTimerService) Start(ctx context.Context) error {
35+
s.ticker = time.NewTicker(CleanupInterval)
36+
s.wg.Add(1)
37+
38+
go func() {
39+
defer s.wg.Done()
40+
41+
// NOTE: Do not run at startup, only on timer
42+
// This avoids slowing daemon startup and prevents cleanup on restart loops
43+
44+
for {
45+
select {
46+
case <-s.ticker.C:
47+
s.cleanup(ctx)
48+
case <-s.stopChan:
49+
return
50+
case <-ctx.Done():
51+
return
52+
}
53+
}
54+
}()
55+
56+
slog.Info("Cleanup timer service started",
57+
slog.Duration("interval", CleanupInterval),
58+
slog.Int64("thresholdMB", s.config.LogCleanup.ThresholdMB))
59+
return nil
60+
}
61+
62+
// Stop stops the cleanup service
63+
func (s *CleanupTimerService) Stop() {
64+
if s.ticker != nil {
65+
s.ticker.Stop()
66+
}
67+
close(s.stopChan)
68+
s.wg.Wait()
69+
slog.Info("Cleanup timer service stopped")
70+
}
71+
72+
// cleanup performs the log cleanup
73+
func (s *CleanupTimerService) cleanup(ctx context.Context) {
74+
thresholdBytes := s.config.LogCleanup.ThresholdMB * 1024 * 1024
75+
76+
slog.Debug("Starting scheduled log cleanup",
77+
slog.Int64("thresholdMB", s.config.LogCleanup.ThresholdMB))
78+
79+
var totalFreed int64
80+
81+
// Clean CLI log files
82+
freedCLI, err := model.CleanLargeLogFiles(thresholdBytes, false)
83+
if err != nil {
84+
slog.Warn("error during CLI log cleanup", slog.Any("err", err))
85+
}
86+
totalFreed += freedCLI
87+
88+
// Clean daemon log files (macOS only)
89+
freedDaemon, err := model.CleanDaemonLogFiles(thresholdBytes, false)
90+
if err != nil {
91+
slog.Warn("error during daemon log cleanup", slog.Any("err", err))
92+
}
93+
totalFreed += freedDaemon
94+
95+
if totalFreed > 0 {
96+
slog.Info("scheduled log cleanup completed",
97+
slog.Int64("totalFreedBytes", totalFreed),
98+
slog.Int64("cliFreedBytes", freedCLI),
99+
slog.Int64("daemonFreedBytes", freedDaemon))
100+
} else {
101+
slog.Debug("scheduled log cleanup completed, no files exceeded threshold")
102+
}
103+
}

model/config.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,18 @@ func mergeConfig(base, local *ShellTimeConfig) {
8686
if local.CCOtel != nil {
8787
base.CCOtel = local.CCOtel
8888
}
89+
if local.LogCleanup != nil {
90+
base.LogCleanup = local.LogCleanup
91+
}
8992
if local.SocketPath != "" {
9093
base.SocketPath = local.SocketPath
9194
}
95+
if local.CodeTracking != nil {
96+
base.CodeTracking = local.CodeTracking
97+
}
98+
if local.LogCleanup != nil {
99+
base.LogCleanup = local.LogCleanup
100+
}
92101
}
93102

94103
func (cs *configService) ReadConfigFile(ctx context.Context, opts ...ReadConfigOption) (config ShellTimeConfig, err error) {
@@ -177,6 +186,21 @@ func (cs *configService) ReadConfigFile(ctx context.Context, opts ...ReadConfigO
177186
config.SocketPath = DefaultSocketPath
178187
}
179188

189+
// Initialize LogCleanup with defaults if not present (enabled by default with 100MB threshold)
190+
if config.LogCleanup == nil {
191+
config.LogCleanup = &LogCleanup{
192+
Enabled: &truthy,
193+
ThresholdMB: 100,
194+
}
195+
} else {
196+
if config.LogCleanup.Enabled == nil {
197+
config.LogCleanup.Enabled = &truthy
198+
}
199+
if config.LogCleanup.ThresholdMB == 0 {
200+
config.LogCleanup.ThresholdMB = 100
201+
}
202+
}
203+
180204
// Save to cache
181205
cs.mu.Lock()
182206
cs.cachedConfig = &config

0 commit comments

Comments
 (0)