• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / cronn / 19323121139

13 Nov 2025 06:51AM UTC coverage: 74.243% (+0.8%) from 73.473%
19323121139

Pull #51

github

umputun
fix: include command output in resume failure notifications

- resumeInterrupted now captures notifyOutput from executeCommand
- combines error message with command output for notifications
- matches pattern used in runJobWithCommand for consistency
- adds comprehensive tests for resume notification behavior
- test verifies failed resume includes both error and output
- test verifies successful resume does not send notification

refactor: replace time.Sleep with require.Eventually in tests

- replace time.Sleep with require.Eventually for async assertions
- improves test reliability and reduces flakiness
- tests run faster (ManualTrigger: 1.6s -> 0.95s, reload: 0.2s -> 0.01s)
- affected tests: manual trigger, reload, resumeInterrupted
- keep time.Sleep only for intentional delays (e.g., timeout simulation)

related to external review feedback
Pull Request #51: Add execution history with SQLite storage and web UI display

222 of 244 new or added lines in 5 files covered. (90.98%)

6 existing lines in 4 files now uncovered.

2502 of 3370 relevant lines covered (74.24%)

35.44 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.71
/app/web/persistence/sqlite.go
1
package persistence
2

3
import (
4
        "context"
5
        "fmt"
6
        "sync"
7
        "time"
8

9
        "github.com/jmoiron/sqlx"
10
        _ "modernc.org/sqlite" // sqlite driver
11

12
        "github.com/umputun/cronn/app/service/request"
13
        "github.com/umputun/cronn/app/web/enums"
14
)
15

16
// JobInfo represents a cron job with its execution state
17
type JobInfo struct {
18
        ID         string          `db:"id"`
19
        Command    string          `db:"command"`
20
        Schedule   string          `db:"schedule"`
21
        NextRun    time.Time       `db:"next_run"`
22
        LastRun    time.Time       `db:"last_run"`
23
        LastStatus enums.JobStatus `db:"last_status"`
24
        IsRunning  bool            `db:"-"` // not stored in DB
25
        Enabled    bool            `db:"enabled"`
26
        CreatedAt  time.Time       `db:"created_at"`
27
        UpdatedAt  time.Time       `db:"updated_at"`
28
        SortIndex  int             `db:"sort_index"`
29
}
30

31
// ExecutionInfo represents a single job execution record
32
type ExecutionInfo struct {
33
        ID              int             `db:"id"`
34
        JobID           string          `db:"job_id"`
35
        StartedAt       time.Time       `db:"started_at"`
36
        FinishedAt      time.Time       `db:"finished_at"`
37
        Status          enums.JobStatus `db:"status"`
38
        ExitCode        int             `db:"exit_code"`
39
        ExecutedCommand string          `db:"executed_command"`
40
        Output          string          `db:"output"`
41
}
42

43
// SQLiteStore implements persistence using SQLite
44
type SQLiteStore struct {
45
        db *sqlx.DB
46
        mu sync.RWMutex // protects concurrent database access
47
}
48

49
// NewSQLiteStore creates a new SQLite store and initializes the database
50
func NewSQLiteStore(dbPath string) (*SQLiteStore, error) {
23✔
51
        db, err := sqlx.Open("sqlite", dbPath)
23✔
52
        if err != nil {
23✔
53
                return nil, fmt.Errorf("failed to open database: %w", err)
×
54
        }
×
55

56
        // helper to execute pragma with proper error handling
57
        execPragma := func(pragma, errMsgPrefix string) error {
68✔
58
                if _, err := db.Exec(pragma); err != nil {
46✔
59
                        if closeErr := db.Close(); closeErr != nil {
1✔
60
                                return fmt.Errorf("%s: %w (also failed to close db: %v)", errMsgPrefix, err, closeErr)
×
61
                        }
×
62
                        return fmt.Errorf("%s: %w", errMsgPrefix, err)
1✔
63
                }
64
                return nil
44✔
65
        }
66

67
        // enable WAL mode for better concurrency
68
        if err := execPragma("PRAGMA journal_mode=WAL", "failed to set WAL mode"); err != nil {
24✔
69
                return nil, err
1✔
70
        }
1✔
71

72
        // set busy timeout to wait when database is locked
73
        if err := execPragma("PRAGMA busy_timeout=5000", "failed to set busy timeout"); err != nil {
22✔
74
                return nil, err
×
75
        }
×
76

77
        store := &SQLiteStore{db: db}
22✔
78

22✔
79
        // initialize database tables
22✔
80
        if err := store.initialize(); err != nil {
22✔
81
                _ = db.Close()
×
82
                return nil, fmt.Errorf("failed to initialize database: %w", err)
×
83
        }
×
84

85
        return store, nil
22✔
86
}
87

88
// initialize creates the database schema
89
func (s *SQLiteStore) initialize() error {
22✔
90
        queries := []string{
22✔
91
                `CREATE TABLE IF NOT EXISTS jobs (
22✔
92
                        id TEXT PRIMARY KEY,
22✔
93
                        command TEXT NOT NULL,
22✔
94
                        schedule TEXT NOT NULL,
22✔
95
                        next_run DATETIME,
22✔
96
                        last_run DATETIME,
22✔
97
                        last_status TEXT,
22✔
98
                        enabled BOOLEAN DEFAULT 1,
22✔
99
                        created_at DATETIME,
22✔
100
                        updated_at DATETIME,
22✔
101
                        sort_index INTEGER DEFAULT 0
22✔
102
                )`,
22✔
103
                `CREATE TABLE IF NOT EXISTS executions (
22✔
104
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
22✔
105
                        job_id TEXT,
22✔
106
                        started_at DATETIME,
22✔
107
                        finished_at DATETIME,
22✔
108
                        status TEXT,
22✔
109
                        exit_code INTEGER,
22✔
110
                        executed_command TEXT,
22✔
111
                        output TEXT,
22✔
112
                        FOREIGN KEY (job_id) REFERENCES jobs(id)
22✔
113
                )`,
22✔
114
                `CREATE INDEX IF NOT EXISTS idx_executions_job_id ON executions(job_id)`,
22✔
115
                `CREATE INDEX IF NOT EXISTS idx_executions_started_at ON executions(started_at)`,
22✔
116
        }
22✔
117

22✔
118
        for _, query := range queries {
110✔
119
                if _, err := s.db.Exec(query); err != nil {
88✔
120
                        return fmt.Errorf("failed to execute query: %w", err)
×
121
                }
×
122
        }
123

124
        // run schema migrations
125
        if err := s.migrate(); err != nil {
22✔
126
                return fmt.Errorf("failed to run migrations: %w", err)
×
127
        }
×
128

129
        return nil
22✔
130
}
131

132
// migrate performs schema migrations for existing databases
133
func (s *SQLiteStore) migrate() error {
22✔
134
        // check if executed_command column exists
22✔
135
        var executedCommandExists bool
22✔
136
        err := s.db.QueryRow(`
22✔
137
                SELECT COUNT(*) > 0
22✔
138
                FROM pragma_table_info('executions')
22✔
139
                WHERE name = 'executed_command'
22✔
140
        `).Scan(&executedCommandExists)
22✔
141
        if err != nil {
22✔
142
                return fmt.Errorf("failed to check for executed_command column: %w", err)
×
143
        }
×
144

145
        // add executed_command column if it doesn't exist
146
        if !executedCommandExists {
23✔
147
                if _, execErr := s.db.Exec("ALTER TABLE executions ADD COLUMN executed_command TEXT DEFAULT ''"); execErr != nil {
1✔
NEW
148
                        return fmt.Errorf("failed to add executed_command column: %w", execErr)
×
NEW
149
                }
×
150
        }
151

152
        // check if output column exists
153
        var outputExists bool
22✔
154
        err = s.db.QueryRow(`
22✔
155
                SELECT COUNT(*) > 0
22✔
156
                FROM pragma_table_info('executions')
22✔
157
                WHERE name = 'output'
22✔
158
        `).Scan(&outputExists)
22✔
159
        if err != nil {
22✔
NEW
160
                return fmt.Errorf("failed to check for output column: %w", err)
×
NEW
161
        }
×
162

163
        // add output column if it doesn't exist
164
        if !outputExists {
24✔
165
                if _, outErr := s.db.Exec("ALTER TABLE executions ADD COLUMN output TEXT DEFAULT ''"); outErr != nil {
2✔
NEW
166
                        return fmt.Errorf("failed to add output column: %w", outErr)
×
UNCOV
167
                }
×
168
        }
169

170
        return nil
22✔
171
}
172

173
// LoadJobs retrieves all jobs from the database
174
func (s *SQLiteStore) LoadJobs() ([]JobInfo, error) {
4✔
175
        s.mu.RLock()
4✔
176
        defer s.mu.RUnlock()
4✔
177

4✔
178
        var jobs []JobInfo
4✔
179
        err := s.db.Select(&jobs, `
4✔
180
                SELECT id, command, schedule, next_run, last_run, last_status, enabled, 
4✔
181
                       created_at, updated_at, sort_index
4✔
182
                FROM jobs
4✔
183
                ORDER BY sort_index`)
4✔
184
        if err != nil {
5✔
185
                return nil, fmt.Errorf("failed to query jobs: %w", err)
1✔
186
        }
1✔
187

188
        // ensure we return empty slice, not nil
189
        if jobs == nil {
4✔
190
                jobs = []JobInfo{}
1✔
191
        }
1✔
192

193
        return jobs, nil
3✔
194
}
195

196
// SaveJobs persists multiple jobs in a transaction
197
func (s *SQLiteStore) SaveJobs(jobs []JobInfo) error {
4✔
198
        s.mu.Lock()
4✔
199
        defer s.mu.Unlock()
4✔
200

4✔
201
        tx, err := s.db.Beginx()
4✔
202
        if err != nil {
4✔
203
                return fmt.Errorf("failed to begin transaction: %w", err)
×
204
        }
×
205
        defer tx.Rollback()
4✔
206

4✔
207
        for idx, job := range jobs {
9✔
208
                // set sort_index based on position
5✔
209
                job.SortIndex = idx
5✔
210

5✔
211
                _, err := tx.NamedExec(`
5✔
212
                        INSERT OR REPLACE INTO jobs 
5✔
213
                        (id, command, schedule, next_run, last_run, last_status, enabled, created_at, updated_at, sort_index)
5✔
214
                        VALUES (:id, :command, :schedule, :next_run, :last_run, :last_status, :enabled, :created_at, :updated_at, :sort_index)`,
5✔
215
                        job)
5✔
216
                if err != nil {
6✔
217
                        return fmt.Errorf("failed to save job %s: %w", job.ID, err)
1✔
218
                }
1✔
219
        }
220

221
        if err := tx.Commit(); err != nil {
3✔
222
                return fmt.Errorf("failed to commit transaction: %w", err)
×
223
        }
×
224

225
        return nil
3✔
226
}
227

228
// RecordExecution logs a job execution event
229
func (s *SQLiteStore) RecordExecution(req request.RecordExecution) error {
54✔
230
        s.mu.Lock()
54✔
231
        defer s.mu.Unlock()
54✔
232

54✔
233
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
54✔
234
        defer cancel()
54✔
235

54✔
236
        _, err := s.db.ExecContext(ctx, `
54✔
237
                INSERT INTO executions (job_id, started_at, finished_at, status, exit_code, executed_command, output)
54✔
238
                VALUES (?, ?, ?, ?, ?, ?, ?)`,
54✔
239
                req.JobID, req.StartedAt, req.FinishedAt, req.Status.String(), req.ExitCode, req.ExecutedCommand, req.Output)
54✔
240

54✔
241
        if err != nil {
54✔
242
                return fmt.Errorf("failed to record execution: %w", err)
×
243
        }
×
244

245
        return nil
54✔
246
}
247

248
// GetExecutions retrieves execution history for a job, limited to the most recent executions
249
func (s *SQLiteStore) GetExecutions(jobID string, limit int) ([]ExecutionInfo, error) {
14✔
250
        s.mu.RLock()
14✔
251
        defer s.mu.RUnlock()
14✔
252

14✔
253
        var executions []ExecutionInfo
14✔
254
        err := s.db.Select(&executions, `
14✔
255
                SELECT id, job_id, started_at, finished_at, status, exit_code, executed_command, output
14✔
256
                FROM executions
14✔
257
                WHERE job_id = ?
14✔
258
                ORDER BY started_at DESC
14✔
259
                LIMIT ?`,
14✔
260
                jobID, limit)
14✔
261

14✔
262
        if err != nil {
15✔
263
                return nil, fmt.Errorf("failed to query executions: %w", err)
1✔
264
        }
1✔
265

266
        // ensure we return empty slice, not nil
267
        if executions == nil {
14✔
268
                executions = []ExecutionInfo{}
1✔
269
        }
1✔
270

271
        return executions, nil
13✔
272
}
273

274
// GetExecutionByID retrieves a specific execution by its ID
275
func (s *SQLiteStore) GetExecutionByID(execID int) (ExecutionInfo, error) {
2✔
276
        s.mu.RLock()
2✔
277
        defer s.mu.RUnlock()
2✔
278

2✔
279
        var execution ExecutionInfo
2✔
280
        err := s.db.Get(&execution, `
2✔
281
                SELECT id, job_id, started_at, finished_at, status, exit_code, executed_command, output
2✔
282
                FROM executions
2✔
283
                WHERE id = ?`,
2✔
284
                execID)
2✔
285

2✔
286
        if err != nil {
3✔
287
                return ExecutionInfo{}, fmt.Errorf("failed to query execution: %w", err)
1✔
288
        }
1✔
289

290
        return execution, nil
1✔
291
}
292

293
// CleanupOldExecutions removes old executions beyond the limit for a job
294
func (s *SQLiteStore) CleanupOldExecutions(jobID string, limit int) error {
3✔
295
        s.mu.Lock()
3✔
296
        defer s.mu.Unlock()
3✔
297

3✔
298
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
3✔
299
        defer cancel()
3✔
300

3✔
301
        // delete executions beyond the limit, keeping the most recent ones
3✔
302
        _, err := s.db.ExecContext(ctx, `
3✔
303
                DELETE FROM executions
3✔
304
                WHERE job_id = ?
3✔
305
                AND id NOT IN (
3✔
306
                        SELECT id FROM executions
3✔
307
                        WHERE job_id = ?
3✔
308
                        ORDER BY started_at DESC
3✔
309
                        LIMIT ?
3✔
310
                )`,
3✔
311
                jobID, jobID, limit)
3✔
312

3✔
313
        if err != nil {
3✔
NEW
314
                return fmt.Errorf("failed to cleanup old executions: %w", err)
×
NEW
315
        }
×
316

317
        return nil
3✔
318
}
319

320
// Close closes the database connection
321
func (s *SQLiteStore) Close() error {
22✔
322
        s.mu.Lock()
22✔
323
        defer s.mu.Unlock()
22✔
324

22✔
325
        if err := s.db.Close(); err != nil {
22✔
326
                return fmt.Errorf("failed to close database: %w", err)
×
327
        }
×
328
        return nil
22✔
329
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc