fix: keep large database startup alive (#3624)

This commit is contained in:
Carlos Florêncio
2026-09-12 16:29:38 +01:00
committed by GitHub
parent 3d3209aa1a
commit b6571a9854
58 changed files with 2781 additions and 439 deletions
+1 -1
View File
@@ -293,7 +293,7 @@ func provideGateway(
portHandlers.RegisterHandlers(gateway.Dispatcher)
}
go gateway.Hub.Run(ctx)
go gateway.Hub.Run(processRuntimeContext(ctx))
gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log)
if taskRepo != nil && eventBus != nil {
var loadPullRequests statussummary.PullRequestLoader
+11 -3
View File
@@ -12,6 +12,7 @@ import (
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/startup"
"go.uber.org/zap"
)
@@ -57,7 +58,11 @@ func (hs *handlerSwitch) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// GET /health's body is identical to healthHandler's in helpers.go (status
// "ok", service, mode, version) since /health is a pure liveness probe and
// its shape must not depend on startup progress.
func newBootstrapHandler(version string) http.Handler {
func newBootstrapHandler(version string, reporters ...*startup.Reporter) http.Handler {
progress := startup.New(nil)
if len(reporters) > 0 {
progress = reporters[0]
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/health" {
body := map[string]any{
@@ -65,6 +70,9 @@ func newBootstrapHandler(version string) http.Handler {
serviceFieldKey: kandevName,
versionFieldKey: version,
}
if r.Method == http.MethodGet && r.URL.Path == "/ready" {
body["startup"] = progress.Snapshot()
}
writeBootstrapJSON(w, http.StatusServiceUnavailable, body)
return
}
@@ -91,8 +99,8 @@ func writeBootstrapJSON(w http.ResponseWriter, status int, body map[string]any)
// The returned handlerSwitch is later Store()d with the real router; the
// returned *http.Server and *serverListeners are the same instances that
// must flow unmodified into awaitShutdown, so shutdown needs no changes.
func bindBootstrapListeners(cfg *config.Config, log *logger.Logger, version string) (*handlerSwitch, *http.Server, *serverListeners, error) {
handler := newHandlerSwitch(newBootstrapHandler(version))
func bindBootstrapListeners(cfg *config.Config, log *logger.Logger, version string, reporters ...*startup.Reporter) (*handlerSwitch, *http.Server, *serverListeners, error) {
handler := newHandlerSwitch(newBootstrapHandler(version, reporters...))
server := &http.Server{
Handler: handler,
ReadTimeout: cfg.Server.ReadTimeoutDuration(),
+56 -27
View File
@@ -37,6 +37,7 @@ import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/common/subproc"
"github.com/kandev/kandev/internal/profiles"
"github.com/kandev/kandev/internal/startup"
// Event bus
"github.com/kandev/kandev/internal/events"
@@ -339,7 +340,14 @@ func run(cfg *config.Config, log *logger.Logger, cleanups *[]func() error, runCl
ctx, cancel := context.WithCancel(context.Background())
addCleanup(func() error { cancel(); return nil })
// 4. Initialize event bus (in-memory for unified mode, or NATS if configured)
return runWithBootstrap(ctx, cfg, log, func(ctx context.Context) bool {
return initializeApplication(ctx, cfg, log, addCleanup, runCleanups)
})
}
func initializeApplication(ctx context.Context, cfg *config.Config, log *logger.Logger,
addCleanup func(func() error), runCleanups func()) bool {
// Initialize the event bus only after the liveness listener is available.
eventBusProvider, cleanup, err := events.Provide(cfg, log)
if err != nil {
log.Error("Failed to initialize event bus", zap.Error(err))
@@ -348,7 +356,7 @@ func run(cfg *config.Config, log *logger.Logger, cleanups *[]func() error, runCl
addCleanup(cleanup)
eventBus := eventBusProvider.Bus
return startServices(ctx, cfg, log, addCleanup, eventBus, runCleanups, cancel)
return startServices(ctx, cfg, log, addCleanup, eventBus, runCleanups, workerCancelFromContext(ctx))
}
// applyStartupRuntimeFlags resolves persisted runtime-flag overrides and
@@ -376,7 +384,7 @@ func startServices( //nolint:cyclop
addCleanup func(func() error),
eventBus bus.EventBus,
runCleanups func(),
cancelContext context.CancelFunc,
cancelWorkers context.CancelFunc,
) bool {
// ============================================
// TASK SERVICE
@@ -385,7 +393,7 @@ func startServices( //nolint:cyclop
dbPool, repos, repoCleanups, err := provideRepositories(ctx, cfg, log, Version)
if err != nil {
log.Error("Failed to initialize repositories", zap.Error(err))
log.Error("Failed to initialize repositories", zap.Error(err), zap.String("phase", string(startup.FromContext(ctx).Snapshot().Phase)))
return false
}
for _, c := range repoCleanups {
@@ -525,7 +533,7 @@ func startServices( //nolint:cyclop
}
return startAgentInfrastructure(ctx, cfg, log, addCleanup, eventBus, agentRuntimeAvailability,
dbPool, repos, services, agentSettingsController, agentRegistry, agentctlBinaryPath, runCleanups, cancelContext)
dbPool, repos, services, agentSettingsController, agentRegistry, agentctlBinaryPath, runCleanups, cancelWorkers)
}
// startAgentInfrastructure initializes the agent lifecycle manager, worktree, orchestrator,
@@ -546,7 +554,7 @@ func startAgentInfrastructure(
agentRegistry *registry.Registry,
agentctlBinaryPath string,
runCleanups func(),
cancelContext context.CancelFunc,
cancelWorkers context.CancelFunc,
) bool {
restoreCleanups := make([]func() error, 0)
var databaseQuiesce func() error
@@ -858,7 +866,7 @@ func startAgentInfrastructure(
return startGatewayAndServe(ctx, cfg, log, eventBus, agentRuntimeAvailability, dbPool, repos, services,
agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, repoCloner, agentctlBinaryPath,
func(fn func() error) { addRuntimeCleanup(fn) }, runCleanups, cancelContext, restoreCleanups, databaseQuiesce)
func(fn func() error) { addRuntimeCleanup(fn) }, runCleanups, cancelWorkers, restoreCleanups, databaseQuiesce)
}
// startOrchestratorAndAutomationConsumers establishes the startup chain in
@@ -896,7 +904,7 @@ func closeBoundListeners(server *http.Server, listeners *serverListeners, log *l
return
}
listeners.Stop()
if err := server.Close(); err != nil {
if err := server.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Warn("failed to close HTTP listeners after startup failure", zap.Error(err))
}
}
@@ -923,7 +931,7 @@ func startGatewayAndServe(
agentctlBinaryPath string,
addCleanup func(func() error),
runCleanups func(),
cancelContext context.CancelFunc,
cancelWorkers context.CancelFunc,
restoreCleanups []func() error,
databaseQuiesce func() error,
) bool {
@@ -999,19 +1007,9 @@ func startGatewayAndServe(
services.Plugins.SetUtilityAgent(pluginsUtilityAgentAdapter{svc: services.Utility, userSvc: services.User}, pluginsHostUtilityAdapter{mgr: hostUtilityMgr})
}
var (
handler *handlerSwitch
server *http.Server
listeners *serverListeners
)
bindListeners := func() error {
h, s, l, err := bindBootstrapListeners(cfg, log, Version)
if err != nil {
return err
}
handler, server, listeners = h, s, l
return nil
}
bootstrap := ctx.Value(bootstrapContextKey{}).(*bootstrapRuntime)
handler, server, listeners := bootstrap.handler, bootstrap.server, bootstrap.listeners
bindListeners := func() error { startup.SetPhase(ctx, startup.RecoveringSessions); return ctx.Err() }
if err := startOrchestratorAndAutomationConsumers(
bindListeners,
@@ -1035,7 +1033,7 @@ func startGatewayAndServe(
log.Info("GitHub poller started")
},
); err != nil {
if !errors.Is(err, errServerBindFailed) {
if shouldLogStartupOrchestratorError(err) {
log.Error("Failed to start orchestrator", zap.Error(err))
}
closeBoundListeners(server, listeners, log)
@@ -1082,7 +1080,7 @@ func startGatewayAndServe(
workers = append(workers, restoreCleanups[i])
}
restoreQuiesceErr = quiesceForRestore(
cancelContext,
cancelWorkers,
scheduling.Stop,
orchestratorSvc.Stop,
func() error { return stopLifecycleManager(lifecycleMgr, log) },
@@ -1203,7 +1201,7 @@ func startGatewayAndServe(
}
systemSvc.StartBackground(ctx)
addCleanup(func() error { systemSvc.StopBackground(); return nil })
gateways.RegisterSystemNotifications(ctx, eventBus, gateway.Hub, log)
gateways.RegisterSystemNotifications(processRuntimeContext(ctx), eventBus, gateway.Hub, log)
gateways.RegisterAgentRuntimeNotifications(ctx, eventBus, gateway.Hub, func() (any, bool) {
if agentRuntimeAvailability == nil {
return nil, false
@@ -1253,9 +1251,16 @@ func startGatewayAndServe(
// publishReadiness for why the order matters and
// TestPublishReadinessFlipsReadyBeforeSwappingHandler for the regression
// test pinning it.
publishReadiness(func() { ready.Store(true) }, func() { handler.Store(builtServer.Handler) })
if !bootstrap.beginReadinessPublication(ctx) {
return false
}
publishReadiness(func() {
ready.Store(true)
bootstrap.ready.Store(true)
}, func() { handler.Store(builtServer.Handler) })
awaitShutdown(server, listeners, scheduling, orchestratorSvc, lifecycleMgr, runCleanups, log)
startup.SetPhase(ctx, startup.Ready)
awaitShutdown(ctx, server, listeners, scheduling, orchestratorSvc, lifecycleMgr, runCleanups, log)
return true
}
@@ -2429,6 +2434,7 @@ func buildHTTPServer(
// awaitShutdown waits for an OS signal then performs graceful shutdown.
func awaitShutdown(
ctx context.Context,
server *http.Server,
listeners *serverListeners,
scheduling *schedulingRuntime,
@@ -2440,6 +2446,23 @@ func awaitShutdown(
// ============================================
// GRACEFUL SHUTDOWN
// ============================================
shutdownCtx := processContextFromContext(ctx)
if shutdownCtx == nil {
shutdownCtx = ctx
}
if controller := startupSignalControllerFromContext(ctx); controller != nil {
sig := controller.wait(shutdownCtx)
if sig == nil {
log.Info("Shutdown context canceled without OS signal", zap.Int("pid", os.Getpid()))
return
}
log.Info("Received shutdown signal",
zap.String("signal", sig.String()),
zap.Int("pid", os.Getpid()))
runGracefulShutdown(server, listeners, scheduling, orchestratorSvc, lifecycleMgr, runCleanups, log)
return
}
quit := make(chan os.Signal, 2)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
log.Debug("shutdown signal handler armed",
@@ -2461,6 +2484,12 @@ func awaitShutdown(
runGracefulShutdown(server, listeners, scheduling, orchestratorSvc, lifecycleMgr, runCleanups, log)
}
func shouldLogStartupOrchestratorError(err error) bool {
return !errors.Is(err, errServerBindFailed) &&
!errors.Is(err, context.Canceled) &&
!errors.Is(err, context.DeadlineExceeded)
}
// migrateDefaultUtilityProfile upgrades the portable user's legacy default
// pair only when it identifies one eligible profile. Ambiguous and missing
// values remain untouched for a later retry or user repair.
@@ -6,18 +6,22 @@ import (
)
// quiesceForRestore stops every runtime that can write to the shared database
// before the restore path checkpoints and closes its pool. It keeps going
// after an individual stop failure so callers receive the complete failure
// set and no remaining worker is left running by an early return.
// before the restore path checkpoints and closes its pool. cancelWorkers is
// deliberately limited to worker/runtime contexts: it must not cancel the
// process context that owns the HTTP listener and restore job lifetime. The
// caller therefore remains able to publish restart_required and wait for an
// explicit process shutdown after the restore completes. The function keeps
// going after an individual stop failure so callers receive the complete
// failure set and no remaining worker is left running by an early return.
func quiesceForRestore(
cancelContext func(),
cancelWorkers func(),
stopScheduling func() error,
stopOrchestrator func() error,
stopAgents func() error,
stopWorkers []func() error,
) error {
if cancelContext != nil {
cancelContext()
if cancelWorkers != nil {
cancelWorkers()
}
var errs []error
if stopScheduling != nil {
+221
View File
@@ -0,0 +1,221 @@
package backendapp
import (
"context"
"net/http"
"os"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/startup"
"go.uber.org/zap"
)
type bootstrapContextKey struct{}
type bootstrapRuntime struct {
handler *handlerSwitch
server *http.Server
listeners *serverListeners
ready atomic.Bool
readinessMu sync.Mutex
readinessPublishing bool
startupClosing bool
}
type startupSignalContextKey struct{}
type processContextKey struct{}
type workerCancelContextKey struct{}
type startupSignalController struct {
first chan os.Signal
stop chan struct{}
done chan struct{}
signals chan os.Signal
once sync.Once
}
func newStartupSignalController(ctx context.Context, cancel context.CancelFunc, log *logger.Logger) *startupSignalController {
controller := &startupSignalController{
first: make(chan os.Signal, 1),
stop: make(chan struct{}),
done: make(chan struct{}),
signals: make(chan os.Signal, 2),
}
signal.Notify(controller.signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
if log != nil {
log.Debug("shutdown signal handler armed",
zap.Int("pid", os.Getpid()),
zap.Int("ppid", os.Getppid()))
}
go func() {
defer close(controller.done)
select {
case sig := <-controller.signals:
controller.first <- sig
cancel()
select {
case second := <-controller.signals:
if log != nil {
log.Warn("Received second shutdown signal, forcing exit", zap.String("signal", second.String()))
_ = log.Close()
}
os.Exit(1)
case <-controller.stop:
}
case <-ctx.Done():
case <-controller.stop:
}
}()
return controller
}
func withStartupSignalController(ctx context.Context, controller *startupSignalController) context.Context {
return context.WithValue(ctx, startupSignalContextKey{}, controller)
}
func withProcessContext(ctx, process context.Context) context.Context {
return context.WithValue(ctx, processContextKey{}, process)
}
func processContextFromContext(ctx context.Context) context.Context {
process, _ := ctx.Value(processContextKey{}).(context.Context)
return process
}
// processRuntimeContext returns the process lifetime carried by startup
// workers. Restore cancels workers while the process remains alive to finish
// the restore and deliver its terminal job update.
func processRuntimeContext(ctx context.Context) context.Context {
if process := processContextFromContext(ctx); process != nil {
return process
}
return ctx
}
// beginReadinessPublication serializes the readiness handoff with the startup
// cancellation watcher. Once startup cancellation claims listener closure,
// initialization cannot publish a router into a closing server.
func (runtime *bootstrapRuntime) beginReadinessPublication(ctx context.Context) bool {
runtime.readinessMu.Lock()
defer runtime.readinessMu.Unlock()
if runtime.startupClosing || ctx.Err() != nil {
return false
}
runtime.readinessPublishing = true
return true
}
func (runtime *bootstrapRuntime) claimStartupClose() bool {
runtime.readinessMu.Lock()
defer runtime.readinessMu.Unlock()
if runtime.ready.Load() || runtime.readinessPublishing {
return false
}
runtime.startupClosing = true
return true
}
func withWorkerCancel(ctx context.Context, cancel context.CancelFunc) context.Context {
return context.WithValue(ctx, workerCancelContextKey{}, cancel)
}
func workerCancelFromContext(ctx context.Context) context.CancelFunc {
cancel, _ := ctx.Value(workerCancelContextKey{}).(context.CancelFunc)
return cancel
}
func startupSignalControllerFromContext(ctx context.Context) *startupSignalController {
controller, _ := ctx.Value(startupSignalContextKey{}).(*startupSignalController)
return controller
}
func (controller *startupSignalController) wait(ctx context.Context) os.Signal {
select {
case sig := <-controller.first:
return sig
default:
}
select {
case sig := <-controller.first:
return sig
case <-ctx.Done():
select {
case sig := <-controller.first:
return sig
default:
return nil
}
}
}
func (controller *startupSignalController) stopAndWait() {
if controller == nil {
return
}
controller.once.Do(func() {
signal.Stop(controller.signals)
close(controller.stop)
<-controller.done
})
}
// runWithBootstrap owns the listener for both initialization and application use.
// Constructors run synchronously so resource cleanup cannot race initialization.
// The process context remains live while worker contexts are quiesced, which
// lets restore finish and deliver its job result before explicit shutdown.
func runWithBootstrap(ctx context.Context, cfg *config.Config, log *logger.Logger, initialize func(context.Context) bool) bool {
startupCtx, cancel := context.WithCancel(ctx)
defer cancel()
controller := newStartupSignalController(startupCtx, cancel, log)
defer controller.stopAndWait()
if err := startupCtx.Err(); err != nil {
return false
}
progress := startup.New(log)
startupCtx = startup.WithReporter(startupCtx, progress)
startupCtx = withStartupSignalController(startupCtx, controller)
workerCtx, cancelWorkers := context.WithCancel(startupCtx)
defer cancelWorkers()
workerCtx = withProcessContext(workerCtx, startupCtx)
workerCtx = withWorkerCancel(workerCtx, cancelWorkers)
ready.Store(false)
handler, server, listeners, err := bindBootstrapListeners(cfg, log, Version, progress)
if err != nil {
return false
}
defer closeBoundListeners(server, listeners, log)
runtime := &bootstrapRuntime{handler: handler, server: server, listeners: listeners}
done := make(chan struct{})
stopped := make(chan struct{})
go func() {
defer close(stopped)
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
progress.Report()
for {
select {
case <-startupCtx.Done():
if runtime.claimStartupClose() {
closeBoundListeners(server, listeners, log)
}
return
case <-done:
return
case <-ticker.C:
progress.Report()
}
}
}()
defer func() { close(done); <-stopped }()
ok := initialize(context.WithValue(workerCtx, bootstrapContextKey{}, runtime))
return ok && (startupCtx.Err() == nil || runtime.ready.Load())
}
@@ -0,0 +1,616 @@
package backendapp
import (
"bufio"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"sync"
"syscall"
"testing"
"time"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/persistence"
"github.com/kandev/kandev/internal/startup"
"github.com/kandev/kandev/internal/system/backups"
"github.com/kandev/kandev/internal/system/jobs"
)
// @covers AC-PLATFORM-STARTUP-LIFECYCLE-001.1, .5
func TestRunBindsBeforeDatabaseFailure(t *testing.T) {
core, logs := observer.New(zap.InfoLevel)
log, err := logger.NewFromZap(zap.New(core))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = log.Close() })
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = freePort(t)
cfg.HomeDir = t.TempDir()
cfg.Database.Path = filepath.Join(cfg.HomeDir, "data", "kandev.db")
if err := os.MkdirAll(cfg.Database.Path, 0700); err != nil {
t.Fatal(err)
} // Directory, not a database.
var cleanups []func() error
cleanup := func() {
for i := len(cleanups) - 1; i >= 0; i-- {
if cleanups[i] != nil {
_ = cleanups[i]()
}
}
cleanups = nil
}
defer cleanup()
if run(cfg, log, &cleanups, cleanup) {
t.Fatal("database failure reported success")
}
if logs.FilterMessage("HTTP listener bound; serving liveness probe while startup continues").Len() != 1 {
t.Fatal("database initialization ran before the liveness listener bound")
}
if _, _, err := getJSON("127.0.0.1", cfg.Server.Port, "/health"); err == nil {
t.Fatal("failed initializer left healthy listener")
}
}
func TestBootstrapReportsStartupPhase(t *testing.T) {
w := httptest.NewRecorder()
newBootstrapHandler("test").ServeHTTP(w, httptest.NewRequest("GET", "/ready", nil))
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
startupBody, ok := body["startup"].(map[string]any)
if !ok {
t.Fatalf("startup body = %#v, want an object", body["startup"])
}
if startupBody["phase"] != string(startup.OpeningDatabase) {
t.Fatalf("startup phase = %v, want %q", startupBody["phase"], startup.OpeningDatabase)
}
for _, field := range []string{"elapsed_ms", "phase_elapsed_ms"} {
value, ok := startupBody[field].(float64)
if !ok || value < 0 {
t.Fatalf("startup %s = %v, want a non-negative number", field, startupBody[field])
}
}
}
// @covers AC-PLATFORM-STARTUP-LIFECYCLE-001.2, .6
func TestBootstrapCancellationClosesListenerBeforeInitializerReturns(t *testing.T) {
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = freePort(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
entered := make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
done := make(chan bool, 1)
defer releaseOnce.Do(func() { close(release) })
go func() {
done <- runWithBootstrap(ctx, cfg, testLogger(t), func(ctx context.Context) bool {
close(entered)
<-release
return true
})
}()
<-entered
for _, path := range []string{"/ready", "/api/v1/tasks"} {
status, _, err := getJSON("127.0.0.1", cfg.Server.Port, path)
if err != nil || status != http.StatusServiceUnavailable {
t.Fatalf("%s: %d %v", path, status, err)
}
}
cancel()
deadline := time.After(2 * time.Second)
for {
_, _, err := getJSON("127.0.0.1", cfg.Server.Port, "/health")
if err != nil {
break
}
select {
case <-deadline:
t.Fatal("canceled initialization left listener healthy")
default:
}
}
// The wrapper must join initialization instead of racing resource cleanup.
select {
case <-done:
t.Fatal("returned before initializer drained")
default:
}
releaseOnce.Do(func() { close(release) })
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("initializer did not drain after release")
}
}
// @covers AC-PLATFORM-STARTUP-LIFECYCLE-001.1, .2, .3, .6
func TestBootstrapDelayedSuccessKeepsLivenessUntilReadiness(t *testing.T) {
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = freePort(t)
cfg.Launcher.HealthTimeoutMs = 100
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
entered := make(chan struct{})
release := make(chan struct{})
published := make(chan struct{})
done := make(chan bool, 1)
var releaseOnce sync.Once
var publishOnce sync.Once
defer releaseOnce.Do(func() { close(release) })
go func() {
done <- runWithBootstrap(ctx, cfg, testLogger(t), func(ctx context.Context) bool {
runtime := ctx.Value(bootstrapContextKey{}).(*bootstrapRuntime)
close(entered)
<-release
application := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" {
writeBootstrapJSON(w, http.StatusOK, map[string]any{
statusKey: "ok",
serviceFieldKey: kandevName,
"mode": "websocket+http",
versionFieldKey: Version,
})
return
}
if r.URL.Path == "/ready" || r.URL.Path == "/api/v1/tasks" {
writeBootstrapJSON(w, http.StatusOK, map[string]any{statusKey: "ok"})
return
}
writeBootstrapJSON(w, http.StatusNotFound, map[string]any{statusKey: "not_found"})
})
if !runtime.beginReadinessPublication(ctx) {
return false
}
publishReadiness(func() {
ready.Store(true)
runtime.ready.Store(true)
publishOnce.Do(func() { close(published) })
}, func() { runtime.handler.Store(application) })
<-processContextFromContext(ctx).Done()
return true
})
}()
<-entered
// Keep the bootstrap initializer behind the same short health budget the
// launcher uses for its keep-or-kill decision. Readiness has no matching
// deadline: it must keep polling while recovery is still running.
healthDeadline := time.Now().Add(time.Duration(cfg.Launcher.HealthTimeoutMs) * time.Millisecond)
for time.Now().Before(healthDeadline) {
status, body, err := getJSON("127.0.0.1", cfg.Server.Port, "/health")
if err != nil || status != http.StatusOK {
t.Fatalf("bootstrap /health during delayed initialization = %d, %v; want 200", status, err)
}
if body[statusKey] != "ok" || body[serviceFieldKey] != kandevName || body[versionFieldKey] != Version {
t.Fatalf("bootstrap /health identity = %#v, want status=ok service=%q version=%q", body, kandevName, Version)
}
status, _, err = getJSON("127.0.0.1", cfg.Server.Port, "/ready")
if err != nil || status != http.StatusServiceUnavailable {
t.Fatalf("bootstrap /ready during delayed initialization = %d, %v; want 503", status, err)
}
status, _, err = getJSON("127.0.0.1", cfg.Server.Port, "/api/v1/tasks")
if err != nil || status != http.StatusServiceUnavailable {
t.Fatalf("bootstrap application route during delayed initialization = %d, %v; want 503", status, err)
}
}
select {
case result := <-done:
t.Fatalf("bootstrap exited before readiness handoff: %v", result)
default:
}
select {
case <-published:
t.Fatal("readiness published before initialization gate released")
default:
}
readyDone := make(chan error, 1)
go func() {
for {
status, _, err := getJSON("127.0.0.1", cfg.Server.Port, "/ready")
if err != nil {
readyDone <- err
return
}
if status == http.StatusOK {
readyDone <- nil
return
}
time.Sleep(10 * time.Millisecond)
}
}()
select {
case err := <-readyDone:
t.Fatalf("unbounded readiness wait completed before initialization release: %v", err)
case <-time.After(50 * time.Millisecond):
}
releaseOnce.Do(func() { close(release) })
select {
case <-published:
case <-time.After(2 * time.Second):
t.Fatal("initializer did not publish readiness after release")
}
select {
case err := <-readyDone:
if err != nil {
t.Fatalf("readiness wait after release: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("readiness wait did not observe router handoff")
}
if status, _, err := getJSON("127.0.0.1", cfg.Server.Port, "/api/v1/tasks"); err != nil || status != http.StatusOK {
t.Fatalf("application route after readiness = %d, %v; want 200", status, err)
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("bootstrap did not stop after explicit process cancellation")
}
}
// @covers AC-PLATFORM-STARTUP-LIFECYCLE-001.3, .6
func TestBootstrapRestoreKeepsProcessAliveUntilResultAndShutdown(t *testing.T) {
root := t.TempDir()
databasePath := filepath.Join(root, "kandev.db")
backupsDir := filepath.Join(root, "backups")
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(databasePath, []byte("before"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(backupsDir, "manual-restore.db"), []byte("after"), 0o600); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = freePort(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
restoreBlocked := make(chan struct{})
restoreRelease := make(chan struct{})
jobIDReady := make(chan string, 1)
resultReady := make(chan *jobs.Job, 1)
bootstrapReady := make(chan struct{})
done := make(chan bool, 1)
var restoreBlockedOnce sync.Once
var bootstrapReadyOnce sync.Once
var restoreReleaseOnce sync.Once
defer restoreReleaseOnce.Do(func() { close(restoreRelease) })
go func() {
done <- runWithBootstrap(ctx, cfg, testLogger(t), func(ctx context.Context) bool {
runtime := ctx.Value(bootstrapContextKey{}).(*bootstrapRuntime)
tracker := jobs.NewTracker(nil, testLogger(t))
svc := backups.NewService(databasePath, nil, tracker, testLogger(t))
svc.RestoreQuiesce = func() error {
return quiesceForRestore(
workerCancelFromContext(ctx),
func() error {
restoreBlockedOnce.Do(func() { close(restoreBlocked) })
<-restoreRelease
return nil
}, nil, nil, nil,
)
}
application := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" || r.URL.Path == "/ready" {
writeBootstrapJSON(w, http.StatusOK, map[string]any{statusKey: "ok"})
return
}
writeBootstrapJSON(w, http.StatusNotFound, map[string]any{statusKey: "not_found"})
})
if !runtime.beginReadinessPublication(ctx) {
return false
}
publishReadiness(func() {
ready.Store(true)
runtime.ready.Store(true)
bootstrapReadyOnce.Do(func() { close(bootstrapReady) })
}, func() { runtime.handler.Store(application) })
jobID, err := svc.Restore(context.Background(), "manual-restore.db", backups.RestoreConfirmToken)
if err != nil {
return false
}
jobIDReady <- jobID
<-restoreBlocked
<-restoreRelease
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if job := tracker.Get(jobID); job != nil && job.State == jobs.StateSucceeded {
resultReady <- job
<-processContextFromContext(ctx).Done()
return true
}
time.Sleep(5 * time.Millisecond)
}
return false
})
}()
select {
case <-bootstrapReady:
case <-time.After(2 * time.Second):
t.Fatal("bootstrap did not publish readiness")
}
select {
case <-jobIDReady:
case <-time.After(2 * time.Second):
t.Fatal("restore job was not submitted")
}
select {
case <-restoreBlocked:
case <-time.After(2 * time.Second):
t.Fatal("restore did not reach the blocked quiesce boundary")
}
if status, _, err := getJSON("127.0.0.1", cfg.Server.Port, "/health"); err != nil || status != http.StatusOK {
t.Fatalf("/health while restore quiesce is blocked = %d, %v; want 200", status, err)
}
if status, _, err := getJSON("127.0.0.1", cfg.Server.Port, "/ready"); err != nil || status != http.StatusOK {
t.Fatalf("/ready while restore quiesce is blocked = %d, %v; want 200", status, err)
}
select {
case result := <-done:
t.Fatalf("backend exited while restore was blocked: %v", result)
default:
}
restoreReleaseOnce.Do(func() { close(restoreRelease) })
select {
case job := <-resultReady:
if got, _ := job.Result["restart_required"].(bool); !got {
t.Fatalf("restore result = %#v, want restart_required=true", job.Result)
}
case <-time.After(2 * time.Second):
t.Fatal("restore result was not delivered")
}
select {
case <-done:
t.Fatal("backend exited before explicit shutdown after restore result")
default:
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("backend did not exit after explicit shutdown")
}
}
// @covers AC-PLATFORM-STARTUP-LIFECYCLE-001.5, .7
func TestRepositoryBackupFailureReportsPhaseAndPreventsMigration(t *testing.T) {
dir := t.TempDir()
dataDir := filepath.Join(dir, "data")
if err := os.MkdirAll(dataDir, 0700); err != nil {
t.Fatal(err)
}
path := filepath.Join(dataDir, "kandev.db")
db, err := sqlx.Open("sqlite3", path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if _, err = db.Exec("CREATE TABLE sentinel(value TEXT); INSERT INTO sentinel VALUES ('original')"); err != nil {
t.Fatal(err)
}
if err = os.WriteFile(filepath.Join(dataDir, "backups"), []byte("blocked"), 0600); err != nil {
t.Fatal(err)
}
cfg := &config.Config{HomeDir: dir}
cfg.Database.Path = path
progress := startup.New(nil)
ctx := startup.WithReporter(context.Background(), progress)
_, _, _, err = provideRepositories(ctx, cfg, testLogger(t), "upgrade-test")
if err == nil {
t.Fatal("backup failure did not fail initialization")
}
if got := progress.Snapshot().Phase; got != startup.BackingUpDatabase {
t.Fatalf("phase=%s, want backup", got)
}
var count int
if err = db.Get(&count, "SELECT count(*) FROM sqlite_master WHERE name='tasks'"); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("migration ran after failed backup")
}
}
func TestCanceledPersistenceDoesNotStartBackup(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{HomeDir: dir}
cfg.Database.Path = filepath.Join(dir, "test.db")
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, cleanup, err := persistence.ProvideContext(ctx, cfg, nil, "test")
if cleanup != nil {
defer func() { _ = cleanup() }()
}
if err == nil {
t.Fatal("canceled initialization opened persistence")
}
}
func TestRepositoryFailureClosesDatabase(t *testing.T) {
if _, err := os.Stat("/proc/self/fd"); err != nil {
t.Skip("requires Linux descriptor inspection")
}
dir := t.TempDir()
data := filepath.Join(dir, "data")
if err := os.MkdirAll(data, 0700); err != nil {
t.Fatal(err)
}
path := filepath.Join(data, "kandev.db")
db, err := sqlx.Open("sqlite3", path)
if err != nil {
t.Fatal(err)
}
if _, err = db.Exec("CREATE TABLE tasks(id TEXT)"); err != nil {
t.Fatal(err)
}
if err = db.Close(); err != nil {
t.Fatal(err)
}
cfg := &config.Config{HomeDir: dir}
cfg.Database.Path = path
_, _, cleanups, err := provideRepositories(context.Background(), cfg, testLogger(t), "")
for _, cleanup := range cleanups {
if cleanup != nil {
_ = cleanup()
}
}
if err == nil {
t.Fatal("invalid schema unexpectedly initialized")
}
fds, err := os.ReadDir("/proc/self/fd")
if err != nil {
t.Fatal(err)
}
for _, fd := range fds {
target, _ := os.Readlink(filepath.Join("/proc/self/fd", fd.Name()))
if target == path {
t.Fatalf("failed migration leaked database descriptor %s", fd.Name())
}
}
}
func TestBootstrapSignalDuringInitialization(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX signals")
}
if os.Getenv("KANDEV_TEST_BOOTSTRAP_SIGNAL") == "1" {
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = freePort(t)
ok := runWithBootstrap(context.Background(), cfg, testLogger(t), func(ctx context.Context) bool {
_, _ = os.Stdout.WriteString("BOUND\n")
<-ctx.Done()
return false
})
if ok {
t.Fatal("canceled startup succeeded")
}
return
}
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, exe, "-test.run=^TestBootstrapSignalDuringInitialization$")
cmd.Env = append(os.Environ(), "KANDEV_TEST_BOOTSTRAP_SIGNAL=1")
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err = cmd.Start(); err != nil {
t.Fatal(err)
}
defer func() { _ = cmd.Process.Kill() }()
scan := bufio.NewScanner(stdout)
bound := false
for scan.Scan() {
if scan.Text() == "BOUND" {
bound = true
break
}
}
if !bound {
t.Fatal("child did not bind")
}
if err = cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
if err = cmd.Wait(); err != nil {
t.Fatalf("initialization did not drain on SIGTERM: %v", err)
}
}
// @covers AC-PLATFORM-STARTUP-LIFECYCLE-001.3, .6
func TestBootstrapSignalAfterReadiness(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX signals")
}
if os.Getenv("KANDEV_TEST_BOOTSTRAP_SIGNAL_READY") == "1" {
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = freePort(t)
ok := runWithBootstrap(context.Background(), cfg, testLogger(t), func(ctx context.Context) bool {
runtime := ctx.Value(bootstrapContextKey{}).(*bootstrapRuntime)
application := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeBootstrapJSON(w, http.StatusOK, map[string]any{statusKey: "ok"})
})
if !runtime.beginReadinessPublication(ctx) {
t.Fatal("readiness publication rejected before signal test")
}
publishReadiness(func() {
ready.Store(true)
runtime.ready.Store(true)
}, func() { runtime.handler.Store(application) })
_, _ = os.Stdout.WriteString("READY\n")
awaitShutdown(ctx, runtime.server, runtime.listeners, &schedulingRuntime{}, nil, nil, func() {}, testLogger(t))
return true
})
if !ok {
t.Fatal("ready bootstrap did not complete graceful shutdown")
}
return
}
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, exe, "-test.run=^TestBootstrapSignalAfterReadiness$")
cmd.Env = append(os.Environ(), "KANDEV_TEST_BOOTSTRAP_SIGNAL_READY=1")
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err = cmd.Start(); err != nil {
t.Fatal(err)
}
defer func() { _ = cmd.Process.Kill() }()
scan := bufio.NewScanner(stdout)
readyOutput := false
for scan.Scan() {
if scan.Text() == "READY" {
readyOutput = true
break
}
}
if !readyOutput {
t.Fatal("child did not publish readiness")
}
if err = cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
if err = cmd.Wait(); err != nil {
t.Fatalf("ready bootstrap did not shut down on SIGTERM: %v", err)
}
}
@@ -1,7 +1,9 @@
package backendapp
import (
"context"
"errors"
"fmt"
"reflect"
"testing"
)
@@ -92,3 +94,65 @@ func TestPublishReadinessFlipsReadyBeforeSwappingHandler(t *testing.T) {
t.Fatalf("publishReadiness order = %v, want %v", order, want)
}
}
func TestShouldLogStartupOrchestratorErrorSkipsExpectedShutdownErrors(t *testing.T) {
for _, test := range []struct {
name string
err error
want bool
}{
{name: "bind failure", err: errServerBindFailed, want: false},
{name: "canceled", err: context.Canceled, want: false},
{name: "wrapped canceled", err: fmt.Errorf("start: %w", context.Canceled), want: false},
{name: "deadline", err: context.DeadlineExceeded, want: false},
{name: "unexpected", err: errors.New("orchestrator unavailable"), want: true},
} {
t.Run(test.name, func(t *testing.T) {
if got := shouldLogStartupOrchestratorError(test.err); got != test.want {
t.Fatalf("shouldLogStartupOrchestratorError(%v) = %v, want %v", test.err, got, test.want)
}
})
}
}
func TestBootstrapRuntimeReadinessHandoffSerializesStartupClose(t *testing.T) {
runtime := &bootstrapRuntime{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if !runtime.beginReadinessPublication(ctx) {
t.Fatal("beginReadinessPublication() = false, want true")
}
cancel()
if runtime.claimStartupClose() {
t.Fatal("claimStartupClose() = true after readiness publication began")
}
}
func TestBootstrapRuntimeStartupCloseWinsBeforeReadiness(t *testing.T) {
runtime := &bootstrapRuntime{}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if runtime.beginReadinessPublication(ctx) {
t.Fatal("beginReadinessPublication() = true after cancellation")
}
if !runtime.claimStartupClose() {
t.Fatal("claimStartupClose() = false before readiness publication")
}
}
func TestProcessRuntimeContextSurvivesWorkerCancellation(t *testing.T) {
processCtx, cancelProcess := context.WithCancel(context.Background())
defer cancelProcess()
workerCtx, cancelWorker := context.WithCancel(context.Background())
workerCtx = withProcessContext(workerCtx, processCtx)
cancelWorker()
if got := processRuntimeContext(workerCtx); got != processCtx {
t.Fatal("processRuntimeContext() did not return the process context")
}
if err := processRuntimeContext(workerCtx).Err(); err != nil {
t.Fatalf("process runtime context error = %v, want nil", err)
}
}
+109 -17
View File
@@ -18,6 +18,7 @@ import (
"github.com/kandev/kandev/internal/persistence/requiredstores"
quickterminalrepository "github.com/kandev/kandev/internal/quickterminal/repository"
"github.com/kandev/kandev/internal/secrets"
"github.com/kandev/kandev/internal/startup"
systemsettings "github.com/kandev/kandev/internal/system/settings"
"github.com/kandev/kandev/internal/task/repository"
"github.com/kandev/kandev/internal/telemetrycontract"
@@ -34,88 +35,152 @@ import (
)
func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Logger, version string) (*db.Pool, *Repositories, []func() error, error) {
// Each check is an admission barrier. Constructors that predate context
// support may finish one in-flight statement after cancellation, but the
// next store is never admitted and cleanup runs only after this synchronous
// initializer has returned.
cleanups := make([]func() error, 0, 12)
initialized := false
defer func() {
if !initialized {
for i := len(cleanups) - 1; i >= 0; i-- {
if cleanups[i] != nil {
_ = cleanups[i]()
}
}
}
}()
tracker, err := requiredstores.NewCatalogTracker()
if err != nil {
return nil, nil, nil, err
}
pool, cleanup, err := persistence.Provide(cfg, log, version)
pool, cleanup, err := persistence.ProvideContext(ctx, cfg, log, version)
if err != nil {
return nil, nil, nil, err
}
cleanups = append(cleanups, cleanup)
startup.SetPhase(ctx, startup.ApplyingMigrations)
writer, reader := pool.Writer(), pool.Reader()
if err := checkStartupContext(ctx, "schema metadata"); err != nil {
return nil, nil, nil, err
}
if err := recordRequiredStore(tracker, "schema-meta", nil); err != nil {
return nil, nil, nil, err
}
taskRepoImpl, cleanup, err := repository.Provide(writer, reader, log)
if err := checkStartupContext(ctx, "task repository"); err != nil {
return nil, nil, nil, err
}
taskRepoImpl, cleanup, err := repository.ProvideContext(ctx, writer, reader, log)
if err := recordRequiredStore(tracker, "task", err); err != nil {
return nil, nil, nil, fmt.Errorf("task store: %w", err)
}
cleanups = append(cleanups, cleanup)
// Workflow repo must be initialized before analytics repo because
// analytics creates indexes on the workflow_steps table.
if err := checkStartupContext(ctx, "workflow repository"); err != nil {
return nil, nil, nil, err
}
workflowRepo, err := workflowrepository.NewWithDB(writer, reader, log)
if err := recordRequiredStore(tracker, "workflow", err); err != nil {
return nil, nil, nil, fmt.Errorf("workflow store: %w", err)
}
if err := checkStartupContext(ctx, "analytics repository"); err != nil {
return nil, nil, nil, err
}
analyticsRepo, cleanup, err := analyticsrepository.Provide(writer, reader)
if err := recordRequiredStore(tracker, "analytics", err); err != nil {
return nil, nil, nil, fmt.Errorf("analytics store: %w", err)
}
cleanups = append(cleanups, cleanup)
if err := checkStartupContext(ctx, "agent settings repository"); err != nil {
return nil, nil, nil, err
}
agentSettingsRepo, cleanup, err := settingsstore.Provide(writer, reader, log)
if err := recordRequiredStore(tracker, "agent-settings", err); err != nil {
return nil, nil, nil, fmt.Errorf("agent settings store: %w", err)
}
cleanups = append(cleanups, cleanup)
if err := checkStartupContext(ctx, "support repositories"); err != nil {
return nil, nil, nil, err
}
supportRepos, supportCleanups, err := provideSupportRepos(ctx, writer, reader, tracker)
if err != nil {
cleanups = append(cleanups, supportCleanups...)
return nil, nil, nil, err
}
cleanups = append(cleanups, supportCleanups...)
if err := checkStartupContext(ctx, "office repository"); err != nil {
return nil, nil, nil, err
}
officeRepo, officeCleanup, err := office.Provide(writer, reader, log)
if err := recordRequiredStore(tracker, "office", err); err != nil {
return nil, nil, nil, fmt.Errorf("office repo: %w", err)
}
cleanups = append(cleanups, officeCleanup)
if err := checkStartupContext(ctx, "terminal repositories"); err != nil {
return nil, nil, nil, err
}
terminalRepoImpl, err := terminalrepo.NewWithDB(writer, reader, log)
if err := recordRequiredStore(tracker, "terminal", err); err != nil {
return nil, nil, nil, fmt.Errorf("terminal repo: %w", err)
}
if err := checkStartupContext(ctx, "quick terminal repository"); err != nil {
return nil, nil, nil, err
}
quickTerminalRepoImpl, err := quickterminalrepository.NewWithDB(writer, reader)
if err := recordRequiredStore(tracker, "quick-terminal", err); err != nil {
return nil, nil, nil, fmt.Errorf("quick terminal repo: %w", err)
}
if err := checkStartupContext(ctx, "runtime flags repository"); err != nil {
return nil, nil, nil, err
}
runtimeFlagsStore, err := runtimeflags.NewSQLiteStore(writer, reader)
if err := recordRequiredStore(tracker, "runtime-flags", err); err != nil {
return nil, nil, nil, fmt.Errorf("runtime flags store: %w", err)
}
if err := checkStartupContext(ctx, "auth repository"); err != nil {
return nil, nil, nil, err
}
authRepo, err := authstore.New(writer, reader)
if err := recordRequiredStore(tracker, "auth", err); err != nil {
return nil, nil, nil, fmt.Errorf("auth store: %w", err)
}
if err := checkStartupContext(ctx, "secret repository"); err != nil {
return nil, nil, nil, err
}
masterKeyProvider, err := secrets.NewMasterKeyProvider(cfg.ResolvedDataDir())
if err != nil {
return nil, nil, nil, fmt.Errorf("master key: %w", err)
}
if err := checkStartupContext(ctx, "secret store"); err != nil {
return nil, nil, nil, err
}
secretStore, cleanup, err := secrets.Provide(writer, reader, masterKeyProvider)
if err := recordRequiredStore(tracker, "secrets", err); err != nil {
return nil, nil, nil, fmt.Errorf("secret store: %w", err)
}
cleanups = append(cleanups, cleanup)
if err := checkStartupContext(ctx, "system settings and hostname repositories"); err != nil {
return nil, nil, nil, err
}
systemSettings, err := systemsettings.NewStore(pool)
if err := recordRequiredStore(tracker, "system-settings", err); err != nil {
return nil, nil, nil, fmt.Errorf("system settings store: %w", err)
}
if err := checkStartupContext(ctx, "hostname repository"); err != nil {
return nil, nil, nil, err
}
hostnameCache, err := hostnames.NewStore(writer, reader)
if err := recordRequiredStore(tracker, "auth-hostnames", err); err != nil {
return nil, nil, nil, fmt.Errorf("hostname cache store: %w", err)
}
if err := checkStartupContext(ctx, "telemetry contract repository"); err != nil {
return nil, nil, nil, err
}
telemetryStore, err := telemetrycontract.NewWithDB(writer, reader)
if err := recordRequiredStore(tracker, "telemetry-contract", err); err != nil {
return nil, nil, nil, fmt.Errorf("telemetry contract store: %w", err)
@@ -143,9 +208,21 @@ func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Lo
HostnameCache: hostnameCache,
SystemSettings: systemSettings,
}
if err := ctx.Err(); err != nil {
return nil, nil, nil, err
}
initialized = true
startup.SetPhase(ctx, startup.InitializingServices)
return pool, repos, cleanups, nil
}
func checkStartupContext(ctx context.Context, stage string) error {
if err := ctx.Err(); err != nil {
return fmt.Errorf("startup canceled before %s: %w", stage, err)
}
return nil
}
// supportRepositorySet groups the lighter-weight support repositories
// that share a common (writer, reader) wire-up pattern.
type supportRepositorySet struct {
@@ -164,41 +241,56 @@ func provideSupportRepos(ctx context.Context, writer, reader *sqlx.DB, tracker *
var cleanups []func() error
var repos supportRepositorySet
userRepo, cleanup, err := userstore.Provide(writer, reader)
if err := recordRequiredStore(tracker, "user", err); err != nil {
return repos, nil, err
if err := checkStartupContext(ctx, "user repository"); err != nil {
return repos, cleanups, err
}
userRepo, cleanup, err := userstore.Provide(writer, reader)
cleanups = append(cleanups, cleanup)
if err := recordRequiredStore(tracker, "user", err); err != nil {
return repos, cleanups, err
}
repos.user = userRepo
// Same concrete store, account-management view (used by internal/auth).
repos.userAccounts = userRepo
notificationRepo, cleanup, err := notificationstore.Provide(ctx, writer, reader)
if err := recordRequiredStore(tracker, "notification", err); err != nil {
return repos, nil, err
if err := checkStartupContext(ctx, "notification repository"); err != nil {
return repos, cleanups, err
}
notificationRepo, cleanup, err := notificationstore.Provide(ctx, writer, reader)
cleanups = append(cleanups, cleanup)
if err := recordRequiredStore(tracker, "notification", err); err != nil {
return repos, cleanups, err
}
repos.notification = notificationRepo
editorRepo, cleanup, err := editorstore.Provide(writer, reader)
if err := recordRequiredStore(tracker, "editor", err); err != nil {
return repos, nil, err
if err := checkStartupContext(ctx, "editor repository"); err != nil {
return repos, cleanups, err
}
editorRepo, cleanup, err := editorstore.Provide(writer, reader)
cleanups = append(cleanups, cleanup)
if err := recordRequiredStore(tracker, "editor", err); err != nil {
return repos, cleanups, err
}
repos.editor = editorRepo
promptRepo, cleanup, err := promptstore.Provide(writer, reader)
if err := recordRequiredStore(tracker, "prompts", err); err != nil {
return repos, nil, err
if err := checkStartupContext(ctx, "prompt repository"); err != nil {
return repos, cleanups, err
}
promptRepo, cleanup, err := promptstore.Provide(writer, reader)
cleanups = append(cleanups, cleanup)
if err := recordRequiredStore(tracker, "prompts", err); err != nil {
return repos, cleanups, err
}
repos.prompts = promptRepo
utilityRepo, cleanup, err := utilitystore.Provide(writer, reader)
if err := recordRequiredStore(tracker, "utility", err); err != nil {
return repos, nil, err
if err := checkStartupContext(ctx, "utility repository"); err != nil {
return repos, cleanups, err
}
utilityRepo, cleanup, err := utilitystore.Provide(writer, reader)
cleanups = append(cleanups, cleanup)
if err := recordRequiredStore(tracker, "utility", err); err != nil {
return repos, cleanups, err
}
repos.utility = utilityRepo
return repos, cleanups, nil
+70 -14
View File
@@ -1,6 +1,8 @@
package db
import (
"context"
"errors"
"fmt"
"github.com/jmoiron/sqlx"
@@ -17,6 +19,7 @@ type MigrateLogger struct {
db *sqlx.DB
log *logger.Logger
strict bool
ctx context.Context
firstErr error
}
@@ -33,8 +36,24 @@ func NewRequiredMigrateLogger(db *sqlx.DB, log *logger.Logger) *MigrateLogger {
return newMigrateLogger(db, log, true)
}
// NewRequiredMigrateLoggerContext creates a required migration logger whose
// statements observe ctx. The context is checked between migration steps and
// by the SQL driver while a statement is running. Once a required statement
// fails, later Apply calls become admission no-ops until the owning
// constructor returns the first error.
func NewRequiredMigrateLoggerContext(db *sqlx.DB, log *logger.Logger, ctx context.Context) *MigrateLogger {
if ctx == nil {
ctx = context.Background()
}
return newMigrateLoggerContext(db, log, true, ctx)
}
func newMigrateLogger(db *sqlx.DB, log *logger.Logger, strict bool) *MigrateLogger {
return &MigrateLogger{db: db, log: log, strict: strict}
return newMigrateLoggerContext(db, log, strict, context.Background())
}
func newMigrateLoggerContext(db *sqlx.DB, log *logger.Logger, strict bool, ctx context.Context) *MigrateLogger {
return &MigrateLogger{db: db, log: log, strict: strict, ctx: ctx}
}
// Apply executes stmt and classifies the result:
@@ -45,22 +64,34 @@ func newMigrateLogger(db *sqlx.DB, log *logger.Logger, strict bool) *MigrateLogg
// In strict mode, the first unexpected error is retained and returned. In
// compatibility mode, unexpected errors are logged and swallowed as before.
func (m *MigrateLogger) Apply(name, stmt string) error {
if _, err := m.db.Exec(stmt); err != nil {
return m.ApplyContext(m.ctx, name, stmt)
}
// ApplyContext executes a migration statement with an explicit context.
func (m *MigrateLogger) ApplyContext(ctx context.Context, name, stmt string) error {
if m.strict && m.firstErr != nil {
return m.firstErr
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return m.recordFailure(name, err)
}
if _, err := m.db.ExecContext(ctx, stmt); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return m.recordFailure(name, errors.Join(err, ctxErr))
}
if IsAlreadyExistsError(err) {
return nil
}
wrapped := fmt.Errorf("migration %q failed: %w", name, err)
if m.strict && m.firstErr == nil {
m.firstErr = wrapped
}
if m.log != nil {
m.log.Warn("migration failed",
zap.String("name", name), zap.Error(wrapped))
}
if m.strict {
return wrapped
}
return nil
return m.recordFailure(name, err)
}
// SQLite can finish an interruptible statement successfully at the same
// time that its context is canceled. Treat cancellation observed at this
// boundary as a failed migration so the next store is never admitted.
if err := ctx.Err(); err != nil {
return m.recordFailure(name, err)
}
if m.log != nil {
m.log.Info("migration applied", zap.String("name", name))
@@ -68,6 +99,31 @@ func (m *MigrateLogger) Apply(name, stmt string) error {
return nil
}
func (m *MigrateLogger) recordFailure(name string, err error) error {
wrapped := fmt.Errorf("migration %q failed: %w", name, err)
if m.strict && m.firstErr == nil {
m.firstErr = wrapped
}
if m.log != nil {
m.log.Warn("migration failed",
zap.String("name", name), zap.Error(wrapped))
}
if m.strict {
return wrapped
}
return nil
}
// Context returns the context used by this migration logger. It is used by
// legacy migration helpers that need to switch a long-running query to
// ExecContext without changing their public constructor signatures.
func (m *MigrateLogger) Context() context.Context {
if m.ctx == nil {
return context.Background()
}
return m.ctx
}
// Err returns the first unexpected migration failure observed by this
// logger. Idempotent duplicate errors are not reported.
func (m *MigrateLogger) Err() error {
+71 -1
View File
@@ -1,10 +1,14 @@
package db
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/mattn/go-sqlite3"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
@@ -139,3 +143,69 @@ func TestMigrateLogger_Apply_NilLog(t *testing.T) {
m.Apply("t.col", `ALTER TABLE t ADD COLUMN col TEXT DEFAULT ''`) // idempotent
m.Apply("bad", `ALTER TABLE missing ADD COLUMN x TEXT`)
}
// TestRequiredMigrateLoggerContextCancelsActiveStatement proves cancellation
// reaches a migration after the driver has started executing it. The custom
// SQLite function gives the test a deterministic statement boundary instead
// of relying on a scheduler sleep or a large-data timing guess.
func TestRequiredMigrateLoggerContextCancelsActiveStatement(t *testing.T) {
db := memDB(t)
db.SetMaxOpenConns(1)
if _, err := db.Exec(`CREATE TABLE t (value INTEGER NOT NULL)`); err != nil {
t.Fatalf("create table: %v", err)
}
if _, err := db.Exec(`INSERT INTO t(value) VALUES (0)`); err != nil {
t.Fatalf("insert row: %v", err)
}
started := make(chan struct{})
release := make(chan struct{})
var startedOnce sync.Once
conn, err := db.Conn(context.Background())
if err != nil {
t.Fatalf("reserve SQLite connection: %v", err)
}
if err := conn.Raw(func(driverConn any) error {
sqliteConn, ok := driverConn.(*sqlite3.SQLiteConn)
if !ok {
t.Fatalf("driver connection = %T, want *sqlite3.SQLiteConn", driverConn)
}
return sqliteConn.RegisterFunc("wait_for_release", func() int64 {
startedOnce.Do(func() { close(started) })
<-release
return 1
}, true)
}); err != nil {
_ = conn.Close()
t.Fatalf("register SQLite function: %v", err)
}
if err := conn.Close(); err != nil {
t.Fatalf("release SQLite connection: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- NewRequiredMigrateLoggerContext(db, nil, ctx).ApplyContext(
ctx,
"active.statement",
`UPDATE t SET value = wait_for_release()`,
)
}()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("migration statement did not enter the SQLite function")
}
cancel()
close(release)
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("active migration error = %v, want context.Canceled", err)
}
case <-time.After(2 * time.Second):
t.Fatal("active migration did not stop after cancellation")
}
}
+20
View File
@@ -98,6 +98,26 @@ func ColumnExists(conn SchemaQuerier, table, column string) (bool, error) {
return columns[column], nil
}
// ColumnExistsContext is the cancellation-aware form of ColumnExists. It uses
// a point-existence query instead of loading every column name, which keeps
// startup schema probes bounded even on a wide catalog.
func ColumnExistsContext(ctx context.Context, conn ContextSchemaQuerier, table, column string) (bool, error) {
query := `SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?
)`
if !isPostgresSchema(conn) {
query = `SELECT EXISTS (
SELECT 1 FROM pragma_table_info(?) WHERE name = ?
)`
}
var exists bool
if err := conn.QueryRowContext(ctx, conn.Rebind(query), table, column).Scan(&exists); err != nil {
return false, err
}
return exists, nil
}
func isPostgresSchema(conn SchemaQuerier) bool {
return conn.DriverName() == "pgx"
}
@@ -17,6 +17,7 @@
{"file": "internal/backendapp/e2e_reset.go", "symbol": "handleE2ECreateAutomation", "rule": "raw-placeholder", "reason": "The E2E seed endpoint operates on the SQLite task repository"},
{"file": "internal/db/schema.go", "symbol": "tableExistsQuery", "rule": "sqlite-catalog", "reason": "This is the central dialect-aware schema inspection helper"},
{"file": "internal/db/schema.go", "symbol": "TableColumns", "rule": "sqlite-catalog", "reason": "This is the central dialect-aware schema inspection helper"},
{"file": "internal/db/schema.go", "symbol": "ColumnExistsContext", "rule": "sqlite-catalog", "reason": "This is the central dialect-aware schema inspection helper"},
{"file": "internal/delivery/store.go", "symbol": "tableExists", "rule": "sqlite-catalog", "reason": "The SQLite branch is the explicit dialect boundary for the schema probe"},
{"file": "internal/delivery/store.go", "symbol": "tableExists", "rule": "raw-placeholder", "reason": "The placeholder is in the explicit SQLite branch of the schema probe"},
{"file": "internal/github/store.go", "symbol": "createTablesSQL", "rule": "boolean-integer", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"},
@@ -96,10 +97,6 @@
{"file": "internal/task/repository/sqlite/base_migrations.go", "symbol": "backfillExecutorsRunningFromTaskSessions", "rule": "raw-placeholder", "reason": "This backfill is a SQLite-only legacy migration"},
{"file": "internal/task/repository/sqlite/base_migrations.go", "symbol": "healDuplicateTaskEnvForTask", "rule": "raw-placeholder", "reason": "This repair is a SQLite-only legacy migration"},
{"file": "internal/task/repository/sqlite/session.go", "symbol": "DismissLastAgentError", "rule": "sqlite-date-function", "reason": "The task SQLite repository owns this SQLite date expression"},
{"file": "internal/task/repository/sqlite/worktree_ownership_migration.go", "symbol": "tableExists", "rule": "sqlite-catalog", "reason": "SQLite-only worktree ownership migration"},
{"file": "internal/task/repository/sqlite/worktree_ownership_migration.go", "symbol": "tableExists", "rule": "raw-placeholder", "reason": "SQLite-only worktree ownership migration"},
{"file": "internal/task/repository/sqlite/worktree_ownership_migration.go", "symbol": "columnExists", "rule": "sqlite-catalog", "reason": "SQLite-only worktree ownership migration"},
{"file": "internal/task/repository/sqlite/worktree_ownership_migration.go", "symbol": "columnExists", "rule": "raw-placeholder", "reason": "SQLite-only worktree ownership migration"},
{"file": "internal/task/repository/sqlite/worktree_ownership_targets.go", "symbol": "tableColumns", "rule": "sqlite-catalog", "reason": "SQLite-only worktree ownership migration"},
{"file": "internal/task/repository/sqlite/worktree_ownership_targets.go", "symbol": "tableColumns", "rule": "raw-placeholder", "reason": "SQLite-only worktree ownership migration"},
{"file": "internal/workflow/repository/phase2_sqlite.go", "symbol": "backfillParticipantsCreatedAtFromRowid", "rule": "sqlite-date-function", "reason": "Guarded by dialect.IsPostgres and returns before this statement on every non-SQLite driver"},
@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
storagepkg "github.com/kandev/kandev/internal/system/storage"
@@ -78,6 +79,58 @@ func TestSystemEventBroadcasterCloseIsIdempotent(t *testing.T) {
eventBus.Close()
}
func TestSystemEventBroadcasterSurvivesWorkerCancellation(t *testing.T) {
log := testLoggerForUserNotifications(t)
eventBus := bus.NewMemoryEventBus(log)
processCtx, cancelProcess := context.WithCancel(context.Background())
t.Cleanup(cancelProcess)
workerCtx, cancelWorker := context.WithCancel(processCtx)
hub := newTestHub(t)
hubDone := make(chan struct{})
go func() {
defer close(hubDone)
hub.Run(processCtx)
}()
t.Cleanup(func() {
cancelProcess()
<-hubDone
eventBus.Close()
})
broadcaster := RegisterSystemNotifications(processCtx, eventBus, hub, log)
t.Cleanup(broadcaster.Close)
client := NewClient("restore-auth", authn.Identity{UserID: "restore-user", Role: authn.RoleMember}, nil, hub, log)
registerTestClient(hub, client)
// Restore quiescing cancels worker-owned services. The process context
// still owns the authenticated WebSocket and system-job broadcaster until
// the terminal restart-required result is delivered.
cancelWorker()
if workerCtx.Err() != context.Canceled {
t.Fatalf("worker context error = %v, want context.Canceled", workerCtx.Err())
}
if err := eventBus.Publish(processCtx, events.SystemJobUpdate,
bus.NewEvent(events.SystemJobUpdate, "restore", map[string]any{
"job_id": "restore-job",
"state": "succeeded",
})); err != nil {
t.Fatalf("publish restore result: %v", err)
}
select {
case frame := <-client.send:
var message ws.Message
if err := json.Unmarshal(frame, &message); err != nil {
t.Fatalf("decode restore notification: %v", err)
}
if message.Action != ws.ActionSystemJobUpdate {
t.Fatalf("restore notification action = %q, want %q", message.Action, ws.ActionSystemJobUpdate)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for restore notification after worker cancellation")
}
}
func TestSystemStorageAnalysisNotificationsBroadcastProgressState(t *testing.T) {
hub := newTestHub(t)
ctx, cancel := context.WithCancel(context.Background())
+1
View File
@@ -73,6 +73,7 @@ func runDev(ctx context.Context, opts Options, build BuildInfo) int {
}
if err := waitForReadyFn(ctx, cfg.ports.BackendURL, backend); err != nil {
supervisor.shutdown("backend readiness failure")
dumpLogs()
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
+39 -7
View File
@@ -2,6 +2,7 @@ package launcher
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -14,6 +15,7 @@ import (
"time"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/startup"
)
const (
@@ -362,16 +364,32 @@ func safeProbeError(err error) string {
// exists to fix.
func waitForReady(ctx context.Context, baseURL string, proc childState) error {
readyURL := baseURL + "/ready"
var last startup.Snapshot
var reported time.Time
exitError := func(code int) error {
if label := last.Phase.Label(); label != "" {
return fmt.Errorf("backend exited (code %d) during %s (elapsed %.1fs); check backend logs for the initialization error", code, label, float64(last.ElapsedMS)/1000)
}
return fmt.Errorf("backend exited (code %d) before it reported ready; check backend logs for the initialization error", code)
}
for ctx.Err() == nil {
if exited, code := proc.Exited(); exited {
return fmt.Errorf("backend exited (code %d) before it reported ready", code)
return exitError(code)
}
if probeReady(ctx, readyURL) {
isReady, snapshot := probeReadyStatus(ctx, readyURL)
if snapshot.Phase.Label() != "" {
if snapshot.Phase != last.Phase || time.Since(reported) >= 15*time.Second {
fmt.Fprintf(os.Stderr, "[kandev] %s (elapsed %.1fs; phase %.1fs)\n", snapshot.Phase.Label(), float64(snapshot.ElapsedMS)/1000, float64(snapshot.PhaseElapsedMS)/1000)
reported = time.Now()
}
last = snapshot
}
if isReady {
if err := ctx.Err(); err != nil {
return fmt.Errorf("backend readiness wait canceled at %s: %w", readyURL, err)
}
if exited, code := proc.Exited(); exited {
return fmt.Errorf("backend exited (code %d) before it reported ready", code)
return exitError(code)
}
return nil
}
@@ -388,17 +406,31 @@ func waitForReady(ctx context.Context, baseURL string, proc childState) error {
// sets X-Kandev-Desktop-Health-Token (that header is /health-only, see
// docs/specs/health-endpoint-version/spec.md AC-21).
func probeReady(ctx context.Context, readyURL string) bool {
ready, _ := probeReadyStatus(ctx, readyURL)
return ready
}
func probeReadyStatus(ctx context.Context, readyURL string) (bool, startup.Snapshot) {
var body struct {
Startup startup.Snapshot `json:"startup"`
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, readyURL, nil)
if err != nil {
return false
return false, body.Startup
}
resp, err := healthProbeClient.Do(req)
if err != nil {
return false
return false, body.Startup
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode >= 200 && resp.StatusCode < 300
if resp.StatusCode == http.StatusServiceUnavailable {
if err := json.NewDecoder(io.LimitReader(resp.Body, 8192)).Decode(&body); err != nil || body.Startup.Phase.Label() == "" || body.Startup.ElapsedMS < 0 || body.Startup.PhaseElapsedMS < 0 {
body.Startup = startup.Snapshot{}
}
} else {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 8192))
}
return resp.StatusCode >= 200 && resp.StatusCode < 300, body.Startup
}
func healthPort(baseURL string) string {
@@ -461,6 +461,72 @@ func TestWaitForReadyReturnsNilOnReadyResponse(t *testing.T) {
}
}
func TestWaitForHealthThenReadyKeepsDelayedBootstrapAlive(t *testing.T) {
const healthToken = "delayed-bootstrap-token"
var initialized atomic.Bool
readyStarted := make(chan struct{})
var readyStartedOnce sync.Once
var readyCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/health":
w.Header().Set("X-Kandev-Desktop-Health-Token", healthToken)
w.WriteHeader(http.StatusOK)
case "/ready":
readyCalls.Add(1)
readyStartedOnce.Do(func() { close(readyStarted) })
if !initialized.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"startup":{"phase":"opening_database","elapsed_ms":1,"phase_elapsed_ms":1}}`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
// The launcher must decide that the process is healthy within this short
// budget, then keep waiting for readiness while initialization remains
// gated beyond that budget.
if readyURL, err := waitForHealth(context.Background(), singleHealthTarget(srv.URL), fakeChild{}, 50*time.Millisecond, healthToken, nil); err != nil {
t.Fatalf("waitForHealth() = %v, want healthy bootstrap", err)
} else if readyURL != srv.URL {
t.Fatalf("waitForHealth() URL = %q, want %q", readyURL, srv.URL)
}
readyCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
readyDone := make(chan error, 1)
go func() { readyDone <- waitForReady(readyCtx, srv.URL, fakeChild{}) }()
select {
case <-readyStarted:
case <-time.After(time.Second):
t.Fatal("waitForReady did not probe the bootstrap readiness endpoint")
}
select {
case err := <-readyDone:
t.Fatalf("waitForReady returned before initialization release: %v", err)
case <-time.After(100 * time.Millisecond):
}
initialized.Store(true)
select {
case err := <-readyDone:
if err != nil {
t.Fatalf("waitForReady after initialization release: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("waitForReady did not observe the released readiness endpoint")
}
if readyCalls.Load() < 1 {
t.Fatal("waitForReady did not issue a readiness request")
}
}
func TestWaitForReadyRejectsChildExitDuringSuccessfulProbe(t *testing.T) {
requestStarted := make(chan struct{})
responseRelease := make(chan struct{})
@@ -617,3 +683,18 @@ func TestWaitForHealthFailsFastWhenBackendExited(t *testing.T) {
t.Fatalf("onFailure called %d times, want 1", failures)
}
}
func TestReadinessExitIncludesLastStartupPhase(t *testing.T) {
var child toggledChild
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"status":"starting","startup":{"phase":"backing_up_database","elapsed_ms":1234,"phase_elapsed_ms":1000}}`))
child.code.Store(3)
child.exited.Store(true)
}))
defer srv.Close()
err := waitForReady(context.Background(), srv.URL, &child)
if err == nil || !strings.Contains(err.Error(), "Backing up database") {
t.Fatalf("missing startup phase in error: %v", err)
}
}
+1
View File
@@ -179,6 +179,7 @@ func runManagedApp(ctx context.Context, cfg managedAppConfig) int {
shutdownDebugf("runManagedApp backend healthy, waiting for readiness")
if err := waitForReadyFn(ctx, cfg.Ports.BackendURL, backend); err != nil {
supervisor.shutdown("backend readiness failure")
dumpLogs()
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
+49 -5
View File
@@ -1,6 +1,7 @@
package persistence
import (
"context"
"fmt"
"os"
"path/filepath"
@@ -12,6 +13,7 @@ import (
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/startup"
)
// Provide creates the database connection pool used by repositories.
@@ -23,6 +25,15 @@ import (
// compared against the stored kandev_version to decide whether to take a
// pre-migration backup. Pass "" in tests that do not care about snapshots.
func Provide(cfg *config.Config, log *logger.Logger, version string) (*db.Pool, func() error, error) {
return ProvideContext(context.Background(), cfg, log, version)
}
// ProvideContext opens persistence for a cancellable backend initialization.
func ProvideContext(ctx context.Context, cfg *config.Config, log *logger.Logger, version string) (*db.Pool, func() error, error) {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
startup.SetPhase(ctx, startup.OpeningDatabase)
driver := cfg.Database.Driver
if driver == "" {
driver = "sqlite"
@@ -30,7 +41,7 @@ func Provide(cfg *config.Config, log *logger.Logger, version string) (*db.Pool,
switch driver {
case "sqlite":
return provideSQLite(cfg, log, version)
return provideSQLite(ctx, cfg, log, version)
case "postgres":
return providePostgres(cfg, log)
default:
@@ -38,7 +49,7 @@ func Provide(cfg *config.Config, log *logger.Logger, version string) (*db.Pool,
}
}
func provideSQLite(cfg *config.Config, log *logger.Logger, version string) (*db.Pool, func() error, error) {
func provideSQLite(ctx context.Context, cfg *config.Config, log *logger.Logger, version string) (*db.Pool, func() error, error) {
selection, err := selectSQLiteDatabase(cfg, log)
if err != nil {
return nil, nil, fmt.Errorf("select sqlite database: %w", err)
@@ -87,16 +98,17 @@ func provideSQLite(cfg *config.Config, log *logger.Logger, version string) (*db.
}
if shouldBackup(storedVersion, version, userTables) {
startup.SetPhase(ctx, startup.BackingUpDatabase)
started := time.Now()
backupDir := filepath.Join(filepath.Dir(dbPath), "backups")
if err := os.MkdirAll(backupDir, 0o755); err != nil {
_ = pool.Close()
return nil, nil, fmt.Errorf("create backup dir: %w", err)
}
path := snapshotPath(backupDir, storedVersion)
size, err := snapshotSQLite(writer, path)
path, size, err := createPreMigrationBackup(ctx, writer, backupDir, storedVersion)
if err != nil {
_ = pool.Close()
return nil, nil, fmt.Errorf("pre-migration backup failed: %w", err)
return nil, nil, err
}
if log != nil {
log.Info("pre-migration backup taken",
@@ -104,6 +116,7 @@ func provideSQLite(cfg *config.Config, log *logger.Logger, version string) (*db.
zap.String("to_version", version),
zap.String("path", path),
zap.Int64("size_bytes", size),
zap.Duration("duration", time.Since(started)),
)
}
_ = pruneBackups(backupDir, 2)
@@ -128,6 +141,37 @@ func provideSQLite(cfg *config.Config, log *logger.Logger, version string) (*db.
return pool, cleanup, nil
}
func createPreMigrationBackup(ctx context.Context, writer *sqlx.DB, backupDir, storedVersion string) (string, int64, error) {
path := snapshotPath(backupDir, storedVersion)
stagingDir, err := os.MkdirTemp(backupDir, ".kandev-backup-*")
if err != nil {
return "", 0, fmt.Errorf("create private backup staging directory: %w", err)
}
defer func() { _ = os.RemoveAll(stagingDir) }()
stagedPath := filepath.Join(stagingDir, filepath.Base(path))
size, err := snapshotSQLiteContext(ctx, writer, stagedPath)
if err != nil {
return "", 0, fmt.Errorf("pre-migration backup failed: %w", err)
}
if err := ctx.Err(); err != nil {
return "", 0, fmt.Errorf("startup canceled after pre-migration backup: %w", err)
}
if err := os.Chmod(stagedPath, 0o600); err != nil {
return "", 0, fmt.Errorf("protect staged pre-migration backup: %w", err)
}
if _, err := inspectSQLiteCandidate(stagedPath); err != nil {
return "", 0, fmt.Errorf("validate staged pre-migration backup: %w", err)
}
if err := ctx.Err(); err != nil {
return "", 0, fmt.Errorf("startup canceled before installing pre-migration backup: %w", err)
}
if err := installStagedSQLite(stagedPath, path); err != nil {
return "", 0, fmt.Errorf("install pre-migration backup: %w", err)
}
return path, size, nil
}
func providePostgres(cfg *config.Config, log *logger.Logger) (*db.Pool, func() error, error) {
dsn := cfg.Database.DSN()
dbConn, err := db.OpenPostgres(dsn, cfg.Database.MaxConns, cfg.Database.MinConns)
+27 -1
View File
@@ -1,6 +1,7 @@
package persistence
import (
"context"
"fmt"
"os"
"path/filepath"
@@ -36,13 +37,38 @@ func SnapshotSQLite(writer *sqlx.DB, path string) (int64, error) {
// which produces a clean, defragmented snapshot including all WAL frames.
// Returns the size of the created file in bytes.
func snapshotSQLite(writer *sqlx.DB, path string) (int64, error) {
if _, err := writer.Exec(`VACUUM INTO ?`, path); err != nil {
return snapshotSQLiteContext(context.Background(), writer, path)
}
func snapshotSQLiteContext(ctx context.Context, writer *sqlx.DB, path string) (int64, error) {
_, statErr := os.Lstat(path)
if statErr != nil && !os.IsNotExist(statErr) {
return 0, fmt.Errorf("inspect snapshot destination %s: %w", path, statErr)
}
pathExisted := statErr == nil
installed := false
defer func() {
if !installed && !pathExisted {
// VACUUM INTO writes the destination incrementally. A canceled or
// failed statement can leave a partial SQLite file behind, which
// must not be mistaken for a usable backup on retry.
_ = os.Remove(path)
}
}()
if _, err := writer.ExecContext(ctx, `VACUUM INTO ?`, path); err != nil {
return 0, fmt.Errorf("vacuum into %s: %w", path, err)
}
if err := ctx.Err(); err != nil {
return 0, fmt.Errorf("snapshot canceled after vacuum into %s: %w", path, err)
}
info, err := os.Stat(path)
if err != nil {
return 0, fmt.Errorf("stat snapshot %s: %w", path, err)
}
if err := ctx.Err(); err != nil {
return 0, fmt.Errorf("snapshot canceled before installing %s: %w", path, err)
}
installed = true
return info.Size(), nil
}
@@ -1,6 +1,7 @@
package persistence
import (
"context"
"os"
"path/filepath"
"testing"
@@ -59,6 +60,26 @@ func TestSnapshotSQLite_CreatesReadableCopy(t *testing.T) {
}
}
func TestSnapshotSQLite_CanceledRemovesDestination(t *testing.T) {
dir := t.TempDir()
srcPath := filepath.Join(dir, "src.db")
dstPath := filepath.Join(dir, "snap.db")
src := fileSQLiteDB(t, srcPath)
if _, err := src.Exec(`CREATE TABLE things (id INTEGER PRIMARY KEY, name TEXT)`); err != nil {
t.Fatalf("create table: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := snapshotSQLiteContext(ctx, src, dstPath); err == nil {
t.Fatal("canceled snapshot succeeded")
}
if _, err := os.Stat(dstPath); !os.IsNotExist(err) {
t.Fatalf("canceled snapshot destination stat error = %v, want not exist", err)
}
}
// TestPruneBackups verifies that pruneBackups keeps exactly the N newest files.
func TestPruneBackups_KeepsNewest(t *testing.T) {
dir := t.TempDir()
@@ -82,6 +82,13 @@ func TestProvide_TakesSnapshotOnUpgrade(t *testing.T) {
// The backup should be a readable SQLite DB containing the seeded row.
snapPath := filepath.Join(backupDir, entries[0].Name())
info, err := os.Stat(snapPath)
if err != nil {
t.Fatalf("stat snapshot: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("snapshot mode = %#o, want 0600", got)
}
snap, err := sqlx.Open("sqlite3", snapPath)
if err != nil {
t.Fatalf("open snapshot: %v", err)
+100
View File
@@ -0,0 +1,100 @@
// Package startup reports process initialization without exposing private diagnostics.
package startup
import (
"context"
"sync"
"time"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
type Phase string
const (
OpeningDatabase Phase = "opening_database"
BackingUpDatabase Phase = "backing_up_database"
ApplyingMigrations Phase = "applying_migrations"
InitializingServices Phase = "initializing_services"
RecoveringSessions Phase = "recovering_sessions"
Ready Phase = "ready"
)
// Label accepts only protocol-owned phase names, never arbitrary server text.
func (p Phase) Label() string {
switch p {
case OpeningDatabase:
return "Opening database"
case BackingUpDatabase:
return "Backing up database"
case ApplyingMigrations:
return "Applying migrations"
case InitializingServices:
return "Initializing services"
case RecoveringSessions:
return "Recovering sessions"
case Ready:
return "Ready"
default:
return ""
}
}
type Snapshot struct {
Phase Phase `json:"phase"`
ElapsedMS int64 `json:"elapsed_ms"`
PhaseElapsedMS int64 `json:"phase_elapsed_ms"`
}
type Reporter struct {
mu sync.Mutex
started, phaseStarted time.Time
phase Phase
log *logger.Logger
}
type contextKey struct{}
func New(log *logger.Logger) *Reporter {
now := time.Now()
return &Reporter{started: now, phaseStarted: now, phase: OpeningDatabase, log: log}
}
func WithReporter(ctx context.Context, r *Reporter) context.Context {
return context.WithValue(ctx, contextKey{}, r)
}
func FromContext(ctx context.Context) *Reporter {
r, _ := ctx.Value(contextKey{}).(*Reporter)
return r
}
func SetPhase(ctx context.Context, phase Phase) {
if r := FromContext(ctx); r != nil {
r.Set(phase)
}
}
func (r *Reporter) Snapshot() Snapshot {
r.mu.Lock()
defer r.mu.Unlock()
return Snapshot{Phase: r.phase, ElapsedMS: time.Since(r.started).Milliseconds(), PhaseElapsedMS: time.Since(r.phaseStarted).Milliseconds()}
}
func (r *Reporter) Set(phase Phase) {
r.mu.Lock()
defer r.mu.Unlock()
if r.phase == phase {
return
}
if r.log != nil {
r.log.Info("Startup phase completed", zap.String("phase", string(r.phase)), zap.Duration("duration", time.Since(r.phaseStarted)), zap.Duration("elapsed", time.Since(r.started)))
}
r.phase = phase
r.phaseStarted = time.Now()
if r.log != nil {
r.log.Info("Startup phase started", zap.String("phase", string(phase)), zap.Duration("elapsed", time.Since(r.started)))
}
}
func (r *Reporter) Report() {
s := r.Snapshot()
if r.log != nil && s.Phase != Ready {
r.log.Info("Startup in progress", zap.String("phase", string(s.Phase)), zap.Int64("elapsed_ms", s.ElapsedMS), zap.Int64("phase_elapsed_ms", s.PhaseElapsedMS))
}
}
@@ -0,0 +1,33 @@
package startup
import (
"context"
"testing"
)
func TestReporterStartsWithOpeningDatabase(t *testing.T) {
reporter := New(nil)
snapshot := reporter.Snapshot()
if snapshot.Phase != OpeningDatabase {
t.Fatalf("initial phase = %q, want %q", snapshot.Phase, OpeningDatabase)
}
if snapshot.ElapsedMS < 0 || snapshot.PhaseElapsedMS < 0 {
t.Fatalf("negative startup durations: %#v", snapshot)
}
}
func TestReporterTransitionsAndKeepsProtocolLabels(t *testing.T) {
reporter := New(nil)
ctx := WithReporter(context.Background(), reporter)
SetPhase(ctx, BackingUpDatabase)
snapshot := reporter.Snapshot()
if snapshot.Phase != BackingUpDatabase {
t.Fatalf("phase = %q, want %q", snapshot.Phase, BackingUpDatabase)
}
if snapshot.Phase.Label() != "Backing up database" {
t.Fatalf("phase label = %q, want %q", snapshot.Phase.Label(), "Backing up database")
}
if Phase("private_error").Label() != "" {
t.Fatal("unknown phase exposed a label")
}
}
@@ -1,6 +1,8 @@
package repository
import (
"context"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/common/logger"
@@ -9,7 +11,13 @@ import (
// Provide creates the SQLite repository using separate writer and reader pools.
func Provide(writer, reader *sqlx.DB, log *logger.Logger) (*sqlite.Repository, func() error, error) {
repo, err := sqlite.NewWithDB(writer, reader, log)
return ProvideContext(context.Background(), writer, reader, log)
}
// ProvideContext creates the SQLite repository while observing startup
// cancellation between schema steps and during context-aware migrations.
func ProvideContext(ctx context.Context, writer, reader *sqlx.DB, log *logger.Logger) (*sqlite.Repository, func() error, error) {
repo, err := sqlite.NewWithDBContext(ctx, writer, reader, log)
if err != nil {
return nil, nil, err
}
@@ -556,7 +556,7 @@ func (r *Repository) deleteClaimedMessageAttachments(
if len(ids) == 0 {
return nil, nil
}
queueTablePresent, err := r.tableExists("queued_messages")
queueTablePresent, err := r.tableExistsContext(ctx, "queued_messages")
if err != nil {
return nil, fmt.Errorf("probe queue attachment references: %w", err)
}
@@ -108,6 +108,13 @@ func (r *Repository) nowUTC() time.Time {
return time.Now().UTC()
}
func (r *Repository) migrationContext() context.Context {
if r.migrate == nil {
return context.Background()
}
return r.migrate.Context()
}
// SetTaskQueuePurger registers the orchestrator-owned queue cleanup for
// in-memory queues. SQLite queues are also purged in the task mutation
// transaction; this callback keeps the explicitly ephemeral queue equivalent.
@@ -178,18 +185,37 @@ func (r *Repository) notifyTaskSessionQueuePurged(ctx context.Context, taskID, s
// NewWithDB creates a new SQLite repository with an existing database connection (shared ownership).
func NewWithDB(writer, reader *sqlx.DB, log *logger.Logger) (*Repository, error) {
return newRepository(writer, reader, log, false)
return NewWithDBContext(context.Background(), writer, reader, log)
}
// NewWithDBContext creates a repository with an existing database connection
// while observing cancellation at schema-step boundaries and in
// context-aware migration statements. A statement that has already entered
// the database driver can finish before that driver reports cancellation; the
// caller retains pool ownership until this constructor returns.
func NewWithDBContext(ctx context.Context, writer, reader *sqlx.DB, log *logger.Logger) (*Repository, error) {
return newRepositoryContext(ctx, writer, reader, log, false)
}
func newRepository(writer, reader *sqlx.DB, log *logger.Logger, ownsDB bool) (*Repository, error) {
return newRepositoryContext(context.Background(), writer, reader, log, ownsDB)
}
func newRepositoryContext(ctx context.Context, writer, reader *sqlx.DB, log *logger.Logger, ownsDB bool) (*Repository, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
repo := &Repository{
db: writer,
ro: reader,
ownsDB: ownsDB,
log: log,
migrate: db.NewRequiredMigrateLogger(writer, log),
migrate: db.NewRequiredMigrateLoggerContext(writer, log, ctx),
}
if err := repo.initSchema(); err != nil {
if err := repo.initSchemaContext(ctx); err != nil {
if ownsDB {
if closeErr := writer.Close(); closeErr != nil {
return nil, fmt.Errorf("failed to close database after schema error: %w", closeErr)
@@ -2,6 +2,7 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
@@ -391,7 +392,8 @@ func (r *Repository) runMigrations() error {
return nil
}
func (r *Repository) backfillTaskSessionQueueIncarnations() error {
rows, err := r.db.Queryx(`SELECT id FROM task_sessions WHERE queue_incarnation_id = ''`)
ctx := r.migrationContext()
rows, err := r.db.QueryxContext(ctx, `SELECT id FROM task_sessions WHERE queue_incarnation_id = ''`)
if err != nil {
return fmt.Errorf("list sessions missing queue incarnation: %w", err)
}
@@ -411,7 +413,7 @@ func (r *Repository) backfillTaskSessionQueueIncarnations() error {
return fmt.Errorf("iterate sessions missing queue incarnation: %w", err)
}
for _, sessionID := range sessionIDs {
if _, err := r.db.Exec(r.db.Rebind(`
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions
SET queue_incarnation_id = ?
WHERE id = ? AND queue_incarnation_id = ''
@@ -440,7 +442,8 @@ func (r *Repository) backfillPromptSeq() error {
AND (%s < %s OR (%s = %s AND u.id <= task_session_messages.id))
)
WHERE author_type = 'user' AND prompt_seq = 0`, nmU, nmM, nmU, nmM)
if _, err := r.db.Exec(update); err != nil {
ctx := r.migrationContext()
if _, err := r.db.ExecContext(ctx, update); err != nil {
return fmt.Errorf("backfill prompt_seq: %w", err)
}
seed := `
@@ -449,7 +452,7 @@ func (r *Repository) backfillPromptSeq() error {
WHERE author_type = 'user' AND prompt_seq > 0
GROUP BY task_session_id
ON CONFLICT(task_session_id) DO NOTHING`
if _, err := r.db.Exec(seed); err != nil {
if _, err := r.db.ExecContext(ctx, seed); err != nil {
return fmt.Errorf("seed prompt sequence counters: %w", err)
}
return nil
@@ -602,14 +605,14 @@ func jsonBoolToInt(postgres bool, column, parent, key string) string {
// the initial implementation, reconciles legacy bootstrap duplicates, then
// enforces uniqueness only for the two hidden workflows created by this flow.
func (r *Repository) ensureImproveKandevWorkflowTemplateUniqueness() error {
tx, err := r.db.Beginx()
tx, err := r.db.BeginTxx(r.migrationContext(), nil)
if err != nil {
return fmt.Errorf("begin improve kandev workflow migration: %w", err)
}
// Keep this template list synchronized with every hidden Improve Kandev
// workflow bootstrapped by internal/improvekandev. Add new IDs to both this
// reconciliation query and the partial-index predicate below.
if _, err := tx.Exec(`
if _, err := tx.ExecContext(r.migrationContext(), `
UPDATE workflows
SET workflow_template_id = ''
WHERE id IN (
@@ -631,11 +634,11 @@ func (r *Repository) ensureImproveKandevWorkflowTemplateUniqueness() error {
_ = tx.Rollback()
return fmt.Errorf("reconcile improve kandev workflow duplicates: %w", err)
}
if _, err := tx.Exec(`DROP INDEX IF EXISTS uniq_workflows_workspace_template_hidden`); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), `DROP INDEX IF EXISTS uniq_workflows_workspace_template_hidden`); err != nil {
_ = tx.Rollback()
return fmt.Errorf("drop broad workflow template index: %w", err)
}
if _, err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_improve_kandev_workflows
if _, err := tx.ExecContext(r.migrationContext(), `CREATE UNIQUE INDEX IF NOT EXISTS uniq_improve_kandev_workflows
ON workflows(workspace_id, workflow_template_id, hidden)
WHERE workflow_template_id IN ('improve-kandev', 'report-kandev-issue')`); err != nil {
_ = tx.Rollback()
@@ -651,7 +654,7 @@ func (r *Repository) ensureImproveKandevWorkflowTemplateUniqueness() error {
// folder attachments existed. CREATE TABLE/INDEX IF NOT EXISTS is replay-safe
// on SQLite and Postgres.
func (r *Repository) ensureTaskWorkspaceFoldersSchema() error {
if _, err := r.db.Exec(`
if _, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS task_workspace_folders (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
@@ -676,7 +679,7 @@ func (r *Repository) ensureTaskWorkspaceFoldersSchema() error {
// existed. It replays the same DDL as the schema-init step; CREATE TABLE/INDEX
// IF NOT EXISTS is replay-safe on SQLite and Postgres.
func (r *Repository) ensureRepositorySetsSchema() error {
if _, err := r.db.Exec(repositorySetsSchemaDDL); err != nil {
if _, err := r.db.ExecContext(r.migrationContext(), repositorySetsSchemaDDL); err != nil {
return fmt.Errorf("create repository sets schema: %w", err)
}
return nil
@@ -685,7 +688,7 @@ func (r *Repository) ensureRepositorySetsSchema() error {
// ensureRepositoryBranchPoliciesSchema replays the policy DDL for databases
// created before repository branch policies existed.
func (r *Repository) ensureRepositoryBranchPoliciesSchema() error {
if _, err := r.db.Exec(repositoryBranchPoliciesSchemaDDL); err != nil {
if _, err := r.db.ExecContext(r.migrationContext(), repositoryBranchPoliciesSchemaDDL); err != nil {
return fmt.Errorf("create repository branch policies schema: %w", err)
}
return nil
@@ -705,7 +708,7 @@ func (r *Repository) recreateTable(tableName, triggerPhrase string, statements [
}
var tableSql string
err := r.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`, tableName).Scan(&tableSql)
err := r.db.QueryRowContext(r.migrationContext(), `SELECT sql FROM sqlite_master WHERE type='table' AND name=?`, tableName).Scan(&tableSql)
if errors.Is(err, sql.ErrNoRows) {
return false, nil // Table doesn't exist yet; migration not applicable
}
@@ -716,19 +719,21 @@ func (r *Repository) recreateTable(tableName, triggerPhrase string, statements [
return false, nil // Trigger phrase absent; migration already applied or not needed
}
if _, err := r.db.Exec(`PRAGMA foreign_keys=OFF`); err != nil {
if _, err := r.db.ExecContext(r.migrationContext(), `PRAGMA foreign_keys=OFF`); err != nil {
return false, fmt.Errorf("disable foreign keys: %w", err)
}
defer func() { _, _ = r.db.Exec(`PRAGMA foreign_keys=ON`) }()
// This restoration is cleanup, so it must not inherit a canceled startup
// context and leave the shared SQLite writer with foreign-key checks off.
defer func() { _, _ = r.db.ExecContext(context.Background(), `PRAGMA foreign_keys=ON`) }()
tx, err := r.db.Beginx()
tx, err := r.db.BeginTxx(r.migrationContext(), nil)
if err != nil {
return false, fmt.Errorf("begin migration transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
for _, stmt := range statements {
if _, err := tx.Exec(stmt); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), stmt); err != nil {
return false, fmt.Errorf("migration %s failed: %w", tableName, err)
}
}
@@ -806,7 +811,7 @@ func (r *Repository) backfillExecutorsRunningFromTaskSessions() error {
// Check whether task_sessions still has the column. If migration already ran,
// the column is gone and there's nothing to backfill.
var tableSql string
if err := r.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='task_sessions'`).Scan(&tableSql); err != nil {
if err := r.db.QueryRowContext(r.migrationContext(), `SELECT sql FROM sqlite_master WHERE type='table' AND name='task_sessions'`).Scan(&tableSql); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil
}
@@ -820,7 +825,7 @@ func (r *Repository) backfillExecutorsRunningFromTaskSessions() error {
// SELECT … LEFT JOIN to find sessions with execution data but no executors_running row.
// Insert with the minimum field set; runtime/status are best-effort defaults
// (subsequent Launch / Resume will overwrite via the lifecycle manager's persistence).
if _, err := r.db.Exec(`
if _, err := r.db.ExecContext(r.migrationContext(), `
INSERT INTO executors_running (
id, session_id, task_id, executor_id, runtime, status, resumable,
resume_token, last_message_uuid, agent_execution_id, container_id,
@@ -887,7 +892,7 @@ const commitCaptureActivatedAtMetaKey = "commit_capture_activated_at"
// exist, so a failure here must abort boot (propagated below) rather than
// leave every future commit insert failing silently forever.
func (r *Repository) migrateSessionCommitsDedupeAndActivation() error {
if _, err := r.db.Exec(`
if _, err := r.db.ExecContext(r.migrationContext(), `
DELETE FROM task_session_commits
WHERE id NOT IN (
SELECT id FROM (
@@ -903,7 +908,7 @@ func (r *Repository) migrateSessionCommitsDedupeAndActivation() error {
`); err != nil {
return fmt.Errorf("dedupe task_session_commits: %w", err)
}
if _, err := r.db.Exec(
if _, err := r.db.ExecContext(r.migrationContext(),
`CREATE UNIQUE INDEX IF NOT EXISTS uniq_session_commits_session_sha ON task_session_commits(session_id, commit_sha)`,
); err != nil {
return fmt.Errorf("create uniq_session_commits_session_sha: %w", err)
@@ -916,7 +921,7 @@ func (r *Repository) migrateSessionCommitsDedupeAndActivation() error {
// production (persistence.Provide creates it before opening any
// repository), but repo-level tests build a bare DB via NewWithDB where
// it does not, so recreate it defensively.
if _, err := r.db.Exec(`
if _, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS kandev_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
@@ -924,7 +929,7 @@ func (r *Repository) migrateSessionCommitsDedupeAndActivation() error {
return fmt.Errorf("ensure kandev_meta: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339Nano)
if _, err := r.db.Exec(r.db.Rebind(`
if _, err := r.db.ExecContext(r.migrationContext(), r.db.Rebind(`
INSERT INTO kandev_meta (key, value) VALUES (?, ?)
ON CONFLICT(key) DO NOTHING
`), commitCaptureActivatedAtMetaKey, now); err != nil {
@@ -1081,10 +1086,10 @@ func (r *Repository) migrateTaskEnvironmentReposAllowMultiBranch() error {
}
func (r *Repository) migrateTaskEnvironmentReposAllowMultiBranchPostgres() error {
if _, err := r.db.Exec(`ALTER TABLE task_environment_repos ADD COLUMN IF NOT EXISTS branch_slug TEXT NOT NULL DEFAULT ''`); err != nil {
if _, err := r.db.ExecContext(r.migrationContext(), `ALTER TABLE task_environment_repos ADD COLUMN IF NOT EXISTS branch_slug TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("add task_environment_repos.branch_slug: %w", err)
}
if _, err := r.db.Exec(`
if _, err := r.db.ExecContext(r.migrationContext(), `
DO $$
DECLARE
old_constraint_name text;
@@ -1254,13 +1259,13 @@ func (r *Repository) migrateSessionsRemoveWorkflowStepID() error {
// Runs before ensureTaskEnvironmentTaskUniqueIndex so the unique constraint
// can be added cleanly. Idempotent — a no-op once the data is healed.
func (r *Repository) healDuplicateTaskEnvironments() error {
tx, err := r.db.Begin()
tx, err := r.db.BeginTx(r.migrationContext(), nil)
if err != nil {
return fmt.Errorf("heal duplicate envs: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
rows, err := tx.Query(`
rows, err := tx.QueryContext(r.migrationContext(), `
SELECT task_id
FROM task_environments
GROUP BY task_id
@@ -1285,7 +1290,7 @@ func (r *Repository) healDuplicateTaskEnvironments() error {
_ = rows.Close()
for _, taskID := range taskIDs {
if err := healDuplicateTaskEnvForTask(tx, taskID); err != nil {
if err := healDuplicateTaskEnvForTask(r.migrationContext(), tx, taskID); err != nil {
return err
}
}
@@ -1294,9 +1299,9 @@ func (r *Repository) healDuplicateTaskEnvironments() error {
// healDuplicateTaskEnvForTask keeps the most recently updated env for a task,
// re-points sessions on the loser rows to the winner, then deletes losers.
func healDuplicateTaskEnvForTask(tx *sql.Tx, taskID string) error {
func healDuplicateTaskEnvForTask(ctx context.Context, tx *sql.Tx, taskID string) error {
var winnerID string
if err := tx.QueryRow(`
if err := tx.QueryRowContext(ctx, `
SELECT id FROM task_environments
WHERE task_id = ?
ORDER BY updated_at DESC, created_at DESC
@@ -1305,7 +1310,7 @@ func healDuplicateTaskEnvForTask(tx *sql.Tx, taskID string) error {
return fmt.Errorf("heal duplicate envs: find winner for task %s: %w", taskID, err)
}
if _, err := tx.Exec(`
if _, err := tx.ExecContext(ctx, `
UPDATE task_sessions
SET task_environment_id = ?
WHERE task_id = ?
@@ -1314,7 +1319,7 @@ func healDuplicateTaskEnvForTask(tx *sql.Tx, taskID string) error {
return fmt.Errorf("heal duplicate envs: relink sessions for task %s: %w", taskID, err)
}
if _, err := tx.Exec(`
if _, err := tx.ExecContext(ctx, `
DELETE FROM task_environments
WHERE task_id = ?
AND id != ?
@@ -1329,7 +1334,7 @@ func healDuplicateTaskEnvForTask(tx *sql.Tx, taskID string) error {
// instead of silently producing two rows for the same task. Must run AFTER
// healDuplicateTaskEnvironments, which collapses any pre-existing duplicates.
func (r *Repository) ensureTaskEnvironmentTaskUniqueIndex() error {
_, err := r.db.Exec(`
_, err := r.db.ExecContext(r.migrationContext(), `
CREATE UNIQUE INDEX IF NOT EXISTS uniq_task_environments_task_id
ON task_environments(task_id)
`)
@@ -1351,7 +1356,7 @@ func (r *Repository) healSessionTaskEnvironmentIDs() error {
// ensureTaskEnvironmentTaskUniqueIndex guarantees ≤1 row per task at
// runtime, but the SQL reads as non-deterministic in isolation. Belt
// and suspenders.
if _, err := r.db.Exec(`
if _, err := r.db.ExecContext(r.migrationContext(), `
UPDATE task_sessions
SET task_environment_id = (
SELECT te.id FROM task_environments te WHERE te.task_id = task_sessions.task_id LIMIT 1
@@ -2,6 +2,7 @@
package sqlite
import (
"context"
"fmt"
"github.com/google/uuid"
@@ -14,6 +15,13 @@ import (
// table so the call list stays readable as new schema/migration steps are
// added without growing the function's cyclomatic complexity.
func (r *Repository) initSchema() error {
return r.initSchemaContext(context.Background())
}
func (r *Repository) initSchemaContext(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
steps := []func() error{
r.initDesktopDiscoverySchema,
r.initCoreSchema,
@@ -51,10 +59,22 @@ func (r *Repository) initSchema() error {
r.ensureMessageMetadataIndexes,
r.ensurePromptOrderIndex,
}
for _, step := range steps {
// Every boundary is checked before and after its step. The task repository
// passes the same context to startup SQL through migrationContext, so a
// cancellation stops later statements and later repository admission. A
// statement already executing is bounded by the database driver's
// cancellation behavior; the constructor never closes the shared pool
// until this function returns, so that in-flight work cannot race cleanup.
for index, step := range steps {
if err := ctx.Err(); err != nil {
return fmt.Errorf("startup schema canceled before step %d: %w", index, err)
}
if err := step(); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return fmt.Errorf("startup schema canceled after step %d: %w", index, err)
}
}
return nil
}
@@ -89,7 +109,7 @@ func (r *Repository) ensureTaskEnvironmentRecoveryClaimsSchema() error {
}
func (r *Repository) initDynamicRoutingSchema() error {
_, err := r.db.Exec(fmt.Sprintf(`
_, err := r.db.ExecContext(r.migrationContext(), fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS dynamic_route_states (
session_id TEXT PRIMARY KEY,
logical_profile_id TEXT NOT NULL,
@@ -161,19 +181,19 @@ const taskResourceCleanupSchemaDDL = `
`
func (r *Repository) initTaskResourceCleanupSchema() error {
_, err := r.db.Exec(taskResourceCleanupSchemaDDL)
_, err := r.db.ExecContext(r.migrationContext(), taskResourceCleanupSchemaDDL)
return err
}
// ensureWorkspaceIndexes creates workspace-related indexes
func (r *Repository) ensureWorkspaceIndexes() error {
if _, err := r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id)`); err != nil {
if _, err := r.db.ExecContext(r.migrationContext(), `CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id)`); err != nil {
return err
}
if _, err := r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_workflows_workspace_id ON workflows(workspace_id)`); err != nil {
if _, err := r.db.ExecContext(r.migrationContext(), `CREATE INDEX IF NOT EXISTS idx_workflows_workspace_id ON workflows(workspace_id)`); err != nil {
return err
}
if _, err := r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_archived ON tasks(workspace_id, archived_at)`); err != nil {
if _, err := r.db.ExecContext(r.migrationContext(), `CREATE INDEX IF NOT EXISTS idx_tasks_workspace_archived ON tasks(workspace_id, archived_at)`); err != nil {
return err
}
return nil
@@ -186,14 +206,14 @@ func (r *Repository) ensureMessageMetadataIndexes() error {
`CREATE INDEX IF NOT EXISTS idx_messages_metadata_tool_call_id ON task_session_messages(task_session_id, (%s))`,
dialect.JSONExtract(driver, "metadata", "tool_call_id"),
)
if _, err := r.db.Exec(toolCallIndex); err != nil {
if _, err := r.db.ExecContext(r.migrationContext(), toolCallIndex); err != nil {
return err
}
pendingIndex := fmt.Sprintf(
`CREATE INDEX IF NOT EXISTS idx_messages_metadata_pending_id ON task_session_messages(task_session_id, (%s))`,
dialect.JSONExtract(driver, "metadata", "pending_id"),
)
if _, err := r.db.Exec(pendingIndex); err != nil {
if _, err := r.db.ExecContext(r.migrationContext(), pendingIndex); err != nil {
return err
}
// idx_messages_metadata_pending_id leads with task_session_id, so it
@@ -204,7 +224,7 @@ func (r *Repository) ensureMessageMetadataIndexes() error {
`CREATE INDEX IF NOT EXISTS idx_messages_metadata_pending_id_lookup ON task_session_messages((%s))`,
dialect.JSONExtract(driver, "metadata", "pending_id"),
)
if _, err := r.db.Exec(pendingIndexLookup); err != nil {
if _, err := r.db.ExecContext(r.migrationContext(), pendingIndexLookup); err != nil {
return err
}
lookupIndex := dialect.PendingIDLookupIndexDDL(
@@ -212,7 +232,7 @@ func (r *Repository) ensureMessageMetadataIndexes() error {
"idx_messages_metadata_pending_id_lookup_ordered",
"task_session_messages",
)
if _, err := r.db.Exec(lookupIndex); err != nil {
if _, err := r.db.ExecContext(r.migrationContext(), lookupIndex); err != nil {
return err
}
return nil
@@ -223,13 +243,13 @@ func (r *Repository) ensureMessageMetadataIndexes() error {
// filtered prompt pages skip interleaved agent and tool rows.
func (r *Repository) ensurePromptOrderIndex() error {
ddl := dialect.PromptOrderIndexDDL(r.db.DriverName(), "idx_messages_prompt_order", "task_session_messages")
if _, err := r.db.Exec(ddl); err != nil {
if _, err := r.db.ExecContext(r.migrationContext(), ddl); err != nil {
return fmt.Errorf("create prompt-order index: %w", err)
}
userDDL := dialect.PromptUserOrderIndexDDL(
r.db.DriverName(), "idx_messages_prompt_user_order", "task_session_messages",
)
if _, err := r.db.Exec(userDDL); err != nil {
if _, err := r.db.ExecContext(r.migrationContext(), userDDL); err != nil {
return fmt.Errorf("create prompt-user-order index: %w", err)
}
return nil
@@ -249,7 +269,7 @@ func (r *Repository) ensureRunnerProjectionTables() error {
// repo so workflow.NewWithDB's later ALTER ADD COLUMNs become no-ops
// (only column-already-exists errors are tolerated). Mirrors
// internal/workflow/repository/sqlite.go (the canonical owner).
if _, err := r.db.Exec(`
if _, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS workflow_steps (
id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL DEFAULT '',
@@ -275,7 +295,7 @@ func (r *Repository) ensureRunnerProjectionTables() error {
)`); err != nil {
return fmt.Errorf("create workflow_steps projection table: %w", err)
}
if _, err := r.db.Exec(`
if _, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS workflow_step_participants (
id TEXT PRIMARY KEY,
step_id TEXT NOT NULL DEFAULT '',
@@ -319,7 +339,7 @@ const taskStatusSummarySchemaDDL = `
`
func (r *Repository) initTaskStatusSummarySchema() error {
_, err := r.db.Exec(taskStatusSummarySchemaDDL)
_, err := r.db.ExecContext(r.migrationContext(), taskStatusSummarySchemaDDL)
return err
}
@@ -441,12 +461,12 @@ const infraSchemaDDL = `
`
func (r *Repository) initInfraSchema() error {
_, err := r.db.Exec(infraSchemaDDL)
_, err := r.db.ExecContext(r.migrationContext(), infraSchemaDDL)
return err
}
func (r *Repository) initTaskSchema() error {
_, err := r.db.Exec(`
_, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL DEFAULT '',
@@ -601,7 +621,7 @@ const repositorySetsSchemaDDL = `
`
func (r *Repository) initRepositorySetsSchema() error {
_, err := r.db.Exec(repositorySetsSchemaDDL)
_, err := r.db.ExecContext(r.migrationContext(), repositorySetsSchemaDDL)
return err
}
@@ -625,12 +645,12 @@ const repositoryBranchPoliciesSchemaDDL = `
`
func (r *Repository) initRepositoryBranchPoliciesSchema() error {
_, err := r.db.Exec(repositoryBranchPoliciesSchemaDDL)
_, err := r.db.ExecContext(r.migrationContext(), repositoryBranchPoliciesSchemaDDL)
return err
}
func (r *Repository) initCoreIndexes() error {
_, err := r.db.Exec(`
_, err := r.db.ExecContext(r.migrationContext(), `
CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id);
CREATE INDEX IF NOT EXISTS idx_tasks_workflow_step_id ON tasks(workflow_step_id);
CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at);
@@ -645,7 +665,7 @@ func (r *Repository) initCoreIndexes() error {
}
func (r *Repository) initPlansSchema() error {
if _, err := r.db.Exec(`
if _, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS task_plans (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL UNIQUE,
@@ -692,7 +712,7 @@ func (r *Repository) initPlansSchema() error {
`); err != nil {
return err
}
if _, err := r.db.Exec(`
if _, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS task_plan_revisions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
@@ -729,7 +749,7 @@ func (r *Repository) initPlansSchema() error {
// as a JSON array in a single column (read/written whole — there is no
// per-step query path), keeping the artifact one row per task like task_plans.
func (r *Repository) initWalkthroughsSchema() error {
_, err := r.db.Exec(`
_, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS task_walkthroughs (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL UNIQUE,
@@ -748,7 +768,7 @@ func (r *Repository) initWalkthroughsSchema() error {
// backfillInitialPlanRevisions ensures every existing task_plans row has at least
// one corresponding revision. Runs once at startup and is idempotent.
func (r *Repository) backfillInitialPlanRevisions() error {
rows, err := r.db.Query(`
rows, err := r.db.QueryContext(r.migrationContext(), `
SELECT p.id, p.task_id, p.title, p.content, p.created_by, p.created_at, p.updated_at
FROM task_plans p
WHERE NOT EXISTS (
@@ -781,7 +801,7 @@ func (r *Repository) backfillInitialPlanRevisions() error {
if authorKind != "user" && authorKind != authorKindAgent {
authorKind = authorKindAgent
}
_, err := r.db.Exec(r.db.Rebind(`
_, err := r.db.ExecContext(r.migrationContext(), r.db.Rebind(`
INSERT INTO task_plan_revisions
(id, task_id, revision_number, title, content, author_kind, author_name, revert_of_revision_id, created_at, updated_at)
VALUES (?, ?, 1, ?, ?, ?, 'legacy', NULL, ?, ?)
@@ -796,7 +816,7 @@ func (r *Repository) backfillInitialPlanRevisions() error {
// initDocumentsSchema creates the task_documents and task_document_revisions tables.
// These tables generalize task_plans: documents have a key (e.g., "plan", "spec") and type.
func (r *Repository) initDocumentsSchema() error {
if _, err := r.db.Exec(`
if _, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS task_documents (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
@@ -844,7 +864,7 @@ func (r *Repository) initDocumentsSchema() error {
// attachments. The storage key is intentionally opaque and never exposed in
// API responses; bytes are owned by the attachment service.
func (r *Repository) initAttachmentsSchema() error {
_, err := r.db.Exec(`
_, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS task_message_attachments (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL DEFAULT '',
@@ -901,7 +921,7 @@ func (r *Repository) initSessionSchema() error {
// row instead of clobbering a later execution's (AC-32). Historical message
// rows are handled separately by the one-time backfill migration.
func (r *Repository) initSubagentContextSchema() error {
_, err := r.db.Exec(`
_, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS task_session_subagents (
id TEXT PRIMARY KEY,
task_session_id TEXT NOT NULL,
@@ -947,9 +967,9 @@ func (r *Repository) initSubagentContextSchema() error {
// now-deleted step must survive that deletion.
func (r *Repository) initStepTransitionsSchema() error {
idCol := dialect.AutoIncrementIDColumn(r.db.DriverName())
_, err := r.db.Exec(`
_, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS task_step_transitions (
` + idCol + `,
`+idCol+`,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
session_id TEXT REFERENCES task_sessions(id) ON DELETE SET NULL,
from_workflow_id TEXT,
@@ -974,7 +994,7 @@ func (r *Repository) initStepTransitionsSchema() error {
}
func (r *Repository) initMessageTurnSchema() error {
_, err := r.db.Exec(`
_, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS task_session_turns (
id TEXT PRIMARY KEY,
task_session_id TEXT NOT NULL,
@@ -1126,12 +1146,12 @@ const sessionWorktreeSchemaDDL = `
`
func (r *Repository) initSessionWorktreeSchema() error {
_, err := r.db.Exec(sessionWorktreeSchemaDDL)
_, err := r.db.ExecContext(r.migrationContext(), sessionWorktreeSchemaDDL)
return err
}
func (r *Repository) initGitSchema() error {
_, err := r.db.Exec(`
_, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS task_session_git_snapshots (
id TEXT PRIMARY KEY,
task_environment_id TEXT NOT NULL,
@@ -1179,7 +1199,7 @@ func (r *Repository) initGitSchema() error {
}
func (r *Repository) initReviewSchema() error {
_, err := r.db.Exec(`
_, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS session_file_reviews (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
@@ -1253,6 +1273,6 @@ const taskReviewSchemaDDL = `
`
func (r *Repository) initTaskReviewSchema() error {
_, err := r.db.Exec(taskReviewSchemaDDL)
_, err := r.db.ExecContext(r.migrationContext(), taskReviewSchemaDDL)
return err
}
@@ -19,14 +19,14 @@ func (r *Repository) tryHealWorkflowStepEvents(
stepID, operation string,
mutate func(*wfmodels.StepEvents) bool,
) (applied, retry bool, err error) {
tx, err := r.db.Beginx()
tx, err := r.db.BeginTxx(r.migrationContext(), nil)
if err != nil {
return false, false, fmt.Errorf("begin %s reconciliation tx: %w", operation, err)
}
defer func() { _ = tx.Rollback() }()
var rawEvents sql.NullString
if err := tx.QueryRow(tx.Rebind(`SELECT events FROM workflow_steps WHERE id = ?`), stepID).Scan(&rawEvents); err != nil {
if err := tx.QueryRowContext(r.migrationContext(), tx.Rebind(`SELECT events FROM workflow_steps WHERE id = ?`), stepID).Scan(&rawEvents); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return true, false, nil
}
@@ -60,7 +60,7 @@ func (r *Repository) tryHealWorkflowStepEvents(
if guardArg != nil {
args = append(args, guardArg)
}
res, err := tx.Exec(tx.Rebind(`
res, err := tx.ExecContext(r.migrationContext(), tx.Rebind(`
UPDATE workflow_steps SET events = ?, updated_at = ?
WHERE id = ? AND `+guardClause), args...)
if err != nil {
@@ -15,7 +15,7 @@ type systemWorkflowStepRow struct{ stepID, workflowID string }
// transactional per-row retry loops never run with an open read cursor over
// the same table.
func (r *Repository) findSystemOwnedWorkflowSteps(templateID, stepName string) ([]systemWorkflowStepRow, error) {
rows, err := r.db.Query(r.db.Rebind(`
rows, err := r.db.QueryContext(r.migrationContext(), r.db.Rebind(`
SELECT ws.id, ws.workflow_id FROM workflow_steps ws
JOIN workflows w ON w.id = ws.workflow_id
WHERE w.is_system = 1 AND w.workflow_template_id = ? AND ws.name = ?
@@ -58,7 +58,7 @@ func (r *Repository) hideBuiltinWorkflows() error {
if err != nil {
return fmt.Errorf("build hidden workflow reconciliation: %w", err)
}
if _, err := r.db.Exec(r.db.Rebind(query), args...); err != nil {
if _, err := r.db.ExecContext(r.migrationContext(), r.db.Rebind(query), args...); err != nil {
return fmt.Errorf("hide builtin workflows: %w", err)
}
return nil
@@ -79,7 +79,7 @@ func (r *Repository) healBuiltinWorkflowStepFlags() error {
for _, tmpl := range templates {
for _, step := range tmpl.Steps {
want := dialect.BoolToInt(step.AutoAdvanceRequiresSignal)
if _, err := r.db.Exec(r.db.Rebind(`
if _, err := r.db.ExecContext(r.migrationContext(), r.db.Rebind(`
UPDATE workflow_steps SET auto_advance_requires_signal = ?, updated_at = ?
WHERE name = ? AND auto_advance_requires_signal != ?
AND workflow_id IN (SELECT id FROM workflows WHERE is_system = 1 AND workflow_template_id = ?)
@@ -93,7 +93,7 @@ func (r *Repository) healBuiltinWorkflowStepFlags() error {
// ensureDefaultWorkspace creates a default workspace if none exists
func (r *Repository) ensureDefaultWorkspace() error {
ctx := context.Background()
ctx := r.migrationContext()
var count int
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM workspaces").Scan(&count); err != nil {
@@ -215,7 +215,7 @@ func (r *Repository) stampWorkspaceOfficeWorkflow(ctx context.Context, workspace
// ensureDefaultExecutorsAndEnvironments creates default executors and environments if none exist
func (r *Repository) ensureDefaultExecutorsAndEnvironments() error {
ctx := context.Background()
ctx := r.migrationContext()
if err := r.ensureDefaultExecutors(ctx); err != nil {
return err
}
@@ -39,14 +39,14 @@ func (r *Repository) initDesktopDiscoverySchema() error {
if err != nil {
return err
}
if _, err := r.db.Exec(desktopDiscoverySchemaDDL); err != nil {
if _, err := r.db.ExecContext(r.migrationContext(), desktopDiscoverySchemaDDL); err != nil {
return err
}
required := 0
if legacyInstallation {
required = 1
}
_, err = r.db.Exec(r.db.Rebind(`
_, err = r.db.ExecContext(r.migrationContext(), r.db.Rebind(`
INSERT INTO desktop_discovery_migration (id, home_confirmation_required, updated_at)
VALUES (1, ?, ?)
ON CONFLICT (id) DO NOTHING
@@ -1,6 +1,7 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
@@ -54,14 +55,15 @@ const dynamicRouteLegacyActiveBackfillLockID int64 = 0x4B44_4452_4142 // "KDDRAB
// is exactly the orphan the startup sweep exists to catch. Re-running a
// value-based backfill on every boot would silently swallow it instead.
func (r *Repository) backfillLegacyActiveDynamicRoutes() error {
exists, err := db.ColumnExists(r.db, "dynamic_route_states", dynamicRouteLegacyActiveBackfillColumn)
ctx := r.migrationContext()
exists, err := db.ColumnExistsContext(ctx, r.db, "dynamic_route_states", dynamicRouteLegacyActiveBackfillColumn)
if err != nil {
return fmt.Errorf("dynamic route legacy backfill: probe marker column: %w", err)
}
if exists {
return nil
}
tx, err := r.db.Beginx()
tx, err := r.db.BeginTxx(r.migrationContext(), nil)
if err != nil {
return fmt.Errorf("dynamic route legacy backfill: begin tx: %w", err)
}
@@ -74,20 +76,20 @@ func (r *Repository) backfillLegacyActiveDynamicRoutes() error {
// Re-probe inside the lock: a concurrent PostgreSQL initializer may have
// already run and committed the backfill between the cheap probe above
// and this transaction acquiring the advisory lock.
exists, err = r.columnExists(tx, "dynamic_route_states", dynamicRouteLegacyActiveBackfillColumn)
exists, err = db.ColumnExistsContext(ctx, tx, "dynamic_route_states", dynamicRouteLegacyActiveBackfillColumn)
if err != nil {
return fmt.Errorf("dynamic route legacy backfill: re-probe marker column: %w", err)
}
if exists {
return nil
}
eligible, err := legacyActiveBackfillVersionAllowed(tx)
eligible, err := legacyActiveBackfillVersionAllowed(ctx, tx)
if err != nil {
return fmt.Errorf("dynamic route legacy backfill: read stored version: %w", err)
}
if eligible {
if _, err := tx.Exec(`
if _, err := tx.ExecContext(r.migrationContext(), `
UPDATE dynamic_route_states
SET state = 'active'
WHERE state = 'starting'
@@ -105,7 +107,7 @@ func (r *Repository) backfillLegacyActiveDynamicRoutes() error {
// Scoped to sessions whose dynamic_route_states row is now 'active' and
// whose generation still matches. A session whose authoritative route row
// or generation differs must keep its projection available for recovery.
if _, err := tx.Exec(`
if _, err := tx.ExecContext(r.migrationContext(), `
UPDATE task_sessions
SET route_state = 'active'
WHERE state = 'IDLE' AND route_state = 'starting'
@@ -120,8 +122,8 @@ func (r *Repository) backfillLegacyActiveDynamicRoutes() error {
return fmt.Errorf("dynamic route legacy backfill: backfill task_sessions: %w", err)
}
}
if _, err := tx.Exec(`ALTER TABLE dynamic_route_states ADD COLUMN ` +
dynamicRouteLegacyActiveBackfillColumn + ` INTEGER NOT NULL DEFAULT 1`); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), `ALTER TABLE dynamic_route_states ADD COLUMN `+
dynamicRouteLegacyActiveBackfillColumn+` INTEGER NOT NULL DEFAULT 1`); err != nil {
return fmt.Errorf("dynamic route legacy backfill: add marker column: %w", err)
}
if err := tx.Commit(); err != nil {
@@ -130,8 +132,8 @@ func (r *Repository) backfillLegacyActiveDynamicRoutes() error {
return nil
}
func legacyActiveBackfillVersionAllowed(conn db.SchemaQuerier) (bool, error) {
exists, err := db.TableExists(conn, "kandev_meta")
func legacyActiveBackfillVersionAllowed(ctx context.Context, conn db.ContextSchemaQuerier) (bool, error) {
exists, err := db.TableExistsContext(ctx, conn, "kandev_meta")
if err != nil {
return false, err
}
@@ -139,7 +141,7 @@ func legacyActiveBackfillVersionAllowed(conn db.SchemaQuerier) (bool, error) {
return false, nil
}
var storedVersion string
err = conn.QueryRow(conn.Rebind(
err = conn.QueryRowContext(ctx, conn.Rebind(
`SELECT value FROM kandev_meta WHERE key = 'kandev_version'`,
)).Scan(&storedVersion)
if errors.Is(err, sql.ErrNoRows) {
@@ -174,10 +176,10 @@ func (r *Repository) acquireDynamicRouteLegacyActiveBackfillLock(tx *sqlx.Tx) er
if !dialect.IsPostgres(r.db.DriverName()) {
return nil
}
if _, err := tx.Exec(`SET LOCAL lock_timeout = '30s'`); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), `SET LOCAL lock_timeout = '30s'`); err != nil {
return fmt.Errorf("dynamic route legacy backfill: set lock timeout: %w", err)
}
if _, err := tx.Exec(`SELECT pg_advisory_xact_lock($1)`, dynamicRouteLegacyActiveBackfillLockID); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), `SELECT pg_advisory_xact_lock($1)`, dynamicRouteLegacyActiveBackfillLockID); err != nil {
return fmt.Errorf("dynamic route legacy backfill: acquire migration advisory lock: %w", err)
}
return nil
@@ -28,7 +28,7 @@ func (r *Repository) migrateGitSnapshotOwnership() error {
return nil
}
tx, err := r.db.Beginx()
tx, err := r.db.BeginTxx(r.migrationContext(), nil)
if err != nil {
return fmt.Errorf("git snapshot cutover: begin tx: %w", err)
}
@@ -70,10 +70,10 @@ func (r *Repository) replayGitSnapshotFinalShape(tx *sqlx.Tx) error {
}
func (r *Repository) prepareGitSnapshotCutover(tx *sqlx.Tx) error {
if _, err := tx.Exec("DROP TABLE IF EXISTS " + gitSnapshotCutoverShadowTable); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), "DROP TABLE IF EXISTS "+gitSnapshotCutoverShadowTable); err != nil {
return fmt.Errorf("git snapshot cutover: remove stale shadow: %w", err)
}
if _, err := tx.Exec(finalGitSnapshotDDL(gitSnapshotCutoverShadowTable)); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), finalGitSnapshotDDL(gitSnapshotCutoverShadowTable)); err != nil {
return fmt.Errorf("git snapshot cutover: create shadow: %w", err)
}
if err := r.maybeFailGitSnapshotCutover("create_shadow"); err != nil {
@@ -99,10 +99,10 @@ func (r *Repository) prepareGitSnapshotCutover(tx *sqlx.Tx) error {
}
func (r *Repository) swapGitSnapshotCutover(tx *sqlx.Tx) error {
if _, err := tx.Exec("DROP TABLE task_session_git_snapshots"); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), "DROP TABLE task_session_git_snapshots"); err != nil {
return fmt.Errorf("git snapshot cutover: drop legacy table: %w", err)
}
if _, err := tx.Exec("ALTER TABLE " + gitSnapshotCutoverShadowTable + " RENAME TO task_session_git_snapshots"); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), "ALTER TABLE "+gitSnapshotCutoverShadowTable+" RENAME TO task_session_git_snapshots"); err != nil {
return fmt.Errorf("git snapshot cutover: rename shadow: %w", err)
}
if err := r.maybeFailGitSnapshotCutover("swap"); err != nil {
@@ -143,13 +143,13 @@ func (r *Repository) acquireGitSnapshotCutoverLocks(tx *sqlx.Tx) error {
if !dialect.IsPostgres(r.db.DriverName()) {
return nil
}
if _, err := tx.Exec("SET LOCAL lock_timeout = '30s'"); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), "SET LOCAL lock_timeout = '30s'"); err != nil {
return fmt.Errorf("git snapshot cutover: set lock timeout: %w", err)
}
if _, err := tx.Exec("SELECT pg_advisory_xact_lock($1)", gitSnapshotCutoverLockID); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), "SELECT pg_advisory_xact_lock($1)", gitSnapshotCutoverLockID); err != nil {
return fmt.Errorf("git snapshot cutover: acquire migration advisory lock: %w", err)
}
if _, err := tx.Exec(`
if _, err := tx.ExecContext(r.migrationContext(), `
LOCK TABLE task_session_git_snapshots, task_sessions, task_environments
IN ACCESS EXCLUSIVE MODE`); err != nil {
return fmt.Errorf("git snapshot cutover: lock ownership tables: %w", err)
@@ -195,7 +195,7 @@ func (r *Repository) copyGitSnapshotWinners(tx *sqlx.Tx) error {
) ranked
WHERE row_number = 1
`
if _, err := tx.Exec(r.db.Rebind(query)); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), r.db.Rebind(query)); err != nil {
return fmt.Errorf("git snapshot cutover: copy current winners: %w", err)
}
return nil
@@ -221,7 +221,7 @@ func (r *Repository) ensureGitSnapshotIndexes(tx *sqlx.Tx) error {
`CREATE INDEX IF NOT EXISTS idx_git_snapshots_session ON task_session_git_snapshots(session_id, created_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_git_snapshots_type ON task_session_git_snapshots(session_id, snapshot_type)`,
} {
if _, err := tx.Exec(query); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), query); err != nil {
return fmt.Errorf("git snapshot cutover: create index: %w", err)
}
}
@@ -0,0 +1,434 @@
package sqlite
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strconv"
"sync"
"testing"
"time"
"github.com/jmoiron/sqlx"
dbpkg "github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/persistence"
"github.com/mattn/go-sqlite3"
)
// TestStartupMigrationCosts records fresh-schema and replay costs on an
// isolated SQLite file. The output is evidence for startup budgeting; it does
// not impose a machine-specific duration threshold.
func TestStartupMigrationCosts(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "startup-costs.db")
freshDB := openStartupCostDB(t, dbPath)
t.Cleanup(func() { _ = freshDB.Close() })
freshStarted := time.Now()
if _, err := NewWithDB(freshDB, freshDB, nil); err != nil {
t.Fatalf("initialize fresh schema: %v", err)
}
freshDuration := time.Since(freshStarted)
if err := freshDB.Close(); err != nil {
t.Fatalf("close fresh database: %v", err)
}
replayDB := openStartupCostDB(t, dbPath)
t.Cleanup(func() { _ = replayDB.Close() })
replayStarted := time.Now()
if _, err := NewWithDB(replayDB, replayDB, nil); err != nil {
t.Fatalf("replay schema: %v", err)
}
replayDuration := time.Since(replayStarted)
if err := replayDB.Close(); err != nil {
t.Fatalf("close replay database: %v", err)
}
t.Logf("startup migration cost: fresh=%s replay=%s", freshDuration, replayDuration)
}
// TestStartupMigrationCostsPopulated measures startup against a repeatable
// legacy-shaped fixture with enough messages for the two recurring backfills
// to do real work. The fixture is intentionally local and synthetic: its
// purpose is to expose the relative costs and the backup/liveness boundary,
// not to predict a particular production machine's total startup time.
func TestStartupMigrationCostsPopulated(t *testing.T) {
const (
sessionCount = 24
messagesPerSession = 250
)
dbPath := filepath.Join(t.TempDir(), "startup-costs-populated.db")
freshDB := openStartupCostDB(t, dbPath)
freshStarted := time.Now()
if _, err := NewWithDB(freshDB, freshDB, nil); err != nil {
t.Fatalf("initialize fresh schema: %v", err)
}
freshDuration := time.Since(freshStarted)
if err := freshDB.Close(); err != nil {
t.Fatalf("close fresh database: %v", err)
}
seedDB := openStartupCostDB(t, dbPath)
if err := makePopulatedLegacyMessageFixture(seedDB, sessionCount, messagesPerSession); err != nil {
_ = seedDB.Close()
t.Fatalf("prepare populated legacy fixture: %v", err)
}
messageCount := sessionCount * messagesPerSession
var measuredMessageCount int
if err := seedDB.Get(&measuredMessageCount, `SELECT COUNT(*) FROM task_session_messages`); err != nil {
_ = seedDB.Close()
t.Fatalf("count fixture messages: %v", err)
}
if measuredMessageCount != messageCount {
_ = seedDB.Close()
t.Fatalf("fixture message count = %d, want %d", measuredMessageCount, messageCount)
}
dbInfo, err := os.Stat(dbPath)
if err != nil {
_ = seedDB.Close()
t.Fatalf("stat populated fixture: %v", err)
}
backupPath := filepath.Join(t.TempDir(), "startup-costs-backup.db")
backupStarted := time.Now()
backupBytes, err := persistence.SnapshotSQLite(seedDB, backupPath)
backupDuration := time.Since(backupStarted)
if err != nil {
_ = seedDB.Close()
t.Fatalf("snapshot populated fixture: %v", err)
}
if err := seedDB.Close(); err != nil {
t.Fatalf("close seeded database: %v", err)
}
replayDB := openStartupCostDB(t, dbPath)
replayStarted := time.Now()
replayRepo, err := NewWithDB(replayDB, replayDB, nil)
if err != nil {
_ = replayDB.Close()
t.Fatalf("replay populated schema: %v", err)
}
replayDuration := time.Since(replayStarted)
// Measure the two recurring data migrations separately on the same
// populated database. Reset statements are excluded from the timings.
if _, err := replayDB.Exec(`UPDATE task_session_messages SET updated_at = NULL`); err != nil {
_ = replayDB.Close()
t.Fatalf("reset updated_at fixture: %v", err)
}
updatedAtStarted := time.Now()
if err := dbpkg.NewRequiredMigrateLogger(replayDB, nil).Apply(
"evidence.task_session_messages.updated_at.backfill",
`UPDATE task_session_messages SET updated_at = created_at WHERE updated_at IS NULL`,
); err != nil {
_ = replayDB.Close()
t.Fatalf("measure updated_at backfill: %v", err)
}
updatedAtDuration := time.Since(updatedAtStarted)
if _, err := replayDB.Exec(`UPDATE task_session_messages SET prompt_seq = 0`); err != nil {
_ = replayDB.Close()
t.Fatalf("reset prompt sequence fixture: %v", err)
}
if _, err := replayDB.Exec(`DELETE FROM task_session_prompt_seq`); err != nil {
_ = replayDB.Close()
t.Fatalf("reset prompt sequence counters: %v", err)
}
promptSeqStarted := time.Now()
if err := replayRepo.backfillPromptSeq(); err != nil {
_ = replayDB.Close()
t.Fatalf("measure prompt sequence backfill: %v", err)
}
promptSeqDuration := time.Since(promptSeqStarted)
if err := replayDB.Close(); err != nil {
t.Fatalf("close replay database: %v", err)
}
t.Logf("populated startup evidence: sessions=%d messages=%d db_bytes=%d backup_bytes=%d fresh_schema=%s backup=%s replay=%s updated_at_backfill=%s prompt_seq_backfill=%s environment=%s/%s go=%s cpus=%d",
sessionCount, messageCount, dbInfo.Size(), backupBytes, freshDuration, backupDuration,
replayDuration, updatedAtDuration, promptSeqDuration, runtime.GOOS, runtime.GOARCH,
runtime.Version(), runtime.NumCPU())
}
// makePopulatedLegacyMessageFixture turns the current empty schema into the
// shape that exercises the recurring startup migrations: updated_at is present
// but nullable, and prompt_seq is absent. The rebuild is isolated to this
// measurement test and does not alter production migration behavior.
func makePopulatedLegacyMessageFixture(db *sqlx.DB, sessionCount, messagesPerSession int) error {
for _, index := range []string{
"idx_messages_session_id",
"idx_messages_created_at",
"idx_messages_session_created",
"idx_messages_turn_id",
"idx_messages_task_author_created",
"idx_messages_session_updated",
"idx_messages_metadata_tool_call_id",
"idx_messages_metadata_pending_id",
"idx_messages_metadata_pending_id_lookup",
"idx_messages_metadata_pending_id_lookup_ordered",
} {
if _, err := db.Exec("DROP INDEX IF EXISTS " + index); err != nil {
return err
}
}
if _, err := db.Exec(`PRAGMA foreign_keys=OFF`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TABLE task_session_messages RENAME TO startup_messages_current`); err != nil {
return err
}
if _, err := db.Exec(`
CREATE TABLE task_session_messages (
id TEXT PRIMARY KEY,
task_session_id TEXT NOT NULL,
task_id TEXT DEFAULT '',
turn_id TEXT NOT NULL,
author_type TEXT NOT NULL DEFAULT 'user',
author_id TEXT DEFAULT '',
content TEXT NOT NULL,
requests_input INTEGER DEFAULT 0,
type TEXT NOT NULL DEFAULT 'message',
metadata TEXT DEFAULT '{}',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP,
FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE,
FOREIGN KEY (turn_id) REFERENCES task_session_turns(id) ON DELETE CASCADE
)`); err != nil {
return err
}
if _, err := db.Exec(`
INSERT INTO task_session_messages
(id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at, updated_at)
SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at, updated_at
FROM startup_messages_current`); err != nil {
return err
}
if _, err := db.Exec(`DROP TABLE startup_messages_current`); err != nil {
return err
}
if _, err := db.Exec(`PRAGMA foreign_keys=ON`); err != nil {
return err
}
var workspaceID, workflowID string
if err := db.QueryRow(`SELECT id FROM workspaces ORDER BY created_at LIMIT 1`).Scan(&workspaceID); err != nil {
return err
}
if err := db.QueryRow(`SELECT id FROM workflows ORDER BY created_at LIMIT 1`).Scan(&workflowID); err != nil {
return err
}
tx, err := db.Beginx()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
baseTime := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)
for sessionIndex := 0; sessionIndex < sessionCount; sessionIndex++ {
taskID := "startup-evidence-task-" + strconv.Itoa(sessionIndex)
sessionID := "startup-evidence-session-" + strconv.Itoa(sessionIndex)
turnID := "startup-evidence-turn-" + strconv.Itoa(sessionIndex)
now := baseTime.Add(time.Duration(sessionIndex) * time.Hour)
if _, err := tx.Exec(`INSERT INTO tasks (id, workspace_id, workflow_id, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, taskID, workspaceID, workflowID, taskID, now, now); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO task_sessions (id, task_id, started_at, updated_at) VALUES (?, ?, ?, ?)`, sessionID, taskID, now, now); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, turnID, sessionID, taskID, now, now, now); err != nil {
return err
}
for messageIndex := 0; messageIndex < messagesPerSession; messageIndex++ {
authorType := "agent"
if messageIndex%3 == 0 {
authorType = "user"
}
createdAt := now.Add(time.Duration(messageIndex) * time.Microsecond)
messageID := "startup-evidence-message-" + strconv.Itoa(sessionIndex) + "-" + strconv.Itoa(messageIndex)
if _, err := tx.Exec(`
INSERT INTO task_session_messages
(id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, '', 'startup evidence', 0, 'message', '{}', ?, NULL)
`, messageID, sessionID, taskID, turnID, authorType, createdAt); err != nil {
return err
}
}
}
return tx.Commit()
}
func openStartupCostDB(t *testing.T, path string) *sqlx.DB {
t.Helper()
conn, err := dbpkg.OpenSQLite(path)
if err != nil {
t.Fatalf("open SQLite database: %v", err)
}
return sqlx.NewDb(conn, "sqlite3")
}
// TestBackfillPromptSeqStopsOnCanceledContext exercises a real recurring
// migration while its UPDATE is executing. The trigger blocks the statement
// at the SQLite driver boundary so cancellation is deterministic and does not
// depend on a timer racing a particular database size.
func TestBackfillPromptSeqStopsOnCanceledContext(t *testing.T) {
db := openStartupCostDB(t, filepath.Join(t.TempDir(), "prompt-cancel.db"))
t.Cleanup(func() { _ = db.Close() })
db.SetMaxOpenConns(1)
if _, err := db.Exec(`
CREATE TABLE task_session_messages (
id TEXT PRIMARY KEY,
task_session_id TEXT NOT NULL,
author_type TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
prompt_seq INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE task_session_prompt_seq (
task_session_id TEXT PRIMARY KEY,
last_seq INTEGER NOT NULL
);
INSERT INTO task_session_messages(id, task_session_id, author_type, created_at, prompt_seq)
VALUES ('message-1', 'session-1', 'user', '2026-01-01T00:00:00Z', 0);
`); err != nil {
t.Fatalf("seed prompt sequence fixture: %v", err)
}
started := make(chan struct{})
release := make(chan struct{})
var startedOnce sync.Once
conn, err := db.Conn(context.Background())
if err != nil {
t.Fatalf("reserve SQLite connection: %v", err)
}
if err := conn.Raw(func(driverConn any) error {
sqliteConn, ok := driverConn.(*sqlite3.SQLiteConn)
if !ok {
t.Fatalf("driver connection = %T, want *sqlite3.SQLiteConn", driverConn)
}
return sqliteConn.RegisterFunc("wait_for_prompt_release", func() int64 {
startedOnce.Do(func() { close(started) })
<-release
return 1
}, true)
}); err != nil {
_ = conn.Close()
t.Fatalf("register SQLite function: %v", err)
}
if err := conn.Close(); err != nil {
t.Fatalf("release SQLite connection: %v", err)
}
if _, err := db.Exec(`
CREATE TRIGGER block_prompt_sequence_update
BEFORE UPDATE OF prompt_seq ON task_session_messages
BEGIN
SELECT wait_for_prompt_release();
END;
`); err != nil {
t.Fatalf("create blocking trigger: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
repo := &Repository{
db: db,
migrate: dbpkg.NewRequiredMigrateLoggerContext(db, nil, ctx),
}
done := make(chan error, 1)
go func() { done <- repo.backfillPromptSeq() }()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("prompt sequence migration did not enter the active statement")
}
cancel()
close(release)
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("prompt sequence migration error = %v, want context.Canceled", err)
}
case <-time.After(2 * time.Second):
t.Fatal("prompt sequence migration did not stop after cancellation")
}
}
// TestNewWithDBContextStopsActiveMigration proves that cancellation reaches
// the real repository constructor at the shared startup boundary. The
// trigger blocks the production prompt-sequence UPDATE, then cancellation
// must abort that constructor before it can admit a later schema step.
func TestNewWithDBContextStopsActiveMigration(t *testing.T) {
db := openStartupCostDB(t, filepath.Join(t.TempDir(), "constructor-cancel.db"))
t.Cleanup(func() { _ = db.Close() })
db.SetMaxOpenConns(1)
repo, err := NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("initialize schema: %v", err)
}
seedForMsgTest(t, repo, "constructor-task", "constructor-session", "constructor-turn")
var workspaceID, workflowID string
if err := db.QueryRow(`SELECT id FROM workspaces ORDER BY created_at LIMIT 1`).Scan(&workspaceID); err != nil {
t.Fatalf("read workspace: %v", err)
}
if err := db.QueryRow(`SELECT id FROM workflows ORDER BY created_at LIMIT 1`).Scan(&workflowID); err != nil {
t.Fatalf("read workflow: %v", err)
}
if _, err := db.Exec(`UPDATE tasks SET workspace_id = ?, workflow_id = ? WHERE id = ?`, workspaceID, workflowID, "constructor-task"); err != nil {
t.Fatalf("make constructor fixture replayable: %v", err)
}
insertPromptRow(t, repo, "constructor-message", "constructor-session", "constructor-turn", "user", "startup cancellation", time.Now().UTC())
started := make(chan struct{})
release := make(chan struct{})
var startedOnce sync.Once
conn, err := db.Conn(context.Background())
if err != nil {
t.Fatalf("reserve SQLite connection: %v", err)
}
if err := conn.Raw(func(driverConn any) error {
sqliteConn, ok := driverConn.(*sqlite3.SQLiteConn)
if !ok {
t.Fatalf("driver connection = %T, want *sqlite3.SQLiteConn", driverConn)
}
return sqliteConn.RegisterFunc("wait_for_constructor_release", func() int64 {
startedOnce.Do(func() { close(started) })
<-release
return 1
}, true)
}); err != nil {
_ = conn.Close()
t.Fatalf("register SQLite function: %v", err)
}
if err := conn.Close(); err != nil {
t.Fatalf("release SQLite connection: %v", err)
}
if _, err := db.Exec(`
CREATE TRIGGER block_constructor_prompt_sequence
BEFORE UPDATE OF prompt_seq ON task_session_messages
BEGIN
SELECT wait_for_constructor_release();
END;
`); err != nil {
t.Fatalf("create blocking trigger: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
_, err := NewWithDBContext(ctx, db, db, nil)
done <- err
}()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("repository constructor did not enter the active migration")
}
cancel()
close(release)
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("constructor error = %v, want context.Canceled", err)
}
case <-time.After(2 * time.Second):
t.Fatal("repository constructor did not stop after cancellation")
}
}
@@ -71,9 +71,9 @@ func isStepEntryMarkerUniqueViolation(err error) bool {
// initStepTransitionsSchema's comment for why that rule doesn't apply here.
func (r *Repository) initStepEntriesSchema() error {
idCol := dialect.AutoIncrementIDColumn(r.db.DriverName())
_, err := r.db.Exec(`
_, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS workflow_step_entries (
` + idCol + `,
`+idCol+`,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
step_id TEXT NOT NULL,
entry_seq INTEGER NOT NULL,
@@ -85,7 +85,7 @@ func (r *Repository) initStepEntriesSchema() error {
ON workflow_step_entries(task_id, step_id);
CREATE TABLE IF NOT EXISTS workflow_step_entry_markers (
` + idCol + `,
`+idCol+`,
entry_id INTEGER NOT NULL REFERENCES workflow_step_entries(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
kind TEXT NOT NULL,
@@ -24,7 +24,7 @@ func subagentContextActivationKeyInsertSQL(key, value string) string {
// empty string as this key's legitimate sentinel value, not "absent".
func (r *Repository) subagentContextActivationKeyPresent(key string) (bool, error) {
var value string
err := r.db.QueryRow(r.db.Rebind(`SELECT value FROM kandev_meta WHERE key = ?`), key).Scan(&value)
err := r.db.QueryRowContext(r.migrationContext(), r.db.Rebind(`SELECT value FROM kandev_meta WHERE key = ?`), key).Scan(&value)
if err == nil {
return true, nil
}
@@ -2,6 +2,7 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
@@ -89,16 +90,17 @@ func (r *Repository) migrateSubagentContextBackfill() {
// trigger, or from a pre-amendment partial pair — is preserved verbatim
// (AC-24 point 5, AC-24d revision 4).
func (r *Repository) migrateSubagentContextBackfillUnsafe() error {
ctx := r.migrationContext()
postgres := dialect.IsPostgres(r.db.DriverName())
predicate, selectCols := subagentContextBackfillPredicateAndColumns(postgres)
tx, err := r.db.Beginx()
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin subagent context backfill transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
backfillThrough, err := subagentContextBackfillHighWaterMark(tx, postgres, predicate)
backfillThrough, err := subagentContextBackfillHighWaterMark(ctx, tx, postgres, predicate)
if err != nil {
return err
}
@@ -148,15 +150,15 @@ func (r *Repository) migrateSubagentContextBackfillUnsafe() error {
WHERE m.rn = 1
ON CONFLICT (task_session_id, agent_execution_id, tool_call_id) DO NOTHING
`
if _, err := tx.Exec(insertSQL); err != nil {
if _, err := tx.ExecContext(ctx, insertSQL); err != nil {
return fmt.Errorf("subagent context backfill insert: %w", err)
}
captureSince := time.Now().UTC().Format(time.RFC3339Nano)
if _, err := tx.Exec(subagentContextActivationKeyInsertSQL(subagentContextCaptureSinceKey, captureSince)); err != nil {
if _, err := tx.ExecContext(ctx, subagentContextActivationKeyInsertSQL(subagentContextCaptureSinceKey, captureSince)); err != nil {
return fmt.Errorf("write subagent_context_capture_since: %w", err)
}
if _, err := tx.Exec(subagentContextActivationKeyInsertSQL(subagentContextBackfillThroughKey, backfillThrough)); err != nil {
if _, err := tx.ExecContext(ctx, subagentContextActivationKeyInsertSQL(subagentContextBackfillThroughKey, backfillThrough)); err != nil {
return fmt.Errorf("write subagent_context_backfill_through: %w", err)
}
@@ -176,11 +178,11 @@ func (r *Repository) migrateSubagentContextBackfillUnsafe() error {
// see the declared column type; wrapping it in MAX() loses that, so the scan
// target must be a string on that dialect (see parseLegacyTimestamp's own
// comment for the same fact). PostgreSQL's driver has no such gap.
func subagentContextBackfillHighWaterMark(tx *sqlx.Tx, postgres bool, predicate string) (string, error) {
func subagentContextBackfillHighWaterMark(ctx context.Context, tx *sqlx.Tx, postgres bool, predicate string) (string, error) {
query := `SELECT MAX(m.created_at) FROM task_session_messages m WHERE ` + predicate
if postgres {
var maxCreatedAt sql.NullTime
if err := tx.QueryRow(query).Scan(&maxCreatedAt); err != nil {
if err := tx.QueryRowContext(ctx, query).Scan(&maxCreatedAt); err != nil {
return "", fmt.Errorf("compute subagent context backfill high-water mark: %w", err)
}
if !maxCreatedAt.Valid {
@@ -189,7 +191,7 @@ func subagentContextBackfillHighWaterMark(tx *sqlx.Tx, postgres bool, predicate
return maxCreatedAt.Time.UTC().Format(time.RFC3339Nano), nil
}
var maxCreatedAt sql.NullString
if err := tx.QueryRow(query).Scan(&maxCreatedAt); err != nil {
if err := tx.QueryRowContext(ctx, query).Scan(&maxCreatedAt); err != nil {
return "", fmt.Errorf("compute subagent context backfill high-water mark: %w", err)
}
if !maxCreatedAt.Valid || maxCreatedAt.String == "" {
@@ -1,7 +1,6 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
@@ -25,7 +24,7 @@ func (r *Repository) migrateTaskPriorityToTextPostgres() error {
}
var dataType string
err := r.db.Get(&dataType, `
err := r.db.GetContext(r.migrationContext(), &dataType, `
SELECT data_type
FROM information_schema.columns
WHERE table_schema = current_schema()
@@ -42,7 +41,7 @@ func (r *Repository) migrateTaskPriorityToTextPostgres() error {
return nil
}
tx, err := r.db.BeginTxx(context.Background(), nil)
tx, err := r.db.BeginTxx(r.migrationContext(), nil)
if err != nil {
return fmt.Errorf("begin tasks.priority migration: %w", err)
}
@@ -59,7 +58,7 @@ func (r *Repository) migrateTaskPriorityToTextPostgres() error {
{"add constraint", `ALTER TABLE tasks ADD CONSTRAINT tasks_priority_check CHECK (priority IN ('critical','high','medium','low'))`},
}
for _, statement := range statements {
if _, err := tx.Exec(statement.sql); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), statement.sql); err != nil {
return fmt.Errorf("tasks.priority migration (%s): %w", statement.name, err)
}
}
@@ -45,7 +45,7 @@ func (r *Repository) ensureTeamAccessSchema() error {
// visible to everyone until the setup wizard claims them, and inventing an
// owner here would lock the single user out of their own data.
func (r *Repository) backfillWorkspaceOwnerMembers() error {
_, err := r.db.Exec(`
_, err := r.db.ExecContext(r.migrationContext(), `
INSERT INTO workspace_members (workspace_id, user_id, role, added_by, created_at)
SELECT w.id, w.owner_id, 'owner', '', w.created_at
FROM workspaces w
@@ -34,9 +34,9 @@ func (r *Repository) initTaskUsageEventsSchema() error {
if dialect.IsPostgres(r.db.DriverName()) {
idCol = "id BIGSERIAL PRIMARY KEY"
}
_, err := r.db.Exec(`
_, err := r.db.ExecContext(r.migrationContext(), `
CREATE TABLE IF NOT EXISTS task_usage_events (
` + idCol + `,
`+idCol+`,
usage_event_id TEXT NOT NULL,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
session_id TEXT REFERENCES task_sessions(id) ON DELETE SET NULL,
@@ -1,6 +1,7 @@
package sqlite
import (
"context"
"fmt"
"github.com/jmoiron/sqlx"
@@ -17,10 +18,13 @@ func (r *Repository) prepareTaskWorktreeCutoverForeignKeys() (func(), error) {
if dialect.IsPostgres(r.db.DriverName()) {
return func() {}, nil
}
if _, err := r.db.Exec(`PRAGMA foreign_keys=OFF`); err != nil {
if _, err := r.db.ExecContext(r.migrationContext(), `PRAGMA foreign_keys=OFF`); err != nil {
return nil, fmt.Errorf("cutover: disable sqlite foreign keys: %w", err)
}
return func() { _, _ = r.db.Exec(`PRAGMA foreign_keys=ON`) }, nil
// Cleanup must restore the connection setting even after startup context
// cancellation; otherwise the shared writer would retain foreign-key checks
// disabled for the next operation.
return func() { _, _ = r.db.ExecContext(context.Background(), `PRAGMA foreign_keys=ON`) }, nil
}
// rebindGitSnapshotEnvironmentForeignKey preserves an already environment-
@@ -41,7 +45,7 @@ func (r *Repository) rebindGitSnapshotEnvironmentForeignKey(c *worktreeCutover,
if !dialect.IsPostgres(r.db.DriverName()) {
return nil
}
if _, err := tx.Exec(postgresGitSnapshotShadowForeignKeyDDL); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), postgresGitSnapshotShadowForeignKeyDDL); err != nil {
return fmt.Errorf("cutover: rebind git snapshot environment foreign key: %w", err)
}
return nil
@@ -53,7 +57,7 @@ func (r *Repository) rehomeGitSnapshotsForCutover(c *worktreeCutover, tx *sqlx.T
if survivingID == "" || survivingID == environmentID {
continue
}
if _, err := tx.Exec(tx.Rebind(`
if _, err := tx.ExecContext(r.migrationContext(), tx.Rebind(`
UPDATE task_session_git_snapshots
SET task_environment_id = ?
WHERE task_environment_id = ?
@@ -81,7 +85,7 @@ func (r *Repository) rebindTaskEnvironmentRecoveryClaimForeignKey(c *worktreeCut
if survivingID == "" || survivingID == environmentID {
continue
}
if _, err := tx.Exec(tx.Rebind(`
if _, err := tx.ExecContext(r.migrationContext(), tx.Rebind(`
UPDATE task_environment_recovery_claims
SET task_environment_id = ?
WHERE task_environment_id = ?
@@ -92,7 +96,7 @@ func (r *Repository) rebindTaskEnvironmentRecoveryClaimForeignKey(c *worktreeCut
if !dialect.IsPostgres(r.db.DriverName()) {
return nil
}
if _, err := tx.Exec(postgresRecoveryClaimShadowForeignKeyDDL); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), postgresRecoveryClaimShadowForeignKeyDDL); err != nil {
return fmt.Errorf("cutover: rebind recovery claim environment foreign key: %w", err)
}
return nil
@@ -1,11 +1,13 @@
package sqlite
import (
"context"
"fmt"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/db/dialect"
)
@@ -107,7 +109,7 @@ func (r *Repository) normalizeTaskWorktreeOwnership() error {
}
defer restoreForeignKeys()
tx, err := r.db.Beginx()
tx, err := r.db.BeginTxx(r.migrationContext(), nil)
if err != nil {
return fmt.Errorf("cutover: begin tx: %w", err)
}
@@ -129,6 +131,7 @@ func (r *Repository) normalizeTaskWorktreeOwnership() error {
return err
}
cut := &worktreeCutover{
ctx: r.migrationContext(),
envs: make(map[string]*legacyEnv),
taskEnvs: make(map[string][]*legacyEnv),
sessions: make(map[string]*legacySession),
@@ -211,10 +214,10 @@ func (r *Repository) cutoverAcquireLocks(tx *sqlx.Tx) error {
if !dialect.IsPostgres(r.db.DriverName()) {
return nil
}
if _, err := tx.Exec(`SET LOCAL lock_timeout = '30s'`); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), `SET LOCAL lock_timeout = '30s'`); err != nil {
return fmt.Errorf("cutover: set lock timeout: %w", err)
}
if _, err := tx.Exec(`SELECT pg_advisory_xact_lock($1)`, taskWorktreeCutoverLockID); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), `SELECT pg_advisory_xact_lock($1)`, taskWorktreeCutoverLockID); err != nil {
return fmt.Errorf("cutover: acquire migration advisory lock: %w", err)
}
claimTable, err := r.columnExists(tx, "task_environment_recovery_claims", "task_environment_id")
@@ -227,7 +230,7 @@ func (r *Repository) cutoverAcquireLocks(tx *sqlx.Tx) error {
if claimTable {
lockTables += `, task_environment_recovery_claims`
}
if _, err := tx.Exec("LOCK TABLE " + lockTables + " IN ACCESS EXCLUSIVE MODE"); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), "LOCK TABLE "+lockTables+" IN ACCESS EXCLUSIVE MODE"); err != nil {
return fmt.Errorf("cutover: lock ownership tables: %w", err)
}
return nil
@@ -235,19 +238,11 @@ func (r *Repository) cutoverAcquireLocks(tx *sqlx.Tx) error {
// tableExists reports whether a table exists on either database engine.
func (r *Repository) tableExists(name string) (bool, error) {
var exists bool
var err error
if dialect.IsPostgres(r.db.DriverName()) {
err = r.db.QueryRow(`
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = $1 AND table_schema = current_schema())`,
name).Scan(&exists)
} else {
err = r.db.QueryRow(
`SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?)`,
name).Scan(&exists)
}
return db.TableExists(r.db, name)
}
func (r *Repository) tableExistsContext(ctx context.Context, name string) (bool, error) {
exists, err := db.TableExistsContext(ctx, r.db, name)
if err != nil {
return false, fmt.Errorf("probe table %s: %w", name, err)
}
@@ -266,7 +261,7 @@ func (r *Repository) cutoverEnsureLegacyBranchSlug(tx *sqlx.Tx) error {
if has {
return nil
}
if _, err := tx.Exec(`ALTER TABLE task_session_worktrees ADD COLUMN branch_slug TEXT NOT NULL DEFAULT ''`); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), `ALTER TABLE task_session_worktrees ADD COLUMN branch_slug TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("cutover: add legacy branch_slug: %w", err)
}
return nil
@@ -274,19 +269,7 @@ func (r *Repository) cutoverEnsureLegacyBranchSlug(tx *sqlx.Tx) error {
// columnExists reports whether a column exists on either database engine.
func (r *Repository) columnExists(tx *sqlx.Tx, table, column string) (bool, error) {
var exists bool
var err error
if dialect.IsPostgres(r.db.DriverName()) {
err = tx.QueryRow(`
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = $1 AND column_name = $2 AND table_schema = current_schema())`,
table, column).Scan(&exists)
} else {
err = tx.QueryRow(`SELECT EXISTS (
SELECT 1 FROM pragma_table_info(?) WHERE name = ?)`,
table, column).Scan(&exists)
}
exists, err := db.ColumnExistsContext(r.migrationContext(), tx, table, column)
if err != nil {
return false, fmt.Errorf("probe column %s.%s: %w", table, column, err)
}
@@ -1,6 +1,7 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
@@ -20,6 +21,7 @@ const (
// worktreeCutover holds the legacy inventory and the normalized result.
type worktreeCutover struct {
ctx context.Context
envs map[string]*legacyEnv
taskEnvs map[string][]*legacyEnv
sessions map[string]*legacySession
@@ -93,7 +95,7 @@ func (c *worktreeCutover) loadLegacy(tx *sqlx.Tx, legacyEnvColumns, legacyRepoCo
func (c *worktreeCutover) loadLegacyEnvs(tx *sqlx.Tx, columns map[string]bool) error {
// Every interpolated expression comes from the fixed deprecated-column allowlist.
//nolint:gosec
rows, err := tx.Query(fmt.Sprintf(`
rows, err := tx.QueryContext(c.ctx, fmt.Sprintf(`
SELECT id, task_id, %s, executor_type, executor_id,
executor_profile_id, control_port, status,
%s, %s, %s, COALESCE(workspace_path, ''),
@@ -137,7 +139,7 @@ func (c *worktreeCutover) loadLegacySessions(tx *sqlx.Tx) error {
// query (SQLite is lenient about functional dependency). started_at is
// scanned as a string because MIN() makes the driver return the raw
// TEXT value instead of a time.
rows, err := tx.Query(`
rows, err := tx.QueryContext(c.ctx, `
SELECT ts.id, ts.task_id, COALESCE(ts.task_environment_id, ''), ts.state,
COALESCE(ts.executor_id, ''), COALESCE(ts.executor_profile_id, ''),
COALESCE(ts.repository_id, ''), COALESCE(er.container_id, ''),
@@ -186,7 +188,7 @@ func parseLegacyTimestamp(raw string) time.Time {
func (c *worktreeCutover) loadLegacyEnvRepos(tx *sqlx.Tx, columns map[string]bool) error {
// Every interpolated expression comes from the fixed lifecycle-column allowlist.
//nolint:gosec
rows, err := tx.Query(fmt.Sprintf(`
rows, err := tx.QueryContext(c.ctx, fmt.Sprintf(`
SELECT id, task_environment_id, repository_id, COALESCE(branch_slug, ''),
COALESCE(worktree_id, ''), COALESCE(worktree_path, ''),
COALESCE(worktree_branch, ''), position, COALESCE(error_message, ''),
@@ -228,7 +230,7 @@ func legacyRepoColumnExpr(columns map[string]bool, column, fallback string) stri
}
func (c *worktreeCutover) loadLegacySessionWorktrees(tx *sqlx.Tx) error {
rows, err := tx.Query(`
rows, err := tx.QueryContext(c.ctx, `
SELECT session_id, worktree_id, repository_id, COALESCE(branch_slug, ''),
position, COALESCE(worktree_path, ''), COALESCE(worktree_branch, ''),
status, created_at, updated_at, merged_at, deleted_at
@@ -710,7 +712,7 @@ func (c *worktreeCutover) resolveExecutorType(tx *sqlx.Tx, executorID string) (s
return cached, nil
}
var executorType string
err := tx.QueryRow(tx.Rebind(`SELECT type FROM executors WHERE id = ?`), executorID).Scan(&executorType)
err := tx.QueryRowContext(c.ctx, tx.Rebind(`SELECT type FROM executors WHERE id = ?`), executorID).Scan(&executorType)
if errors.Is(err, sql.ErrNoRows) {
c.executorTypes[executorID] = executorTypeLocalPC
return executorTypeLocalPC, nil
@@ -220,10 +220,10 @@ func (t *envRepoTarget) verifyWorktree(worktreeID, path, branch string) error {
// cutoverBuildShadows creates the final-shape shadow tables.
func (r *Repository) cutoverBuildShadows(tx *sqlx.Tx) error {
if _, err := tx.Exec(finalTaskEnvironmentsDDL("task_environments_shadow")); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), finalTaskEnvironmentsDDL("task_environments_shadow")); err != nil {
return fmt.Errorf("cutover: create task_environments_shadow: %w", err)
}
if _, err := tx.Exec(finalTaskEnvironmentReposDDL("task_environment_repos_shadow", "task_environments_shadow")); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), finalTaskEnvironmentReposDDL("task_environment_repos_shadow", "task_environments_shadow")); err != nil {
return fmt.Errorf("cutover: create task_environment_repos_shadow: %w", err)
}
return r.maybeFailCutover("create_shadow")
@@ -237,7 +237,7 @@ func (r *Repository) insertNormalized(c *worktreeCutover, tx *sqlx.Tx) error {
if !ok {
return fmt.Errorf("cutover: internal: environment %s for task %s missing", envID, taskID)
}
if _, err := tx.Exec(tx.Rebind(`
if _, err := tx.ExecContext(r.migrationContext(), tx.Rebind(`
INSERT INTO task_environments_shadow (
id, task_id, ownership_generation, executor_type, executor_id, executor_profile_id,
control_port, status, materialization_session_id, workspace_path, container_id,
@@ -270,7 +270,7 @@ func (r *Repository) insertNormalized(c *worktreeCutover, tx *sqlx.Tx) error {
if envID == "" {
continue
}
if _, err := tx.Exec(tx.Rebind(
if _, err := tx.ExecContext(r.migrationContext(), tx.Rebind(
`UPDATE task_sessions SET task_environment_id = ? WHERE id = ?`),
envID, sessionID); err != nil {
return fmt.Errorf("cutover: link session %s to environment %s: %w", sessionID, envID, err)
@@ -293,7 +293,7 @@ func (c *worktreeCutover) insertRepoRow(tx *sqlx.Tx, taskID, envID string, targe
if target.deletedAt != nil {
deletedAt = *target.deletedAt
}
if _, err := tx.Exec(tx.Rebind(`
if _, err := tx.ExecContext(c.ctx, tx.Rebind(`
INSERT INTO task_environment_repos_shadow (
id, task_environment_id, repository_id, branch_slug,
worktree_id, worktree_path, worktree_branch, position,
@@ -462,11 +462,11 @@ func (r *Repository) tableColumns(tx *sqlx.Tx, table string) (map[string]bool, e
var rows *sql.Rows
var err error
if dialect.IsPostgres(r.db.DriverName()) {
rows, err = tx.Query(`
rows, err = tx.QueryContext(r.migrationContext(), `
SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND table_schema = current_schema()`, table)
} else {
rows, err = tx.Query(`SELECT name FROM pragma_table_info(?)`, table)
rows, err = tx.QueryContext(r.migrationContext(), `SELECT name FROM pragma_table_info(?)`, table)
}
if err != nil {
return nil, fmt.Errorf("cutover: read columns of %s: %w", table, err)
@@ -490,18 +490,18 @@ func (r *Repository) tableColumns(tx *sqlx.Tx, table string) (map[string]bool, e
// the swap. Postgres constraint names are restored to their canonical form
// afterwards.
func (r *Repository) cutoverSwap(c *worktreeCutover, tx *sqlx.Tx) error {
if _, err := tx.Exec(`DROP TABLE task_session_worktrees`); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), `DROP TABLE task_session_worktrees`); err != nil {
return fmt.Errorf("cutover: drop task_session_worktrees: %w", err)
}
// Preview-build session-delete cleanup jobs are invalid under the
// task-owned model; purge them before any cleanup worker can claim them.
if _, err := tx.Exec(tx.Rebind(`DELETE FROM task_resource_cleanup_jobs WHERE trigger = ?`), "session_delete"); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), tx.Rebind(`DELETE FROM task_resource_cleanup_jobs WHERE trigger = ?`), "session_delete"); err != nil {
return fmt.Errorf("cutover: purge preview session_delete cleanup jobs: %w", err)
}
if err := r.maybeFailCutover("drop_legacy"); err != nil {
return err
}
if _, err := tx.Exec(`DROP TABLE task_environment_repos`); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), `DROP TABLE task_environment_repos`); err != nil {
return fmt.Errorf("cutover: drop legacy task_environment_repos: %w", err)
}
if err := r.rebindGitSnapshotEnvironmentForeignKey(c, tx); err != nil {
@@ -510,13 +510,13 @@ func (r *Repository) cutoverSwap(c *worktreeCutover, tx *sqlx.Tx) error {
if err := r.rebindTaskEnvironmentRecoveryClaimForeignKey(c, tx); err != nil {
return err
}
if _, err := tx.Exec(`DROP TABLE task_environments`); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), `DROP TABLE task_environments`); err != nil {
return fmt.Errorf("cutover: drop legacy task_environments: %w", err)
}
if _, err := tx.Exec(`ALTER TABLE task_environments_shadow RENAME TO task_environments`); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), `ALTER TABLE task_environments_shadow RENAME TO task_environments`); err != nil {
return fmt.Errorf("cutover: rename task_environments_shadow: %w", err)
}
if _, err := tx.Exec(`ALTER TABLE task_environment_repos_shadow RENAME TO task_environment_repos`); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), `ALTER TABLE task_environment_repos_shadow RENAME TO task_environment_repos`); err != nil {
return fmt.Errorf("cutover: rename task_environment_repos_shadow: %w", err)
}
if err := r.maybeFailCutover("swap"); err != nil {
@@ -533,7 +533,7 @@ func (r *Repository) cutoverSwap(c *worktreeCutover, tx *sqlx.Tx) error {
`CREATE INDEX IF NOT EXISTS idx_task_environment_repos_env_id ON task_environment_repos(task_environment_id)`,
`CREATE INDEX IF NOT EXISTS idx_task_environment_repos_repository_id ON task_environment_repos(repository_id)`,
} {
if _, err := tx.Exec(stmt); err != nil {
if _, err := tx.ExecContext(r.migrationContext(), stmt); err != nil {
return fmt.Errorf("cutover: recreate index: %w", err)
}
}
@@ -556,7 +556,7 @@ func (r *Repository) cutoverRenamePostgresConstraints(tx *sqlx.Tx) error {
}
for _, rename := range renames {
var constraintName string
err := tx.QueryRow(tx.Rebind(`
err := tx.QueryRowContext(r.migrationContext(), tx.Rebind(`
SELECT conname FROM pg_constraint
WHERE conrelid = (SELECT oid FROM pg_class WHERE relname = ? AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema()))
AND conname LIKE ?
@@ -564,7 +564,7 @@ func (r *Repository) cutoverRenamePostgresConstraints(tx *sqlx.Tx) error {
if err != nil {
return fmt.Errorf("cutover: find postgres constraint on %s: %w", rename.table, err)
}
if _, err := tx.Exec(fmt.Sprintf(
if _, err := tx.ExecContext(r.migrationContext(), fmt.Sprintf(
`ALTER TABLE %s RENAME CONSTRAINT %s TO %s`, rename.table, constraintName, rename.canonical)); err != nil {
return fmt.Errorf("cutover: rename postgres constraint %s: %w", constraintName, err)
}