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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## v0.14.9 - Unreleased

- Recover truncated scheduler history tails after interrupted writes while preserving valid final records without a newline and reporting write or cleanup failures. Thanks @SebTardif.
- Update SQLite to v1.58.0 with its required libc v1.75.6 runtime, refresh x/crypto and go-runewidth, and prefer Go 1.27.1 while retaining the Go 1.27.0 minimum.
- Refresh the pinned TruffleHog secret-scanning action to v3.97.4. Thanks @dependabot.

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ See the [package guide](docs/packages.md) for the complete inventory and [Go pac

`crawlctl` discovers installed crawl apps through their machine-readable metadata, runs configured refresh jobs under a single-process lock, and records JSONL run history.

If a write is interrupted, history reads ignore a truncated final JSON value and the next run repairs that tail before appending. Valid final records without a trailing newline are retained; complete corrupt records still report an error.

| Command | Purpose |
| --- | --- |
| `init` | Discover crawl apps and write a controller config |
Expand Down
96 changes: 92 additions & 4 deletions scheduler/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package scheduler

import (
"bufio"
"bytes"
"context"
"crypto/rand"
"encoding/hex"
Expand Down Expand Up @@ -279,13 +280,89 @@ func appendHistory(path string, record RunRecord) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
// The scheduler lock serializes writes. Avoid O_APPEND: Windows append
// handles lack the write-data access required for recovery truncation.
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return err
}
defer file.Close()
enc := json.NewEncoder(file)
return enc.Encode(record)
return appendHistoryFile(file, record)
}

type historyFile interface {
io.Writer
io.ReaderAt
io.Seeker
Stat() (os.FileInfo, error)
Truncate(int64) error
Close() error
}

func appendHistoryFile(file historyFile, record RunRecord) (err error) {
defer func() { err = errors.Join(err, file.Close()) }()
info, err := file.Stat()
if err != nil {
return err
}
start := info.Size()
tailStart, tail, err := readHistoryTail(file, start)
if err != nil {
return err
}
if len(tail) > 0 {
var previous RunRecord
if err := json.Unmarshal(tail, &previous); err != nil {
if !incompleteHistoryRecord(tail) {
return err
}
if err := file.Truncate(tailStart); err != nil {
return err
}
start = tailStart
tail = nil
}
}
var buf bytes.Buffer
if len(tail) > 0 {
// A valid EOF record may lack only its separator; retain every byte.
buf.WriteByte('\n')
}
if err := json.NewEncoder(&buf).Encode(record); err != nil {
return err
}
if _, err := file.Seek(start, io.SeekStart); err != nil {
return err
}
n, err := file.Write(buf.Bytes())
if err == nil && n != buf.Len() {
err = io.ErrShortWrite
}
if err != nil {
return errors.Join(err, file.Truncate(start))
}
return nil
}

func readHistoryTail(file io.ReaderAt, end int64) (int64, []byte, error) {
var tail []byte
for end > 0 {
start := max(int64(0), end-4096)
block := make([]byte, end-start)
if _, err := file.ReadAt(block, start); err != nil {
return 0, nil, err
}
if n := bytes.LastIndexByte(block, '\n'); n >= 0 {
return start + int64(n) + 1, append(block[n+1:], tail...), nil
}
tail = append(block, tail...)
end = start
}
return 0, tail, nil
}

func incompleteHistoryRecord(data []byte) bool {
var value json.RawMessage
return errors.Is(json.NewDecoder(bytes.NewReader(data)).Decode(&value), io.ErrUnexpectedEOF)
}

func ReadHistory(path string) ([]RunRecord, error) {
Expand All @@ -299,9 +376,20 @@ func ReadHistory(path string) ([]RunRecord, error) {
defer file.Close()
var records []RunRecord
scanner := bufio.NewScanner(file)
terminated := false
scanner.Split(func(data []byte, atEOF bool) (int, []byte, error) {
advance, token, err := bufio.ScanLines(data, atEOF)
if advance > 0 {
terminated = data[advance-1] == '\n'
}
return advance, token, err
})
for scanner.Scan() {
var record RunRecord
if err := json.Unmarshal(scanner.Bytes(), &record); err != nil {
if !terminated && incompleteHistoryRecord(scanner.Bytes()) {
break
}
return nil, err
}
records = append(records, record)
Expand Down
Loading