perf(reconciler): batch workloads per tick, drop redundant image inspect

Load every workload once per tick into a map instead of a per-container
GetWorkloadByID (N+1) in the upsert loop plus a second ListWorkloads in
the plugin pass: one query per tick, zero GetWorkloadByID. The
ListWorkloads error path returns before the missing-sweep so a failed
load can't flip live container rows to 'missing'.

image.Reconcile is now a no-op: the generic upsert+markMissing pass
already syncs every labeled container's state from the single
ListAllForReconciler (docker ps -a) snapshot earlier in the same tick,
so the former per-container IsContainerRunning loop was N redundant
Docker calls/tick. (Its no-op body sits in image.go, which landed with
the preceding commit; the tests are here.) compose/static reconcile do
non-redundant work and are intentionally untouched.

Reviewed: go APPROVE.
This commit is contained in:
2026-05-29 13:51:27 +03:00
parent 93b6911b34
commit 5c17885197
3 changed files with 185 additions and 19 deletions
@@ -1,6 +1,7 @@
package image
import (
"context"
"strings"
"testing"
"time"
@@ -8,6 +9,20 @@ import (
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// TestReconcileIsNoOp locks Fix B: image.Reconcile must do nothing and touch
// neither the Store nor Docker (the generic reconciler pass syncs state). We
// pass a zero-value plugin.Deps whose Store and Docker are nil — the old
// implementation called deps.Store.ListContainersByWorkload then
// deps.Docker.IsContainerRunning, both of which would nil-panic. Returning nil
// without panicking proves it dereferences neither.
func TestReconcileIsNoOp(t *testing.T) {
src := &source{}
w := plugin.Workload{ID: "wl-1", Name: "app", SourceKind: "image"}
if err := src.Reconcile(context.Background(), plugin.Deps{}, w); err != nil {
t.Fatalf("Reconcile should be a no-op returning nil, got %v", err)
}
}
func TestBuildContainerName(t *testing.T) {
ts := time.Unix(1700000000, 0)
name := buildContainerName("My App", "abcd1234-5678-1234-abcd-deadbeef0000", "v1.2.3", ts)
@@ -56,10 +71,10 @@ func TestFaceEnabled(t *testing.T) {
func TestFqdnFor(t *testing.T) {
cases := []struct {
name string
face plugin.PublicFace
defDom string
want string
name string
face plugin.PublicFace
defDom string
want string
}{
{"subdomain + face domain", plugin.PublicFace{Subdomain: "api", Domain: "example.com"}, "default.io", "api.example.com"},
{"subdomain inherits default", plugin.PublicFace{Subdomain: "api"}, "default.io", "api.default.io"},
@@ -78,8 +93,8 @@ func TestFqdnFor(t *testing.T) {
func TestPrimaryFace(t *testing.T) {
t.Run("returns first enabled", func(t *testing.T) {
faces := []plugin.PublicFace{
{}, // disabled
{Subdomain: "api"}, // first enabled
{}, // disabled
{Subdomain: "api"}, // first enabled
{Domain: "second.example.com"},
}
got := primaryFace(faces)