From 36a59c30c03d97a87cd81364ecb3044519619294 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Sun, 03 May 2026 20:57:51 +0000 Subject: [PATCH] spindle: limit concurrent workflows to cap total memory usage Add a channel-based semaphore (SPINDLE_SERVER_MAX_CONCURRENT_WORKFLOWS, default 8) that blocks workflow goroutines from starting a container until a slot is free. Each workflow acquires a slot before SetupWorkflow and releases it on exit via defer. Combined with the 6 GiB per-container limit (MAX_JOB_MEMORY_MB), this bounds total container memory to ~48 GiB on the current 64 GiB host. MAX_JOB_COUNT still controls pipeline-level concurrency at the queue. Without this semaphore, max workflows were effectively unbounded and (wrongly) assumed to be capped at MAX_JOB_COUNT. Signed-off-by: Anirudh Oppiliappan --- spindle/config/config.go | 3 ++- spindle/engine/engine.go | 6 +++++- spindle/server.go | 35 ++++++++++++++++++++--------------- 3 file(s) changed, 27 insertion(s)(+), 17 deletion(s)(-) diff --git a/spindle/config/config.go b/spindle/config/config.go --- a/spindle/config/config.go +++ b/spindle/config/config.go @@ -19,7 +19,8 @@ Owner string `env:"OWNER, required"` Secrets Secrets `env:",prefix=SECRETS_"` LogDir string `env:"LOG_DIR, default=/var/log/spindle"` QueueSize int `env:"QUEUE_SIZE, default=100"` - MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time + MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of pipelines that run at a time + MaxConcurrentWorkflows int `env:"MAX_CONCURRENT_WORKFLOWS, default=8"` // max number of workflow containers running at once (memory cap) } func (s Server) Did() syntax.DID { diff --git a/spindle/engine/engine.go b/spindle/engine/engine.go --- a/spindle/engine/engine.go +++ b/spindle/engine/engine.go @@ -21,7 +21,7 @@ ErrTimedOut = errors.New("timed out") ErrWorkflowFailed = errors.New("workflow failed") ) -func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, db *db.DB, n *notifier.Notifier, ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { +func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, db *db.DB, n *notifier.Notifier, workflowSem chan struct{}, ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { l.Info("starting all workflows in parallel", "pipeline", pipelineId) // extract secrets @@ -80,6 +80,10 @@ if err != nil { l.Error("failed to set workflow status to running", "wid", wid, "err", err) return } + + // acquire semaphore slot before starting the container + workflowSem <- struct{}{} + defer func() { <-workflowSem }() err = eng.SetupWorkflow(ctx, wid, &w, wfLogger) if err != nil { diff --git a/spindle/server.go b/spindle/server.go --- a/spindle/server.go +++ b/spindle/server.go @@ -48,9 +48,10 @@ jq *queue.Queue cfg *config.Config ks *eventconsumer.Consumer res *idresolver.Resolver - vault secrets.Manager - motd []byte - motdMu sync.RWMutex + vault secrets.Manager + motd []byte + motdMu sync.RWMutex + workflowSem chan struct{} } // New creates a new Spindle server with the provided configuration and engines. @@ -98,6 +99,9 @@ jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount) + workflowSem := make(chan struct{}, cfg.Server.MaxConcurrentWorkflows) + logger.Info("initialized workflow semaphore", "maxConcurrentWorkflows", cfg.Server.MaxConcurrentWorkflows) + collections := []string{ tangled.SpindleMemberNSID, tangled.RepoNSID, @@ -121,17 +125,18 @@ resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) spindle := &Spindle{ - jc: jc, - e: e, - db: d, - l: logger, - n: &n, - engs: engines, - jq: jq, - cfg: cfg, - res: resolver, - vault: vault, - motd: defaultMotd, + jc: jc, + e: e, + db: d, + l: logger, + n: &n, + engs: engines, + jq: jq, + cfg: cfg, + res: resolver, + vault: vault, + motd: defaultMotd, + workflowSem: workflowSem, } err = e.AddSpindle(rbacDomain) @@ -393,7 +398,7 @@ } ok := s.jq.Enqueue(queue.Job{ Run: func() error { - engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ + engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, s.workflowSem, ctx, &models.Pipeline{ RepoOwner: tpl.TriggerMetadata.Repo.Did, RepoName: repoName, Workflows: workflows, -- tangled.sh