feat: implement phase3 tier2 snapshot race downloader hardening and exit classification
- commit
- faa8558cdbd41631137f1434247e1dd03bc76d82
- author
- brice · · 2026-02-07 05:37 UTC
- parent
- 00ab191
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +799 -16
M docs/reference/index.md +1 -0
| @@ -6,6 +6,7 @@ | |||
| 6 | 6 | - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu | |
| 7 | 7 | - [Keybindings](./keybindings.md) - All keyboard shortcuts | |
| 8 | 8 | - [Troubleshooting](./troubleshooting.md) - Common issues and solutions | |
| 9 | + | - [Session Exit Classification Runbook](./session-exit-classification-runbook.md) - Marker-based exit classification and optional system-log correlation | |
| 9 | 10 | ||
| 10 | 11 | ## Related | |
| 11 | 12 | ||
| @@ -6,6 +6,7 @@ | |||||
| 6 | - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu | 6 | - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu | ||
| 7 | - [Keybindings](./keybindings.md) - All keyboard shortcuts | 7 | - [Keybindings](./keybindings.md) - All keyboard shortcuts | ||
| 8 | - [Troubleshooting](./troubleshooting.md) - Common issues and solutions | 8 | - [Troubleshooting](./troubleshooting.md) - Common issues and solutions | ||
| 9 | + | - [Session Exit Classification Runbook](./session-exit-classification-runbook.md) - Marker-based exit classification and optional system-log correlation | |||
| 9 | 10 | ||||
| 10 | ## Related | 11 | ## Related | ||
| 11 | 12 | ||||
A docs/reference/session-exit-classification-runbook.md +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | # Session Exit Classification Runbook | |
| 2 | + | ||
| 3 | + | This runbook classifies browser sessions using existing P0 marker files in the log directory: | |
| 4 | + | ||
| 5 | + | - `session_<id>.startup.marker` | |
| 6 | + | - `session_<id>.shutdown.marker` | |
| 7 | + | - `session_<id>.abrupt.marker` | |
| 8 | + | ||
| 9 | + | Marker directory (default): | |
| 10 | + | ||
| 11 | + | ```bash | |
| 12 | + | LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs" | |
| 13 | + | ``` | |
| 14 | + | ||
| 15 | + | ## Classification Model | |
| 16 | + | ||
| 17 | + | - `clean_exit` (`marker-confirmed`) | |
| 18 | + | - `shutdown.marker` is present. | |
| 19 | + | - `main_process_crash_or_abrupt` (`marker-confirmed`) | |
| 20 | + | - `abrupt.marker` is present and no `shutdown.marker` exists. | |
| 21 | + | - `external_kill_or_oom_inferred` (`best-effort`) | |
| 22 | + | - `startup.marker` exists, but `shutdown.marker` and `abrupt.marker` are both missing. | |
| 23 | + | - This is explicitly inferred, not confirmed. | |
| 24 | + | ||
| 25 | + | Implementation path in code: | |
| 26 | + | ||
| 27 | + | - `internal/bootstrap/session_exit_classification.go` | |
| 28 | + | - `ClassifySessionExitFromMarkers(lockDir, sessionID)` | |
| 29 | + | - `BuildSessionExitReport(lockDir)` | |
| 30 | + | ||
| 31 | + | ## Quick Marker Report (Shell) | |
| 32 | + | ||
| 33 | + | ```bash | |
| 34 | + | LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs" | |
| 35 | + | ||
| 36 | + | for startup in "$LOG_DIR"/session_*.startup.marker; do | |
| 37 | + | [ -e "$startup" ] || continue | |
| 38 | + | base="$(basename "$startup")" | |
| 39 | + | sid="${base#session_}" | |
| 40 | + | sid="${sid%.startup.marker}" | |
| 41 | + | shutdown="$LOG_DIR/session_${sid}.shutdown.marker" | |
| 42 | + | abrupt="$LOG_DIR/session_${sid}.abrupt.marker" | |
| 43 | + | ||
| 44 | + | if [ -f "$shutdown" ]; then | |
| 45 | + | class="clean_exit" | |
| 46 | + | inf="marker-confirmed" | |
| 47 | + | elif [ -f "$abrupt" ]; then | |
| 48 | + | class="main_process_crash_or_abrupt" | |
| 49 | + | inf="marker-confirmed" | |
| 50 | + | else | |
| 51 | + | class="external_kill_or_oom_inferred" | |
| 52 | + | inf="best-effort" | |
| 53 | + | fi | |
| 54 | + | ||
| 55 | + | printf "%s\t%s\t%s\n" "$sid" "$class" "$inf" | |
| 56 | + | done | sort | |
| 57 | + | ``` | |
| 58 | + | ||
| 59 | + | ## Optional System-Log Correlation | |
| 60 | + | ||
| 61 | + | Use this only when `external_kill_or_oom_inferred` needs more evidence. | |
| 62 | + | ||
| 63 | + | 1. Get the startup timestamp for a session: | |
| 64 | + | ||
| 65 | + | ```bash | |
| 66 | + | SID="<session-id>" | |
| 67 | + | LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs" | |
| 68 | + | cat "$LOG_DIR/session_${SID}.startup.marker" | |
| 69 | + | ``` | |
| 70 | + | ||
| 71 | + | 2. Inspect user journal around that window: | |
| 72 | + | ||
| 73 | + | ```bash | |
| 74 | + | journalctl --user --since "2026-02-07 10:00:00" --until "2026-02-07 10:10:00" | rg -i "dumber|killed|oom|sigkill|signal" | |
| 75 | + | ``` | |
| 76 | + | ||
| 77 | + | 3. Inspect kernel/system OOM evidence: | |
| 78 | + | ||
| 79 | + | ```bash | |
| 80 | + | journalctl -k --since "2026-02-07 10:00:00" --until "2026-02-07 10:10:00" | rg -i "oom|out of memory|killed process" | |
| 81 | + | ``` | |
| 82 | + | ||
| 83 | + | 4. Optional coredump check: | |
| 84 | + | ||
| 85 | + | ```bash | |
| 86 | + | coredumpctl list | rg -i "dumber" | |
| 87 | + | ``` | |
| 88 | + | ||
| 89 | + | Interpretation guidance: | |
| 90 | + | ||
| 91 | + | - OOM/killed lines near the startup window strengthen `external_kill_or_oom_inferred`. | |
| 92 | + | - No OOM evidence does not prove a clean exit; keep inference label explicit. | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | # Session Exit Classification Runbook | |||
| 2 | + | ||||
| 3 | + | This runbook classifies browser sessions using existing P0 marker files in the log directory: | |||
| 4 | + | ||||
| 5 | + | - `session_<id>.startup.marker` | |||
| 6 | + | - `session_<id>.shutdown.marker` | |||
| 7 | + | - `session_<id>.abrupt.marker` | |||
| 8 | + | ||||
| 9 | + | Marker directory (default): | |||
| 10 | + | ||||
| 11 | + | ```bash | |||
| 12 | + | LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs" | |||
| 13 | + | ``` | |||
| 14 | + | ||||
| 15 | + | ## Classification Model | |||
| 16 | + | ||||
| 17 | + | - `clean_exit` (`marker-confirmed`) | |||
| 18 | + | - `shutdown.marker` is present. | |||
| 19 | + | - `main_process_crash_or_abrupt` (`marker-confirmed`) | |||
| 20 | + | - `abrupt.marker` is present and no `shutdown.marker` exists. | |||
| 21 | + | - `external_kill_or_oom_inferred` (`best-effort`) | |||
| 22 | + | - `startup.marker` exists, but `shutdown.marker` and `abrupt.marker` are both missing. | |||
| 23 | + | - This is explicitly inferred, not confirmed. | |||
| 24 | + | ||||
| 25 | + | Implementation path in code: | |||
| 26 | + | ||||
| 27 | + | - `internal/bootstrap/session_exit_classification.go` | |||
| 28 | + | - `ClassifySessionExitFromMarkers(lockDir, sessionID)` | |||
| 29 | + | - `BuildSessionExitReport(lockDir)` | |||
| 30 | + | ||||
| 31 | + | ## Quick Marker Report (Shell) | |||
| 32 | + | ||||
| 33 | + | ```bash | |||
| 34 | + | LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs" | |||
| 35 | + | ||||
| 36 | + | for startup in "$LOG_DIR"/session_*.startup.marker; do | |||
| 37 | + | [ -e "$startup" ] || continue | |||
| 38 | + | base="$(basename "$startup")" | |||
| 39 | + | sid="${base#session_}" | |||
| 40 | + | sid="${sid%.startup.marker}" | |||
| 41 | + | shutdown="$LOG_DIR/session_${sid}.shutdown.marker" | |||
| 42 | + | abrupt="$LOG_DIR/session_${sid}.abrupt.marker" | |||
| 43 | + | ||||
| 44 | + | if [ -f "$shutdown" ]; then | |||
| 45 | + | class="clean_exit" | |||
| 46 | + | inf="marker-confirmed" | |||
| 47 | + | elif [ -f "$abrupt" ]; then | |||
| 48 | + | class="main_process_crash_or_abrupt" | |||
| 49 | + | inf="marker-confirmed" | |||
| 50 | + | else | |||
| 51 | + | class="external_kill_or_oom_inferred" | |||
| 52 | + | inf="best-effort" | |||
| 53 | + | fi | |||
| 54 | + | ||||
| 55 | + | printf "%s\t%s\t%s\n" "$sid" "$class" "$inf" | |||
| 56 | + | done | sort | |||
| 57 | + | ``` | |||
| 58 | + | ||||
| 59 | + | ## Optional System-Log Correlation | |||
| 60 | + | ||||
| 61 | + | Use this only when `external_kill_or_oom_inferred` needs more evidence. | |||
| 62 | + | ||||
| 63 | + | 1. Get the startup timestamp for a session: | |||
| 64 | + | ||||
| 65 | + | ```bash | |||
| 66 | + | SID="<session-id>" | |||
| 67 | + | LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs" | |||
| 68 | + | cat "$LOG_DIR/session_${SID}.startup.marker" | |||
| 69 | + | ``` | |||
| 70 | + | ||||
| 71 | + | 2. Inspect user journal around that window: | |||
| 72 | + | ||||
| 73 | + | ```bash | |||
| 74 | + | journalctl --user --since "2026-02-07 10:00:00" --until "2026-02-07 10:10:00" | rg -i "dumber|killed|oom|sigkill|signal" | |||
| 75 | + | ``` | |||
| 76 | + | ||||
| 77 | + | 3. Inspect kernel/system OOM evidence: | |||
| 78 | + | ||||
| 79 | + | ```bash | |||
| 80 | + | journalctl -k --since "2026-02-07 10:00:00" --until "2026-02-07 10:10:00" | rg -i "oom|out of memory|killed process" | |||
| 81 | + | ``` | |||
| 82 | + | ||||
| 83 | + | 4. Optional coredump check: | |||
| 84 | + | ||||
| 85 | + | ```bash | |||
| 86 | + | coredumpctl list | rg -i "dumber" | |||
| 87 | + | ``` | |||
| 88 | + | ||||
| 89 | + | Interpretation guidance: | |||
| 90 | + | ||||
| 91 | + | - OOM/killed lines near the startup window strengthen `external_kill_or_oom_inferred`. | |||
| 92 | + | - No OOM evidence does not prove a clean exit; keep inference label explicit. | |||
A internal/bootstrap/session_exit_classification.go +201 -0
| @@ -0,0 +1,201 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "sort" | |
| 8 | + | "strings" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type SessionExitClass string | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | SessionExitCleanExit SessionExitClass = "clean_exit" | |
| 16 | + | SessionExitMainProcessCrashOrAbrupt SessionExitClass = "main_process_crash_or_abrupt" | |
| 17 | + | SessionExitExternalKillOrOOMInferred SessionExitClass = "external_kill_or_oom_inferred" | |
| 18 | + | SessionExitUnknown SessionExitClass = "unknown" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | type SessionExitClassification struct { | |
| 22 | + | SessionID string | |
| 23 | + | Class SessionExitClass | |
| 24 | + | Inference string | |
| 25 | + | Reason string | |
| 26 | + | StartupAt *time.Time | |
| 27 | + | ShutdownAt *time.Time | |
| 28 | + | AbruptDetectedAt *time.Time | |
| 29 | + | LastMarkerObserved time.Time | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func ClassifySessionExitFromMarkers(lockDir, sessionID string) (SessionExitClassification, error) { | |
| 33 | + | classification := SessionExitClassification{ | |
| 34 | + | SessionID: sessionID, | |
| 35 | + | Class: SessionExitUnknown, | |
| 36 | + | Inference: "marker-missing", | |
| 37 | + | Reason: "no known marker files found for session", | |
| 38 | + | } | |
| 39 | + | if lockDir == "" || sessionID == "" { | |
| 40 | + | return classification, fmt.Errorf("lockDir and sessionID are required") | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | startupAt, startupExists, startupModTime, err := readMarkerTime(startupMarkerPath(lockDir, sessionID), "") | |
| 44 | + | if err != nil { | |
| 45 | + | return classification, err | |
| 46 | + | } | |
| 47 | + | shutdownAt, shutdownExists, shutdownModTime, err := readMarkerTime(shutdownMarkerPath(lockDir, sessionID), "") | |
| 48 | + | if err != nil { | |
| 49 | + | return classification, err | |
| 50 | + | } | |
| 51 | + | abruptAt, abruptExists, abruptModTime, err := readMarkerTime(abruptMarkerPath(lockDir, sessionID), "detected_at=") | |
| 52 | + | if err != nil { | |
| 53 | + | return classification, err | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | classification.StartupAt = startupAt | |
| 57 | + | classification.ShutdownAt = shutdownAt | |
| 58 | + | classification.AbruptDetectedAt = abruptAt | |
| 59 | + | classification.LastMarkerObserved = latestNonZero(startupModTime, shutdownModTime, abruptModTime) | |
| 60 | + | ||
| 61 | + | switch { | |
| 62 | + | case shutdownExists: | |
| 63 | + | classification.Class = SessionExitCleanExit | |
| 64 | + | classification.Inference = "marker-confirmed" | |
| 65 | + | classification.Reason = "shutdown marker present" | |
| 66 | + | case abruptExists: | |
| 67 | + | classification.Class = SessionExitMainProcessCrashOrAbrupt | |
| 68 | + | classification.Inference = "marker-confirmed" | |
| 69 | + | classification.Reason = "abrupt marker present and no shutdown marker" | |
| 70 | + | case startupExists: | |
| 71 | + | classification.Class = SessionExitExternalKillOrOOMInferred | |
| 72 | + | classification.Inference = "best-effort" | |
| 73 | + | classification.Reason = "startup marker present without shutdown/abrupt markers" | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | return classification, nil | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func BuildSessionExitReport(lockDir string) ([]SessionExitClassification, error) { | |
| 80 | + | if lockDir == "" { | |
| 81 | + | return nil, nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | sessionIDs, err := discoverSessionIDsFromMarkers(lockDir) | |
| 85 | + | if err != nil { | |
| 86 | + | return nil, err | |
| 87 | + | } | |
| 88 | + | if len(sessionIDs) == 0 { | |
| 89 | + | return nil, nil | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | report := make([]SessionExitClassification, 0, len(sessionIDs)) | |
| 93 | + | for _, sessionID := range sessionIDs { | |
| 94 | + | classification, classErr := ClassifySessionExitFromMarkers(lockDir, sessionID) | |
| 95 | + | if classErr != nil { | |
| 96 | + | return nil, classErr | |
| 97 | + | } | |
| 98 | + | report = append(report, classification) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | sort.Slice(report, func(i, j int) bool { | |
| 102 | + | return report[i].LastMarkerObserved.After(report[j].LastMarkerObserved) | |
| 103 | + | }) | |
| 104 | + | return report, nil | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func discoverSessionIDsFromMarkers(lockDir string) ([]string, error) { | |
| 108 | + | pattern := filepath.Join(lockDir, "session_*.marker") | |
| 109 | + | paths, err := filepath.Glob(pattern) | |
| 110 | + | if err != nil { | |
| 111 | + | return nil, err | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | ids := make(map[string]struct{}, len(paths)) | |
| 115 | + | for _, path := range paths { | |
| 116 | + | base := filepath.Base(path) | |
| 117 | + | sessionID, ok := markerSessionID(base) | |
| 118 | + | if !ok { | |
| 119 | + | continue | |
| 120 | + | } | |
| 121 | + | ids[sessionID] = struct{}{} | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | sessionIDs := make([]string, 0, len(ids)) | |
| 125 | + | for id := range ids { | |
| 126 | + | sessionIDs = append(sessionIDs, id) | |
| 127 | + | } | |
| 128 | + | sort.Strings(sessionIDs) | |
| 129 | + | return sessionIDs, nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func markerSessionID(base string) (string, bool) { | |
| 133 | + | if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".marker") { | |
| 134 | + | return "", false | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | trimmed := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".marker") | |
| 138 | + | switch { | |
| 139 | + | case strings.HasSuffix(trimmed, ".startup"): | |
| 140 | + | return strings.TrimSuffix(trimmed, ".startup"), true | |
| 141 | + | case strings.HasSuffix(trimmed, ".shutdown"): | |
| 142 | + | return strings.TrimSuffix(trimmed, ".shutdown"), true | |
| 143 | + | case strings.HasSuffix(trimmed, ".abrupt"): | |
| 144 | + | return strings.TrimSuffix(trimmed, ".abrupt"), true | |
| 145 | + | default: | |
| 146 | + | return "", false | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func readMarkerTime(path, keyPrefix string) (*time.Time, bool, time.Time, error) { | |
| 151 | + | stat, err := os.Stat(path) | |
| 152 | + | if err != nil { | |
| 153 | + | if os.IsNotExist(err) { | |
| 154 | + | return nil, false, time.Time{}, nil | |
| 155 | + | } | |
| 156 | + | return nil, false, time.Time{}, err | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | content, err := os.ReadFile(path) | |
| 160 | + | if err != nil { | |
| 161 | + | return nil, true, stat.ModTime(), err | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | raw := strings.TrimSpace(string(content)) | |
| 165 | + | if raw == "" { | |
| 166 | + | return nil, true, stat.ModTime(), nil | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | if keyPrefix != "" { | |
| 170 | + | lines := strings.Split(raw, "\n") | |
| 171 | + | for _, line := range lines { | |
| 172 | + | if strings.HasPrefix(line, keyPrefix) { | |
| 173 | + | parsed, parseErr := time.Parse(time.RFC3339Nano, strings.TrimPrefix(line, keyPrefix)) | |
| 174 | + | if parseErr != nil { | |
| 175 | + | return nil, true, stat.ModTime(), nil | |
| 176 | + | } | |
| 177 | + | return &parsed, true, stat.ModTime(), nil | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | return nil, true, stat.ModTime(), nil | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | parsed, parseErr := time.Parse(time.RFC3339Nano, raw) | |
| 184 | + | if parseErr != nil { | |
| 185 | + | return nil, true, stat.ModTime(), nil | |
| 186 | + | } | |
| 187 | + | return &parsed, true, stat.ModTime(), nil | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func latestNonZero(values ...time.Time) time.Time { | |
| 191 | + | var latest time.Time | |
| 192 | + | for _, value := range values { | |
| 193 | + | if value.IsZero() { | |
| 194 | + | continue | |
| 195 | + | } | |
| 196 | + | if latest.IsZero() || value.After(latest) { | |
| 197 | + | latest = value | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | return latest | |
| 201 | + | } | |
| @@ -0,0 +1,201 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "sort" | |||
| 8 | + | "strings" | |||
| 9 | + | "time" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type SessionExitClass string | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | SessionExitCleanExit SessionExitClass = "clean_exit" | |||
| 16 | + | SessionExitMainProcessCrashOrAbrupt SessionExitClass = "main_process_crash_or_abrupt" | |||
| 17 | + | SessionExitExternalKillOrOOMInferred SessionExitClass = "external_kill_or_oom_inferred" | |||
| 18 | + | SessionExitUnknown SessionExitClass = "unknown" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | type SessionExitClassification struct { | |||
| 22 | + | SessionID string | |||
| 23 | + | Class SessionExitClass | |||
| 24 | + | Inference string | |||
| 25 | + | Reason string | |||
| 26 | + | StartupAt *time.Time | |||
| 27 | + | ShutdownAt *time.Time | |||
| 28 | + | AbruptDetectedAt *time.Time | |||
| 29 | + | LastMarkerObserved time.Time | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func ClassifySessionExitFromMarkers(lockDir, sessionID string) (SessionExitClassification, error) { | |||
| 33 | + | classification := SessionExitClassification{ | |||
| 34 | + | SessionID: sessionID, | |||
| 35 | + | Class: SessionExitUnknown, | |||
| 36 | + | Inference: "marker-missing", | |||
| 37 | + | Reason: "no known marker files found for session", | |||
| 38 | + | } | |||
| 39 | + | if lockDir == "" || sessionID == "" { | |||
| 40 | + | return classification, fmt.Errorf("lockDir and sessionID are required") | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | startupAt, startupExists, startupModTime, err := readMarkerTime(startupMarkerPath(lockDir, sessionID), "") | |||
| 44 | + | if err != nil { | |||
| 45 | + | return classification, err | |||
| 46 | + | } | |||
| 47 | + | shutdownAt, shutdownExists, shutdownModTime, err := readMarkerTime(shutdownMarkerPath(lockDir, sessionID), "") | |||
| 48 | + | if err != nil { | |||
| 49 | + | return classification, err | |||
| 50 | + | } | |||
| 51 | + | abruptAt, abruptExists, abruptModTime, err := readMarkerTime(abruptMarkerPath(lockDir, sessionID), "detected_at=") | |||
| 52 | + | if err != nil { | |||
| 53 | + | return classification, err | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | classification.StartupAt = startupAt | |||
| 57 | + | classification.ShutdownAt = shutdownAt | |||
| 58 | + | classification.AbruptDetectedAt = abruptAt | |||
| 59 | + | classification.LastMarkerObserved = latestNonZero(startupModTime, shutdownModTime, abruptModTime) | |||
| 60 | + | ||||
| 61 | + | switch { | |||
| 62 | + | case shutdownExists: | |||
| 63 | + | classification.Class = SessionExitCleanExit | |||
| 64 | + | classification.Inference = "marker-confirmed" | |||
| 65 | + | classification.Reason = "shutdown marker present" | |||
| 66 | + | case abruptExists: | |||
| 67 | + | classification.Class = SessionExitMainProcessCrashOrAbrupt | |||
| 68 | + | classification.Inference = "marker-confirmed" | |||
| 69 | + | classification.Reason = "abrupt marker present and no shutdown marker" | |||
| 70 | + | case startupExists: | |||
| 71 | + | classification.Class = SessionExitExternalKillOrOOMInferred | |||
| 72 | + | classification.Inference = "best-effort" | |||
| 73 | + | classification.Reason = "startup marker present without shutdown/abrupt markers" | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | return classification, nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func BuildSessionExitReport(lockDir string) ([]SessionExitClassification, error) { | |||
| 80 | + | if lockDir == "" { | |||
| 81 | + | return nil, nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | sessionIDs, err := discoverSessionIDsFromMarkers(lockDir) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return nil, err | |||
| 87 | + | } | |||
| 88 | + | if len(sessionIDs) == 0 { | |||
| 89 | + | return nil, nil | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | report := make([]SessionExitClassification, 0, len(sessionIDs)) | |||
| 93 | + | for _, sessionID := range sessionIDs { | |||
| 94 | + | classification, classErr := ClassifySessionExitFromMarkers(lockDir, sessionID) | |||
| 95 | + | if classErr != nil { | |||
| 96 | + | return nil, classErr | |||
| 97 | + | } | |||
| 98 | + | report = append(report, classification) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | sort.Slice(report, func(i, j int) bool { | |||
| 102 | + | return report[i].LastMarkerObserved.After(report[j].LastMarkerObserved) | |||
| 103 | + | }) | |||
| 104 | + | return report, nil | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func discoverSessionIDsFromMarkers(lockDir string) ([]string, error) { | |||
| 108 | + | pattern := filepath.Join(lockDir, "session_*.marker") | |||
| 109 | + | paths, err := filepath.Glob(pattern) | |||
| 110 | + | if err != nil { | |||
| 111 | + | return nil, err | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | ids := make(map[string]struct{}, len(paths)) | |||
| 115 | + | for _, path := range paths { | |||
| 116 | + | base := filepath.Base(path) | |||
| 117 | + | sessionID, ok := markerSessionID(base) | |||
| 118 | + | if !ok { | |||
| 119 | + | continue | |||
| 120 | + | } | |||
| 121 | + | ids[sessionID] = struct{}{} | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | sessionIDs := make([]string, 0, len(ids)) | |||
| 125 | + | for id := range ids { | |||
| 126 | + | sessionIDs = append(sessionIDs, id) | |||
| 127 | + | } | |||
| 128 | + | sort.Strings(sessionIDs) | |||
| 129 | + | return sessionIDs, nil | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func markerSessionID(base string) (string, bool) { | |||
| 133 | + | if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".marker") { | |||
| 134 | + | return "", false | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | trimmed := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".marker") | |||
| 138 | + | switch { | |||
| 139 | + | case strings.HasSuffix(trimmed, ".startup"): | |||
| 140 | + | return strings.TrimSuffix(trimmed, ".startup"), true | |||
| 141 | + | case strings.HasSuffix(trimmed, ".shutdown"): | |||
| 142 | + | return strings.TrimSuffix(trimmed, ".shutdown"), true | |||
| 143 | + | case strings.HasSuffix(trimmed, ".abrupt"): | |||
| 144 | + | return strings.TrimSuffix(trimmed, ".abrupt"), true | |||
| 145 | + | default: | |||
| 146 | + | return "", false | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func readMarkerTime(path, keyPrefix string) (*time.Time, bool, time.Time, error) { | |||
| 151 | + | stat, err := os.Stat(path) | |||
| 152 | + | if err != nil { | |||
| 153 | + | if os.IsNotExist(err) { | |||
| 154 | + | return nil, false, time.Time{}, nil | |||
| 155 | + | } | |||
| 156 | + | return nil, false, time.Time{}, err | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | content, err := os.ReadFile(path) | |||
| 160 | + | if err != nil { | |||
| 161 | + | return nil, true, stat.ModTime(), err | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | raw := strings.TrimSpace(string(content)) | |||
| 165 | + | if raw == "" { | |||
| 166 | + | return nil, true, stat.ModTime(), nil | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | if keyPrefix != "" { | |||
| 170 | + | lines := strings.Split(raw, "\n") | |||
| 171 | + | for _, line := range lines { | |||
| 172 | + | if strings.HasPrefix(line, keyPrefix) { | |||
| 173 | + | parsed, parseErr := time.Parse(time.RFC3339Nano, strings.TrimPrefix(line, keyPrefix)) | |||
| 174 | + | if parseErr != nil { | |||
| 175 | + | return nil, true, stat.ModTime(), nil | |||
| 176 | + | } | |||
| 177 | + | return &parsed, true, stat.ModTime(), nil | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | return nil, true, stat.ModTime(), nil | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | parsed, parseErr := time.Parse(time.RFC3339Nano, raw) | |||
| 184 | + | if parseErr != nil { | |||
| 185 | + | return nil, true, stat.ModTime(), nil | |||
| 186 | + | } | |||
| 187 | + | return &parsed, true, stat.ModTime(), nil | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func latestNonZero(values ...time.Time) time.Time { | |||
| 191 | + | var latest time.Time | |||
| 192 | + | for _, value := range values { | |||
| 193 | + | if value.IsZero() { | |||
| 194 | + | continue | |||
| 195 | + | } | |||
| 196 | + | if latest.IsZero() || value.After(latest) { | |||
| 197 | + | latest = value | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | return latest | |||
| 201 | + | } | |||
A internal/bootstrap/session_exit_classification_test.go +115 -0
| @@ -0,0 +1,115 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestClassifySessionExitFromMarkers_CleanExit(t *testing.T) { | |
| 14 | + | lockDir := t.TempDir() | |
| 15 | + | sessionID := "clean-1" | |
| 16 | + | startedAt := time.Date(2026, 2, 7, 9, 0, 0, 0, time.UTC) | |
| 17 | + | endedAt := startedAt.Add(2 * time.Minute) | |
| 18 | + | ||
| 19 | + | require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt)) | |
| 20 | + | require.NoError(t, writeShutdownMarker(lockDir, sessionID, endedAt)) | |
| 21 | + | ||
| 22 | + | classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | assert.Equal(t, SessionExitCleanExit, classification.Class) | |
| 25 | + | assert.Equal(t, "marker-confirmed", classification.Inference) | |
| 26 | + | assert.Equal(t, "shutdown marker present", classification.Reason) | |
| 27 | + | require.NotNil(t, classification.StartupAt) | |
| 28 | + | assert.Equal(t, startedAt, *classification.StartupAt) | |
| 29 | + | require.NotNil(t, classification.ShutdownAt) | |
| 30 | + | assert.Equal(t, endedAt, *classification.ShutdownAt) | |
| 31 | + | assert.Nil(t, classification.AbruptDetectedAt) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestClassifySessionExitFromMarkers_AbruptMarker(t *testing.T) { | |
| 35 | + | lockDir := t.TempDir() | |
| 36 | + | sessionID := "abrupt-1" | |
| 37 | + | startedAt := time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC) | |
| 38 | + | detectedAt := startedAt.Add(5 * time.Minute) | |
| 39 | + | ||
| 40 | + | require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt)) | |
| 41 | + | require.NoError(t, os.WriteFile( | |
| 42 | + | abruptMarkerPath(lockDir, sessionID), | |
| 43 | + | []byte("detected_at="+detectedAt.Format(time.RFC3339Nano)+"\n"), | |
| 44 | + | markerFilePerm, | |
| 45 | + | )) | |
| 46 | + | ||
| 47 | + | classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | assert.Equal(t, SessionExitMainProcessCrashOrAbrupt, classification.Class) | |
| 50 | + | assert.Equal(t, "marker-confirmed", classification.Inference) | |
| 51 | + | assert.Equal(t, "abrupt marker present and no shutdown marker", classification.Reason) | |
| 52 | + | require.NotNil(t, classification.StartupAt) | |
| 53 | + | assert.Equal(t, startedAt, *classification.StartupAt) | |
| 54 | + | assert.Nil(t, classification.ShutdownAt) | |
| 55 | + | require.NotNil(t, classification.AbruptDetectedAt) | |
| 56 | + | assert.Equal(t, detectedAt, *classification.AbruptDetectedAt) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestClassifySessionExitFromMarkers_ExternalKillOrOOMInferred(t *testing.T) { | |
| 60 | + | lockDir := t.TempDir() | |
| 61 | + | sessionID := "inferred-1" | |
| 62 | + | startedAt := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC) | |
| 63 | + | ||
| 64 | + | require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt)) | |
| 65 | + | ||
| 66 | + | classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID) | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | assert.Equal(t, SessionExitExternalKillOrOOMInferred, classification.Class) | |
| 69 | + | assert.Equal(t, "best-effort", classification.Inference) | |
| 70 | + | assert.Equal(t, "startup marker present without shutdown/abrupt markers", classification.Reason) | |
| 71 | + | require.NotNil(t, classification.StartupAt) | |
| 72 | + | assert.Equal(t, startedAt, *classification.StartupAt) | |
| 73 | + | assert.Nil(t, classification.ShutdownAt) | |
| 74 | + | assert.Nil(t, classification.AbruptDetectedAt) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestClassifySessionExitFromMarkers_RequiresInputs(t *testing.T) { | |
| 78 | + | _, err := ClassifySessionExitFromMarkers("", "s1") | |
| 79 | + | require.Error(t, err) | |
| 80 | + | ||
| 81 | + | _, err = ClassifySessionExitFromMarkers(t.TempDir(), "") | |
| 82 | + | require.Error(t, err) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestBuildSessionExitReport(t *testing.T) { | |
| 86 | + | lockDir := t.TempDir() | |
| 87 | + | ||
| 88 | + | require.NoError(t, writeStartupMarker(lockDir, "clean-r", time.Date(2026, 2, 7, 8, 0, 0, 0, time.UTC))) | |
| 89 | + | require.NoError(t, writeShutdownMarker(lockDir, "clean-r", time.Date(2026, 2, 7, 8, 1, 0, 0, time.UTC))) | |
| 90 | + | ||
| 91 | + | require.NoError(t, writeStartupMarker(lockDir, "abrupt-r", time.Date(2026, 2, 7, 8, 5, 0, 0, time.UTC))) | |
| 92 | + | require.NoError(t, os.WriteFile( | |
| 93 | + | abruptMarkerPath(lockDir, "abrupt-r"), | |
| 94 | + | []byte("detected_at=2026-02-07T08:08:00Z\n"), | |
| 95 | + | markerFilePerm, | |
| 96 | + | )) | |
| 97 | + | ||
| 98 | + | require.NoError(t, writeStartupMarker(lockDir, "inferred-r", time.Date(2026, 2, 7, 8, 10, 0, 0, time.UTC))) | |
| 99 | + | ||
| 100 | + | // Unknown marker type should not be included. | |
| 101 | + | require.NoError(t, os.WriteFile(filepath.Join(lockDir, "session_unknown.custom.marker"), []byte("x"), markerFilePerm)) | |
| 102 | + | ||
| 103 | + | report, err := BuildSessionExitReport(lockDir) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | require.Len(t, report, 3) | |
| 106 | + | ||
| 107 | + | got := map[string]SessionExitClass{} | |
| 108 | + | for _, item := range report { | |
| 109 | + | got[item.SessionID] = item.Class | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | assert.Equal(t, SessionExitCleanExit, got["clean-r"]) | |
| 113 | + | assert.Equal(t, SessionExitMainProcessCrashOrAbrupt, got["abrupt-r"]) | |
| 114 | + | assert.Equal(t, SessionExitExternalKillOrOOMInferred, got["inferred-r"]) | |
| 115 | + | } | |
| @@ -0,0 +1,115 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestClassifySessionExitFromMarkers_CleanExit(t *testing.T) { | |||
| 14 | + | lockDir := t.TempDir() | |||
| 15 | + | sessionID := "clean-1" | |||
| 16 | + | startedAt := time.Date(2026, 2, 7, 9, 0, 0, 0, time.UTC) | |||
| 17 | + | endedAt := startedAt.Add(2 * time.Minute) | |||
| 18 | + | ||||
| 19 | + | require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt)) | |||
| 20 | + | require.NoError(t, writeShutdownMarker(lockDir, sessionID, endedAt)) | |||
| 21 | + | ||||
| 22 | + | classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID) | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | assert.Equal(t, SessionExitCleanExit, classification.Class) | |||
| 25 | + | assert.Equal(t, "marker-confirmed", classification.Inference) | |||
| 26 | + | assert.Equal(t, "shutdown marker present", classification.Reason) | |||
| 27 | + | require.NotNil(t, classification.StartupAt) | |||
| 28 | + | assert.Equal(t, startedAt, *classification.StartupAt) | |||
| 29 | + | require.NotNil(t, classification.ShutdownAt) | |||
| 30 | + | assert.Equal(t, endedAt, *classification.ShutdownAt) | |||
| 31 | + | assert.Nil(t, classification.AbruptDetectedAt) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestClassifySessionExitFromMarkers_AbruptMarker(t *testing.T) { | |||
| 35 | + | lockDir := t.TempDir() | |||
| 36 | + | sessionID := "abrupt-1" | |||
| 37 | + | startedAt := time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC) | |||
| 38 | + | detectedAt := startedAt.Add(5 * time.Minute) | |||
| 39 | + | ||||
| 40 | + | require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt)) | |||
| 41 | + | require.NoError(t, os.WriteFile( | |||
| 42 | + | abruptMarkerPath(lockDir, sessionID), | |||
| 43 | + | []byte("detected_at="+detectedAt.Format(time.RFC3339Nano)+"\n"), | |||
| 44 | + | markerFilePerm, | |||
| 45 | + | )) | |||
| 46 | + | ||||
| 47 | + | classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID) | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | assert.Equal(t, SessionExitMainProcessCrashOrAbrupt, classification.Class) | |||
| 50 | + | assert.Equal(t, "marker-confirmed", classification.Inference) | |||
| 51 | + | assert.Equal(t, "abrupt marker present and no shutdown marker", classification.Reason) | |||
| 52 | + | require.NotNil(t, classification.StartupAt) | |||
| 53 | + | assert.Equal(t, startedAt, *classification.StartupAt) | |||
| 54 | + | assert.Nil(t, classification.ShutdownAt) | |||
| 55 | + | require.NotNil(t, classification.AbruptDetectedAt) | |||
| 56 | + | assert.Equal(t, detectedAt, *classification.AbruptDetectedAt) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestClassifySessionExitFromMarkers_ExternalKillOrOOMInferred(t *testing.T) { | |||
| 60 | + | lockDir := t.TempDir() | |||
| 61 | + | sessionID := "inferred-1" | |||
| 62 | + | startedAt := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC) | |||
| 63 | + | ||||
| 64 | + | require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt)) | |||
| 65 | + | ||||
| 66 | + | classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID) | |||
| 67 | + | require.NoError(t, err) | |||
| 68 | + | assert.Equal(t, SessionExitExternalKillOrOOMInferred, classification.Class) | |||
| 69 | + | assert.Equal(t, "best-effort", classification.Inference) | |||
| 70 | + | assert.Equal(t, "startup marker present without shutdown/abrupt markers", classification.Reason) | |||
| 71 | + | require.NotNil(t, classification.StartupAt) | |||
| 72 | + | assert.Equal(t, startedAt, *classification.StartupAt) | |||
| 73 | + | assert.Nil(t, classification.ShutdownAt) | |||
| 74 | + | assert.Nil(t, classification.AbruptDetectedAt) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestClassifySessionExitFromMarkers_RequiresInputs(t *testing.T) { | |||
| 78 | + | _, err := ClassifySessionExitFromMarkers("", "s1") | |||
| 79 | + | require.Error(t, err) | |||
| 80 | + | ||||
| 81 | + | _, err = ClassifySessionExitFromMarkers(t.TempDir(), "") | |||
| 82 | + | require.Error(t, err) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestBuildSessionExitReport(t *testing.T) { | |||
| 86 | + | lockDir := t.TempDir() | |||
| 87 | + | ||||
| 88 | + | require.NoError(t, writeStartupMarker(lockDir, "clean-r", time.Date(2026, 2, 7, 8, 0, 0, 0, time.UTC))) | |||
| 89 | + | require.NoError(t, writeShutdownMarker(lockDir, "clean-r", time.Date(2026, 2, 7, 8, 1, 0, 0, time.UTC))) | |||
| 90 | + | ||||
| 91 | + | require.NoError(t, writeStartupMarker(lockDir, "abrupt-r", time.Date(2026, 2, 7, 8, 5, 0, 0, time.UTC))) | |||
| 92 | + | require.NoError(t, os.WriteFile( | |||
| 93 | + | abruptMarkerPath(lockDir, "abrupt-r"), | |||
| 94 | + | []byte("detected_at=2026-02-07T08:08:00Z\n"), | |||
| 95 | + | markerFilePerm, | |||
| 96 | + | )) | |||
| 97 | + | ||||
| 98 | + | require.NoError(t, writeStartupMarker(lockDir, "inferred-r", time.Date(2026, 2, 7, 8, 10, 0, 0, time.UTC))) | |||
| 99 | + | ||||
| 100 | + | // Unknown marker type should not be included. | |||
| 101 | + | require.NoError(t, os.WriteFile(filepath.Join(lockDir, "session_unknown.custom.marker"), []byte("x"), markerFilePerm)) | |||
| 102 | + | ||||
| 103 | + | report, err := BuildSessionExitReport(lockDir) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | require.Len(t, report, 3) | |||
| 106 | + | ||||
| 107 | + | got := map[string]SessionExitClass{} | |||
| 108 | + | for _, item := range report { | |||
| 109 | + | got[item.SessionID] = item.Class | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | assert.Equal(t, SessionExitCleanExit, got["clean-r"]) | |||
| 113 | + | assert.Equal(t, SessionExitMainProcessCrashOrAbrupt, got["abrupt-r"]) | |||
| 114 | + | assert.Equal(t, SessionExitExternalKillOrOOMInferred, got["inferred-r"]) | |||
| 115 | + | } | |||
M internal/infrastructure/filtering/downloader.go +38 -9
| @@ -164,6 +164,10 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string, | |||
| 164 | 164 | url := fmt.Sprintf("%s/%s", d.baseURL, filename) | |
| 165 | 165 | localPath := filepath.Join(d.cacheDir, filename) | |
| 166 | 166 | ||
| 167 | + | if err := os.MkdirAll(d.cacheDir, cacheDirPerm); err != nil { | |
| 168 | + | return "", 0, fmt.Errorf("failed to create cache dir: %w", err) | |
| 169 | + | } | |
| 170 | + | ||
| 167 | 171 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) | |
| 168 | 172 | if err != nil { | |
| 169 | 173 | return "", 0, fmt.Errorf("failed to create request: %w", err) | |
| @@ -183,33 +187,58 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string, | |||
| 183 | 187 | return "", 0, fmt.Errorf("download %s failed with status %d", filename, resp.StatusCode) | |
| 184 | 188 | } | |
| 185 | 189 | ||
| 186 | - | // Write to temp file first, then atomic rename | |
| 187 | - | tmpPath := localPath + ".tmp" | |
| 188 | - | file, err := os.Create(tmpPath) | |
| 190 | + | // Write to temp file in cacheDir, then atomic rename into destination. | |
| 191 | + | tmpFile, err := os.CreateTemp(d.cacheDir, filepath.Base(filename)+".*.tmp") | |
| 189 | 192 | if err != nil { | |
| 190 | 193 | return "", 0, fmt.Errorf("failed to create temp file: %w", err) | |
| 191 | 194 | } | |
| 195 | + | tmpPath := tmpFile.Name() | |
| 192 | 196 | ||
| 193 | - | bytesWritten, err := io.Copy(file, resp.Body) | |
| 197 | + | bytesWritten, err := io.Copy(tmpFile, resp.Body) | |
| 194 | 198 | if err != nil { | |
| 195 | - | _ = file.Close() | |
| 199 | + | _ = tmpFile.Close() | |
| 196 | 200 | _ = os.Remove(tmpPath) | |
| 197 | 201 | return "", 0, fmt.Errorf("failed to write %s: %w", filename, err) | |
| 198 | 202 | } | |
| 199 | - | if err := file.Close(); err != nil { | |
| 203 | + | if err := tmpFile.Close(); err != nil { | |
| 200 | 204 | _ = os.Remove(tmpPath) | |
| 201 | 205 | return "", 0, fmt.Errorf("failed to close temp file: %w", err) | |
| 202 | 206 | } | |
| 203 | 207 | ||
| 204 | - | // Atomic rename | |
| 205 | - | if err := os.Rename(tmpPath, localPath); err != nil { | |
| 208 | + | if err := d.renameTempFile(tmpPath, localPath); err != nil { | |
| 206 | 209 | _ = os.Remove(tmpPath) | |
| 207 | - | return "", 0, fmt.Errorf("failed to rename temp file: %w", err) | |
| 210 | + | return "", 0, err | |
| 208 | 211 | } | |
| 209 | 212 | ||
| 210 | 213 | return localPath, bytesWritten, nil | |
| 211 | 214 | } | |
| 212 | 215 | ||
| 216 | + | func (*Downloader) renameTempFile(tmpPath, localPath string) error { | |
| 217 | + | parentDir := filepath.Dir(localPath) | |
| 218 | + | ||
| 219 | + | if err := os.MkdirAll(parentDir, cacheDirPerm); err != nil { | |
| 220 | + | return fmt.Errorf("failed to create target dir: %w", err) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | parentInfo, err := os.Stat(parentDir) | |
| 224 | + | if err != nil { | |
| 225 | + | return fmt.Errorf("failed to stat target dir: %w", err) | |
| 226 | + | } | |
| 227 | + | if !parentInfo.IsDir() { | |
| 228 | + | return fmt.Errorf("target parent path is not a directory: %s", parentDir) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | if _, err := os.Stat(tmpPath); err != nil { | |
| 232 | + | return fmt.Errorf("temp file missing before rename: %w", err) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | if err := os.Rename(tmpPath, localPath); err != nil { | |
| 236 | + | return fmt.Errorf("failed to rename temp file: %w", err) | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | return nil | |
| 240 | + | } | |
| 241 | + | ||
| 213 | 242 | // downloadAndCacheManifest downloads and caches the manifest. | |
| 214 | 243 | func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error { | |
| 215 | 244 | manifest, err := d.FetchManifest(ctx) | |
| @@ -164,6 +164,10 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string, | |||||
| 164 | url := fmt.Sprintf("%s/%s", d.baseURL, filename) | 164 | url := fmt.Sprintf("%s/%s", d.baseURL, filename) | ||
| 165 | localPath := filepath.Join(d.cacheDir, filename) | 165 | localPath := filepath.Join(d.cacheDir, filename) | ||
| 166 | 166 | ||||
| 167 | + | if err := os.MkdirAll(d.cacheDir, cacheDirPerm); err != nil { | |||
| 168 | + | return "", 0, fmt.Errorf("failed to create cache dir: %w", err) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 167 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) | 171 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) | ||
| 168 | if err != nil { | 172 | if err != nil { | ||
| 169 | return "", 0, fmt.Errorf("failed to create request: %w", err) | 173 | return "", 0, fmt.Errorf("failed to create request: %w", err) | ||
| @@ -183,33 +187,58 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string, | |||||
| 183 | return "", 0, fmt.Errorf("download %s failed with status %d", filename, resp.StatusCode) | 187 | return "", 0, fmt.Errorf("download %s failed with status %d", filename, resp.StatusCode) | ||
| 184 | } | 188 | } | ||
| 185 | 189 | ||||
| 186 | - | // Write to temp file first, then atomic rename | 190 | + | // Write to temp file in cacheDir, then atomic rename into destination. |
| 187 | - | tmpPath := localPath + ".tmp" | 191 | + | tmpFile, err := os.CreateTemp(d.cacheDir, filepath.Base(filename)+".*.tmp") |
| 188 | - | file, err := os.Create(tmpPath) | |||
| 189 | if err != nil { | 192 | if err != nil { | ||
| 190 | return "", 0, fmt.Errorf("failed to create temp file: %w", err) | 193 | return "", 0, fmt.Errorf("failed to create temp file: %w", err) | ||
| 191 | } | 194 | } | ||
| 195 | + | tmpPath := tmpFile.Name() | |||
| 192 | 196 | ||||
| 193 | - | bytesWritten, err := io.Copy(file, resp.Body) | 197 | + | bytesWritten, err := io.Copy(tmpFile, resp.Body) |
| 194 | if err != nil { | 198 | if err != nil { | ||
| 195 | - | _ = file.Close() | 199 | + | _ = tmpFile.Close() |
| 196 | _ = os.Remove(tmpPath) | 200 | _ = os.Remove(tmpPath) | ||
| 197 | return "", 0, fmt.Errorf("failed to write %s: %w", filename, err) | 201 | return "", 0, fmt.Errorf("failed to write %s: %w", filename, err) | ||
| 198 | } | 202 | } | ||
| 199 | - | if err := file.Close(); err != nil { | 203 | + | if err := tmpFile.Close(); err != nil { |
| 200 | _ = os.Remove(tmpPath) | 204 | _ = os.Remove(tmpPath) | ||
| 201 | return "", 0, fmt.Errorf("failed to close temp file: %w", err) | 205 | return "", 0, fmt.Errorf("failed to close temp file: %w", err) | ||
| 202 | } | 206 | } | ||
| 203 | 207 | ||||
| 204 | - | // Atomic rename | 208 | + | if err := d.renameTempFile(tmpPath, localPath); err != nil { |
| 205 | - | if err := os.Rename(tmpPath, localPath); err != nil { | |||
| 206 | _ = os.Remove(tmpPath) | 209 | _ = os.Remove(tmpPath) | ||
| 207 | - | return "", 0, fmt.Errorf("failed to rename temp file: %w", err) | 210 | + | return "", 0, err |
| 208 | } | 211 | } | ||
| 209 | 212 | ||||
| 210 | return localPath, bytesWritten, nil | 213 | return localPath, bytesWritten, nil | ||
| 211 | } | 214 | } | ||
| 212 | 215 | ||||
| 216 | + | func (*Downloader) renameTempFile(tmpPath, localPath string) error { | |||
| 217 | + | parentDir := filepath.Dir(localPath) | |||
| 218 | + | ||||
| 219 | + | if err := os.MkdirAll(parentDir, cacheDirPerm); err != nil { | |||
| 220 | + | return fmt.Errorf("failed to create target dir: %w", err) | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | parentInfo, err := os.Stat(parentDir) | |||
| 224 | + | if err != nil { | |||
| 225 | + | return fmt.Errorf("failed to stat target dir: %w", err) | |||
| 226 | + | } | |||
| 227 | + | if !parentInfo.IsDir() { | |||
| 228 | + | return fmt.Errorf("target parent path is not a directory: %s", parentDir) | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | if _, err := os.Stat(tmpPath); err != nil { | |||
| 232 | + | return fmt.Errorf("temp file missing before rename: %w", err) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | if err := os.Rename(tmpPath, localPath); err != nil { | |||
| 236 | + | return fmt.Errorf("failed to rename temp file: %w", err) | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | return nil | |||
| 240 | + | } | |||
| 241 | + | ||||
| 213 | // downloadAndCacheManifest downloads and caches the manifest. | 242 | // downloadAndCacheManifest downloads and caches the manifest. | ||
| 214 | func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error { | 243 | func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error { | ||
| 215 | manifest, err := d.FetchManifest(ctx) | 244 | manifest, err := d.FetchManifest(ctx) | ||
A internal/infrastructure/filtering/downloader_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package filtering | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestDownloader_DownloadFile_RecoversWhenTargetDirDeletedConcurrently(t *testing.T) { | |
| 16 | + | t.Parallel() | |
| 17 | + | ||
| 18 | + | cacheDir := filepath.Join(t.TempDir(), "cache") | |
| 19 | + | filename := filepath.Join("nested", "combined-part1.json") | |
| 20 | + | localPath := filepath.Join(cacheDir, filename) | |
| 21 | + | targetDir := filepath.Dir(localPath) | |
| 22 | + | ||
| 23 | + | require.NoError(t, os.MkdirAll(targetDir, cacheDirPerm)) | |
| 24 | + | ||
| 25 | + | downloadStarted := make(chan struct{}) | |
| 26 | + | allowResponse := make(chan struct{}) | |
| 27 | + | ||
| 28 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 29 | + | if r.URL.Path == "/"+filename { | |
| 30 | + | close(downloadStarted) | |
| 31 | + | <-allowResponse | |
| 32 | + | _, _ = io.WriteString(w, `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`) | |
| 33 | + | return | |
| 34 | + | } | |
| 35 | + | http.NotFound(w, r) | |
| 36 | + | })) | |
| 37 | + | defer server.Close() | |
| 38 | + | ||
| 39 | + | d := NewDownloader(cacheDir) | |
| 40 | + | d.baseURL = server.URL | |
| 41 | + | ||
| 42 | + | type result struct { | |
| 43 | + | path string | |
| 44 | + | n int64 | |
| 45 | + | err error | |
| 46 | + | } | |
| 47 | + | resultCh := make(chan result, 1) | |
| 48 | + | go func() { | |
| 49 | + | path, n, err := d.downloadFile(context.Background(), filename) | |
| 50 | + | resultCh <- result{path: path, n: n, err: err} | |
| 51 | + | }() | |
| 52 | + | ||
| 53 | + | <-downloadStarted | |
| 54 | + | require.NoError(t, os.RemoveAll(targetDir)) | |
| 55 | + | close(allowResponse) | |
| 56 | + | ||
| 57 | + | res := <-resultCh | |
| 58 | + | require.NoError(t, res.err) | |
| 59 | + | require.Equal(t, localPath, res.path) | |
| 60 | + | require.Positive(t, res.n) | |
| 61 | + | ||
| 62 | + | content, err := os.ReadFile(localPath) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | require.Contains(t, string(content), "url-filter") | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestDownloader_RenameTempFile_RequiresDirectoryParent(t *testing.T) { | |
| 68 | + | t.Parallel() | |
| 69 | + | ||
| 70 | + | cacheDir := filepath.Join(t.TempDir(), "cache") | |
| 71 | + | require.NoError(t, os.MkdirAll(cacheDir, cacheDirPerm)) | |
| 72 | + | ||
| 73 | + | tmpFile := filepath.Join(cacheDir, "payload.tmp") | |
| 74 | + | require.NoError(t, os.WriteFile(tmpFile, []byte("payload"), manifestFilePerm)) | |
| 75 | + | ||
| 76 | + | parentAsFile := filepath.Join(cacheDir, "target") | |
| 77 | + | require.NoError(t, os.WriteFile(parentAsFile, []byte("not-a-directory"), manifestFilePerm)) | |
| 78 | + | ||
| 79 | + | d := NewDownloader(cacheDir) | |
| 80 | + | ||
| 81 | + | err := d.renameTempFile(tmpFile, filepath.Join(parentAsFile, "filter.json")) | |
| 82 | + | require.Error(t, err) | |
| 83 | + | require.Contains(t, err.Error(), "not a directory") | |
| 84 | + | } | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package filtering | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "io" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/http/httptest" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestDownloader_DownloadFile_RecoversWhenTargetDirDeletedConcurrently(t *testing.T) { | |||
| 16 | + | t.Parallel() | |||
| 17 | + | ||||
| 18 | + | cacheDir := filepath.Join(t.TempDir(), "cache") | |||
| 19 | + | filename := filepath.Join("nested", "combined-part1.json") | |||
| 20 | + | localPath := filepath.Join(cacheDir, filename) | |||
| 21 | + | targetDir := filepath.Dir(localPath) | |||
| 22 | + | ||||
| 23 | + | require.NoError(t, os.MkdirAll(targetDir, cacheDirPerm)) | |||
| 24 | + | ||||
| 25 | + | downloadStarted := make(chan struct{}) | |||
| 26 | + | allowResponse := make(chan struct{}) | |||
| 27 | + | ||||
| 28 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 29 | + | if r.URL.Path == "/"+filename { | |||
| 30 | + | close(downloadStarted) | |||
| 31 | + | <-allowResponse | |||
| 32 | + | _, _ = io.WriteString(w, `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`) | |||
| 33 | + | return | |||
| 34 | + | } | |||
| 35 | + | http.NotFound(w, r) | |||
| 36 | + | })) | |||
| 37 | + | defer server.Close() | |||
| 38 | + | ||||
| 39 | + | d := NewDownloader(cacheDir) | |||
| 40 | + | d.baseURL = server.URL | |||
| 41 | + | ||||
| 42 | + | type result struct { | |||
| 43 | + | path string | |||
| 44 | + | n int64 | |||
| 45 | + | err error | |||
| 46 | + | } | |||
| 47 | + | resultCh := make(chan result, 1) | |||
| 48 | + | go func() { | |||
| 49 | + | path, n, err := d.downloadFile(context.Background(), filename) | |||
| 50 | + | resultCh <- result{path: path, n: n, err: err} | |||
| 51 | + | }() | |||
| 52 | + | ||||
| 53 | + | <-downloadStarted | |||
| 54 | + | require.NoError(t, os.RemoveAll(targetDir)) | |||
| 55 | + | close(allowResponse) | |||
| 56 | + | ||||
| 57 | + | res := <-resultCh | |||
| 58 | + | require.NoError(t, res.err) | |||
| 59 | + | require.Equal(t, localPath, res.path) | |||
| 60 | + | require.Positive(t, res.n) | |||
| 61 | + | ||||
| 62 | + | content, err := os.ReadFile(localPath) | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | require.Contains(t, string(content), "url-filter") | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestDownloader_RenameTempFile_RequiresDirectoryParent(t *testing.T) { | |||
| 68 | + | t.Parallel() | |||
| 69 | + | ||||
| 70 | + | cacheDir := filepath.Join(t.TempDir(), "cache") | |||
| 71 | + | require.NoError(t, os.MkdirAll(cacheDir, cacheDirPerm)) | |||
| 72 | + | ||||
| 73 | + | tmpFile := filepath.Join(cacheDir, "payload.tmp") | |||
| 74 | + | require.NoError(t, os.WriteFile(tmpFile, []byte("payload"), manifestFilePerm)) | |||
| 75 | + | ||||
| 76 | + | parentAsFile := filepath.Join(cacheDir, "target") | |||
| 77 | + | require.NoError(t, os.WriteFile(parentAsFile, []byte("not-a-directory"), manifestFilePerm)) | |||
| 78 | + | ||||
| 79 | + | d := NewDownloader(cacheDir) | |||
| 80 | + | ||||
| 81 | + | err := d.renameTempFile(tmpFile, filepath.Join(parentAsFile, "filter.json")) | |||
| 82 | + | require.Error(t, err) | |||
| 83 | + | require.Contains(t, err.Error(), "not a directory") | |||
| 84 | + | } | |||
M internal/infrastructure/persistence/sqlite/session_state_repo.go +25 -3
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "database/sql" | |
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "errors" | |
| 8 | + | "fmt" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 11 | "github.com/bnema/dumber/internal/domain/repository" | |
| @@ -13,12 +14,16 @@ import ( | |||
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | type sessionStateRepo struct { | |
| 17 | + | db *sql.DB | |
| 16 | 18 | queries *sqlc.Queries | |
| 17 | 19 | } | |
| 18 | 20 | ||
| 19 | 21 | // NewSessionStateRepository creates a new session state repository. | |
| 20 | 22 | func NewSessionStateRepository(db *sql.DB) repository.SessionStateRepository { | |
| 21 | - | return &sessionStateRepo{queries: sqlc.New(db)} | |
| 23 | + | return &sessionStateRepo{ | |
| 24 | + | db: db, | |
| 25 | + | queries: sqlc.New(db), | |
| 26 | + | } | |
| 22 | 27 | } | |
| 23 | 28 | ||
| 24 | 29 | // SaveSnapshot saves or updates a session state snapshot. | |
| @@ -40,14 +45,31 @@ func (r *sessionStateRepo) SaveSnapshot(ctx context.Context, state *entity.Sessi | |||
| 40 | 45 | Int("pane_count", state.CountPanes()). | |
| 41 | 46 | Msg("saving session state snapshot") | |
| 42 | 47 | ||
| 43 | - | return r.queries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{ | |
| 48 | + | tx, err := r.db.BeginTx(ctx, nil) | |
| 49 | + | if err != nil { | |
| 50 | + | return fmt.Errorf("begin snapshot transaction: %w", err) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | txQueries := r.queries.WithTx(tx) | |
| 54 | + | if err := txQueries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{ | |
| 44 | 55 | SessionID: string(state.SessionID), | |
| 45 | 56 | StateJson: string(stateJSON), | |
| 46 | 57 | Version: int64(state.Version), | |
| 47 | 58 | TabCount: int64(len(state.Tabs)), | |
| 48 | 59 | PaneCount: int64(state.CountPanes()), | |
| 49 | 60 | UpdatedAt: state.SavedAt, | |
| 50 | - | }) | |
| 61 | + | }); err != nil { | |
| 62 | + | if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { | |
| 63 | + | return fmt.Errorf("save snapshot rollback after upsert failure: %w", errors.Join(err, rollbackErr)) | |
| 64 | + | } | |
| 65 | + | return err | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | if err := tx.Commit(); err != nil { | |
| 69 | + | return fmt.Errorf("commit snapshot transaction: %w", err) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | return nil | |
| 51 | 73 | } | |
| 52 | 74 | ||
| 53 | 75 | // GetSnapshot returns the latest snapshot for a session. | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "database/sql" | 5 | "database/sql" | ||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | + | "fmt" | |||
| 8 | 9 | ||||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 10 | "github.com/bnema/dumber/internal/domain/repository" | 11 | "github.com/bnema/dumber/internal/domain/repository" | ||
| @@ -13,12 +14,16 @@ import ( | |||||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 15 | type sessionStateRepo struct { | 16 | type sessionStateRepo struct { | ||
| 17 | + | db *sql.DB | |||
| 16 | queries *sqlc.Queries | 18 | queries *sqlc.Queries | ||
| 17 | } | 19 | } | ||
| 18 | 20 | ||||
| 19 | // NewSessionStateRepository creates a new session state repository. | 21 | // NewSessionStateRepository creates a new session state repository. | ||
| 20 | func NewSessionStateRepository(db *sql.DB) repository.SessionStateRepository { | 22 | func NewSessionStateRepository(db *sql.DB) repository.SessionStateRepository { | ||
| 21 | - | return &sessionStateRepo{queries: sqlc.New(db)} | 23 | + | return &sessionStateRepo{ |
| 24 | + | db: db, | |||
| 25 | + | queries: sqlc.New(db), | |||
| 26 | + | } | |||
| 22 | } | 27 | } | ||
| 23 | 28 | ||||
| 24 | // SaveSnapshot saves or updates a session state snapshot. | 29 | // SaveSnapshot saves or updates a session state snapshot. | ||
| @@ -40,14 +45,31 @@ func (r *sessionStateRepo) SaveSnapshot(ctx context.Context, state *entity.Sessi | |||||
| 40 | Int("pane_count", state.CountPanes()). | 45 | Int("pane_count", state.CountPanes()). | ||
| 41 | Msg("saving session state snapshot") | 46 | Msg("saving session state snapshot") | ||
| 42 | 47 | ||||
| 43 | - | return r.queries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{ | 48 | + | tx, err := r.db.BeginTx(ctx, nil) |
| 49 | + | if err != nil { | |||
| 50 | + | return fmt.Errorf("begin snapshot transaction: %w", err) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | txQueries := r.queries.WithTx(tx) | |||
| 54 | + | if err := txQueries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{ | |||
| 44 | SessionID: string(state.SessionID), | 55 | SessionID: string(state.SessionID), | ||
| 45 | StateJson: string(stateJSON), | 56 | StateJson: string(stateJSON), | ||
| 46 | Version: int64(state.Version), | 57 | Version: int64(state.Version), | ||
| 47 | TabCount: int64(len(state.Tabs)), | 58 | TabCount: int64(len(state.Tabs)), | ||
| 48 | PaneCount: int64(state.CountPanes()), | 59 | PaneCount: int64(state.CountPanes()), | ||
| 49 | UpdatedAt: state.SavedAt, | 60 | UpdatedAt: state.SavedAt, | ||
| 50 | - | }) | 61 | + | }); err != nil { |
| 62 | + | if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { | |||
| 63 | + | return fmt.Errorf("save snapshot rollback after upsert failure: %w", errors.Join(err, rollbackErr)) | |||
| 64 | + | } | |||
| 65 | + | return err | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | if err := tx.Commit(); err != nil { | |||
| 69 | + | return fmt.Errorf("commit snapshot transaction: %w", err) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | return nil | |||
| 51 | } | 73 | } | ||
| 52 | 74 | ||||
| 53 | // GetSnapshot returns the latest snapshot for a session. | 75 | // GetSnapshot returns the latest snapshot for a session. | ||
M internal/infrastructure/snapshot/service.go +105 -4
| @@ -2,6 +2,9 @@ package snapshot | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 5 | 8 | "sync" | |
| 6 | 9 | "time" | |
| 7 | 10 | ||
| @@ -10,11 +13,19 @@ import ( | |||
| 10 | 13 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 14 | ) | |
| 12 | 15 | ||
| 16 | + | const ( | |
| 17 | + | defaultSnapshotIntervalMs = 5000 | |
| 18 | + | maxFKRetries = 3 | |
| 19 | + | fkRetryDelay = 100 * time.Millisecond | |
| 20 | + | ) | |
| 21 | + | ||
| 13 | 22 | // Service handles debounced session state snapshots. | |
| 14 | 23 | type Service struct { | |
| 15 | 24 | snapshotUC *usecase.SnapshotSessionUseCase | |
| 16 | 25 | provider port.TabListProvider | |
| 17 | 26 | interval time.Duration | |
| 27 | + | retries int | |
| 28 | + | retryDelay time.Duration | |
| 18 | 29 | ||
| 19 | 30 | mu sync.Mutex | |
| 20 | 31 | timer *time.Timer | |
| @@ -31,12 +42,14 @@ func NewService( | |||
| 31 | 42 | intervalMs int, | |
| 32 | 43 | ) *Service { | |
| 33 | 44 | if intervalMs <= 0 { | |
| 34 | - | intervalMs = 5000 // Default 5 seconds | |
| 45 | + | intervalMs = defaultSnapshotIntervalMs | |
| 35 | 46 | } | |
| 36 | 47 | return &Service{ | |
| 37 | 48 | snapshotUC: snapshotUC, | |
| 38 | 49 | provider: provider, | |
| 39 | 50 | interval: time.Duration(intervalMs) * time.Millisecond, | |
| 51 | + | retries: maxFKRetries, | |
| 52 | + | retryDelay: fkRetryDelay, | |
| 40 | 53 | } | |
| 41 | 54 | } | |
| 42 | 55 | ||
| @@ -54,8 +67,20 @@ func (s *Service) Start(ctx context.Context) { | |||
| 54 | 67 | // to avoid FK constraint violations. | |
| 55 | 68 | func (s *Service) SetReady() { | |
| 56 | 69 | s.mu.Lock() | |
| 57 | - | defer s.mu.Unlock() | |
| 58 | 70 | s.ready = true | |
| 71 | + | dirty := s.dirty | |
| 72 | + | ctx := s.ctx | |
| 73 | + | s.mu.Unlock() | |
| 74 | + | ||
| 75 | + | if !dirty || ctx == nil { | |
| 76 | + | return | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | go func() { | |
| 80 | + | if err := s.saveSnapshot(ctx); err != nil { | |
| 81 | + | logging.FromContext(ctx).Error().Err(err).Msg("failed to save pending session snapshot after ready") | |
| 82 | + | } | |
| 83 | + | }() | |
| 59 | 84 | } | |
| 60 | 85 | ||
| 61 | 86 | // Stop stops the service and saves final state. | |
| @@ -135,11 +160,87 @@ func (s *Service) saveSnapshot(ctx context.Context) error { | |||
| 135 | 160 | sessionID := s.provider.GetSessionID() | |
| 136 | 161 | ||
| 137 | 162 | if sessionID == "" { | |
| 163 | + | s.markDirty() | |
| 138 | 164 | return nil | |
| 139 | 165 | } | |
| 140 | 166 | ||
| 141 | - | return s.snapshotUC.Execute(ctx, usecase.SnapshotInput{ | |
| 167 | + | if err := s.executeWithRetry(ctx, usecase.SnapshotInput{ | |
| 142 | 168 | SessionID: sessionID, | |
| 143 | 169 | TabList: tabList, | |
| 144 | - | }) | |
| 170 | + | }); err != nil { | |
| 171 | + | s.markDirty() | |
| 172 | + | return err | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | return nil | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func (s *Service) executeWithRetry(ctx context.Context, input usecase.SnapshotInput) error { | |
| 179 | + | log := logging.FromContext(ctx) | |
| 180 | + | maxAttempts := s.retries + 1 | |
| 181 | + | ||
| 182 | + | var lastErr error | |
| 183 | + | for attempt := 1; attempt <= maxAttempts; attempt++ { | |
| 184 | + | if err := s.snapshotUC.Execute(ctx, input); err != nil { | |
| 185 | + | lastErr = err | |
| 186 | + | if !isTransientFKError(err) || attempt == maxAttempts { | |
| 187 | + | if attempt == maxAttempts && isTransientFKError(err) { | |
| 188 | + | log.Error(). | |
| 189 | + | Err(err). | |
| 190 | + | Str("session_id", string(input.SessionID)). | |
| 191 | + | Int("attempts", maxAttempts). | |
| 192 | + | Msg("snapshot save retries exhausted after transient fk violation") | |
| 193 | + | } | |
| 194 | + | return err | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | log.Warn(). | |
| 198 | + | Err(err). | |
| 199 | + | Str("session_id", string(input.SessionID)). | |
| 200 | + | Int("attempt", attempt). | |
| 201 | + | Int("max_attempts", maxAttempts). | |
| 202 | + | Dur("retry_delay", s.retryDelay). | |
| 203 | + | Msg("transient fk violation while saving snapshot; retrying") | |
| 204 | + | ||
| 205 | + | if waitErr := waitForRetry(ctx, s.retryDelay); waitErr != nil { | |
| 206 | + | return fmt.Errorf("waiting to retry snapshot save: %w", waitErr) | |
| 207 | + | } | |
| 208 | + | continue | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | return nil | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | return lastErr | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func (s *Service) markDirty() { | |
| 218 | + | s.mu.Lock() | |
| 219 | + | s.dirty = true | |
| 220 | + | s.mu.Unlock() | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func waitForRetry(ctx context.Context, delay time.Duration) error { | |
| 224 | + | if delay <= 0 { | |
| 225 | + | return nil | |
| 226 | + | } | |
| 227 | + | timer := time.NewTimer(delay) | |
| 228 | + | defer timer.Stop() | |
| 229 | + | ||
| 230 | + | select { | |
| 231 | + | case <-ctx.Done(): | |
| 232 | + | return ctx.Err() | |
| 233 | + | case <-timer.C: | |
| 234 | + | return nil | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func isTransientFKError(err error) bool { | |
| 239 | + | if err == nil { | |
| 240 | + | return false | |
| 241 | + | } | |
| 242 | + | if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | |
| 243 | + | return false | |
| 244 | + | } | |
| 245 | + | return strings.Contains(strings.ToLower(err.Error()), "foreign key") | |
| 145 | 246 | } | |
| @@ -2,6 +2,9 @@ package snapshot | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 5 | "sync" | 8 | "sync" | ||
| 6 | "time" | 9 | "time" | ||
| 7 | 10 | ||||
| @@ -10,11 +13,19 @@ import ( | |||||
| 10 | "github.com/bnema/dumber/internal/logging" | 13 | "github.com/bnema/dumber/internal/logging" | ||
| 11 | ) | 14 | ) | ||
| 12 | 15 | ||||
| 16 | + | const ( | |||
| 17 | + | defaultSnapshotIntervalMs = 5000 | |||
| 18 | + | maxFKRetries = 3 | |||
| 19 | + | fkRetryDelay = 100 * time.Millisecond | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 13 | // Service handles debounced session state snapshots. | 22 | // Service handles debounced session state snapshots. | ||
| 14 | type Service struct { | 23 | type Service struct { | ||
| 15 | snapshotUC *usecase.SnapshotSessionUseCase | 24 | snapshotUC *usecase.SnapshotSessionUseCase | ||
| 16 | provider port.TabListProvider | 25 | provider port.TabListProvider | ||
| 17 | interval time.Duration | 26 | interval time.Duration | ||
| 27 | + | retries int | |||
| 28 | + | retryDelay time.Duration | |||
| 18 | 29 | ||||
| 19 | mu sync.Mutex | 30 | mu sync.Mutex | ||
| 20 | timer *time.Timer | 31 | timer *time.Timer | ||
| @@ -31,12 +42,14 @@ func NewService( | |||||
| 31 | intervalMs int, | 42 | intervalMs int, | ||
| 32 | ) *Service { | 43 | ) *Service { | ||
| 33 | if intervalMs <= 0 { | 44 | if intervalMs <= 0 { | ||
| 34 | - | intervalMs = 5000 // Default 5 seconds | 45 | + | intervalMs = defaultSnapshotIntervalMs |
| 35 | } | 46 | } | ||
| 36 | return &Service{ | 47 | return &Service{ | ||
| 37 | snapshotUC: snapshotUC, | 48 | snapshotUC: snapshotUC, | ||
| 38 | provider: provider, | 49 | provider: provider, | ||
| 39 | interval: time.Duration(intervalMs) * time.Millisecond, | 50 | interval: time.Duration(intervalMs) * time.Millisecond, | ||
| 51 | + | retries: maxFKRetries, | |||
| 52 | + | retryDelay: fkRetryDelay, | |||
| 40 | } | 53 | } | ||
| 41 | } | 54 | } | ||
| 42 | 55 | ||||
| @@ -54,8 +67,20 @@ func (s *Service) Start(ctx context.Context) { | |||||
| 54 | // to avoid FK constraint violations. | 67 | // to avoid FK constraint violations. | ||
| 55 | func (s *Service) SetReady() { | 68 | func (s *Service) SetReady() { | ||
| 56 | s.mu.Lock() | 69 | s.mu.Lock() | ||
| 57 | - | defer s.mu.Unlock() | |||
| 58 | s.ready = true | 70 | s.ready = true | ||
| 71 | + | dirty := s.dirty | |||
| 72 | + | ctx := s.ctx | |||
| 73 | + | s.mu.Unlock() | |||
| 74 | + | ||||
| 75 | + | if !dirty || ctx == nil { | |||
| 76 | + | return | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | go func() { | |||
| 80 | + | if err := s.saveSnapshot(ctx); err != nil { | |||
| 81 | + | logging.FromContext(ctx).Error().Err(err).Msg("failed to save pending session snapshot after ready") | |||
| 82 | + | } | |||
| 83 | + | }() | |||
| 59 | } | 84 | } | ||
| 60 | 85 | ||||
| 61 | // Stop stops the service and saves final state. | 86 | // Stop stops the service and saves final state. | ||
| @@ -135,11 +160,87 @@ func (s *Service) saveSnapshot(ctx context.Context) error { | |||||
| 135 | sessionID := s.provider.GetSessionID() | 160 | sessionID := s.provider.GetSessionID() | ||
| 136 | 161 | ||||
| 137 | if sessionID == "" { | 162 | if sessionID == "" { | ||
| 163 | + | s.markDirty() | |||
| 138 | return nil | 164 | return nil | ||
| 139 | } | 165 | } | ||
| 140 | 166 | ||||
| 141 | - | return s.snapshotUC.Execute(ctx, usecase.SnapshotInput{ | 167 | + | if err := s.executeWithRetry(ctx, usecase.SnapshotInput{ |
| 142 | SessionID: sessionID, | 168 | SessionID: sessionID, | ||
| 143 | TabList: tabList, | 169 | TabList: tabList, | ||
| 144 | - | }) | 170 | + | }); err != nil { |
| 171 | + | s.markDirty() | |||
| 172 | + | return err | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | return nil | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func (s *Service) executeWithRetry(ctx context.Context, input usecase.SnapshotInput) error { | |||
| 179 | + | log := logging.FromContext(ctx) | |||
| 180 | + | maxAttempts := s.retries + 1 | |||
| 181 | + | ||||
| 182 | + | var lastErr error | |||
| 183 | + | for attempt := 1; attempt <= maxAttempts; attempt++ { | |||
| 184 | + | if err := s.snapshotUC.Execute(ctx, input); err != nil { | |||
| 185 | + | lastErr = err | |||
| 186 | + | if !isTransientFKError(err) || attempt == maxAttempts { | |||
| 187 | + | if attempt == maxAttempts && isTransientFKError(err) { | |||
| 188 | + | log.Error(). | |||
| 189 | + | Err(err). | |||
| 190 | + | Str("session_id", string(input.SessionID)). | |||
| 191 | + | Int("attempts", maxAttempts). | |||
| 192 | + | Msg("snapshot save retries exhausted after transient fk violation") | |||
| 193 | + | } | |||
| 194 | + | return err | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | log.Warn(). | |||
| 198 | + | Err(err). | |||
| 199 | + | Str("session_id", string(input.SessionID)). | |||
| 200 | + | Int("attempt", attempt). | |||
| 201 | + | Int("max_attempts", maxAttempts). | |||
| 202 | + | Dur("retry_delay", s.retryDelay). | |||
| 203 | + | Msg("transient fk violation while saving snapshot; retrying") | |||
| 204 | + | ||||
| 205 | + | if waitErr := waitForRetry(ctx, s.retryDelay); waitErr != nil { | |||
| 206 | + | return fmt.Errorf("waiting to retry snapshot save: %w", waitErr) | |||
| 207 | + | } | |||
| 208 | + | continue | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | return nil | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | return lastErr | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func (s *Service) markDirty() { | |||
| 218 | + | s.mu.Lock() | |||
| 219 | + | s.dirty = true | |||
| 220 | + | s.mu.Unlock() | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func waitForRetry(ctx context.Context, delay time.Duration) error { | |||
| 224 | + | if delay <= 0 { | |||
| 225 | + | return nil | |||
| 226 | + | } | |||
| 227 | + | timer := time.NewTimer(delay) | |||
| 228 | + | defer timer.Stop() | |||
| 229 | + | ||||
| 230 | + | select { | |||
| 231 | + | case <-ctx.Done(): | |||
| 232 | + | return ctx.Err() | |||
| 233 | + | case <-timer.C: | |||
| 234 | + | return nil | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func isTransientFKError(err error) bool { | |||
| 239 | + | if err == nil { | |||
| 240 | + | return false | |||
| 241 | + | } | |||
| 242 | + | if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | |||
| 243 | + | return false | |||
| 244 | + | } | |||
| 245 | + | return strings.Contains(strings.ToLower(err.Error()), "foreign key") | |||
| 145 | } | 246 | } | ||
A internal/infrastructure/snapshot/service_test.go +138 -0
| @@ -0,0 +1,138 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type testProvider struct { | |
| 18 | + | sessionID entity.SessionID | |
| 19 | + | tabList *entity.TabList | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (p *testProvider) GetTabList() *entity.TabList { | |
| 23 | + | return p.tabList | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (p *testProvider) GetSessionID() entity.SessionID { | |
| 27 | + | return p.sessionID | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) { | |
| 31 | + | repo := repomocks.NewMockSessionStateRepository(t) | |
| 32 | + | calls := 0 | |
| 33 | + | repo.EXPECT(). | |
| 34 | + | SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |
| 35 | + | RunAndReturn(func(_ context.Context, _ *entity.SessionState) error { | |
| 36 | + | calls++ | |
| 37 | + | if calls == 1 { | |
| 38 | + | return errors.New("FOREIGN KEY constraint failed") | |
| 39 | + | } | |
| 40 | + | return nil | |
| 41 | + | }) | |
| 42 | + | ||
| 43 | + | uc := usecase.NewSnapshotSessionUseCase(repo) | |
| 44 | + | svc := NewService(uc, &testProvider{ | |
| 45 | + | sessionID: "20260207_120000_fk_retry_ok", | |
| 46 | + | tabList: entity.NewTabList(), | |
| 47 | + | }, 1) | |
| 48 | + | svc.retryDelay = time.Millisecond | |
| 49 | + | svc.ready = true | |
| 50 | + | svc.dirty = true | |
| 51 | + | ||
| 52 | + | err := svc.saveSnapshot(context.Background()) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | assert.Equal(t, 2, calls) | |
| 55 | + | assert.False(t, svc.dirty) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestService_SaveSnapshot_RetriesTransientFKAndFails(t *testing.T) { | |
| 59 | + | repo := repomocks.NewMockSessionStateRepository(t) | |
| 60 | + | calls := 0 | |
| 61 | + | fkErr := errors.New("save session snapshot: FOREIGN KEY constraint failed") | |
| 62 | + | repo.EXPECT(). | |
| 63 | + | SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |
| 64 | + | RunAndReturn(func(_ context.Context, _ *entity.SessionState) error { | |
| 65 | + | calls++ | |
| 66 | + | return fkErr | |
| 67 | + | }) | |
| 68 | + | ||
| 69 | + | uc := usecase.NewSnapshotSessionUseCase(repo) | |
| 70 | + | svc := NewService(uc, &testProvider{ | |
| 71 | + | sessionID: "20260207_120000_fk_retry_fail", | |
| 72 | + | tabList: entity.NewTabList(), | |
| 73 | + | }, 1) | |
| 74 | + | svc.retryDelay = time.Millisecond | |
| 75 | + | svc.ready = true | |
| 76 | + | svc.dirty = true | |
| 77 | + | ||
| 78 | + | err := svc.saveSnapshot(context.Background()) | |
| 79 | + | require.Error(t, err) | |
| 80 | + | require.ErrorIs(t, err, fkErr) | |
| 81 | + | assert.Equal(t, svc.retries+1, calls) | |
| 82 | + | assert.True(t, svc.dirty) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestService_SaveSnapshot_DoesNotRetryNonFKError(t *testing.T) { | |
| 86 | + | repo := repomocks.NewMockSessionStateRepository(t) | |
| 87 | + | calls := 0 | |
| 88 | + | nonFKErr := errors.New("database is read-only") | |
| 89 | + | repo.EXPECT(). | |
| 90 | + | SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |
| 91 | + | RunAndReturn(func(_ context.Context, _ *entity.SessionState) error { | |
| 92 | + | calls++ | |
| 93 | + | return nonFKErr | |
| 94 | + | }) | |
| 95 | + | ||
| 96 | + | uc := usecase.NewSnapshotSessionUseCase(repo) | |
| 97 | + | svc := NewService(uc, &testProvider{ | |
| 98 | + | sessionID: "20260207_120000_non_fk", | |
| 99 | + | tabList: entity.NewTabList(), | |
| 100 | + | }, 1) | |
| 101 | + | svc.retryDelay = time.Millisecond | |
| 102 | + | svc.ready = true | |
| 103 | + | svc.dirty = true | |
| 104 | + | ||
| 105 | + | err := svc.saveSnapshot(context.Background()) | |
| 106 | + | require.Error(t, err) | |
| 107 | + | require.ErrorIs(t, err, nonFKErr) | |
| 108 | + | assert.Equal(t, 1, calls) | |
| 109 | + | assert.True(t, svc.dirty) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestService_SetReady_SavesPendingDirtySnapshot(t *testing.T) { | |
| 113 | + | repo := repomocks.NewMockSessionStateRepository(t) | |
| 114 | + | saved := make(chan struct{}, 1) | |
| 115 | + | repo.EXPECT(). | |
| 116 | + | SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |
| 117 | + | RunAndReturn(func(_ context.Context, _ *entity.SessionState) error { | |
| 118 | + | saved <- struct{}{} | |
| 119 | + | return nil | |
| 120 | + | }) | |
| 121 | + | ||
| 122 | + | uc := usecase.NewSnapshotSessionUseCase(repo) | |
| 123 | + | svc := NewService(uc, &testProvider{ | |
| 124 | + | sessionID: "20260207_120000_ready_flush", | |
| 125 | + | tabList: entity.NewTabList(), | |
| 126 | + | }, 1) | |
| 127 | + | svc.Start(context.Background()) | |
| 128 | + | svc.dirty = true | |
| 129 | + | ||
| 130 | + | svc.SetReady() | |
| 131 | + | ||
| 132 | + | select { | |
| 133 | + | case <-saved: | |
| 134 | + | case <-time.After(500 * time.Millisecond): | |
| 135 | + | t.Fatal("expected pending snapshot to be saved after SetReady") | |
| 136 | + | } | |
| 137 | + | assert.False(t, svc.dirty) | |
| 138 | + | } | |
| @@ -0,0 +1,138 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 11 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | type testProvider struct { | |||
| 18 | + | sessionID entity.SessionID | |||
| 19 | + | tabList *entity.TabList | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (p *testProvider) GetTabList() *entity.TabList { | |||
| 23 | + | return p.tabList | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (p *testProvider) GetSessionID() entity.SessionID { | |||
| 27 | + | return p.sessionID | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) { | |||
| 31 | + | repo := repomocks.NewMockSessionStateRepository(t) | |||
| 32 | + | calls := 0 | |||
| 33 | + | repo.EXPECT(). | |||
| 34 | + | SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |||
| 35 | + | RunAndReturn(func(_ context.Context, _ *entity.SessionState) error { | |||
| 36 | + | calls++ | |||
| 37 | + | if calls == 1 { | |||
| 38 | + | return errors.New("FOREIGN KEY constraint failed") | |||
| 39 | + | } | |||
| 40 | + | return nil | |||
| 41 | + | }) | |||
| 42 | + | ||||
| 43 | + | uc := usecase.NewSnapshotSessionUseCase(repo) | |||
| 44 | + | svc := NewService(uc, &testProvider{ | |||
| 45 | + | sessionID: "20260207_120000_fk_retry_ok", | |||
| 46 | + | tabList: entity.NewTabList(), | |||
| 47 | + | }, 1) | |||
| 48 | + | svc.retryDelay = time.Millisecond | |||
| 49 | + | svc.ready = true | |||
| 50 | + | svc.dirty = true | |||
| 51 | + | ||||
| 52 | + | err := svc.saveSnapshot(context.Background()) | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | assert.Equal(t, 2, calls) | |||
| 55 | + | assert.False(t, svc.dirty) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestService_SaveSnapshot_RetriesTransientFKAndFails(t *testing.T) { | |||
| 59 | + | repo := repomocks.NewMockSessionStateRepository(t) | |||
| 60 | + | calls := 0 | |||
| 61 | + | fkErr := errors.New("save session snapshot: FOREIGN KEY constraint failed") | |||
| 62 | + | repo.EXPECT(). | |||
| 63 | + | SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |||
| 64 | + | RunAndReturn(func(_ context.Context, _ *entity.SessionState) error { | |||
| 65 | + | calls++ | |||
| 66 | + | return fkErr | |||
| 67 | + | }) | |||
| 68 | + | ||||
| 69 | + | uc := usecase.NewSnapshotSessionUseCase(repo) | |||
| 70 | + | svc := NewService(uc, &testProvider{ | |||
| 71 | + | sessionID: "20260207_120000_fk_retry_fail", | |||
| 72 | + | tabList: entity.NewTabList(), | |||
| 73 | + | }, 1) | |||
| 74 | + | svc.retryDelay = time.Millisecond | |||
| 75 | + | svc.ready = true | |||
| 76 | + | svc.dirty = true | |||
| 77 | + | ||||
| 78 | + | err := svc.saveSnapshot(context.Background()) | |||
| 79 | + | require.Error(t, err) | |||
| 80 | + | require.ErrorIs(t, err, fkErr) | |||
| 81 | + | assert.Equal(t, svc.retries+1, calls) | |||
| 82 | + | assert.True(t, svc.dirty) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestService_SaveSnapshot_DoesNotRetryNonFKError(t *testing.T) { | |||
| 86 | + | repo := repomocks.NewMockSessionStateRepository(t) | |||
| 87 | + | calls := 0 | |||
| 88 | + | nonFKErr := errors.New("database is read-only") | |||
| 89 | + | repo.EXPECT(). | |||
| 90 | + | SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |||
| 91 | + | RunAndReturn(func(_ context.Context, _ *entity.SessionState) error { | |||
| 92 | + | calls++ | |||
| 93 | + | return nonFKErr | |||
| 94 | + | }) | |||
| 95 | + | ||||
| 96 | + | uc := usecase.NewSnapshotSessionUseCase(repo) | |||
| 97 | + | svc := NewService(uc, &testProvider{ | |||
| 98 | + | sessionID: "20260207_120000_non_fk", | |||
| 99 | + | tabList: entity.NewTabList(), | |||
| 100 | + | }, 1) | |||
| 101 | + | svc.retryDelay = time.Millisecond | |||
| 102 | + | svc.ready = true | |||
| 103 | + | svc.dirty = true | |||
| 104 | + | ||||
| 105 | + | err := svc.saveSnapshot(context.Background()) | |||
| 106 | + | require.Error(t, err) | |||
| 107 | + | require.ErrorIs(t, err, nonFKErr) | |||
| 108 | + | assert.Equal(t, 1, calls) | |||
| 109 | + | assert.True(t, svc.dirty) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestService_SetReady_SavesPendingDirtySnapshot(t *testing.T) { | |||
| 113 | + | repo := repomocks.NewMockSessionStateRepository(t) | |||
| 114 | + | saved := make(chan struct{}, 1) | |||
| 115 | + | repo.EXPECT(). | |||
| 116 | + | SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |||
| 117 | + | RunAndReturn(func(_ context.Context, _ *entity.SessionState) error { | |||
| 118 | + | saved <- struct{}{} | |||
| 119 | + | return nil | |||
| 120 | + | }) | |||
| 121 | + | ||||
| 122 | + | uc := usecase.NewSnapshotSessionUseCase(repo) | |||
| 123 | + | svc := NewService(uc, &testProvider{ | |||
| 124 | + | sessionID: "20260207_120000_ready_flush", | |||
| 125 | + | tabList: entity.NewTabList(), | |||
| 126 | + | }, 1) | |||
| 127 | + | svc.Start(context.Background()) | |||
| 128 | + | svc.dirty = true | |||
| 129 | + | ||||
| 130 | + | svc.SetReady() | |||
| 131 | + | ||||
| 132 | + | select { | |||
| 133 | + | case <-saved: | |||
| 134 | + | case <-time.After(500 * time.Millisecond): | |||
| 135 | + | t.Fatal("expected pending snapshot to be saved after SetReady") | |||
| 136 | + | } | |||
| 137 | + | assert.False(t, svc.dirty) | |||
| 138 | + | } | |||