package dockerfile import ( "encoding/json" "errors" "fmt" "log/slog" "sync" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/workload/plugin" ) // runtimeState is the per-workload state we persist inside the // container row's extra_json blob. Mirrors the static plugin's // runtimeState shape so anyone reading the DB can interpret the two // kinds identically. // // LastImageDigest is the build's image ID — distinct from a registry // digest (we never push) but useful for "did the build actually // produce a different artifact?" diffing when we add caching later. type runtimeState struct { LastCommitSHA string `json:"last_commit_sha,omitempty"` LastImageDigest string `json:"last_image_digest,omitempty"` LastSyncAt string `json:"last_sync_at,omitempty"` LastError string `json:"last_error,omitempty"` Status string `json:"status,omitempty"` } // runtimeStateKeys lists every JSON field name owned by runtimeState. // saveState strips these from the generic map before re-emitting so // the typed values do not double-write under both their JSON tag and // any subsequent extension's tag. var runtimeStateKeys = []string{ "last_commit_sha", "last_image_digest", "last_sync_at", "last_error", "status", } // containerRowID is the deterministic container row ID. Stable across // redeploys so saveState upserts in place. func containerRowID(w plugin.Workload) string { return w.ID + ":dockerfile" } // loadState returns the persisted runtime state plus the underlying // container row. Both values are zero on first deploy. func loadState(deps plugin.Deps, w plugin.Workload) (runtimeState, *store.Container, error) { row, err := deps.Store.GetContainerByID(containerRowID(w)) if err != nil { if errors.Is(err, store.ErrNotFound) { return runtimeState{}, nil, nil } return runtimeState{}, nil, fmt.Errorf("dockerfile source: load state: %w", err) } st := runtimeState{} if row.ExtraJSON != "" && row.ExtraJSON != "{}" { if err := json.Unmarshal([]byte(row.ExtraJSON), &st); err != nil { slog.Debug("dockerfile source: decode extra_json", "workload", w.ID, "error", err) } } return st, &row, nil } // saveLocks serializes per-workload RMW of the container row. Same // pattern as the static plugin — SQLite's MaxOpenConns=1 serializes // statements but not the caller's read-then-write intent, so two // concurrent deploys for the same workload could stomp each other's // container_id / proxy_route_id without this mutex. // // Entries are reference-counted and removed only when the last holder // releases. This bounds memory (no per-workload-ID leak) WITHOUT the // use-after-delete hazard of deleting an entry on teardown: deleting a // live entry while a concurrent saveState still holds (or is about to // lock) it would let a fresh saveState mint a SECOND mutex for the same // workload, losing the RMW serialization the lock exists to provide. var saveLocks struct { mu sync.Mutex locks map[string]*saveLock } type saveLock struct { mu sync.Mutex refs int } // acquireSaveLock returns the per-workload lock (creating it on first use), // registers this caller as a holder, and takes the lock. Pair with // releaseSaveLock. The outer mutex is held only for the bookkeeping; callers // contend on the returned per-workload lock. func acquireSaveLock(workloadID string) *saveLock { saveLocks.mu.Lock() if saveLocks.locks == nil { saveLocks.locks = map[string]*saveLock{} } l, ok := saveLocks.locks[workloadID] if !ok { l = &saveLock{} saveLocks.locks[workloadID] = l } l.refs++ saveLocks.mu.Unlock() l.mu.Lock() return l } // releaseSaveLock unlocks and drops the caller's reference, removing the map // entry once no holders remain. Because refs is incremented under saveLocks.mu // before the entry can be observed for deletion, an entry with a pending // acquirer is never deleted. func releaseSaveLock(workloadID string, l *saveLock) { l.mu.Unlock() saveLocks.mu.Lock() l.refs-- if l.refs == 0 { delete(saveLocks.locks, workloadID) } saveLocks.mu.Unlock() } // saveState upserts the container row, calling mutate so callers can // adjust both the typed runtime state and the row's first-class fields // in one transaction. Unknown keys in extra_json survive the round-trip // so future writers can extend the blob without forcing this struct to // grow. func saveState(deps plugin.Deps, w plugin.Workload, mutate func(*runtimeState, *store.Container)) error { lk := acquireSaveLock(w.ID) defer releaseSaveLock(w.ID, lk) prev, prevRow, err := loadState(deps, w) if err != nil { return err } row := store.Container{ ID: containerRowID(w), WorkloadID: w.ID, WorkloadKind: string(store.WorkloadKindBuild), Host: "local", } if prevRow != nil { row = *prevRow } generic := map[string]json.RawMessage{} if row.ExtraJSON != "" && row.ExtraJSON != "{}" { if err := json.Unmarshal([]byte(row.ExtraJSON), &generic); err != nil { slog.Debug("dockerfile source: decode extra_json (generic)", "workload", w.ID, "error", err) } } for _, k := range runtimeStateKeys { delete(generic, k) } state := prev mutate(&state, &row) typedBytes, err := json.Marshal(state) if err != nil { return fmt.Errorf("dockerfile source: marshal state: %w", err) } typedMap := map[string]json.RawMessage{} if err := json.Unmarshal(typedBytes, &typedMap); err != nil { return fmt.Errorf("dockerfile source: re-decode typed state: %w", err) } for k, v := range typedMap { generic[k] = v } merged, err := json.Marshal(generic) if err != nil { return fmt.Errorf("dockerfile source: marshal merged state: %w", err) } row.ExtraJSON = string(merged) row.LastSeenAt = store.Now() if err := deps.Store.UpsertContainer(row); err != nil { return fmt.Errorf("dockerfile source: upsert container row: %w", err) } return nil }