Skip to content

Commit cf5e6ff

Browse files
Fix: remove close(l.buffer) to prevent send-on-closed-channel panic i… (#92)
* Fix: remove close(l.buffer) to prevent send-on-closed-channel panic in async loggers A race existed between Write() and flushLoop() during shutdown: a goroutine could pass the l.closed check, get preempted, and resume after close(l.buffer), causing a panic. Closing the buffer channel is unnecessary since flushLoop exits via l.done; the GC reclaims the channel. Drain now uses a non-blocking select loop instead of range. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix: increase SQLite connection pool to unlock WAL concurrent reads The single-connection pool (MaxOpenConns=1) serialized all DB access, negating WAL mode's native support for concurrent readers. Dashboard read queries blocked on background flush writes and vice versa. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix: add writes WaitGroup + idempotent Close() to audit logger Mirrors the synchronization pattern already in usage/logger.go: - writes WaitGroup tracks in-flight Write() calls so Close() waits for them before signaling flushLoop to stop - Double-check pattern in Write() closes the race window between the first closed.Load() and writes.Add(1) - Close() uses closed.Swap(true) for idempotency — safe to call multiple times during shutdown Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Revert date-range helper inlining in usage readers Restore sqliteDateRangeConditions(), pgDateRangeConditions(), and mongoDateRangeFilter() helper functions. The inlining was an unrelated refactoring regression that duplicated identical date-range logic across 12 call sites with no behavioral change. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8dcaa58 commit cf5e6ff

3 files changed

Lines changed: 47 additions & 16 deletions

File tree

internal/auditlog/logger.go

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type Logger struct {
1717
buffer chan *LogEntry
1818
done chan struct{}
1919
wg sync.WaitGroup
20+
writes sync.WaitGroup // tracks in-flight Write calls
2021
flushInterval time.Duration
2122
closed atomic.Bool
2223
}
@@ -58,6 +59,15 @@ func (l *Logger) Write(entry *LogEntry) {
5859
return
5960
}
6061

62+
// Track this write to prevent Close from closing buffer while we're sending
63+
l.writes.Add(1)
64+
defer l.writes.Done()
65+
66+
// Double-check after registering - Close() may have set closed between first check and Add(1)
67+
if l.closed.Load() {
68+
return
69+
}
70+
6171
select {
6272
case l.buffer <- entry:
6373
// Entry queued successfully
@@ -81,7 +91,16 @@ func (l *Logger) Config() Config {
8191

8292
// Close stops the logger and flushes remaining entries.
8393
// This should be called during graceful shutdown.
94+
// Close is idempotent - calling it multiple times is safe.
8495
func (l *Logger) Close() error {
96+
// Make Close idempotent - if already closed, return immediately
97+
if l.closed.Swap(true) {
98+
return nil
99+
}
100+
101+
// Wait for any in-flight Write calls to complete
102+
l.writes.Wait()
103+
85104
// Signal the flush loop to stop
86105
close(l.done)
87106

@@ -119,13 +138,19 @@ func (l *Logger) flushLoop() {
119138
}
120139

121140
case <-l.done:
122-
// Shutdown: mark as closed before closing buffer to prevent Write() panics
123-
l.closed.Store(true)
124-
close(l.buffer)
125-
// Drain remaining entries from buffer
126-
for entry := range l.buffer {
127-
batch = append(batch, entry)
141+
// Shutdown: drain remaining entries from buffer using non-blocking loop.
142+
// Note: l.closed is already set by Close() before sending on l.done.
143+
// We do NOT close(l.buffer) — closing is unnecessary since flushLoop
144+
// exits via l.done, and closing creates a race with concurrent Write() calls.
145+
for {
146+
select {
147+
case entry := <-l.buffer:
148+
batch = append(batch, entry)
149+
default:
150+
goto drainComplete
151+
}
128152
}
153+
drainComplete:
129154
// Final flush
130155
if len(batch) > 0 {
131156
l.flushBatch(batch)

internal/storage/sqlite.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ func NewSQLite(cfg SQLiteConfig) (Storage, error) {
3434
return nil, fmt.Errorf("failed to open SQLite database: %w", err)
3535
}
3636

37-
// Use a single connection to serialize all database access.
38-
// This prevents "database is locked" errors at the cost of no concurrent reads.
39-
db.SetMaxOpenConns(1)
40-
db.SetMaxIdleConns(1)
37+
// Allow multiple readers to operate concurrently. WAL mode handles this natively.
38+
// We keep a modest pool to prevent file descriptor exhaustion.
39+
db.SetMaxOpenConns(16)
40+
db.SetMaxIdleConns(4)
4141

4242
// Verify connection
4343
if err := db.Ping(); err != nil {

internal/usage/logger.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,19 @@ func (l *Logger) flushLoop() {
138138
}
139139

140140
case <-l.done:
141-
// Shutdown: close buffer to stop accepting entries
142-
// Note: l.closed is already set by Close() before sending on l.done
143-
close(l.buffer)
144-
// Drain remaining entries from buffer
145-
for entry := range l.buffer {
146-
batch = append(batch, entry)
141+
// Shutdown: drain remaining entries from buffer using non-blocking loop.
142+
// Note: l.closed is already set by Close() before sending on l.done.
143+
// We do NOT close(l.buffer) — closing is unnecessary since flushLoop
144+
// exits via l.done, and closing creates a race with concurrent Write() calls.
145+
for {
146+
select {
147+
case entry := <-l.buffer:
148+
batch = append(batch, entry)
149+
default:
150+
goto drainComplete
151+
}
147152
}
153+
drainComplete:
148154
// Final flush
149155
if len(batch) > 0 {
150156
l.flushBatch(batch)

0 commit comments

Comments
 (0)