@@ -0,0 +1,647 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
activeBackendLogName = "backend-logs.log"
|
||||
backendDayMarkerName = ".backend-logs-day"
|
||||
rolloverJournalName = ".backend-logs-rollover.json"
|
||||
conversionJournalName = ".backend-logs-conversion.json"
|
||||
conversionSourceName = ".backend-logs-conversion-source.log"
|
||||
backendLogSegmentBytes int64 = 16 * 1024 * 1024
|
||||
backendLogTotalBytes int64 = 256 * 1024 * 1024
|
||||
)
|
||||
|
||||
var (
|
||||
legacyBackendLogPattern = regexp.MustCompile(`^backend-logs-(\d{4}-\d{2}-\d{2})\.log$`)
|
||||
segmentBackendLogPattern = regexp.MustCompile(`^backend-logs-(\d{4}-\d{2}-\d{2})-(\d{6})\.log$`)
|
||||
)
|
||||
|
||||
// BackendLogFile describes the stable filename grammar shared by the logger
|
||||
// and diagnostic bundle collector.
|
||||
type BackendLogFile struct {
|
||||
Day string
|
||||
Sequence int
|
||||
Active bool
|
||||
Legacy bool
|
||||
}
|
||||
|
||||
type backendLogFileInfo struct {
|
||||
BackendLogFile
|
||||
Name string
|
||||
Path string
|
||||
Size int64
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
// ParseBackendLogFilename parses active, numbered, and legacy backend log names.
|
||||
func ParseBackendLogFilename(name string) (BackendLogFile, bool) {
|
||||
if name == activeBackendLogName {
|
||||
return BackendLogFile{Active: true}, true
|
||||
}
|
||||
if match := segmentBackendLogPattern.FindStringSubmatch(name); len(match) == 3 {
|
||||
sequence, err := strconv.Atoi(match[2])
|
||||
if err == nil && validUTCDay(match[1]) {
|
||||
return BackendLogFile{Day: match[1], Sequence: sequence}, true
|
||||
}
|
||||
}
|
||||
if match := legacyBackendLogPattern.FindStringSubmatch(name); len(match) == 2 && validUTCDay(match[1]) {
|
||||
return BackendLogFile{Day: match[1], Legacy: true}, true
|
||||
}
|
||||
return BackendLogFile{}, false
|
||||
}
|
||||
|
||||
func backendLogSegmentName(day string, sequence int) string {
|
||||
return fmt.Sprintf("backend-logs-%s-%06d.log", day, sequence)
|
||||
}
|
||||
|
||||
func scanBackendLogFiles(logDir string) ([]backendLogFileInfo, error) {
|
||||
entries, err := os.ReadDir(logDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files := make([]backendLogFileInfo, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
parsed, ok := ParseBackendLogFilename(entry.Name())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(logDir, entry.Name())
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
files = append(files, backendLogFileInfo{
|
||||
BackendLogFile: parsed, Name: entry.Name(), Path: path,
|
||||
Size: info.Size(), ModTime: info.ModTime(),
|
||||
})
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func backendLogBytes(files []backendLogFileInfo) int64 {
|
||||
var total int64
|
||||
for _, file := range files {
|
||||
total += file.Size
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func closedBackendLogFiles(files []backendLogFileInfo) []backendLogFileInfo {
|
||||
closed := make([]backendLogFileInfo, 0, len(files))
|
||||
for _, file := range files {
|
||||
if !file.Active {
|
||||
closed = append(closed, file)
|
||||
}
|
||||
}
|
||||
return closed
|
||||
}
|
||||
|
||||
func sortBackendLogFiles(files []backendLogFileInfo) {
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
left, right := files[i], files[j]
|
||||
if left.Day != right.Day {
|
||||
return left.Day < right.Day
|
||||
}
|
||||
if left.Active != right.Active {
|
||||
return !left.Active
|
||||
}
|
||||
if left.Legacy != right.Legacy {
|
||||
return left.Legacy
|
||||
}
|
||||
return left.Sequence < right.Sequence
|
||||
})
|
||||
}
|
||||
|
||||
func nextBackendLogSequence(files []backendLogFileInfo, day string) (int, error) {
|
||||
next := 1
|
||||
for _, file := range files {
|
||||
if file.Day == day && !file.Active && !file.Legacy && file.Sequence >= next {
|
||||
next = file.Sequence + 1
|
||||
}
|
||||
}
|
||||
if next > 999999 {
|
||||
return 0, fmt.Errorf("backend log segment sequence exhausted")
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func validUTCDay(value string) bool {
|
||||
parsed, err := time.Parse(time.DateOnly, value)
|
||||
return err == nil && parsed.Format(time.DateOnly) == value
|
||||
}
|
||||
|
||||
// BackendLogDayRetained reports whether a closed backend log is within the
|
||||
// three-UTC-day maximum age relative to today.
|
||||
func BackendLogDayRetained(day string, today time.Time) bool {
|
||||
parsed, err := time.Parse(time.DateOnly, day)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
todayUTC := today.UTC()
|
||||
cutoff := time.Date(
|
||||
todayUTC.Year(), todayUTC.Month(), todayUTC.Day(), 0, 0, 0, 0, time.UTC,
|
||||
).AddDate(0, 0, -2)
|
||||
todayDay := time.Date(
|
||||
todayUTC.Year(), todayUTC.Month(), todayUTC.Day(), 0, 0, 0, 0, time.UTC,
|
||||
)
|
||||
return !parsed.Before(cutoff) && !parsed.After(todayDay)
|
||||
}
|
||||
|
||||
type rolloverJournal struct {
|
||||
SourceDay string `json:"source_day"`
|
||||
SourceSize int64 `json:"source_size"`
|
||||
SourceModUnixNano int64 `json:"source_mod_unix_nano"`
|
||||
Destination string `json:"destination"`
|
||||
DestinationStart int64 `json:"destination_start"`
|
||||
CopiedOffset int64 `json:"copied_offset"`
|
||||
}
|
||||
|
||||
func (w *dailyWriter) recoverRollover() error {
|
||||
path := filepath.Join(w.logDir, rolloverJournalName)
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var journal rolloverJournal
|
||||
if err := json.Unmarshal(data, &journal); err != nil {
|
||||
return err
|
||||
}
|
||||
activePath := filepath.Join(w.logDir, activeBackendLogName)
|
||||
if _, err := os.Stat(activePath); os.IsNotExist(err) {
|
||||
return removeIfExists(path)
|
||||
}
|
||||
if err := w.copyRollover(activePath, &journal); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(activePath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return removeIfExists(path)
|
||||
}
|
||||
|
||||
func (w *dailyWriter) copyRollover(activePath string, journal *rolloverJournal) error {
|
||||
source, err := os.Open(activePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer func() { _ = source.Close() }()
|
||||
sourceInfo, err := source.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sourceInfo.Size() != journal.SourceSize || sourceInfo.ModTime().UnixNano() != journal.SourceModUnixNano {
|
||||
journal.SourceSize = sourceInfo.Size()
|
||||
journal.SourceModUnixNano = sourceInfo.ModTime().UnixNano()
|
||||
if err := writeJSONOwnerFile(filepath.Join(w.logDir, rolloverJournalName), journal); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
destinationPath := filepath.Join(w.logDir, filepath.Base(journal.Destination))
|
||||
destination, err := openOwnerAppend(destinationPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = destination.Close() }()
|
||||
destinationInfo, err := destination.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
copied := destinationInfo.Size() - journal.DestinationStart
|
||||
if copied < 0 || copied > journal.SourceSize {
|
||||
return fmt.Errorf("invalid rollover destination size")
|
||||
}
|
||||
journal.CopiedOffset = copied
|
||||
if _, err := source.Seek(copied, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyJournaled(source, destination, journal, filepath.Join(w.logDir, rolloverJournalName)); err != nil {
|
||||
return err
|
||||
}
|
||||
return destination.Sync()
|
||||
}
|
||||
|
||||
func copyJournaled(source io.Reader, destination io.Writer, journal *rolloverJournal, path string) error {
|
||||
buf := make([]byte, 1024*1024)
|
||||
for {
|
||||
n, readErr := source.Read(buf)
|
||||
if n > 0 {
|
||||
written, writeErr := destination.Write(buf[:n])
|
||||
journal.CopiedOffset += int64(written)
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
if written != n {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
if err := writeJSONOwnerFile(path, journal); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type conversionJournal struct {
|
||||
SourceDay string `json:"source_day"`
|
||||
SourceSize int64 `json:"source_size"`
|
||||
SourceModUnixNano int64 `json:"source_mod_unix_nano"`
|
||||
TailOffset int64 `json:"tail_offset"`
|
||||
BackupName string `json:"backup_name"`
|
||||
NextOutput int `json:"next_output"`
|
||||
Outputs []conversionOutput `json:"outputs"`
|
||||
}
|
||||
|
||||
type conversionOutput struct {
|
||||
Name string `json:"name"`
|
||||
Offset int64 `json:"offset"`
|
||||
Length int64 `json:"length"`
|
||||
}
|
||||
|
||||
func (w *dailyWriter) convertActive(day string, sourceSize int64) error {
|
||||
if sourceSize <= w.maxBytes {
|
||||
return nil
|
||||
}
|
||||
files, err := scanBackendLogFiles(w.logDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
closed := closedBackendLogFiles(files)
|
||||
closedBytes := backendLogBytes(closed)
|
||||
sortBackendLogFiles(closed)
|
||||
desiredKeep := sourceSize
|
||||
if desiredKeep > w.maxTotalBytes {
|
||||
desiredKeep = w.maxTotalBytes
|
||||
}
|
||||
for closedBytes+desiredKeep > w.maxTotalBytes && len(closed) > 0 {
|
||||
oldest := closed[0]
|
||||
if err := os.Remove(oldest.Path); err != nil {
|
||||
return fmt.Errorf("remove old log during conversion: %w", err)
|
||||
}
|
||||
closedBytes -= oldest.Size
|
||||
closed = closed[1:]
|
||||
}
|
||||
available := w.maxTotalBytes - closedBytes
|
||||
if available <= 0 {
|
||||
return fmt.Errorf("no budget remains for active log conversion")
|
||||
}
|
||||
keep := desiredKeep
|
||||
if keep > available {
|
||||
keep = available
|
||||
}
|
||||
firstSequence, err := nextBackendLogSequence(files, day)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputs, err := conversionOutputs(day, keep, w.maxBytes, firstSequence)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
journal := &conversionJournal{
|
||||
SourceDay: day, SourceSize: sourceSize, TailOffset: sourceSize - keep,
|
||||
BackupName: conversionSourceName, NextOutput: len(outputs) - 1, Outputs: outputs,
|
||||
}
|
||||
path := filepath.Join(w.logDir, conversionJournalName)
|
||||
if err := writeJSONOwnerFile(path, journal); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := renameExclusive(filepath.Join(w.logDir, activeBackendLogName), filepath.Join(w.logDir, conversionSourceName)); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.finishConversion(journal)
|
||||
}
|
||||
|
||||
func conversionOutputs(day string, keep, segmentBytes int64, firstSequence int) ([]conversionOutput, error) {
|
||||
if keep <= 0 || segmentBytes <= 0 || firstSequence <= 0 {
|
||||
return nil, fmt.Errorf("invalid active log conversion limits")
|
||||
}
|
||||
outputs := make([]conversionOutput, 0, (keep+segmentBytes-1)/segmentBytes)
|
||||
for offset, sequence := int64(0), firstSequence; offset < keep; sequence++ {
|
||||
if sequence > 999999 {
|
||||
return nil, fmt.Errorf("backend log segment sequence exhausted")
|
||||
}
|
||||
length := keep - offset
|
||||
if length > segmentBytes {
|
||||
length = segmentBytes
|
||||
}
|
||||
outputs = append(outputs, conversionOutput{
|
||||
Name: backendLogSegmentName(day, sequence), Offset: offset, Length: length,
|
||||
})
|
||||
offset += length
|
||||
}
|
||||
return outputs, nil
|
||||
}
|
||||
|
||||
func (w *dailyWriter) recoverConversion() error {
|
||||
path := filepath.Join(w.logDir, conversionJournalName)
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var journal conversionJournal
|
||||
if err := json.Unmarshal(data, &journal); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateConversionJournal(&journal); err != nil {
|
||||
return err
|
||||
}
|
||||
activePath := filepath.Join(w.logDir, activeBackendLogName)
|
||||
backupPath := filepath.Join(w.logDir, journal.BackupName)
|
||||
complete, err := w.ensureConversionSource(path, activePath, backupPath, &journal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if complete {
|
||||
return nil
|
||||
}
|
||||
if err := w.finishConversion(&journal); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *dailyWriter) ensureConversionSource(
|
||||
journalPath, activePath, backupPath string, journal *conversionJournal,
|
||||
) (bool, error) {
|
||||
_, err := os.Lstat(backupPath)
|
||||
if err == nil {
|
||||
return false, nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return false, err
|
||||
}
|
||||
if allConversionOutputsPresent(w.logDir, journal.Outputs) {
|
||||
return true, removeIfExists(journalPath)
|
||||
}
|
||||
_, activeErr := os.Lstat(activePath)
|
||||
if activeErr == nil {
|
||||
if err := renameExclusive(activePath, backupPath); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if !os.IsNotExist(activeErr) {
|
||||
return false, activeErr
|
||||
}
|
||||
return false, fmt.Errorf("conversion source is missing")
|
||||
}
|
||||
|
||||
func (w *dailyWriter) finishConversion(journal *conversionJournal) error {
|
||||
if err := validateConversionJournal(journal); err != nil {
|
||||
return err
|
||||
}
|
||||
backupPath := filepath.Join(w.logDir, journal.BackupName)
|
||||
backupInfo, err := os.Lstat(backupPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nextOutput, err := nextConversionOutput(journal, backupInfo.Size())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for index := nextOutput; index >= 0; index-- {
|
||||
if err := w.finishConversionOutput(backupPath, journal, index); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !allConversionOutputsPresent(w.logDir, journal.Outputs) {
|
||||
return fmt.Errorf("conversion outputs are incomplete")
|
||||
}
|
||||
if err := removeIfExists(backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return removeIfExists(filepath.Join(w.logDir, conversionJournalName))
|
||||
}
|
||||
|
||||
func (w *dailyWriter) finishConversionOutput(
|
||||
backupPath string, journal *conversionJournal, index int,
|
||||
) error {
|
||||
output := journal.Outputs[index]
|
||||
outputPath := filepath.Join(w.logDir, output.Name)
|
||||
if err := removeIfExists(outputPath + ".tmp"); err != nil {
|
||||
return err
|
||||
}
|
||||
backupInfo, err := os.Lstat(backupPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !backupInfo.Mode().IsRegular() {
|
||||
return fmt.Errorf("conversion source is not a regular file")
|
||||
}
|
||||
sourceStart := journal.TailOffset + output.Offset
|
||||
sourceEnd := sourceStart + output.Length
|
||||
if sourceEnd > journal.SourceSize || sourceStart < 0 {
|
||||
return fmt.Errorf("invalid conversion source range")
|
||||
}
|
||||
if backupInfo.Size() < sourceStart {
|
||||
return fmt.Errorf("invalid conversion source size")
|
||||
}
|
||||
if backupInfo.Size() < sourceEnd {
|
||||
if err := validateConversionOutputFile(outputPath, output); err != nil {
|
||||
return err
|
||||
}
|
||||
return recordConversionProgress(journal, index, filepath.Join(filepath.Dir(backupPath), conversionJournalName))
|
||||
}
|
||||
if backupInfo.Size() != sourceEnd {
|
||||
return fmt.Errorf("invalid conversion source size")
|
||||
}
|
||||
if err := ensureConversionOutput(backupPath, outputPath, journal, output); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := truncateConversionSource(backupPath, sourceStart); err != nil {
|
||||
return err
|
||||
}
|
||||
return recordConversionProgress(journal, index, filepath.Join(filepath.Dir(backupPath), conversionJournalName))
|
||||
}
|
||||
|
||||
func ensureConversionOutput(
|
||||
backupPath, outputPath string, journal *conversionJournal, output conversionOutput,
|
||||
) error {
|
||||
if err := validateConversionOutputFile(outputPath, output); err == nil {
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
offset := journal.TailOffset + output.Offset
|
||||
if err := copyFileRange(backupPath, outputPath+".tmp", offset, output.Length); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := renameExclusive(outputPath+".tmp", outputPath); err != nil {
|
||||
if !os.IsExist(err) {
|
||||
return err
|
||||
}
|
||||
return removeIfExists(outputPath + ".tmp")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConversionOutputFile(path string, output conversionOutput) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() != output.Length {
|
||||
return fmt.Errorf("conversion output %s is invalid", output.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nextConversionOutput(journal *conversionJournal, sourceSize int64) (int, error) {
|
||||
if sourceSize == journal.TailOffset {
|
||||
return -1, nil
|
||||
}
|
||||
for index := len(journal.Outputs) - 1; index >= 0; index-- {
|
||||
output := journal.Outputs[index]
|
||||
if journal.TailOffset+output.Offset+output.Length == sourceSize {
|
||||
return index, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("invalid conversion source progress")
|
||||
}
|
||||
|
||||
func truncateConversionSource(path string, size int64) error {
|
||||
file, err := os.OpenFile(path, os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.Truncate(size); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
func recordConversionProgress(journal *conversionJournal, index int, journalPath string) error {
|
||||
journal.NextOutput = index - 1
|
||||
return writeJSONOwnerFile(journalPath, journal)
|
||||
}
|
||||
|
||||
func allConversionOutputsPresent(logDir string, outputs []conversionOutput) bool {
|
||||
for _, output := range outputs {
|
||||
info, err := os.Lstat(filepath.Join(logDir, output.Name))
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() != output.Length {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateConversionJournal(journal *conversionJournal) error {
|
||||
if journal.BackupName != conversionSourceName || !validUTCDay(journal.SourceDay) ||
|
||||
journal.SourceSize < 0 || journal.TailOffset < 0 || journal.TailOffset > journal.SourceSize {
|
||||
return fmt.Errorf("invalid conversion journal")
|
||||
}
|
||||
if len(journal.Outputs) == 0 {
|
||||
return fmt.Errorf("conversion output is empty")
|
||||
}
|
||||
if journal.NextOutput < -1 || journal.NextOutput >= len(journal.Outputs) {
|
||||
return fmt.Errorf("invalid conversion output progress")
|
||||
}
|
||||
var previousSequence int
|
||||
var total int64
|
||||
for index, output := range journal.Outputs {
|
||||
var err error
|
||||
total, previousSequence, err = validateConversionOutput(
|
||||
journal, output, index, previousSequence, total,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if journal.TailOffset+total != journal.SourceSize {
|
||||
return fmt.Errorf("conversion output exceeds source")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConversionOutput(
|
||||
journal *conversionJournal,
|
||||
output conversionOutput,
|
||||
index, previousSequence int,
|
||||
total int64,
|
||||
) (int64, int, error) {
|
||||
parsed, ok := ParseBackendLogFilename(output.Name)
|
||||
if !ok || parsed.Active || parsed.Legacy || parsed.Day != journal.SourceDay || output.Length <= 0 || output.Offset < 0 {
|
||||
return 0, 0, fmt.Errorf("invalid conversion output")
|
||||
}
|
||||
if index > 0 && parsed.Sequence <= previousSequence {
|
||||
return 0, 0, fmt.Errorf("conversion outputs are not ordered")
|
||||
}
|
||||
if output.Offset+output.Length > journal.SourceSize-journal.TailOffset || output.Offset != total {
|
||||
return 0, 0, fmt.Errorf("conversion output exceeds source range")
|
||||
}
|
||||
return total + output.Length, parsed.Sequence, nil
|
||||
}
|
||||
|
||||
func copyFileRange(sourcePath, destinationPath string, offset, length int64) error {
|
||||
source, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = source.Close() }()
|
||||
if _, err := source.Seek(offset, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
destination, err := os.OpenFile(destinationPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.CopyN(destination, source, length); err != nil {
|
||||
_ = destination.Close()
|
||||
_ = os.Remove(destinationPath)
|
||||
return err
|
||||
}
|
||||
if err := destination.Sync(); err != nil {
|
||||
_ = destination.Close()
|
||||
_ = os.Remove(destinationPath)
|
||||
return err
|
||||
}
|
||||
if err := destination.Close(); err != nil {
|
||||
_ = os.Remove(destinationPath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSONOwnerFile(path string, value any) error {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeOwnerFile(path, data)
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDailyWriterRotatesFullActiveSegment(t *testing.T) {
|
||||
logDir := t.TempDir()
|
||||
now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
|
||||
writer, err := newDailyWriter(logDir, func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatalf("newDailyWriter: %v", err)
|
||||
}
|
||||
defer func() { _ = writer.Close() }()
|
||||
writer.maxBytes = int64(len("first\n"))
|
||||
|
||||
if _, err := writer.Write([]byte("first\n")); err != nil {
|
||||
t.Fatalf("write first segment: %v", err)
|
||||
}
|
||||
if _, err := writer.Write([]byte("next\n")); err != nil {
|
||||
t.Fatalf("write second segment: %v", err)
|
||||
}
|
||||
|
||||
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000001.log")); got != "first\n" {
|
||||
t.Fatalf("closed segment = %q, want first entry", got)
|
||||
}
|
||||
if got := readLogFile(t, filepath.Join(logDir, activeBackendLogName)); got != "next\n" {
|
||||
t.Fatalf("active segment = %q, want next entry", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyWriterEvictsOldestClosedSegment(t *testing.T) {
|
||||
logDir := t.TempDir()
|
||||
now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
|
||||
writer := newTestDailyWriter(t, logDir, now, 4, 8)
|
||||
|
||||
for _, entry := range []string{"one\n", "two\n", "tri\n"} {
|
||||
if _, err := writer.Write([]byte(entry)); err != nil {
|
||||
t.Fatalf("write %q: %v", entry, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(logDir, "backend-logs-2026-08-22-000001.log")); !os.IsNotExist(err) {
|
||||
t.Fatalf("oldest segment still exists, stat error = %v", err)
|
||||
}
|
||||
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000002.log")); got != "two\n" {
|
||||
t.Fatalf("retained closed segment = %q, want two", got)
|
||||
}
|
||||
if got := readLogFile(t, filepath.Join(logDir, activeBackendLogName)); got != "tri\n" {
|
||||
t.Fatalf("active segment = %q, want tri", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyWriterEvictsOldestSegmentAcrossUTCDays(t *testing.T) {
|
||||
logDir := t.TempDir()
|
||||
now := time.Date(2026, 8, 21, 23, 59, 0, 0, time.UTC)
|
||||
current := now
|
||||
writer := newTestDailyWriterWithClock(t, logDir, func() time.Time { return current }, 4, 8)
|
||||
if _, err := writer.Write([]byte("one\n")); err != nil {
|
||||
t.Fatalf("write one: %v", err)
|
||||
}
|
||||
if _, err := writer.Write([]byte("two\n")); err != nil {
|
||||
t.Fatalf("write two: %v", err)
|
||||
}
|
||||
current = current.Add(2 * time.Minute)
|
||||
if _, err := writer.Write([]byte("tri\n")); err != nil {
|
||||
t.Fatalf("write tri: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(logDir, "backend-logs-2026-08-21-000001.log")); !os.IsNotExist(err) {
|
||||
t.Fatalf("oldest cross-day segment remains: %v", err)
|
||||
}
|
||||
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-21-000002.log")); got != "two\n" {
|
||||
t.Fatalf("retained prior-day segment = %q, want two", got)
|
||||
}
|
||||
if got := readLogFile(t, filepath.Join(logDir, activeBackendLogName)); got != "tri\n" {
|
||||
t.Fatalf("active next-day segment = %q, want tri", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyWriterResumesSequenceAfterRestart(t *testing.T) {
|
||||
logDir := t.TempDir()
|
||||
now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
|
||||
writer := newTestDailyWriter(t, logDir, now, 4, 20)
|
||||
if _, err := writer.Write([]byte("one\n")); err != nil {
|
||||
t.Fatalf("write one: %v", err)
|
||||
}
|
||||
if _, err := writer.Write([]byte("two\n")); err != nil {
|
||||
t.Fatalf("write two: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close first writer: %v", err)
|
||||
}
|
||||
|
||||
writer = newTestDailyWriter(t, logDir, now, 4, 20)
|
||||
if _, err := writer.Write([]byte("tri\n")); err != nil {
|
||||
t.Fatalf("write tri: %v", err)
|
||||
}
|
||||
|
||||
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000001.log")); got != "one\n" {
|
||||
t.Fatalf("first sequence = %q, want one", got)
|
||||
}
|
||||
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000002.log")); got != "two\n" {
|
||||
t.Fatalf("second sequence = %q, want two", got)
|
||||
}
|
||||
if got := readLogFile(t, filepath.Join(logDir, activeBackendLogName)); got != "tri\n" {
|
||||
t.Fatalf("active after restart = %q, want tri", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyWriterConvertsOversizedActiveLog(t *testing.T) {
|
||||
logDir := t.TempDir()
|
||||
day := "2026-08-22"
|
||||
source := []byte("one-two-three\n")
|
||||
if err := os.WriteFile(filepath.Join(logDir, activeBackendLogName), source, 0o600); err != nil {
|
||||
t.Fatalf("seed active log: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(logDir, backendDayMarkerName), []byte(day), 0o600); err != nil {
|
||||
t.Fatalf("seed day marker: %v", err)
|
||||
}
|
||||
|
||||
_ = newTestDailyWriter(t, logDir, time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC), 5, 10)
|
||||
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000001.log")); got != "two-t" {
|
||||
t.Fatalf("converted first segment = %q, want two-t", got)
|
||||
}
|
||||
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000002.log")); got != "hree\n" {
|
||||
t.Fatalf("converted second segment = %q, want hree", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(logDir, "backend-logs-2026-08-22-000003.log")); !os.IsNotExist(err) {
|
||||
t.Fatalf("discarded third segment remains: %v", err)
|
||||
}
|
||||
if got := readLogFile(t, filepath.Join(logDir, activeBackendLogName)); got != "" {
|
||||
t.Fatalf("converted active log = %q, want empty", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(logDir, conversionJournalName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("conversion journal remains: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyWriterRecoversOversizedConversionWithTailOffset(t *testing.T) {
|
||||
logDir := t.TempDir()
|
||||
source := []byte("one-two-three\n")
|
||||
journal := &conversionJournal{
|
||||
SourceDay: "2026-08-22", SourceSize: int64(len(source)), TailOffset: 4,
|
||||
BackupName: conversionSourceName,
|
||||
Outputs: []conversionOutput{
|
||||
{Name: "backend-logs-2026-08-22-000001.log", Offset: 0, Length: 5},
|
||||
{Name: "backend-logs-2026-08-22-000002.log", Offset: 5, Length: 5},
|
||||
},
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(logDir, conversionSourceName), source, 0o600); err != nil {
|
||||
t.Fatalf("seed conversion source: %v", err)
|
||||
}
|
||||
if err := writeJSONOwnerFile(filepath.Join(logDir, conversionJournalName), journal); err != nil {
|
||||
t.Fatalf("seed conversion journal: %v", err)
|
||||
}
|
||||
|
||||
_ = newTestDailyWriter(t, logDir, time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC), 5, 10)
|
||||
for name, want := range map[string]string{
|
||||
"backend-logs-2026-08-22-000001.log": "two-t",
|
||||
"backend-logs-2026-08-22-000002.log": "hree\n",
|
||||
} {
|
||||
if got := readLogFile(t, filepath.Join(logDir, name)); got != want {
|
||||
t.Fatalf("recovered %s = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(logDir, conversionSourceName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("conversion source remains: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendLogDayRetainedRejectsFutureDays(t *testing.T) {
|
||||
today := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
|
||||
for day, want := range map[string]bool{
|
||||
"2026-08-20": true,
|
||||
"2026-08-21": true,
|
||||
"2026-08-22": true,
|
||||
"2026-08-23": false,
|
||||
"not-a-day": false,
|
||||
} {
|
||||
if got := BackendLogDayRetained(day, today); got != want {
|
||||
t.Fatalf("BackendLogDayRetained(%q) = %v, want %v", day, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyWriterConversionPrefersActiveTailOverClosedLogs(t *testing.T) {
|
||||
logDir := t.TempDir()
|
||||
source := []byte("one-two-three\n")
|
||||
if err := os.WriteFile(filepath.Join(logDir, "backend-logs-2026-08-21.log"), []byte("old-data"), 0o600); err != nil {
|
||||
t.Fatalf("seed old log: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(logDir, activeBackendLogName), source, 0o600); err != nil {
|
||||
t.Fatalf("seed active log: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(logDir, backendDayMarkerName), []byte("2026-08-22"), 0o600); err != nil {
|
||||
t.Fatalf("seed day marker: %v", err)
|
||||
}
|
||||
|
||||
_ = newTestDailyWriter(t, logDir, time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC), 5, 15)
|
||||
if _, err := os.Stat(filepath.Join(logDir, "backend-logs-2026-08-21.log")); !os.IsNotExist(err) {
|
||||
t.Fatalf("older log was retained while converting active log: %v", err)
|
||||
}
|
||||
for name, want := range map[string]string{
|
||||
"backend-logs-2026-08-22-000001.log": "one-t",
|
||||
"backend-logs-2026-08-22-000002.log": "wo-th",
|
||||
"backend-logs-2026-08-22-000003.log": "ree\n",
|
||||
} {
|
||||
if got := readLogFile(t, filepath.Join(logDir, name)); got != want {
|
||||
t.Fatalf("converted %s = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyWriterCleansExpiredSegmentsAndLegacyFiles(t *testing.T) {
|
||||
logDir := t.TempDir()
|
||||
for name := range map[string]string{
|
||||
"backend-logs-2026-08-19.log": "expired-legacy",
|
||||
"backend-logs-2026-08-20.log": "oldest-retained",
|
||||
"backend-logs-2026-08-21-000001.log": "yesterday",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(logDir, name), []byte(name), 0o600); err != nil {
|
||||
t.Fatalf("seed %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
_ = newTestDailyWriter(t, logDir, time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC), 8, 100)
|
||||
if _, err := os.Stat(filepath.Join(logDir, "backend-logs-2026-08-19.log")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expired legacy file remains: %v", err)
|
||||
}
|
||||
for _, name := range []string{"backend-logs-2026-08-20.log", "backend-logs-2026-08-21-000001.log"} {
|
||||
if _, err := os.Stat(filepath.Join(logDir, name)); err != nil {
|
||||
t.Fatalf("retained file %s missing: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyWriterRecoversInterruptedConversion(t *testing.T) {
|
||||
logDir := t.TempDir()
|
||||
source := []byte("one-two-three\n")
|
||||
backup := filepath.Join(logDir, conversionSourceName)
|
||||
if err := os.WriteFile(backup, source, 0o600); err != nil {
|
||||
t.Fatalf("seed conversion source: %v", err)
|
||||
}
|
||||
journal := &conversionJournal{
|
||||
SourceDay: "2026-08-22", SourceSize: int64(len(source)), TailOffset: 0,
|
||||
BackupName: conversionSourceName,
|
||||
Outputs: []conversionOutput{
|
||||
{Name: "backend-logs-2026-08-22-000001.log", Offset: 0, Length: 5},
|
||||
{Name: "backend-logs-2026-08-22-000002.log", Offset: 5, Length: 5},
|
||||
{Name: "backend-logs-2026-08-22-000003.log", Offset: 10, Length: 4},
|
||||
},
|
||||
}
|
||||
if err := writeJSONOwnerFile(filepath.Join(logDir, conversionJournalName), journal); err != nil {
|
||||
t.Fatalf("seed conversion journal: %v", err)
|
||||
}
|
||||
_ = newTestDailyWriter(t, logDir, time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC), 5, 20)
|
||||
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000001.log")); got != "one-t" {
|
||||
t.Fatalf("recovered first segment = %q", got)
|
||||
}
|
||||
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000003.log")); got != "ree\n" {
|
||||
t.Fatalf("recovered last segment = %q", got)
|
||||
}
|
||||
if _, err := os.Stat(backup); !os.IsNotExist(err) {
|
||||
t.Fatalf("conversion source remains: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyWriterRecoversStaleConversionTemp(t *testing.T) {
|
||||
logDir := t.TempDir()
|
||||
source := []byte("one-two-three\n")
|
||||
if err := os.WriteFile(filepath.Join(logDir, conversionSourceName), source, 0o600); err != nil {
|
||||
t.Fatalf("seed conversion source: %v", err)
|
||||
}
|
||||
journal := &conversionJournal{
|
||||
SourceDay: "2026-08-22", SourceSize: int64(len(source)), TailOffset: 0,
|
||||
BackupName: conversionSourceName,
|
||||
Outputs: []conversionOutput{
|
||||
{Name: "backend-logs-2026-08-22-000001.log", Offset: 0, Length: 5},
|
||||
{Name: "backend-logs-2026-08-22-000002.log", Offset: 5, Length: 5},
|
||||
{Name: "backend-logs-2026-08-22-000003.log", Offset: 10, Length: 4},
|
||||
},
|
||||
}
|
||||
if err := writeJSONOwnerFile(filepath.Join(logDir, conversionJournalName), journal); err != nil {
|
||||
t.Fatalf("seed conversion journal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(logDir, journal.Outputs[0].Name+".tmp"), []byte("stale"), 0o600); err != nil {
|
||||
t.Fatalf("seed stale temporary output: %v", err)
|
||||
}
|
||||
|
||||
_ = newTestDailyWriter(t, logDir, time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC), 5, 20)
|
||||
if got := readLogFile(t, filepath.Join(logDir, journal.Outputs[0].Name)); got != "one-t" {
|
||||
t.Fatalf("recovered first segment = %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(logDir, journal.Outputs[0].Name+".tmp")); !os.IsNotExist(err) {
|
||||
t.Fatalf("stale temporary output remains: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyWriterResumesInterruptedConversionTruncation(t *testing.T) {
|
||||
logDir := t.TempDir()
|
||||
journal := &conversionJournal{
|
||||
SourceDay: "2026-08-22", SourceSize: 14, TailOffset: 0,
|
||||
BackupName: conversionSourceName,
|
||||
Outputs: []conversionOutput{
|
||||
{Name: "backend-logs-2026-08-22-000001.log", Offset: 0, Length: 5},
|
||||
{Name: "backend-logs-2026-08-22-000002.log", Offset: 5, Length: 5},
|
||||
{Name: "backend-logs-2026-08-22-000003.log", Offset: 10, Length: 4},
|
||||
},
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(logDir, conversionSourceName), []byte("one-two-th"), 0o600); err != nil {
|
||||
t.Fatalf("seed partially truncated source: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(logDir, journal.Outputs[2].Name), []byte("ree\n"), 0o600); err != nil {
|
||||
t.Fatalf("seed newest output: %v", err)
|
||||
}
|
||||
if err := writeJSONOwnerFile(filepath.Join(logDir, conversionJournalName), journal); err != nil {
|
||||
t.Fatalf("seed conversion journal: %v", err)
|
||||
}
|
||||
|
||||
_ = newTestDailyWriter(t, logDir, time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC), 5, 20)
|
||||
for name, want := range map[string]string{
|
||||
"backend-logs-2026-08-22-000001.log": "one-t",
|
||||
"backend-logs-2026-08-22-000002.log": "wo-th",
|
||||
"backend-logs-2026-08-22-000003.log": "ree\n",
|
||||
} {
|
||||
if got := readLogFile(t, filepath.Join(logDir, name)); got != want {
|
||||
t.Fatalf("recovered %s = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyWriterPreservesFreshActiveLogAfterCompletedConversion(t *testing.T) {
|
||||
logDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(logDir, activeBackendLogName), []byte("new\n"), 0o600); err != nil {
|
||||
t.Fatalf("seed fresh active log: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(logDir, backendDayMarkerName), []byte("2026-08-22"), 0o600); err != nil {
|
||||
t.Fatalf("seed active day marker: %v", err)
|
||||
}
|
||||
journal := &conversionJournal{
|
||||
SourceDay: "2026-08-22", SourceSize: 14, TailOffset: 0,
|
||||
BackupName: conversionSourceName,
|
||||
Outputs: []conversionOutput{
|
||||
{Name: "backend-logs-2026-08-22-000001.log", Offset: 0, Length: 5},
|
||||
{Name: "backend-logs-2026-08-22-000002.log", Offset: 5, Length: 5},
|
||||
{Name: "backend-logs-2026-08-22-000003.log", Offset: 10, Length: 4},
|
||||
},
|
||||
}
|
||||
for _, output := range journal.Outputs {
|
||||
contents := map[int64]string{0: "one-t", 5: "wo-th", 10: "ree\n"}[output.Offset]
|
||||
if err := os.WriteFile(filepath.Join(logDir, output.Name), []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("seed %s: %v", output.Name, err)
|
||||
}
|
||||
}
|
||||
if err := writeJSONOwnerFile(filepath.Join(logDir, conversionJournalName), journal); err != nil {
|
||||
t.Fatalf("seed conversion journal: %v", err)
|
||||
}
|
||||
|
||||
_ = newTestDailyWriter(t, logDir, time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC), 5, 20)
|
||||
if got := readLogFile(t, filepath.Join(logDir, activeBackendLogName)); got != "new\n" {
|
||||
t.Fatalf("fresh active log = %q, want new entry", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestDailyWriter(t *testing.T, logDir string, now time.Time, segmentBytes, totalBytes int64) *dailyWriter {
|
||||
return newTestDailyWriterWithClock(t, logDir, func() time.Time { return now }, segmentBytes, totalBytes)
|
||||
}
|
||||
|
||||
func newTestDailyWriterWithClock(
|
||||
t *testing.T, logDir string, now func() time.Time, segmentBytes, totalBytes int64,
|
||||
) *dailyWriter {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(logDir, 0o700); err != nil {
|
||||
t.Fatalf("create log directory: %v", err)
|
||||
}
|
||||
writer := &dailyWriter{
|
||||
logDir: logDir, now: now,
|
||||
maxBytes: segmentBytes, maxTotalBytes: totalBytes,
|
||||
}
|
||||
t.Cleanup(func() { _ = writer.Close() })
|
||||
if err := writer.prepare(); err != nil {
|
||||
t.Fatalf("prepare writer: %v", err)
|
||||
}
|
||||
return writer
|
||||
}
|
||||
@@ -241,9 +241,6 @@ func (w *retryDailyWriter) Write(data []byte) (int, error) {
|
||||
}
|
||||
written, err := w.writer.Write(data)
|
||||
if err != nil {
|
||||
if errors.Is(err, errDailyBackendLogLimit) {
|
||||
return written, err
|
||||
}
|
||||
_ = w.writer.Close()
|
||||
w.writer = nil
|
||||
w.nextRetry = w.now().Add(30 * time.Second)
|
||||
|
||||
@@ -1,36 +1,14 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
activeBackendLogName = "backend-logs.log"
|
||||
backendDayMarkerName = ".backend-logs-day"
|
||||
rolloverJournalName = ".backend-logs-rollover.json"
|
||||
dailyBackendLogBytes = 256 * 1024 * 1024
|
||||
)
|
||||
|
||||
var datedBackendLogPattern = regexp.MustCompile(`^backend-logs-(\d{4}-\d{2}-\d{2})\.log$`)
|
||||
var errDailyBackendLogLimit = errors.New("daily backend log byte limit reached")
|
||||
|
||||
type rolloverJournal struct {
|
||||
SourceDay string `json:"source_day"`
|
||||
SourceSize int64 `json:"source_size"`
|
||||
SourceModUnixNano int64 `json:"source_mod_unix_nano"`
|
||||
Destination string `json:"destination"`
|
||||
DestinationStart int64 `json:"destination_start"`
|
||||
CopiedOffset int64 `json:"copied_offset"`
|
||||
}
|
||||
|
||||
type dailyWriter struct {
|
||||
mu sync.Mutex
|
||||
logDir string
|
||||
@@ -38,7 +16,9 @@ type dailyWriter struct {
|
||||
day string
|
||||
file *os.File
|
||||
size int64
|
||||
retainedBytes int64
|
||||
maxBytes int64
|
||||
maxTotalBytes int64
|
||||
closed bool
|
||||
maintenanceErr error
|
||||
}
|
||||
@@ -53,7 +33,10 @@ func newDailyWriter(logDir string, now func() time.Time) (*dailyWriter, error) {
|
||||
if err := os.Chmod(logDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("secure log directory: %w", err)
|
||||
}
|
||||
writer := &dailyWriter{logDir: logDir, now: now, maxBytes: dailyBackendLogBytes}
|
||||
writer := &dailyWriter{
|
||||
logDir: logDir, now: now, maxBytes: backendLogSegmentBytes,
|
||||
maxTotalBytes: backendLogTotalBytes,
|
||||
}
|
||||
if err := writer.prepare(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -65,19 +48,36 @@ func (w *dailyWriter) prepare() error {
|
||||
if err := w.recoverRollover(); err != nil {
|
||||
return fmt.Errorf("recover rollover: %w", err)
|
||||
}
|
||||
if err := w.recoverConversion(); err != nil {
|
||||
return fmt.Errorf("recover log conversion: %w", err)
|
||||
}
|
||||
w.recordMaintenance(w.cleanup(today))
|
||||
|
||||
day, err := w.readActiveDay(today)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if day != today {
|
||||
if err := w.rollover(day); err != nil {
|
||||
active, exists, err := w.activeInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists && active.Size > w.maxBytes {
|
||||
if err := w.convertActive(day, active.Size); err != nil {
|
||||
return fmt.Errorf("convert oversized active log: %w", err)
|
||||
}
|
||||
exists = false
|
||||
}
|
||||
if exists && day != today {
|
||||
if err := w.rotateActive(day); err != nil {
|
||||
return fmt.Errorf("roll over %s: %w", day, err)
|
||||
}
|
||||
}
|
||||
if err := w.openActive(today); err != nil {
|
||||
return err
|
||||
}
|
||||
w.maintenanceErr = w.cleanup(today)
|
||||
w.recordMaintenance(w.cleanup(today))
|
||||
w.recordMaintenance(w.refreshRetainedBytes())
|
||||
w.recordMaintenance(w.enforceBudget(0))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -87,56 +87,203 @@ func (w *dailyWriter) Write(data []byte) (int, error) {
|
||||
if w.closed {
|
||||
return 0, os.ErrClosed
|
||||
}
|
||||
if int64(len(data)) > w.maxBytes {
|
||||
return 0, fmt.Errorf("backend log entry exceeds segment limit")
|
||||
}
|
||||
today := utcDay(w.now())
|
||||
if today != w.day {
|
||||
if err := w.switchDay(today); err != nil {
|
||||
// Preserve the triggering entry in the active file. The next write
|
||||
// retries rollover while the marker still names the prior day.
|
||||
if w.file == nil {
|
||||
return 0, err
|
||||
}
|
||||
return w.file.Write(data)
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if w.size+int64(len(data)) > w.maxBytes {
|
||||
return 0, errDailyBackendLogLimit
|
||||
if err := w.rotateActive(w.day); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := w.openActive(w.day); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := w.enforceBudget(int64(len(data))); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
written, err := w.file.Write(data)
|
||||
w.size += int64(written)
|
||||
w.retainedBytes += int64(written)
|
||||
return written, err
|
||||
}
|
||||
|
||||
func (w *dailyWriter) switchDay(today string) error {
|
||||
previous := w.day
|
||||
if err := w.file.Close(); err != nil {
|
||||
if err := w.rotateActive(w.day); err != nil {
|
||||
return err
|
||||
}
|
||||
w.file = nil
|
||||
if err := w.rollover(previous); err != nil {
|
||||
return w.reopenPrevious(previous, err)
|
||||
}
|
||||
if err := w.openActive(today); err != nil {
|
||||
return err
|
||||
}
|
||||
w.maintenanceErr = w.cleanup(today)
|
||||
w.recordMaintenance(w.cleanup(today))
|
||||
w.recordMaintenance(w.refreshRetainedBytes())
|
||||
w.recordMaintenance(w.enforceBudget(0))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *dailyWriter) reopenPrevious(day string, rolloverErr error) error {
|
||||
file, openErr := openOwnerAppend(filepath.Join(w.logDir, activeBackendLogName))
|
||||
if openErr != nil {
|
||||
return errors.Join(rolloverErr, openErr)
|
||||
}
|
||||
w.file = file
|
||||
w.day = day
|
||||
info, statErr := file.Stat()
|
||||
if statErr != nil {
|
||||
_ = file.Close()
|
||||
func (w *dailyWriter) rotateActive(day string) error {
|
||||
if w.file != nil {
|
||||
if err := w.file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
w.file = nil
|
||||
return errors.Join(rolloverErr, statErr)
|
||||
}
|
||||
w.size = info.Size()
|
||||
return rolloverErr
|
||||
active, exists, err := w.activeInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return removeIfExists(filepath.Join(w.logDir, backendDayMarkerName))
|
||||
}
|
||||
if active.Size == 0 {
|
||||
if err := os.Remove(active.Path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return removeIfExists(filepath.Join(w.logDir, backendDayMarkerName))
|
||||
}
|
||||
sequence, err := w.nextSequence(day)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destination := filepath.Join(w.logDir, backendLogSegmentName(day, sequence))
|
||||
if err := renameExclusive(active.Path, destination); err != nil {
|
||||
return fmt.Errorf("rename active log: %w", err)
|
||||
}
|
||||
return removeIfExists(filepath.Join(w.logDir, backendDayMarkerName))
|
||||
}
|
||||
|
||||
func (w *dailyWriter) openActive(day string) error {
|
||||
file, err := openOwnerAppend(filepath.Join(w.logDir, activeBackendLogName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open active log: %w", err)
|
||||
}
|
||||
if err := writeOwnerFile(filepath.Join(w.logDir, backendDayMarkerName), []byte(day)); err != nil {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("write active day marker: %w", err)
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("stat active log: %w", err)
|
||||
}
|
||||
if _, err := w.nextSequence(day); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
w.file, w.day, w.size = file, day, info.Size()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *dailyWriter) activeInfo() (backendLogFileInfo, bool, error) {
|
||||
path := filepath.Join(w.logDir, activeBackendLogName)
|
||||
info, err := os.Lstat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return backendLogFileInfo{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return backendLogFileInfo{}, false, fmt.Errorf("stat active log: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return backendLogFileInfo{}, false, fmt.Errorf("active log is not a regular file")
|
||||
}
|
||||
return backendLogFileInfo{
|
||||
Name: activeBackendLogName, Path: path,
|
||||
BackendLogFile: BackendLogFile{Active: true}, Size: info.Size(), ModTime: info.ModTime(),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (w *dailyWriter) readActiveDay(today string) (string, error) {
|
||||
active, exists, err := w.activeInfo()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return today, nil
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(w.logDir, backendDayMarkerName))
|
||||
if err == nil && validUTCDay(string(data)) {
|
||||
return string(data), nil
|
||||
}
|
||||
return active.ModTime.UTC().Format(time.DateOnly), nil
|
||||
}
|
||||
|
||||
func (w *dailyWriter) nextSequence(day string) (int, error) {
|
||||
files, err := scanBackendLogFiles(w.logDir)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return nextBackendLogSequence(files, day)
|
||||
}
|
||||
|
||||
func (w *dailyWriter) enforceBudget(additional int64) error {
|
||||
if additional < 0 || w.maxTotalBytes <= 0 {
|
||||
return fmt.Errorf("invalid backend log budget")
|
||||
}
|
||||
if w.retainedBytes+additional <= w.maxTotalBytes {
|
||||
return nil
|
||||
}
|
||||
files, err := scanBackendLogFiles(w.logDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total := backendLogBytes(files)
|
||||
closed := closedBackendLogFiles(files)
|
||||
sortBackendLogFiles(closed)
|
||||
for total+additional > w.maxTotalBytes && len(closed) > 0 {
|
||||
oldest := closed[0]
|
||||
if err := os.Remove(oldest.Path); err != nil {
|
||||
return fmt.Errorf("remove oldest backend log %s: %w", oldest.Name, err)
|
||||
}
|
||||
total -= oldest.Size
|
||||
closed = closed[1:]
|
||||
}
|
||||
w.retainedBytes = total
|
||||
if total+additional > w.maxTotalBytes {
|
||||
return fmt.Errorf("backend log budget exceeded")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *dailyWriter) refreshRetainedBytes() error {
|
||||
files, err := scanBackendLogFiles(w.logDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.retainedBytes = backendLogBytes(files)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *dailyWriter) cleanup(today string) error {
|
||||
files, err := scanBackendLogFiles(w.logDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
todayTime, err := time.Parse(time.DateOnly, today)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, file := range files {
|
||||
if file.Active || file.Day == "" {
|
||||
continue
|
||||
}
|
||||
if !BackendLogDayRetained(file.Day, todayTime) {
|
||||
if err := os.Remove(file.Path); err != nil {
|
||||
return fmt.Errorf("remove expired backend log %s: %w", file.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *dailyWriter) recordMaintenance(err error) {
|
||||
if err != nil {
|
||||
w.maintenanceErr = errors.Join(w.maintenanceErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *dailyWriter) Close() error {
|
||||
@@ -158,222 +305,10 @@ func (w *dailyWriter) MaintenanceError() error {
|
||||
return w.maintenanceErr
|
||||
}
|
||||
|
||||
func (w *dailyWriter) readActiveDay(today string) (string, error) {
|
||||
activePath := filepath.Join(w.logDir, activeBackendLogName)
|
||||
if _, err := os.Stat(activePath); os.IsNotExist(err) {
|
||||
return today, nil
|
||||
} else if err != nil {
|
||||
return "", fmt.Errorf("stat active log: %w", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(w.logDir, backendDayMarkerName))
|
||||
if err == nil {
|
||||
if day := string(data); validUTCDay(day) {
|
||||
return day, nil
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(activePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat unmarked active log: %w", err)
|
||||
}
|
||||
return info.ModTime().UTC().Format(time.DateOnly), nil
|
||||
}
|
||||
|
||||
func (w *dailyWriter) openActive(day string) error {
|
||||
file, err := openOwnerAppend(filepath.Join(w.logDir, activeBackendLogName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open active log: %w", err)
|
||||
}
|
||||
if err := writeOwnerFile(filepath.Join(w.logDir, backendDayMarkerName), []byte(day)); err != nil {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("write active day marker: %w", err)
|
||||
}
|
||||
w.file = file
|
||||
w.day = day
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
w.file = nil
|
||||
return fmt.Errorf("stat active log: %w", err)
|
||||
}
|
||||
w.size = info.Size()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *dailyWriter) rollover(day string) error {
|
||||
activePath := filepath.Join(w.logDir, activeBackendLogName)
|
||||
info, err := os.Stat(activePath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
journal, err := w.loadOrCreateJournal(day, info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.copyRollover(activePath, journal); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(activePath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(filepath.Join(w.logDir, backendDayMarkerName))
|
||||
return removeIfExists(filepath.Join(w.logDir, rolloverJournalName))
|
||||
}
|
||||
|
||||
func (w *dailyWriter) loadOrCreateJournal(day string, info os.FileInfo) (*rolloverJournal, error) {
|
||||
path := filepath.Join(w.logDir, rolloverJournalName)
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
var journal rolloverJournal
|
||||
if err := json.Unmarshal(data, &journal); err != nil {
|
||||
return nil, fmt.Errorf("decode rollover journal: %w", err)
|
||||
}
|
||||
return &journal, nil
|
||||
}
|
||||
destination := fmt.Sprintf("backend-logs-%s.log", day)
|
||||
destinationInfo, err := os.Stat(filepath.Join(w.logDir, destination))
|
||||
var destinationStart int64
|
||||
if err == nil {
|
||||
destinationStart = destinationInfo.Size()
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
journal := &rolloverJournal{
|
||||
SourceDay: day, SourceSize: info.Size(), SourceModUnixNano: info.ModTime().UnixNano(),
|
||||
Destination: destination, DestinationStart: destinationStart,
|
||||
}
|
||||
return journal, writeJSONOwnerFile(path, journal)
|
||||
}
|
||||
|
||||
func (w *dailyWriter) copyRollover(activePath string, journal *rolloverJournal) error {
|
||||
source, err := os.Open(activePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer func() { _ = source.Close() }()
|
||||
sourceInfo, err := source.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sourceInfo.Size() != journal.SourceSize ||
|
||||
sourceInfo.ModTime().UnixNano() != journal.SourceModUnixNano {
|
||||
journal.SourceSize = sourceInfo.Size()
|
||||
journal.SourceModUnixNano = sourceInfo.ModTime().UnixNano()
|
||||
if err := writeJSONOwnerFile(filepath.Join(w.logDir, rolloverJournalName), journal); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
destinationPath := filepath.Join(w.logDir, filepath.Base(journal.Destination))
|
||||
destination, err := openOwnerAppend(destinationPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = destination.Close() }()
|
||||
destinationInfo, err := destination.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
copied := destinationInfo.Size() - journal.DestinationStart
|
||||
if copied < 0 || copied > journal.SourceSize {
|
||||
return fmt.Errorf("invalid rollover destination size")
|
||||
}
|
||||
journal.CopiedOffset = copied
|
||||
if _, err := source.Seek(copied, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyJournaled(source, destination, journal, filepath.Join(w.logDir, rolloverJournalName)); err != nil {
|
||||
return err
|
||||
}
|
||||
return destination.Sync()
|
||||
}
|
||||
|
||||
func copyJournaled(source io.Reader, destination io.Writer, journal *rolloverJournal, path string) error {
|
||||
buf := make([]byte, 1024*1024)
|
||||
for {
|
||||
n, readErr := source.Read(buf)
|
||||
if n > 0 {
|
||||
written, writeErr := destination.Write(buf[:n])
|
||||
journal.CopiedOffset += int64(written)
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
if written != n {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
if err := writeJSONOwnerFile(path, journal); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *dailyWriter) recoverRollover() error {
|
||||
path := filepath.Join(w.logDir, rolloverJournalName)
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var journal rolloverJournal
|
||||
if err := json.Unmarshal(data, &journal); err != nil {
|
||||
return err
|
||||
}
|
||||
activePath := filepath.Join(w.logDir, activeBackendLogName)
|
||||
if _, err := os.Stat(activePath); os.IsNotExist(err) {
|
||||
return removeIfExists(path)
|
||||
}
|
||||
if err := w.copyRollover(activePath, &journal); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(activePath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return removeIfExists(path)
|
||||
}
|
||||
|
||||
func (w *dailyWriter) cleanup(today string) error {
|
||||
entries, err := os.ReadDir(w.logDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cutoff, _ := time.Parse(time.DateOnly, today)
|
||||
cutoff = cutoff.AddDate(0, 0, -2)
|
||||
for _, entry := range entries {
|
||||
match := datedBackendLogPattern.FindStringSubmatch(entry.Name())
|
||||
if len(match) != 2 {
|
||||
continue
|
||||
}
|
||||
day, err := time.Parse(time.DateOnly, match[1])
|
||||
if err == nil && day.Before(cutoff) {
|
||||
if err := os.Remove(filepath.Join(w.logDir, entry.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func utcDay(now time.Time) string {
|
||||
return now.UTC().Format(time.DateOnly)
|
||||
}
|
||||
|
||||
func validUTCDay(value string) bool {
|
||||
parsed, err := time.Parse(time.DateOnly, value)
|
||||
return err == nil && parsed.Format(time.DateOnly) == value
|
||||
}
|
||||
|
||||
func openOwnerAppend(path string) (*os.File, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
@@ -393,17 +328,18 @@ func writeOwnerFile(path string, data []byte) error {
|
||||
return os.Rename(temp, path)
|
||||
}
|
||||
|
||||
func writeJSONOwnerFile(path string, value any) error {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeOwnerFile(path, data)
|
||||
}
|
||||
|
||||
func removeIfExists(path string) error {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renameExclusive(source, destination string) error {
|
||||
if _, err := os.Lstat(destination); err == nil {
|
||||
return os.ErrExist
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestDailyWriterAppendsAndRollsByUTCDay(t *testing.T) {
|
||||
t.Fatalf("close day two: %v", err)
|
||||
}
|
||||
|
||||
archived := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-07-29.log"))
|
||||
archived := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-07-29-000001.log"))
|
||||
if archived != "day-one\nsame-day\n" {
|
||||
t.Fatalf("archived log = %q", archived)
|
||||
}
|
||||
@@ -131,7 +131,7 @@ func TestDailyWriterResumesJournalWithoutDuplicatingDestination(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyWriterRejectsEntriesBeyondDailyByteLimit(t *testing.T) {
|
||||
func TestDailyWriterRejectsEntryLargerThanSegment(t *testing.T) {
|
||||
logDir := t.TempDir()
|
||||
activePath := filepath.Join(logDir, activeBackendLogName)
|
||||
const dailyLimit = int64(len("full\n"))
|
||||
@@ -148,8 +148,8 @@ func TestDailyWriterRejectsEntriesBeyondDailyByteLimit(t *testing.T) {
|
||||
t.Fatalf("newDailyWriter: %v", err)
|
||||
}
|
||||
defer func() { _ = writer.Close() }()
|
||||
if writer.maxBytes != dailyBackendLogBytes {
|
||||
t.Fatalf("default daily limit = %d, want %d", writer.maxBytes, dailyBackendLogBytes)
|
||||
if writer.maxBytes != backendLogSegmentBytes {
|
||||
t.Fatalf("default segment limit = %d, want %d", writer.maxBytes, backendLogSegmentBytes)
|
||||
}
|
||||
writer.maxBytes = dailyLimit
|
||||
|
||||
|
||||
@@ -137,10 +137,11 @@ func (c *Client) ReadPump(_ context.Context) {
|
||||
for {
|
||||
_, message, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
// CloseNormalClosure (1000): Normal browser close
|
||||
// CloseGoingAway (1001): Client navigating away
|
||||
// CloseNoStatusReceived (1005): Client closed without status (normal browser close)
|
||||
// CloseAbnormalClosure (1006): Abnormal close (network drop)
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived, websocket.CloseAbnormalClosure) {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived, websocket.CloseAbnormalClosure) {
|
||||
c.logger.Error("WebSocket read error", zap.Error(err))
|
||||
}
|
||||
break
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gorillaws "github.com/gorilla/websocket"
|
||||
"github.com/kandev/kandev/internal/auth/authn"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func TestReadPumpCloseLogging(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "normal", code: gorillaws.CloseNormalClosure},
|
||||
{name: "unexpected", code: gorillaws.CloseInternalServerErr, wantError: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
core, recorded := observer.New(zap.DebugLevel)
|
||||
log, err := logger.NewFromZap(zap.New(core, zap.AddStacktrace(zap.ErrorLevel)))
|
||||
if err != nil {
|
||||
t.Fatalf("create logger: %v", err)
|
||||
}
|
||||
hub := NewHub(nil, log)
|
||||
hubCtx, cancelHub := context.WithCancel(context.Background())
|
||||
hubDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(hubDone)
|
||||
hub.Run(hubCtx)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
cancelHub()
|
||||
joinWithin(t, hubDone, "websocket hub")
|
||||
})
|
||||
|
||||
browser, server := newTerminalWSPair(t)
|
||||
client := NewClient("close-test", authn.Identity{}, server, hub, log)
|
||||
hub.Register(client)
|
||||
waitForClientCount(t, hub, client.ID)
|
||||
|
||||
pumpDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(pumpDone)
|
||||
client.ReadPump(context.Background())
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
_ = browser.Close()
|
||||
_ = server.Close()
|
||||
joinWithin(t, pumpDone, "read pump")
|
||||
})
|
||||
|
||||
if err := browser.WriteControl(
|
||||
gorillaws.CloseMessage,
|
||||
gorillaws.FormatCloseMessage(test.code, "test close"),
|
||||
time.Now().Add(wsTestTimeout),
|
||||
); err != nil {
|
||||
t.Fatalf("write close frame: %v", err)
|
||||
}
|
||||
joinWithin(t, pumpDone, "read pump")
|
||||
|
||||
records := recorded.FilterMessage("WebSocket read error").All()
|
||||
wantRecords := 0
|
||||
if test.wantError {
|
||||
wantRecords = 1
|
||||
}
|
||||
if len(records) != wantRecords {
|
||||
t.Fatalf("read error records = %d, want %d; records = %+v", len(records), wantRecords, records)
|
||||
}
|
||||
if !test.wantError {
|
||||
return
|
||||
}
|
||||
if records[0].Stack == "" {
|
||||
t.Fatal("unexpected close error did not include a stack trace")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -278,48 +278,29 @@ func (s *Service) addBackendFiles(
|
||||
manifestFiles := make([]backendArchiveFile, 0, len(candidates))
|
||||
included := false
|
||||
for _, candidate := range candidates {
|
||||
info, err := os.Lstat(candidate.Path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, included, partial, warnings, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
if remaining == 0 {
|
||||
partial = true
|
||||
warnings = appendUnique(warnings, "backend logs were truncated to the archive byte limit")
|
||||
continue
|
||||
}
|
||||
length := min(info.Size(), remaining)
|
||||
offset := info.Size() - length
|
||||
if length < info.Size() {
|
||||
file, ok, err := addBackendCandidate(writer, candidate, remaining, openBackendCandidate)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
partial = true
|
||||
warnings = appendUnique(warnings, "backend logs changed during collection")
|
||||
continue
|
||||
}
|
||||
return nil, included, partial, warnings, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if file.Length < file.Size {
|
||||
partial = true
|
||||
warnings = appendUnique(warnings, "backend logs were truncated to the archive byte limit")
|
||||
}
|
||||
source, err := os.Open(candidate.Path)
|
||||
if err != nil {
|
||||
return nil, included, partial, warnings, err
|
||||
}
|
||||
header := &zip.FileHeader{Name: "backend/" + candidate.Name, Method: zip.Store}
|
||||
header.SetMode(0o600)
|
||||
destination, err := writer.CreateHeader(header)
|
||||
if err == nil {
|
||||
err = copySection(destination, source, offset, length)
|
||||
}
|
||||
closeErr := source.Close()
|
||||
if err != nil {
|
||||
return nil, included, partial, warnings, err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, included, partial, warnings, closeErr
|
||||
}
|
||||
manifestFiles = append(manifestFiles, backendArchiveFile{
|
||||
Name: candidate.Name, Size: info.Size(), Offset: offset, Length: length,
|
||||
})
|
||||
remaining -= length
|
||||
manifestFiles = append(manifestFiles, file)
|
||||
remaining -= file.Length
|
||||
included = true
|
||||
}
|
||||
if !included {
|
||||
@@ -329,24 +310,40 @@ func (s *Service) addBackendFiles(
|
||||
return manifestFiles, included, partial, warnings, nil
|
||||
}
|
||||
|
||||
type backendCandidate struct {
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
func (s *Service) backendCandidates() []backendCandidate {
|
||||
logDir := filepath.Join(s.config.HomeDir, "logs")
|
||||
now := s.config.Now().UTC()
|
||||
names := []string{"backend-logs.log"}
|
||||
for daysAgo := 1; daysAgo <= 2; daysAgo++ {
|
||||
date := now.AddDate(0, 0, -daysAgo).Format("2006-01-02")
|
||||
names = append(names, "backend-logs-"+date+".log")
|
||||
func addBackendCandidate(
|
||||
writer *zip.Writer, candidate backendCandidate, budget int64,
|
||||
open func(string) (*os.File, error),
|
||||
) (backendArchiveFile, bool, error) {
|
||||
source, err := open(candidate.Path)
|
||||
if err != nil {
|
||||
return backendArchiveFile{}, false, err
|
||||
}
|
||||
out := make([]backendCandidate, 0, len(names))
|
||||
for _, name := range names {
|
||||
out = append(out, backendCandidate{Name: name, Path: filepath.Join(logDir, name)})
|
||||
info, err := source.Stat()
|
||||
if err != nil {
|
||||
_ = source.Close()
|
||||
return backendArchiveFile{}, false, err
|
||||
}
|
||||
return out
|
||||
if !info.Mode().IsRegular() {
|
||||
return backendArchiveFile{}, false, source.Close()
|
||||
}
|
||||
length := min(info.Size(), budget)
|
||||
offset := info.Size() - length
|
||||
header := &zip.FileHeader{Name: "backend/" + candidate.Name, Method: zip.Store}
|
||||
header.SetMode(0o600)
|
||||
destination, err := writer.CreateHeader(header)
|
||||
if err == nil {
|
||||
err = copySection(destination, source, offset, length)
|
||||
}
|
||||
closeErr := source.Close()
|
||||
if err != nil {
|
||||
return backendArchiveFile{}, false, err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return backendArchiveFile{}, false, closeErr
|
||||
}
|
||||
return backendArchiveFile{
|
||||
Name: candidate.Name, Size: info.Size(), Offset: offset, Length: length,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func addFrontendFiles(writer *zip.Writer, item *job) ([]frontendArchiveFile, error) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package logbundle
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
type backendCandidate struct {
|
||||
Name string
|
||||
Path string
|
||||
Day string
|
||||
Sequence int
|
||||
Active bool
|
||||
Legacy bool
|
||||
}
|
||||
|
||||
func (s *Service) backendCandidates() []backendCandidate {
|
||||
logDir := filepath.Join(s.config.HomeDir, "logs")
|
||||
entries, err := os.ReadDir(logDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
today := s.config.Now().UTC()
|
||||
todayDay := today.Format(time.DateOnly)
|
||||
candidates := make([]backendCandidate, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
parsed, ok := logger.ParseBackendLogFilename(entry.Name())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(logDir, entry.Name())
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
day := parsed.Day
|
||||
if parsed.Active {
|
||||
day = todayDay
|
||||
}
|
||||
if !logger.BackendLogDayRetained(day, today) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, backendCandidate{
|
||||
Name: entry.Name(), Path: path, Day: day, Sequence: parsed.Sequence,
|
||||
Active: parsed.Active, Legacy: parsed.Legacy,
|
||||
})
|
||||
}
|
||||
slices.SortFunc(candidates, compareBackendCandidates)
|
||||
return candidates
|
||||
}
|
||||
|
||||
func compareBackendCandidates(left, right backendCandidate) int {
|
||||
if left.Day != right.Day {
|
||||
if left.Day > right.Day {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
leftRank, rightRank := backendCandidateRank(left), backendCandidateRank(right)
|
||||
if leftRank != rightRank {
|
||||
if leftRank > rightRank {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if left.Name < right.Name {
|
||||
return -1
|
||||
}
|
||||
if left.Name > right.Name {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func backendCandidateRank(candidate backendCandidate) int {
|
||||
if candidate.Active {
|
||||
return int(^uint(0) >> 1)
|
||||
}
|
||||
if candidate.Legacy {
|
||||
return -1
|
||||
}
|
||||
return candidate.Sequence
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package logbundle
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackendCandidatesDiscoverNewestSegments(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
logDir := filepath.Join(home, "logs")
|
||||
if err := os.MkdirAll(logDir, 0o700); err != nil {
|
||||
t.Fatalf("create log directory: %v", err)
|
||||
}
|
||||
for name, contents := range map[string]string{
|
||||
"backend-logs.log": "active",
|
||||
"backend-logs-2026-08-22-000001.log": "first",
|
||||
"backend-logs-2026-08-22-000002.log": "latest",
|
||||
"backend-logs-2026-08-23-000001.log": "future",
|
||||
"backend-logs-2026-08-21.log": "legacy",
|
||||
"backend-logs-2026-08-19-000001.log": "expired",
|
||||
"backend-logs-not-a-segment.log": "malformed",
|
||||
"backend-logs-2026-08-22-000003.log.tmp": "temporary",
|
||||
} {
|
||||
writeTestFile(t, filepath.Join(logDir, name), contents)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(logDir, "notes.log"), "private")
|
||||
if err := os.Symlink(filepath.Join(logDir, "notes.log"), filepath.Join(logDir, "backend-logs-2026-08-22-000004.log")); err != nil {
|
||||
t.Fatalf("create symlink: %v", err)
|
||||
}
|
||||
|
||||
service := newTestService(t, Config{
|
||||
HomeDir: home,
|
||||
Now: func() time.Time { return time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
candidates := service.backendCandidates()
|
||||
if got, want := len(candidates), 4; got != want {
|
||||
t.Fatalf("candidate count = %d, want %d", got, want)
|
||||
}
|
||||
want := []string{
|
||||
"backend-logs.log",
|
||||
"backend-logs-2026-08-22-000002.log",
|
||||
"backend-logs-2026-08-22-000001.log",
|
||||
"backend-logs-2026-08-21.log",
|
||||
}
|
||||
for index, candidate := range candidates {
|
||||
if candidate.Name != want[index] {
|
||||
t.Fatalf("candidate[%d] = %q, want %q", index, candidate.Name, want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendOnlyBundleKeepsNewestSegmentsWithinBudget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
logDir := filepath.Join(home, "logs")
|
||||
if err := os.MkdirAll(logDir, 0o700); err != nil {
|
||||
t.Fatalf("create log directory: %v", err)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(logDir, "backend-logs.log"), "latest\n")
|
||||
writeTestFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000002.log"), "second\n")
|
||||
writeTestFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000001.log"), "first\n")
|
||||
writeTestFile(t, filepath.Join(logDir, "backend-logs-2026-08-21.log"), "yesterday\n")
|
||||
service := newTestService(t, Config{
|
||||
HomeDir: home,
|
||||
Now: func() time.Time { return time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
|
||||
var archive bytes.Buffer
|
||||
writer := zip.NewWriter(&archive)
|
||||
files, included, partial, warnings, err := service.addBackendFiles(writer, false, nil, int64(len("latest\nsecond\n")))
|
||||
if err != nil {
|
||||
t.Fatalf("add backend files: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close archive: %v", err)
|
||||
}
|
||||
if !included || !partial || len(warnings) == 0 || len(files) != 2 {
|
||||
t.Fatalf("files=%#v included=%v partial=%v warnings=%v", files, included, partial, warnings)
|
||||
}
|
||||
reader, err := zip.NewReader(bytes.NewReader(archive.Bytes()), int64(archive.Len()))
|
||||
if err != nil {
|
||||
t.Fatalf("read archive: %v", err)
|
||||
}
|
||||
contents := make(map[string]string)
|
||||
for _, file := range reader.File {
|
||||
if file.Name == "manifest.json" {
|
||||
continue
|
||||
}
|
||||
opened, err := file.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", file.Name, err)
|
||||
}
|
||||
data, err := io.ReadAll(opened)
|
||||
_ = opened.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", file.Name, err)
|
||||
}
|
||||
contents[file.Name] = string(data)
|
||||
}
|
||||
if contents["backend/backend-logs.log"] != "latest\n" || contents["backend/backend-logs-2026-08-22-000002.log"] != "second\n" {
|
||||
t.Fatalf("newest archive contents = %#v", contents)
|
||||
}
|
||||
if _, ok := contents["backend/backend-logs-2026-08-22-000001.log"]; ok {
|
||||
t.Fatal("old segment was included after the archive budget was full")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendCandidateCollectionUsesOpenedHandleAcrossRotation(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
logDir := filepath.Join(home, "logs")
|
||||
if err := os.MkdirAll(logDir, 0o700); err != nil {
|
||||
t.Fatalf("create log directory: %v", err)
|
||||
}
|
||||
path := filepath.Join(logDir, "backend-logs.log")
|
||||
writeTestFile(t, path, "old-content")
|
||||
service := newTestService(t, Config{
|
||||
HomeDir: home,
|
||||
Now: func() time.Time { return time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
candidates := service.backendCandidates()
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("candidate count = %d, want 1", len(candidates))
|
||||
}
|
||||
|
||||
var archive bytes.Buffer
|
||||
writer := zip.NewWriter(&archive)
|
||||
rotatedPath := path + ".rotated"
|
||||
open := func(candidatePath string) (*os.File, error) {
|
||||
source, err := openBackendCandidate(candidatePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Rename(candidatePath, rotatedPath); err != nil {
|
||||
_ = source.Close()
|
||||
return nil, err
|
||||
}
|
||||
writeTestFile(t, candidatePath, "new")
|
||||
return source, nil
|
||||
}
|
||||
file, included, err := addBackendCandidate(writer, candidates[0], int64(len("old-content")), open)
|
||||
if err != nil {
|
||||
t.Fatalf("add backend candidate: %v", err)
|
||||
}
|
||||
if !included || file.Length != int64(len("old-content")) {
|
||||
t.Fatalf("file=%#v included=%v", file, included)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close archive: %v", err)
|
||||
}
|
||||
|
||||
reader, err := zip.NewReader(bytes.NewReader(archive.Bytes()), int64(archive.Len()))
|
||||
if err != nil {
|
||||
t.Fatalf("read archive: %v", err)
|
||||
}
|
||||
opened, err := reader.File[0].Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open archived log: %v", err)
|
||||
}
|
||||
contents, err := io.ReadAll(opened)
|
||||
_ = opened.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read archived log: %v", err)
|
||||
}
|
||||
if got := string(contents); got != "old-content" {
|
||||
t.Fatalf("archived contents = %q, want old-content", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build !windows
|
||||
|
||||
package logbundle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func openBackendCandidate(path string) (*os.File, error) {
|
||||
fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
if errors.Is(err, unix.ELOOP) {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return os.NewFile(uintptr(fd), path), nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build windows
|
||||
|
||||
package logbundle
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func openBackendCandidate(path string) (*os.File, error) {
|
||||
handle, err := windows.CreateFile(
|
||||
windows.StringToUTF16Ptr(path), windows.GENERIC_READ,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
nil, windows.OPEN_EXISTING, windows.FILE_FLAG_OPEN_REPARSE_POINT, 0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.NewFile(uintptr(handle), path), nil
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
# ADR-2026-07-30-file-backed-diagnostic-bundles: File-backed diagnostic bundles
|
||||
|
||||
**Status:** accepted
|
||||
**Status:** accepted (backend file retention amended by ADR-2026-08-22-preserve-newest-bounded-backend-logs)
|
||||
**Date:** 2026-07-30
|
||||
**Area:** backend, frontend, infra, protocol, workflow
|
||||
|
||||
The 2026-08-22 amendment replaces the stop-at-256-MiB behavior with bounded
|
||||
size segments and one 256 MiB budget across retained backend logs. The original
|
||||
bundle, privacy, UTC-day, and maximum-age decisions remain accepted.
|
||||
|
||||
## Context
|
||||
|
||||
Frontend error toasts and console failures can be the only user-visible
|
||||
@@ -126,10 +130,10 @@ does not mistake a best-effort bundle for complete evidence. Diagnostic bundle
|
||||
requests can be temporarily busy or coalesced instead of competing with normal
|
||||
product activity.
|
||||
|
||||
The fixed daily file bound limits worst-case disk growth but can omit later
|
||||
same-day evidence after sustained pressure. Count and byte limits reduce how
|
||||
quickly automatic browser reports consume that budget, while UTC rollover
|
||||
restores capacity without operator action.
|
||||
The aggregate backend-log budget limits worst-case disk growth while oldest
|
||||
closed segments are evicted first, so later evidence remains available during
|
||||
sustained pressure. Count and byte limits reduce how quickly automatic browser
|
||||
reports consume that budget, while UTC age cleanup removes stale evidence.
|
||||
|
||||
Fixed byte/profile caps mean a very large retained backend file or a fifth
|
||||
browser profile may be represented only partially. Newest backend bytes are
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# ADR-2026-08-22-preserve-newest-bounded-backend-logs: Preserve Newest Bounded Backend Logs
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-08-22
|
||||
**Area:** backend, infra, operations
|
||||
|
||||
## Context
|
||||
|
||||
The backend stops file logging after the active UTC-day file reaches 256 MiB.
|
||||
This behavior bounds disk use, but it preserves the earliest entries and
|
||||
removes later diagnostic evidence.
|
||||
|
||||
Issue #2929 exposed this problem through repeated normal WebSocket close
|
||||
errors. A local debug instance reached the same limit through normal debug
|
||||
traffic. In both cases, the useful evidence occurred after the file stopped.
|
||||
|
||||
## Decision
|
||||
|
||||
Kandev keeps at most 256 MiB across all retained backend log segments. The
|
||||
three-day UTC window is a maximum age, not a guaranteed allocation for each
|
||||
day. Quiet installations can retain three days. Busy installations retain a
|
||||
shorter newest-first window within the same total budget.
|
||||
|
||||
The active file remains `backend-logs.log`. It accepts at most 16 MiB. Before
|
||||
the next entry exceeds that limit, the logger closes the file as
|
||||
`backend-logs-YYYY-MM-DD-NNNNNN.log` and opens a new active file.
|
||||
|
||||
Closed segment sequence numbers increase during one UTC day. The logger
|
||||
removes the oldest closed segments across retained days when all recognized
|
||||
backend logs need more than 256 MiB. The active file and newest closed segments
|
||||
remain available.
|
||||
|
||||
At UTC midnight, the logger closes the active file as the final segment for
|
||||
the prior day. It opens a new active file for the new day and removes segments
|
||||
outside the current-plus-two-day window.
|
||||
|
||||
The segment size, total budget, UTC boundary, and maximum age are not
|
||||
configurable. Files remain owner-only on supported Unix systems.
|
||||
|
||||
Restarts reconstruct the current day, next sequence, and total retained bytes
|
||||
from recognized files and the day marker. Existing singleton files named
|
||||
`backend-logs-YYYY-MM-DD.log` remain valid inputs for budget cleanup and
|
||||
diagnostic bundles.
|
||||
|
||||
An upgrade can find an active file from the old format that is larger than one
|
||||
segment. The logger converts its newest content into bounded segments before
|
||||
it continues normal writes. This conversion and all rotations are
|
||||
crash-recoverable.
|
||||
|
||||
Diagnostic bundles discover the active file, numbered segments, and legacy
|
||||
singleton files. They select candidates newest-first and retain their existing
|
||||
source budgets and manifest byte-range reporting.
|
||||
|
||||
This decision amends only the backend file-retention policy in
|
||||
[ADR-2026-07-30-file-backed-diagnostic-bundles](2026-07-30-file-backed-diagnostic-bundles.md).
|
||||
The bundle, privacy, and access decisions remain unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
Backend logging continues during sustained traffic. A failure that occurs late
|
||||
in a busy day remains available until newer traffic replaces its segment.
|
||||
|
||||
High volume can retain less than one day of evidence. Low volume can retain up
|
||||
to three days. Retained backend log data uses at most 256 MiB in steady state,
|
||||
which reduces the former three-day maximum by two thirds.
|
||||
|
||||
One day can contain multiple retained log files instead of one file. Diagnostic
|
||||
collection and manual searches must recognize and order these segments.
|
||||
|
||||
Rotation adds filesystem rename, open, cleanup, and directory-scan operations.
|
||||
These operations run on the asynchronous file-sink path and do not block
|
||||
application producers. File-sink queue limits still apply during slow storage
|
||||
operations.
|
||||
|
||||
The first upgrade from the single-file format can copy bounded tail data once.
|
||||
The conversion needs temporary disk space of at most one segment and crash
|
||||
recovery.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Keep the hard daily stop.** Rejected because it removes the newest evidence
|
||||
after sustained traffic.
|
||||
- **Create unlimited size-rotated files.** Rejected because one busy day can
|
||||
consume unbounded disk space.
|
||||
- **Use one 256 MiB rolling file.** Rejected because removing an old prefix
|
||||
requires large repeated copies or a non-text circular format.
|
||||
- **Keep 256 MiB for each retained day.** Rejected because rolling segments do
|
||||
not need a reserved budget for each day. This option can use approximately
|
||||
768 MiB.
|
||||
- **Use a smaller global budget.** Rejected because 256 MiB matches the old
|
||||
single-file ceiling and retains more busy-period evidence without preserving
|
||||
the old three-file maximum.
|
||||
- **Use a generic rotation library.** Rejected because the existing contract
|
||||
requires UTC-day ownership, legacy discovery, owner-only files, and bundle
|
||||
ordering.
|
||||
@@ -102,7 +102,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 2026-07-29-interactive-accent-surface-semantics | [Separate Brand Accent from Interactive Surface Fills](2026-07-29-interactive-accent-surface-semantics.md) | accepted | frontend | 2026-07-29 |
|
||||
| 2026-07-30-embedded-editor-executor-capabilities | [Derive Embedded Editor Availability from the Active Executor](2026-07-30-embedded-editor-executor-capabilities.md) | accepted | backend, frontend, protocol | 2026-07-30 |
|
||||
| 2026-07-30-session-owned-mcp-observability | [Keep MCP Attachment Evidence Session Owned](2026-07-30-session-owned-mcp-observability.md) | accepted | backend, frontend, protocol, security | 2026-07-30 |
|
||||
| 2026-07-30-file-backed-diagnostic-bundles | [File-backed diagnostic bundles](2026-07-30-file-backed-diagnostic-bundles.md) | accepted | backend, frontend, infra, protocol, workflow | 2026-07-30 |
|
||||
| 2026-07-30-file-backed-diagnostic-bundles | [File-backed diagnostic bundles](2026-07-30-file-backed-diagnostic-bundles.md) | accepted (backend file retention amended by 2026-08-22) | backend, frontend, infra, protocol, workflow | 2026-07-30 |
|
||||
| 2026-07-30-runtime-task-state-before-running-event | [Publish Task State Before Running Session State](2026-07-30-runtime-task-state-before-running-event.md) | accepted | backend, frontend, protocol, workflow | 2026-07-30 |
|
||||
| 2026-07-31-authenticated-plugin-actions | [Authenticated Plugin Actions](2026-07-31-authenticated-plugin-actions.md) | accepted | backend, frontend, protocol, security | 2026-07-31 |
|
||||
| 2026-07-31-plugin-repository-provider-extensions | [Plugin Repository Provider Extensions](2026-07-31-plugin-repository-provider-extensions.md) | accepted | frontend, backend, protocol | 2026-07-31 |
|
||||
@@ -191,6 +191,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 2026-08-20-acp-client-non-underscore-extension-methods | [Route Non-Underscore Inbound Client Methods to the Extension Handler](2026-08-20-acp-client-non-underscore-extension-methods.md) | accepted | backend, protocol | 2026-08-20 |
|
||||
| 2026-08-22-persistent-fork-approval-labels | [Persist Fork Approval Labels Across Pushes](2026-08-22-persistent-fork-approval-labels.md) | accepted | infra, workflow, security | 2026-08-22 |
|
||||
| 2026-08-22-plugin-version-retention | [Keep Exactly One Superseded Plugin Version](2026-08-22-plugin-version-retention.md) | accepted | backend | 2026-08-22 |
|
||||
| 2026-08-22-preserve-newest-bounded-backend-logs | [Preserve Newest Bounded Backend Logs](2026-08-22-preserve-newest-bounded-backend-logs.md) | accepted | backend, infra, operations | 2026-08-22 |
|
||||
| 2026-08-12-plugin-webhook-auth-gate | [Require Auth for Plugin Webhooks Unless the Manifest Declares Them Public](2026-08-12-plugin-webhook-auth-gate.md) | accepted | backend, frontend, security | 2026-08-12 |
|
||||
| 2026-08-22-system-oriented-specifications | [Organize specifications by system](2026-08-22-system-oriented-specifications.md) | accepted | workflow, infra | 2026-08-22 |
|
||||
| 2026-08-22-pr-walkthrough-r2-hosting | [Host PR walkthrough HTML in Cloudflare R2](2026-08-22-pr-walkthrough-r2-hosting.md) | accepted | infra, workflow, security | 2026-08-22 |
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
spec: docs/specs/platform/diagnostic-logging.md
|
||||
created: 2026-08-22
|
||||
status: done
|
||||
---
|
||||
|
||||
# Implementation Plan: Bounded Rolling Backend Logs
|
||||
|
||||
## Overview
|
||||
|
||||
Issue [#2929](https://github.com/kdlbs/kandev/issues/2929) exposed two related
|
||||
diagnostic failures. The gateway logs normal WebSocket close code `1000` as an
|
||||
error with a stack trace. The backend then stops file logging after the current
|
||||
UTC-day file reaches 256 MiB, so later failures are absent from diagnostics.
|
||||
|
||||
The repair classifies normal closes correctly and replaces the daily hard stop
|
||||
with bounded size segments. All retained backend logs share one 256 MiB budget.
|
||||
Three UTC days is a maximum age, not a reserved budget for each day. A 16 MiB
|
||||
active segment and oldest-segment eviction preserve the newest evidence while
|
||||
reducing the former maximum by two thirds.
|
||||
|
||||
## Confirmed Root Causes
|
||||
|
||||
`Client.ReadPump` calls `websocket.IsUnexpectedCloseError` in
|
||||
`apps/backend/internal/gateway/websocket/client.go`. Its expected-code list
|
||||
omits `websocket.CloseNormalClosure` (`1000`). Gorilla therefore classifies a
|
||||
normal browser close as unexpected, and the error-level Zap entry includes a
|
||||
stack trace.
|
||||
|
||||
`dailyWriter.Write` in
|
||||
`apps/backend/internal/common/logger/daily_writer.go` returns
|
||||
`errDailyBackendLogLimit` when the active file reaches 256 MiB. The retrying
|
||||
sink does not rotate or reactivate for this error. File logging stays stopped
|
||||
until the UTC day changes.
|
||||
|
||||
A focused real WebSocket reproduction confirmed one error entry and stack for
|
||||
code `1000`. The existing writer regression confirmed the hard daily stop. The
|
||||
local debug instance also reached 256 MiB without this specific error burst,
|
||||
which shows that the retention failure is independent of the reported noise.
|
||||
|
||||
## Backend Design
|
||||
|
||||
### Gateway close classification
|
||||
|
||||
- Add `websocket.CloseNormalClosure` to the expected close-code list in
|
||||
`Client.ReadPump`.
|
||||
- Keep the current expected handling for codes `1001`, `1005`, and `1006`.
|
||||
- Keep error-level stack traces for close codes outside the expected list.
|
||||
|
||||
### Segment storage
|
||||
|
||||
- Keep `backend-logs.log` as the active file and cap it at 16 MiB.
|
||||
- Close full segments as `backend-logs-YYYY-MM-DD-NNNNNN.log` with an
|
||||
increasing six-digit sequence for each UTC day.
|
||||
- Keep at most 256 MiB across the active file and all retained closed segments.
|
||||
- Remove the oldest closed segments across retained days before opening space
|
||||
for newer entries.
|
||||
- At UTC midnight, close the prior day's active segment and start a new active
|
||||
file for the current day.
|
||||
- Reconstruct the active day, retained bytes, and next sequence at startup.
|
||||
- Keep legacy `backend-logs-YYYY-MM-DD.log` files eligible for retention and
|
||||
bundles. Count them toward the global budget.
|
||||
- Convert an oversized active file from the old format into bounded segments,
|
||||
preserving its newest content before accepting new writes. Process outputs
|
||||
newest-first, sync each output, truncate the source, and journal progress so
|
||||
migration I/O stays linear.
|
||||
- Preserve owner-only file permissions and crash recovery for size rotation,
|
||||
day rotation, and legacy conversion.
|
||||
- Keep segment metadata and filesystem operations in a focused companion file
|
||||
so the writer remains within backend function and file-size limits.
|
||||
|
||||
Rotation errors use the existing retrying sink behavior. The writer preserves
|
||||
the existing files, does not exceed the total budget, records sink loss, and
|
||||
retries activation after 30 seconds. The old permanent
|
||||
`errDailyBackendLogLimit` path is removed.
|
||||
|
||||
### Diagnostic bundles
|
||||
|
||||
- Discover the active file, numbered segments, and legacy singleton files.
|
||||
- Restrict candidates to the current UTC day and the two preceding UTC days.
|
||||
- Order candidates by day, sequence, and active-file recency from newest to
|
||||
oldest before applying the existing archive source budget.
|
||||
- Keep the current symlink, regular-file, byte-range, manifest, and privacy
|
||||
rules. Open each candidate once without following the final path component,
|
||||
use the opened handle for size and copying, and mark a disappeared candidate
|
||||
as partial.
|
||||
|
||||
## Tests
|
||||
|
||||
Implementation follows red-green-refactor in each task.
|
||||
|
||||
- Gateway integration tests send real normal and unexpected close frames and
|
||||
inspect an observed Zap core.
|
||||
- Writer tests use small injected segment and total limits. They cover size
|
||||
rotation, continued writes after a full budget, cross-day oldest eviction, a
|
||||
later marker remaining readable, UTC rollover, restart sequencing, maximum
|
||||
age, legacy input, oversized active-file conversion, and crash recovery.
|
||||
- Failure tests verify that rename, removal, or open failures do not replace
|
||||
existing files or exceed the configured test budget.
|
||||
- Bundle tests cover numbered-segment discovery, newest-first ordering, legacy
|
||||
compatibility, archive truncation, symlink exclusion, future dates, and a
|
||||
rotation during collection.
|
||||
|
||||
No browser end-to-end test is required. The behavior changes only backend log
|
||||
classification, storage, and collection.
|
||||
|
||||
## Public Documentation
|
||||
|
||||
The implementation updates the backend log-retention descriptions in:
|
||||
|
||||
- `docs/public/operations.md`
|
||||
- `docs/public/configuration.md`
|
||||
- `docs/public/docker.md`
|
||||
- `docs/public/k8s.md`
|
||||
|
||||
The pages must explain that high-volume days keep the newest bounded window,
|
||||
list the active and numbered filenames, and preserve the existing disk-budget
|
||||
guidance. `docs/public/cli.md` needs no change because its current text only
|
||||
identifies the log directory.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- `cd apps/backend && go test ./internal/gateway/websocket -run
|
||||
TestReadPumpCloseLogging -count=1` passed.
|
||||
- `cd apps/backend && go test ./internal/common/logger -run
|
||||
'TestDailyWriter|TestBackendLogSegment|TestRetryDailyWriter' -count=1` passed.
|
||||
- `cd apps/backend && go test ./internal/system/logbundle -run
|
||||
'TestBackendOnlyBundle|TestBackendCandidates' -count=1` passed.
|
||||
- `node --test scripts/validate-public-docs.test.mjs` passed all 61 tests and
|
||||
`node scripts/validate-public-docs.mjs` validated 41 pages.
|
||||
- Affected package tests passed: logger 31, WebSocket 520, logbundle 22.
|
||||
- `golangci-lint run ./... --new-from-rev="origin/main" --timeout=5m` passed
|
||||
with no issues. Changed Go files are gofmt-clean.
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
Wave 1:
|
||||
|
||||
- [x] [Task 01: Classify normal WebSocket closes](task-01-classify-normal-websocket-closes.md)
|
||||
- [x] [Task 02: Implement bounded log segments](task-02-implement-bounded-log-segments.md)
|
||||
|
||||
Tasks 01 and 02 touch separate packages and are parallel candidates. The
|
||||
default execution order remains sequential unless the user authorizes
|
||||
delegation.
|
||||
|
||||
Wave 2, after Task 02:
|
||||
|
||||
- [x] [Task 03: Extend diagnostic bundle discovery](task-03-extend-diagnostic-bundle-discovery.md)
|
||||
|
||||
Wave 3, after Tasks 02 and 03:
|
||||
|
||||
- [x] [Task 04: Update public logging documentation](task-04-update-public-logging-documentation.md)
|
||||
|
||||
## Risks And Out Of Scope
|
||||
|
||||
- The close-code change must not suppress unexpected protocol or application
|
||||
closes.
|
||||
- Upgrade conversion must survive interruption without duplicating, replacing,
|
||||
or losing the newest bounded content.
|
||||
- Segment discovery must ignore unrelated files, malformed names, directories,
|
||||
and symlinks.
|
||||
- Whole-segment eviction can retain slightly less than 256 MiB. It must never
|
||||
retain more than the total budget in steady state.
|
||||
- This repair does not change browser reconnection behavior.
|
||||
- This repair does not increase the 256 MiB total budget or three-day maximum
|
||||
age.
|
||||
- This repair does not add a user-configurable rotation policy.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
id: "01-classify-normal-websocket-closes"
|
||||
title: "Classify normal WebSocket closes"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/platform/diagnostic-logging.md"
|
||||
---
|
||||
|
||||
# Task 01: Classify Normal WebSocket Closes
|
||||
|
||||
## Intent
|
||||
|
||||
Stop error-level stack traces for WebSocket close code `1000`. Keep the
|
||||
existing diagnostic signal for unexpected close codes.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `Client.ReadPump` treats `websocket.CloseNormalClosure` as an expected close.
|
||||
- A real code-`1000` close produces no `WebSocket read error` entry.
|
||||
- An unexpected close still produces one error entry with a stack trace.
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/backend/internal/gateway/websocket/client.go`
|
||||
- `apps/backend/internal/gateway/websocket/client_pump_close_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`parallel-safe` with Task 02. This task owns the two gateway files. Task 02
|
||||
owns only logger-package files. Execute sequentially unless the user authorizes
|
||||
delegation.
|
||||
|
||||
## Inputs
|
||||
|
||||
- The WebSocket close requirements in the diagnostic-logging specification.
|
||||
- The confirmed root cause in `plan.md`.
|
||||
- The real WebSocket fixture helpers in the websocket test package.
|
||||
- The observed-log pattern in `terminal_wsutil_test.go`.
|
||||
|
||||
## Implementation
|
||||
|
||||
1. Add a focused integration test for normal and unexpected close codes.
|
||||
2. Run the test and confirm that the normal-close case fails.
|
||||
3. Add `websocket.CloseNormalClosure` to the expected close-code list.
|
||||
4. Run the focused test again and refactor only if clarity improves.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/gateway/websocket -run TestReadPumpCloseLogging -count=1
|
||||
```
|
||||
|
||||
## Output Contract
|
||||
|
||||
Report the changed files, the exact test result, blockers, and remaining risks.
|
||||
Update this task and `plan.md` in the same conversation.
|
||||
|
||||
## Results
|
||||
|
||||
- Changed `apps/backend/internal/gateway/websocket/client.go` to treat close
|
||||
code `1000` as an expected lifecycle close.
|
||||
- Added `client_pump_close_test.go`, which drives real WebSocket close frames
|
||||
and checks both normal and unexpected logging behavior.
|
||||
- Verification: `cd apps/backend && go test ./internal/gateway/websocket
|
||||
-run TestReadPumpCloseLogging -count=1` passed.
|
||||
- Blockers: none.
|
||||
- Remaining risk: the writer and bundle tasks still need to preserve later
|
||||
diagnostics under sustained log volume.
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
id: "02-implement-bounded-log-segments"
|
||||
title: "Implement bounded log segments"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/platform/diagnostic-logging.md"
|
||||
---
|
||||
|
||||
# Task 02: Implement Bounded Log Segments
|
||||
|
||||
## Intent
|
||||
|
||||
Replace the stop-at-256-MiB writer with bounded segments that preserve the
|
||||
newest backend evidence within one 256 MiB total budget.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The active file rotates before it exceeds 16 MiB.
|
||||
- Writes continue after retained output exceeds 256 MiB. All recognized backend
|
||||
logs remain within the total budget, and later entries replace the oldest
|
||||
complete segments across days.
|
||||
- UTC rollover, restart, cleanup, legacy input, and interrupted operations
|
||||
preserve the specified data and filename contracts.
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/backend/internal/common/logger/daily_writer.go`
|
||||
- `apps/backend/internal/common/logger/daily_writer_test.go`
|
||||
- `apps/backend/internal/common/logger/backend_log_segments.go`
|
||||
- `apps/backend/internal/common/logger/backend_log_segments_test.go`
|
||||
- `apps/backend/internal/common/logger/retry_daily_writer.go`
|
||||
- `apps/backend/internal/common/logger/retry_daily_writer_test.go`
|
||||
|
||||
The companion segment files are new if they keep the existing writer within
|
||||
the backend complexity limits.
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`parallel-safe` with Task 01. This task owns only logger-package files. Task 01
|
||||
owns the gateway files. Task 03 depends on this task's filename and ordering
|
||||
contract.
|
||||
|
||||
## Inputs
|
||||
|
||||
- The storage requirements and scenarios in the diagnostic-logging
|
||||
specification.
|
||||
- The accepted bounded-log ADR.
|
||||
- The existing daily writer, retrying writer, rollover journal, and tests.
|
||||
|
||||
## Implementation
|
||||
|
||||
1. Parameterize limits in test fixtures and add failing cases for rotation,
|
||||
eviction, later-entry retention, restart, UTC rollover, cleanup, legacy
|
||||
conversion, and operation failure.
|
||||
2. Add strict parsing and sorting for active, numbered, and legacy files.
|
||||
3. Rotate the active file to an exclusive six-digit sequence name before its
|
||||
next entry exceeds the segment limit.
|
||||
4. Enforce the total budget by removing the oldest complete segments across
|
||||
retained days before newer writes continue.
|
||||
5. Reconstruct day, byte totals, and sequence state during startup without
|
||||
following symlinks or replacing existing files.
|
||||
6. Convert an oversized old-format active file by keeping its bounded tail and
|
||||
recording recoverable progress.
|
||||
7. Adapt the existing journal or add the smallest explicit transaction state
|
||||
needed for crash-safe size, day, and conversion operations.
|
||||
8. Remove the permanent daily-limit error branch from the retrying writer.
|
||||
Route filesystem failures through the existing 30-second retry and loss
|
||||
reporting behavior.
|
||||
9. Run focused tests, then refactor helpers to satisfy Go complexity limits.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/common/logger -run 'TestDailyWriter|TestBackendLogSegment|TestRetryDailyWriter' -count=1
|
||||
```
|
||||
|
||||
## Output Contract
|
||||
|
||||
Report the migration algorithm, crash-recovery invariant, changed files, exact
|
||||
test result, blockers, and remaining risks. Update this task and `plan.md` in
|
||||
the same conversation.
|
||||
|
||||
## Results
|
||||
|
||||
- Changed `daily_writer.go` and `backend_log_segments.go` to use 16 MiB active
|
||||
segments, a 256 MiB global budget, oldest-closed-segment eviction, UTC-day
|
||||
rollover, strict filename parsing, legacy-file retention, and bounded
|
||||
oversized-active-file conversion.
|
||||
- Conversion renames the source to an owner-only backup and processes retained
|
||||
outputs from newest to oldest. Each output is copied through a temporary
|
||||
file, synced, and atomically renamed. The backup is then truncated and the
|
||||
journal records the next output. This keeps migration I/O linear and lets
|
||||
recovery resume after a stale temporary file, a truncated source, or an
|
||||
interrupted journal write. Recovery checks completed outputs before it
|
||||
adopts a fresh active file. Atomic rename is used for normal size and day
|
||||
rotation, so completed segments are never replaced.
|
||||
- Retention accepts only dates from two UTC days before today through today.
|
||||
Future-dated segments are excluded from cleanup and bundle selection.
|
||||
- Changed `backend_logger.go` so filesystem and rotation errors use the normal
|
||||
30-second retry path instead of a permanent daily-limit stop.
|
||||
- Added writer, migration, restart, UTC rollover, global-eviction, legacy,
|
||||
and interrupted-conversion regression coverage.
|
||||
- Verification: `cd apps/backend && go test ./internal/common/logger -run
|
||||
'TestDailyWriter|TestBackendLogSegment|TestRetryDailyWriter' -count=1` passed.
|
||||
- Blockers: none.
|
||||
- Remaining risk: filesystem failures on platforms with unusual rename or
|
||||
permission semantics need CI coverage in addition to local Unix coverage.
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
id: "03-extend-diagnostic-bundle-discovery"
|
||||
title: "Extend diagnostic bundle discovery"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "02-implement-bounded-log-segments"
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/platform/diagnostic-logging.md"
|
||||
---
|
||||
|
||||
# Task 03: Extend Diagnostic Bundle Discovery
|
||||
|
||||
## Intent
|
||||
|
||||
Include the active file, numbered segments, and legacy singleton files in
|
||||
diagnostic bundles, with the newest evidence selected first.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Backend candidate discovery recognizes only valid retained log files.
|
||||
- Candidates are ordered newest-first across active, numbered, and legacy
|
||||
files before the existing archive budget is applied.
|
||||
- Existing symlink exclusion, tail truncation, manifest byte ranges, and
|
||||
source-budget behavior remain intact.
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/backend/internal/system/logbundle/archive.go`
|
||||
- `apps/backend/internal/system/logbundle/service_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 02 defines the final segment parser, filename, sequence, and retention
|
||||
contract. Reuse that parser through a narrow exported helper only if doing so
|
||||
does not couple bundle policy to writer state. Otherwise, mirror the strict
|
||||
filename grammar in the bundle package and cover it with table tests.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential` after Task 02. Task 04 depends on the final collection behavior.
|
||||
|
||||
## Inputs
|
||||
|
||||
- The bundle requirements in the diagnostic-logging specification.
|
||||
- The accepted bounded-log ADR.
|
||||
- The segment naming and ordering behavior delivered by Task 02.
|
||||
- The current archive candidate and safe-file-copy implementation.
|
||||
|
||||
## Implementation
|
||||
|
||||
1. Add failing bundle tests with active, numbered, legacy, malformed, and
|
||||
symlink candidates across retained and expired UTC days.
|
||||
2. Replace the fixed three-file candidate list with strict directory discovery.
|
||||
3. Sort valid candidates by UTC day and segment chronology from newest to
|
||||
oldest. Treat the active file as the newest current-day candidate.
|
||||
4. Feed the sorted candidates through the existing regular-file checks, tail
|
||||
truncation, source budget, archive naming, and manifest reporting.
|
||||
5. Verify that a late marker remains in a constrained bundle when older
|
||||
segments exceed the archive budget.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/system/logbundle -run 'TestBackendOnlyBundle|TestBackendCandidates' -count=1
|
||||
```
|
||||
|
||||
## Output Contract
|
||||
|
||||
Report the ordering rule, changed files, exact test result, blockers, and
|
||||
remaining risks. Update this task and `plan.md` in the same conversation.
|
||||
|
||||
## Results
|
||||
|
||||
- Changed `archive.go` and added `backend_candidates.go` plus focused candidate
|
||||
tests to discover valid active, numbered, and legacy files from the log
|
||||
directory.
|
||||
- Candidates are restricted to the current UTC day and two preceding days,
|
||||
sorted newest-first by day and segment sequence, and filtered with `Lstat`
|
||||
so symlinks and unrelated names are excluded.
|
||||
- Existing archive source budgets, tail copying, truncation warnings, and
|
||||
manifest byte ranges remain in the archive path. Each candidate opens once
|
||||
without following the final path component. The collector uses the opened
|
||||
handle for both `Fstat` and copying. A candidate that disappears before
|
||||
opening produces a partial bundle warning.
|
||||
- Added discovery-order, future-date, newest-within-budget, and deterministic
|
||||
rotation-during-collection bundle tests.
|
||||
- Verification: `cd apps/backend && go test ./internal/system/logbundle -run
|
||||
'TestBackendOnlyBundle|TestBackendCandidates' -count=1` passed.
|
||||
- Blockers: none.
|
||||
- Remaining risk: bundle collection still depends on the existing archive
|
||||
budget, so a constrained archive can be partial by design.
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
---
|
||||
id: "04-update-public-logging-documentation"
|
||||
title: "Update public logging documentation"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on:
|
||||
- "02-implement-bounded-log-segments"
|
||||
- "03-extend-diagnostic-bundle-discovery"
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/platform/diagnostic-logging.md"
|
||||
---
|
||||
|
||||
# Task 04: Update Public Logging Documentation
|
||||
|
||||
## Intent
|
||||
|
||||
Explain the bounded rolling behavior so operators know which evidence remains
|
||||
available during high-volume days and how much disk space Kandev can use.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Operations and deployment pages describe the active file, numbered segment
|
||||
names, 16 MiB segment bound, 256 MiB total budget, and three-day maximum age.
|
||||
- The text states that high volume keeps the newest bounded evidence and can
|
||||
shorten the available time window. Configuration text states that segment
|
||||
and retention settings are not configurable.
|
||||
- Public-document validation passes.
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `docs/public/operations.md`
|
||||
- `docs/public/configuration.md`
|
||||
- `docs/public/docker.md`
|
||||
- `docs/public/k8s.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tasks 02 and 03 must confirm the final filenames, byte limits, upgrade behavior,
|
||||
and bundle ordering before this task updates operator guidance.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential` after Tasks 02 and 03.
|
||||
|
||||
## Inputs
|
||||
|
||||
- The final writer and bundle behavior from Tasks 02 and 03.
|
||||
- The diagnostic-logging specification.
|
||||
- The accepted bounded-log ADR.
|
||||
- The repository public-document style and validation rules.
|
||||
|
||||
## Implementation
|
||||
|
||||
1. Update each existing backend-log section in place. Keep its current page
|
||||
type and operator focus.
|
||||
2. Use the same filenames, byte limits, UTC-day terms, and newest-evidence
|
||||
explanation on every page.
|
||||
3. Preserve existing permissions, deployment path, and disk-sizing guidance.
|
||||
4. Run the public-document validator and correct all reported issues.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
node scripts/validate-public-docs.mjs
|
||||
```
|
||||
|
||||
## Output Contract
|
||||
|
||||
Report the updated pages, exact validation results, blockers, and remaining
|
||||
risks. Update this task and `plan.md` in the same conversation.
|
||||
|
||||
## Results
|
||||
|
||||
- Updated `docs/public/operations.md`, `configuration.md`, `docker.md`, and
|
||||
`k8s.md` with the active and numbered filenames, 16 MiB segment bound,
|
||||
256 MiB total budget, three-day maximum age, and newest-evidence behavior.
|
||||
- Preserved deployment paths, persistence guidance, permissions, and existing
|
||||
disk-budget context.
|
||||
- Verification: `node --test scripts/validate-public-docs.test.mjs` passed all
|
||||
61 tests; `node scripts/validate-public-docs.mjs` validated 41 pages.
|
||||
- Blockers: none.
|
||||
- Remaining risk: none known for the documented contract.
|
||||
@@ -255,7 +255,9 @@ launch and is not an environment configuration source.
|
||||
| `logging.level` | `KANDEV_LOG_LEVEL` | `info` | File threshold: `debug`, `info`, `warn`, or `error`. `--debug` selects `debug`; normal and `--verbose` launches select `info`. |
|
||||
| `logging.format` | `KANDEV_LOGGING_FORMAT` | `text`, or `json` in production/Kubernetes | `text` or `json`; `auto` is not accepted. |
|
||||
|
||||
Every backend launch writes to `<home>/logs/backend-logs.log` and prints that resolved path at startup. The active file appends across same-day restarts and accepts at most 256 MiB; later entries are dropped until the next UTC day rather than allowing diagnostics to fill the disk. At the next UTC day it rolls to `backend-logs-YYYY-MM-DD.log`; Kandev retains the current UTC day and the two preceding days. Files are owner-only (`0600`) on Unix.
|
||||
Every backend launch writes to `<home>/logs/backend-logs.log` and prints that resolved path at startup. The active file appends across same-day restarts and accepts at most 16 MiB. Before a new entry would exceed that limit, Kandev closes the file as `backend-logs-YYYY-MM-DD-NNNNNN.log` and opens a new active file.
|
||||
|
||||
Active and closed backend files use at most 256 MiB in total. Kandev removes the oldest closed segments when needed, so high-volume periods keep the newest evidence instead of stopping file logging. Three UTC days is the maximum file age, not a reserved allocation for each day. The segment size, total budget, and maximum age are fixed and are not configurable. Legacy `backend-logs-YYYY-MM-DD.log` files remain readable during upgrades and count toward the total budget. Files are owner-only (`0600`) on Unix.
|
||||
|
||||
Normal launches write info and above to the file and warn and above to stdout. `--debug` writes debug and above to the file while stdout remains warn and above. `--verbose` writes info and above to both. The format default becomes JSON when `KUBERNETES_SERVICE_HOST` is non-empty or `KANDEV_ENV` is exactly `production`/`prod`; otherwise it is text.
|
||||
|
||||
|
||||
@@ -280,7 +280,7 @@ healthcheck:
|
||||
start_period: 15s
|
||||
```
|
||||
|
||||
The backend writes its daily files to `/data/logs/backend-logs.log` on the persistent volume and prints the path at startup. Each daily file accepts at most 256 MiB, and Kandev retains the current UTC day plus the two preceding days. `docker logs` shows the bounded stdout stream; use **Settings > System > Logs** for a frontend+backend diagnostic ZIP.
|
||||
The backend writes its active file to `/data/logs/backend-logs.log` on the persistent volume and prints the path at startup. Each segment accepts at most 16 MiB and is named `backend-logs-YYYY-MM-DD-NNNNNN.log` when it rolls. Active and closed backend files use at most 256 MiB in total. Kandev removes the oldest closed segments when needed, so high-volume periods keep the newest evidence. Three UTC days is the maximum file age. `docker logs` shows the bounded stdout stream; use **Settings > System > Logs** for a frontend+backend diagnostic ZIP.
|
||||
|
||||
## Upgrade and remove
|
||||
|
||||
|
||||
+1
-1
@@ -140,7 +140,7 @@ See [Configuration](configuration.md) for exact YAML and `KANDEV_` names. Import
|
||||
| `KANDEV_LOG_LEVEL` | `info` | Backend log threshold |
|
||||
| `KANDEV_DATABASE_DRIVER` | `sqlite` by default | Set `postgres` for an external database |
|
||||
|
||||
Kubernetes detection makes the default log format JSON. Kandev writes daily files under `/data/logs/`, caps each day at 256 MiB, and emits warn-and-above to stdout by default; ensure the home path is persistent if the three-day file history must survive pod replacement.
|
||||
Kubernetes detection makes the default log format JSON. Kandev writes the active file under `/data/logs/backend-logs.log`, closes it before an entry would exceed 16 MiB, and names the closed segment `backend-logs-YYYY-MM-DD-NNNNNN.log`. Active and closed backend files use at most 256 MiB in total. High-volume periods keep the newest evidence and can shorten the available history. Three UTC days is the maximum file age. Kandev emits warn-and-above to stdout by default; ensure the home path is persistent if the retained file history must survive pod replacement.
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
|
||||
@@ -359,7 +359,9 @@ Standard bundles do not read stored chat transcripts, session messages, or agent
|
||||
|
||||
ACP evidence is a separate, debug-only opt-in. It is limited to one to ten sessions the caller is authorized to view and may contain prompts, responses, tool calls, file contents, MCP data, environment-derived values, and secrets. Collection is on demand from retained host files or reachable executors; unavailable, expired, invalid, or truncated sessions make the archive partial instead of broadening the request or continuously copying ACP frames.
|
||||
|
||||
Every launch writes info and above to `<home>/logs/backend-logs.log`; normal and debug stdout show warn and above. `kandev --verbose` also shows info on stdout. `kandev --debug` records debug events in the file and enables ACP message dumps. The backend appends on same-day restarts, caps each daily file at 256 MiB, rolls at UTC midnight, and retains the current day plus the two previous UTC days.
|
||||
Every launch writes info and above to `<home>/logs/backend-logs.log`; normal and debug stdout show warn and above. `kandev --verbose` also shows info on stdout. `kandev --debug` records debug events in the file and enables ACP message dumps.
|
||||
|
||||
The active backend file accepts at most 16 MiB. Before a new entry would exceed that limit, Kandev closes it as `backend-logs-YYYY-MM-DD-NNNNNN.log` and opens a new active file. Active and closed backend files use at most 256 MiB in total. Kandev removes the oldest closed segments when needed, so logging continues and high-volume periods keep the newest evidence. Three UTC days is the maximum file age, not a reserved allocation for each day. Same-day restarts append to the active file, and UTC midnight starts a new active file.
|
||||
|
||||
The browser keeps a bounded three-day console history locally and sends it only when an authenticated bundle request asks for frontend evidence. Console calls are not continuously uploaded. Error toasts are reported immediately, but automatic reports remove URL query strings and fragments and use count and byte rate limits. A bundle can be partial if no browser responds, IndexedDB is unavailable, queues shed entries, or byte/profile limits truncate data. Ready bundles expire after 15 minutes; one active job per user and bounded global capacity can return `429` or `503`. Standard bundles cap backend/frontend/runtime sources at 160/80/2 MiB; ACP-inclusive bundles use 96/48/2 MiB plus 96 MiB ACP, with no budget transfer between sources.
|
||||
|
||||
|
||||
@@ -6,9 +6,8 @@ owner: tbd
|
||||
|
||||
# Diagnostic logging
|
||||
|
||||
Decision: [ADR-2026-07-30-file-backed-diagnostic-bundles](../../decisions/2026-07-30-file-backed-diagnostic-bundles.md)
|
||||
|
||||
Implementation plan: [diagnostic-logging](../../plans/diagnostic-logging/plan.md)
|
||||
Decisions: [bundles](../../decisions/2026-07-30-file-backed-diagnostic-bundles.md),
|
||||
[bounded logs](../../decisions/2026-08-22-preserve-newest-bounded-backend-logs.md).
|
||||
|
||||
## Why
|
||||
|
||||
@@ -34,28 +33,22 @@ history or returning an unbounded log export.
|
||||
remains `warn` and higher.
|
||||
- An explicitly verbose run writes `info` and higher entries to both the file
|
||||
and stdout.
|
||||
- Normal browser close code `1000` is lifecycle-only; unexpected codes remain
|
||||
error entries with stacks.
|
||||
- `logging.level` / `KANDEV_LOG_LEVEL` remains the supported override for the
|
||||
file threshold, and `logging.format` remains supported.
|
||||
`logging.outputPath`, `logging.maxSizeMb`, `logging.maxBackups`,
|
||||
`logging.maxAgeDays`, and `logging.compress` are removed; the diagnostic
|
||||
path and daily retention policy are not configurable.
|
||||
- `backend-logs.log` represents the current UTC calendar day. At UTC midnight,
|
||||
the backend atomically rolls it to `backend-logs-YYYY-MM-DD.log` and creates
|
||||
a new owner-readable, owner-writable active file.
|
||||
- A restart during the same UTC day appends to `backend-logs.log`. On the first
|
||||
startup after a day boundary, the previous active file is rolled to the date
|
||||
it represents before new entries are written.
|
||||
- Cleanup retains a rolling three-day UTC window: the current active day and
|
||||
at most the two preceding dated daily files. Older recognized daily files
|
||||
are removed; unrelated files in the log directory are preserved.
|
||||
- Each daily backend file accepts at most 256 MiB. Once the active file reaches
|
||||
that bound, entries that would exceed it are dropped without blocking
|
||||
application work and are reflected in file-sink loss statistics. UTC
|
||||
rollover resumes normal writes into the next day's file.
|
||||
- Rollover is crash-recoverable. A small owner-only rollover journal records
|
||||
the source day, source identity/size, destination, and copied offset before
|
||||
an existing dated destination is extended. Recovery resumes that transaction
|
||||
exactly once and never replaces an existing dated file.
|
||||
- `backend-logs.log` is the current-day active segment, capped at 16 MiB. Full
|
||||
files become `backend-logs-YYYY-MM-DD-NNNNNN.log`.
|
||||
- Active, closed, and legacy files share a 256 MiB budget; rotation evicts old
|
||||
closed files so logging continues with newest evidence.
|
||||
- At UTC midnight, the active file becomes the prior day's final segment and a
|
||||
new file opens. Cleanup removes recognized files older than three UTC days;
|
||||
this is a maximum age, not a per-day allocation.
|
||||
- Legacy singleton files remain readable and count toward the budget. An
|
||||
oversized legacy active file is converted to bounded newest segments.
|
||||
- The structured backend in-memory ring buffer is removed. The System Logs
|
||||
page, Improve Kandev, and agent diagnostics use the files and bundles
|
||||
described below.
|
||||
@@ -229,8 +222,7 @@ history or returning an unbounded log export.
|
||||
|
||||
```text
|
||||
manifest.json
|
||||
backend/backend-logs.log
|
||||
backend/backend-logs-YYYY-MM-DD.log
|
||||
backend/backend-logs*.log
|
||||
frontend/browser-01.jsonl
|
||||
frontend/browser-02.jsonl
|
||||
runtime/sessions.json
|
||||
@@ -238,8 +230,9 @@ acp/session-01/raw-acp.jsonl
|
||||
acp/session-01/normalized-acp.jsonl
|
||||
```
|
||||
|
||||
- `backend/` contains the recognized active and dated files still inside the
|
||||
three-day retention window. File bytes are copied without reformatting.
|
||||
- `backend/` contains recognized active, segmented, and legacy files inside the
|
||||
three-day retention window. Candidates are selected newest-first, and file
|
||||
bytes are copied without reformatting.
|
||||
- `frontend/` contains one JSON Lines file per distinct responding browser
|
||||
profile. Multiple tabs from one browser profile are deduplicated. Client
|
||||
values never determine archive paths or filenames.
|
||||
@@ -559,9 +552,9 @@ with the same task/session rules and excludes message bodies and user identity.
|
||||
backend startup prints the intended path and a warning to stderr, continues
|
||||
with stdout/stderr logging, and retries the file sink every 30 seconds.
|
||||
Retention-cleanup failures are warnings and do not prevent startup.
|
||||
- If midnight rollover fails after startup, the backend emits an error to
|
||||
stderr and its existing sinks, continues writing to the active file, and
|
||||
retries rollover on the next write without dropping the triggering entry.
|
||||
- If size or day rotation fails, the backend preserves the existing files and
|
||||
does not exceed the total retention budget. It reports the error to stderr
|
||||
and retries the file sink after 30 seconds.
|
||||
- If browser persistence fails, capture continues in the bounded memory
|
||||
fallback and the bundle manifest marks the degraded storage mode.
|
||||
- If no eligible frontend is connected, a frontend-only bundle becomes
|
||||
@@ -597,12 +590,12 @@ with the same task/session rules and excludes message bodies and user identity.
|
||||
|
||||
## Persistence guarantees
|
||||
|
||||
- `backend-logs.log` preserves entries accepted by the file queue during the
|
||||
current UTC day until the fixed 256 MiB daily bound. Pressure drops, startup
|
||||
fallback gaps, bounded-shutdown loss, and archive truncation are explicit in
|
||||
counters and bundle manifests.
|
||||
- Dated archives preserve the two preceding UTC calendar days. Files outside
|
||||
that rolling window are deleted at startup and after successful rollover.
|
||||
- Backend segments preserve the newest accepted entries across retained UTC
|
||||
days, up to the fixed 256 MiB total budget. High volume shortens the available
|
||||
time window instead of removing later evidence.
|
||||
- Three UTC days is the maximum backend-log age. It is not a guaranteed
|
||||
allocation for each day. Files outside that window are removed at startup
|
||||
and after successful rotation.
|
||||
- Browser console history remains in the browser profile for three UTC days,
|
||||
subject to its entry/byte caps. It is not server-persistent before capture.
|
||||
- Raw ACP files retain their existing debug-only executor/host retention: at
|
||||
@@ -632,12 +625,19 @@ with the same task/session rules and excludes message bodies and user identity.
|
||||
error entries, **THEN** all are in the file while only warn/error are on
|
||||
stdout.
|
||||
- **GIVEN** a same-UTC-day restart, **WHEN** the backend opens its log,
|
||||
**THEN** it appends without losing earlier entries.
|
||||
**THEN** it appends to the active segment and does not replace closed
|
||||
segments.
|
||||
- **GIVEN** logging exceeds 256 MiB, restarts after rotation, or upgrades from
|
||||
an oversized legacy file, **WHEN** the backend resumes, **THEN** it continues
|
||||
with newest bounded content and the next sequence.
|
||||
- **GIVEN** the configured log directory is temporarily unwritable, **WHEN**
|
||||
Kandev starts, **THEN** product startup succeeds with a stderr warning and
|
||||
the backend retries file activation every 30 seconds.
|
||||
- **GIVEN** more than three UTC days of backend files, **WHEN** cleanup runs,
|
||||
**THEN** only the current and two preceding days remain.
|
||||
- **GIVEN** the gateway receives close code `1000` or an unexpected close code,
|
||||
**WHEN** the read pump handles it, **THEN** only the unexpected close creates
|
||||
a `WebSocket read error` entry with a stack trace.
|
||||
- **GIVEN** an error toast on a recognized task route, **WHEN** its report is
|
||||
accepted, **THEN** one backend error entry includes its visible text,
|
||||
browser context, and `task_id`.
|
||||
@@ -726,3 +726,5 @@ with the same task/session rules and excludes message bodies and user identity.
|
||||
- Adding a setting for log path, retention, browser capture, or bundle bounds.
|
||||
- Supporting configurable size-based, per-process, or local-time backend
|
||||
rotation.
|
||||
- Changing the browser reconnection policy or increasing the fixed 256 MiB
|
||||
total backend-log budget.
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"docs/specs/office/scheduler.md": 50967,
|
||||
"docs/specs/office/tasks.md": 42862,
|
||||
"docs/specs/office-agent-tier-routing/spec.md": 58448,
|
||||
"docs/specs/platform/diagnostic-logging.md": 40700,
|
||||
"docs/specs/platform/diagnostic-logging.md": 40696,
|
||||
"docs/specs/plugin-nav-sidebar-footer/spec.md": 63150,
|
||||
"docs/specs/plugins/spec.md": 77806,
|
||||
"docs/specs/subagent-context-persistence/spec.md": 160552,
|
||||
|
||||
Reference in New Issue
Block a user