Files
beads/internal/storage/sqlite/config.go
Roland Tritsch 5264d7aa60 fix(daemon): prevent zombie state after database file replacement (#1213)
fix(daemon): prevent zombie state after database file replacement

Adds checkFreshness() to health check paths (GetMetadata, GetConfig, GetAllConfig) and refactors reconnect() to validate new connection before closing old.

PR-URL: https://github.com/steveyegge/beads/pull/1213
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-21 22:46:59 -08:00

199 lines
6.7 KiB
Go

package sqlite
import (
"context"
"database/sql"
"strings"
"github.com/steveyegge/beads/internal/config"
)
// SetConfig sets a configuration value
func (s *SQLiteStorage) SetConfig(ctx context.Context, key, value string) 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()
_, err := s.db.ExecContext(ctx, `
INSERT INTO config (key, value) VALUES (?, ?)
ON CONFLICT (key) DO UPDATE SET value = excluded.value
`, key, value)
return wrapDBError("set config", err)
}
// GetConfig gets a configuration value
func (s *SQLiteStorage) GetConfig(ctx context.Context, key string) (string, error) {
s.checkFreshness()
// 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 value string
err := s.db.QueryRowContext(ctx, `SELECT value FROM config WHERE key = ?`, key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, wrapDBError("get config", err)
}
// GetAllConfig gets all configuration key-value pairs
func (s *SQLiteStorage) GetAllConfig(ctx context.Context) (map[string]string, error) {
s.checkFreshness()
// 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()
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM config ORDER BY key`)
if err != nil {
return nil, wrapDBError("query all config", err)
}
defer func() { _ = rows.Close() }()
config := make(map[string]string)
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
return nil, wrapDBError("scan config row", err)
}
config[key] = value
}
return config, wrapDBError("iterate config rows", rows.Err())
}
// DeleteConfig deletes a configuration value
func (s *SQLiteStorage) DeleteConfig(ctx context.Context, key string) 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()
_, err := s.db.ExecContext(ctx, `DELETE FROM config WHERE key = ?`, key)
return wrapDBError("delete config", err)
}
// OrphanHandling defines how to handle orphan issues during import
type OrphanHandling string
const (
OrphanStrict OrphanHandling = "strict" // Reject imports with orphans
OrphanResurrect OrphanHandling = "resurrect" // Auto-resurrect parents from JSONL
OrphanSkip OrphanHandling = "skip" // Skip orphans silently
OrphanAllow OrphanHandling = "allow" // Allow orphans (default)
)
// GetOrphanHandling gets the import.orphan_handling config value
// Returns OrphanAllow (the default) if not set or if value is invalid
func (s *SQLiteStorage) GetOrphanHandling(ctx context.Context) OrphanHandling {
value, err := s.GetConfig(ctx, "import.orphan_handling")
if err != nil || value == "" {
return OrphanAllow // Default
}
switch OrphanHandling(value) {
case OrphanStrict, OrphanResurrect, OrphanSkip, OrphanAllow:
return OrphanHandling(value)
default:
return OrphanAllow // Invalid value, use default
}
}
// SetMetadata sets a metadata value (for internal state like import hashes)
func (s *SQLiteStorage) SetMetadata(ctx context.Context, key, value string) 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()
_, err := s.db.ExecContext(ctx, `
INSERT INTO metadata (key, value) VALUES (?, ?)
ON CONFLICT (key) DO UPDATE SET value = excluded.value
`, key, value)
return wrapDBError("set metadata", err)
}
// GetMetadata gets a metadata value (for internal state like import hashes)
func (s *SQLiteStorage) GetMetadata(ctx context.Context, key string) (string, error) {
s.checkFreshness()
// 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 value string
err := s.db.QueryRowContext(ctx, `SELECT value FROM metadata WHERE key = ?`, key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, wrapDBError("get metadata", err)
}
// CustomStatusConfigKey is the config key for custom status states
const CustomStatusConfigKey = "status.custom"
// CustomTypeConfigKey is the config key for custom issue types
const CustomTypeConfigKey = "types.custom"
// GetCustomStatuses retrieves the list of custom status states from config.
// Custom statuses are stored as comma-separated values in the "status.custom" config key.
// Returns an empty slice if no custom statuses are configured.
func (s *SQLiteStorage) GetCustomStatuses(ctx context.Context) ([]string, error) {
value, err := s.GetConfig(ctx, CustomStatusConfigKey)
if err != nil {
return nil, err
}
if value == "" {
return nil, nil
}
return parseCustomStatuses(value), nil
}
// GetCustomTypes retrieves the list of custom issue types from config.
// Custom types are stored as comma-separated values in the "types.custom" config key.
// If the database doesn't have custom types configured, falls back to config.yaml.
// This fallback is essential during bd init when the database is being created
// but auto-import needs to validate issues with custom types (GH#1225).
// Returns an empty slice if no custom types are configured.
func (s *SQLiteStorage) GetCustomTypes(ctx context.Context) ([]string, error) {
value, err := s.GetConfig(ctx, CustomTypeConfigKey)
if err != nil {
return nil, err
}
if value != "" {
return parseCommaSeparatedList(value), nil
}
// Fallback to config.yaml when database doesn't have types.custom set.
// This allows auto-import during bd init to work with custom types
// defined in config.yaml before they're persisted to the database.
if yamlTypes := config.GetCustomTypesFromYAML(); len(yamlTypes) > 0 {
return yamlTypes, nil
}
return nil, nil
}
// parseCommaSeparatedList splits a comma-separated string into a slice of trimmed entries.
// Empty entries are filtered out.
func parseCommaSeparatedList(value string) []string {
if value == "" {
return nil
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// parseCustomStatuses is an alias for parseCommaSeparatedList for backward compatibility.
func parseCustomStatuses(value string) []string {
return parseCommaSeparatedList(value)
}