Update withTx to use BEGIN IMMEDIATE with exponential backoff retry on SQLITE_BUSY errors. This prevents "database is locked" failures during concurrent operations (daemon + CLI, multi-agent workflows). Changes: - withTx now uses beginImmediateWithRetry (same pattern as RunInTransaction) - Add dbExecutor interface for helper functions that work with both *sql.Tx and *sql.Conn - Update all withTx callers to use *sql.Conn - Refactor DeleteIssue to use withTx (fixes the specific error in auto-import) - Update markIssuesDirtyTx to accept dbExecutor interface Affected paths: - MarkIssuesDirty, ClearDirtyIssuesByID (dirty.go) - AddDependency, RemoveDependency (dependencies.go) - executeLabelOperation (labels.go) - AddComment (events.go) - ApplyCompaction (compact.go) - DeleteIssue (queries.go) Note: Some direct BeginTx calls in queries.go (CloseIssue, UpdateIssue, ReopenIssue, DeleteIssues) still use the old pattern and could be refactored in a follow-up. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
296 lines
9.0 KiB
Go
296 lines
9.0 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
const limitClause = " LIMIT ?"
|
|
|
|
// AddComment adds a comment to an issue
|
|
func (s *SQLiteStorage) AddComment(ctx context.Context, issueID, actor, comment string) error {
|
|
return s.withTx(ctx, func(conn *sql.Conn) error {
|
|
// Update issue updated_at timestamp first to verify issue exists
|
|
now := time.Now()
|
|
res, err := conn.ExecContext(ctx, `
|
|
UPDATE issues SET updated_at = ? WHERE id = ?
|
|
`, now, issueID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update timestamp: %w", err)
|
|
}
|
|
|
|
rows, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get rows affected: %w", err)
|
|
}
|
|
if rows == 0 {
|
|
return fmt.Errorf("issue %s not found", issueID)
|
|
}
|
|
|
|
_, err = conn.ExecContext(ctx, `
|
|
INSERT INTO events (issue_id, event_type, actor, comment)
|
|
VALUES (?, ?, ?, ?)
|
|
`, issueID, types.EventCommented, actor, comment)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add comment: %w", err)
|
|
}
|
|
|
|
// Mark issue as dirty for incremental export
|
|
_, err = conn.ExecContext(ctx, `
|
|
INSERT INTO dirty_issues (issue_id, marked_at)
|
|
VALUES (?, ?)
|
|
ON CONFLICT (issue_id) DO UPDATE SET marked_at = excluded.marked_at
|
|
`, issueID, now)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to mark issue dirty: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// GetEvents returns the event history for an issue
|
|
func (s *SQLiteStorage) GetEvents(ctx context.Context, issueID string, limit int) ([]*types.Event, error) {
|
|
// Hold read lock during database operations to prevent reconnect() from
|
|
// closing the connection mid-query (GH#607 race condition fix)
|
|
s.reconnectMu.RLock()
|
|
defer s.reconnectMu.RUnlock()
|
|
|
|
args := []interface{}{issueID}
|
|
limitSQL := ""
|
|
if limit > 0 {
|
|
limitSQL = limitClause
|
|
args = append(args, limit)
|
|
}
|
|
|
|
// #nosec G201 - safe SQL with controlled formatting
|
|
query := fmt.Sprintf(`
|
|
SELECT id, issue_id, event_type, actor, old_value, new_value, comment, created_at
|
|
FROM events
|
|
WHERE issue_id = ?
|
|
ORDER BY created_at DESC
|
|
%s
|
|
`, limitSQL)
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get events: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var events []*types.Event
|
|
for rows.Next() {
|
|
var event types.Event
|
|
var oldValue, newValue, comment sql.NullString
|
|
|
|
err := rows.Scan(
|
|
&event.ID, &event.IssueID, &event.EventType, &event.Actor,
|
|
&oldValue, &newValue, &comment, &event.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to scan event: %w", err)
|
|
}
|
|
|
|
if oldValue.Valid {
|
|
event.OldValue = &oldValue.String
|
|
}
|
|
if newValue.Valid {
|
|
event.NewValue = &newValue.String
|
|
}
|
|
if comment.Valid {
|
|
event.Comment = &comment.String
|
|
}
|
|
|
|
events = append(events, &event)
|
|
}
|
|
|
|
return events, nil
|
|
}
|
|
|
|
// GetStatistics returns aggregate statistics
|
|
func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, error) {
|
|
// Hold read lock during database operations to prevent reconnect() from
|
|
// closing the connection mid-query (GH#607 race condition fix)
|
|
s.reconnectMu.RLock()
|
|
defer s.reconnectMu.RUnlock()
|
|
|
|
var stats types.Statistics
|
|
|
|
// Get counts (bd-nyt: exclude tombstones from TotalIssues, report separately)
|
|
// (bd-6v2: also count pinned issues)
|
|
// (bd-4jr: also count deferred issues)
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT
|
|
COALESCE(SUM(CASE WHEN status != 'tombstone' THEN 1 ELSE 0 END), 0) as total,
|
|
COALESCE(SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END), 0) as open,
|
|
COALESCE(SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END), 0) as in_progress,
|
|
COALESCE(SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END), 0) as closed,
|
|
COALESCE(SUM(CASE WHEN status = 'deferred' THEN 1 ELSE 0 END), 0) as deferred,
|
|
COALESCE(SUM(CASE WHEN status = 'tombstone' THEN 1 ELSE 0 END), 0) as tombstone,
|
|
COALESCE(SUM(CASE WHEN pinned = 1 THEN 1 ELSE 0 END), 0) as pinned
|
|
FROM issues
|
|
`).Scan(&stats.TotalIssues, &stats.OpenIssues, &stats.InProgressIssues, &stats.ClosedIssues, &stats.DeferredIssues, &stats.TombstoneIssues, &stats.PinnedIssues)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get issue counts: %w", err)
|
|
}
|
|
|
|
// Get blocked count
|
|
err = s.db.QueryRowContext(ctx, `
|
|
SELECT COUNT(DISTINCT i.id)
|
|
FROM issues i
|
|
JOIN dependencies d ON i.id = d.issue_id
|
|
JOIN issues blocker ON d.depends_on_id = blocker.id
|
|
WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked')
|
|
AND d.type = 'blocks'
|
|
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked')
|
|
`).Scan(&stats.BlockedIssues)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get blocked count: %w", err)
|
|
}
|
|
|
|
// Get ready count
|
|
err = s.db.QueryRowContext(ctx, `
|
|
SELECT COUNT(*)
|
|
FROM issues i
|
|
WHERE i.status = 'open'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM dependencies d
|
|
JOIN issues blocker ON d.depends_on_id = blocker.id
|
|
WHERE d.issue_id = i.id
|
|
AND d.type = 'blocks'
|
|
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked')
|
|
)
|
|
`).Scan(&stats.ReadyIssues)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get ready count: %w", err)
|
|
}
|
|
|
|
// Get average lead time (hours from created to closed)
|
|
var avgLeadTime sql.NullFloat64
|
|
err = s.db.QueryRowContext(ctx, `
|
|
SELECT AVG(
|
|
(julianday(closed_at) - julianday(created_at)) * 24
|
|
)
|
|
FROM issues
|
|
WHERE closed_at IS NOT NULL
|
|
`).Scan(&avgLeadTime)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return nil, fmt.Errorf("failed to get lead time: %w", err)
|
|
}
|
|
if avgLeadTime.Valid {
|
|
stats.AverageLeadTime = avgLeadTime.Float64
|
|
}
|
|
|
|
// Get epics eligible for closure count
|
|
err = s.db.QueryRowContext(ctx, `
|
|
WITH epic_children AS (
|
|
SELECT
|
|
d.depends_on_id AS epic_id,
|
|
i.status AS child_status
|
|
FROM dependencies d
|
|
JOIN issues i ON i.id = d.issue_id
|
|
WHERE d.type = 'parent-child'
|
|
),
|
|
epic_stats AS (
|
|
SELECT
|
|
epic_id,
|
|
COUNT(*) AS total_children,
|
|
SUM(CASE WHEN child_status = 'closed' THEN 1 ELSE 0 END) AS closed_children
|
|
FROM epic_children
|
|
GROUP BY epic_id
|
|
)
|
|
SELECT COUNT(*)
|
|
FROM issues i
|
|
JOIN epic_stats es ON es.epic_id = i.id
|
|
WHERE i.issue_type = 'epic'
|
|
AND i.status != 'closed'
|
|
AND es.total_children > 0
|
|
AND es.closed_children = es.total_children
|
|
`).Scan(&stats.EpicsEligibleForClosure)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get eligible epics count: %w", err)
|
|
}
|
|
|
|
return &stats, nil
|
|
}
|
|
|
|
// GetMoleculeProgress returns efficient progress stats for a molecule.
|
|
// Uses indexed queries on dependencies table instead of loading all steps.
|
|
func (s *SQLiteStorage) GetMoleculeProgress(ctx context.Context, moleculeID string) (*types.MoleculeProgressStats, error) {
|
|
// Hold read lock during database operations to prevent reconnect() from
|
|
// closing the connection mid-query (GH#607 race condition fix)
|
|
s.reconnectMu.RLock()
|
|
defer s.reconnectMu.RUnlock()
|
|
|
|
// First get the molecule's title
|
|
var title string
|
|
err := s.db.QueryRowContext(ctx, `SELECT title FROM issues WHERE id = ?`, moleculeID).Scan(&title)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("molecule not found: %s", moleculeID)
|
|
}
|
|
return nil, fmt.Errorf("failed to get molecule: %w", err)
|
|
}
|
|
|
|
stats := &types.MoleculeProgressStats{
|
|
MoleculeID: moleculeID,
|
|
MoleculeTitle: title,
|
|
}
|
|
|
|
// Get counts from direct children via parent-child dependency
|
|
// Uses idx_dependencies_depends_on_type index
|
|
err = s.db.QueryRowContext(ctx, `
|
|
SELECT
|
|
COUNT(*) as total,
|
|
COALESCE(SUM(CASE WHEN i.status = 'closed' THEN 1 ELSE 0 END), 0) as completed,
|
|
COALESCE(SUM(CASE WHEN i.status = 'in_progress' THEN 1 ELSE 0 END), 0) as in_progress
|
|
FROM dependencies d
|
|
JOIN issues i ON d.issue_id = i.id
|
|
WHERE d.depends_on_id = ? AND d.type = 'parent-child'
|
|
`, moleculeID).Scan(&stats.Total, &stats.Completed, &stats.InProgress)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get child counts: %w", err)
|
|
}
|
|
|
|
// Get first in_progress step ID (for "current step" display)
|
|
var currentStepID sql.NullString
|
|
err = s.db.QueryRowContext(ctx, `
|
|
SELECT i.id
|
|
FROM dependencies d
|
|
JOIN issues i ON d.issue_id = i.id
|
|
WHERE d.depends_on_id = ? AND d.type = 'parent-child' AND i.status = 'in_progress'
|
|
ORDER BY i.created_at
|
|
LIMIT 1
|
|
`, moleculeID).Scan(¤tStepID)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return nil, fmt.Errorf("failed to get current step: %w", err)
|
|
}
|
|
if currentStepID.Valid {
|
|
stats.CurrentStepID = currentStepID.String
|
|
}
|
|
|
|
// Get first and last closure times for rate calculation
|
|
var firstClosed, lastClosed sql.NullTime
|
|
err = s.db.QueryRowContext(ctx, `
|
|
SELECT MIN(i.closed_at), MAX(i.closed_at)
|
|
FROM dependencies d
|
|
JOIN issues i ON d.issue_id = i.id
|
|
WHERE d.depends_on_id = ? AND d.type = 'parent-child' AND i.status = 'closed'
|
|
`, moleculeID).Scan(&firstClosed, &lastClosed)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return nil, fmt.Errorf("failed to get closure times: %w", err)
|
|
}
|
|
if firstClosed.Valid {
|
|
stats.FirstClosed = &firstClosed.Time
|
|
}
|
|
if lastClosed.Valid {
|
|
stats.LastClosed = &lastClosed.Time
|
|
}
|
|
|
|
return stats, nil
|
|
}
|