This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/sandbox/batch_module.go
Jeffrey Smith 8957e99610 Feat v0.7.12 batch.exec concurrent execution primitive
Add batch.exec(callables, timeout=10) — a general-purpose concurrent
execution primitive that runs arbitrary Starlark callables in parallel,
each in its own thread with independent step budget. Enables extensions
to fan out library function calls (frozen exports from lib.require)
without decomposing them back into raw http.post parameters.

New permission: batch.exec. Max 8 callables, timeout 1-30s.
Nesting prohibited via atomic flag. 12 new tests, all pass with -race.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:52:01 +00:00

141 lines
3.8 KiB
Go

// Package sandbox — batch_module.go
//
// Permission: batch.exec
//
// Starlark API:
// results, errors = batch.exec([fn_a, fn_b, fn_c], timeout=10)
//
// Runs a list of zero-arg callables concurrently, each in its own
// starlark.Thread with an independent step budget. Results and errors
// are returned in input order. Max 8 callables per call.
//
// Each branch gets a fresh Sandbox (thread + step counter) and a
// child context with the per-branch timeout. The callable's closed-over
// bindings (frozen library structs, module builtins) are used as-is —
// the Go resources underneath (*sql.DB, http.Client) are concurrent-safe.
//
// lib.require() is not available inside branches (Option A).
// print() output from branches is discarded.
// Nested batch.exec() is prohibited via an atomic flag.
package sandbox
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"go.starlark.net/starlark"
"go.starlark.net/starlarkstruct"
)
const (
batchMaxCallables = 8
batchDefaultTimeout = 10
batchMaxTimeout = 30
)
// BuildBatchModule creates the "batch" module.
// The runner/packageID/manifest/rc/lc parameters are stored for future
// expansion (Option B: per-branch lib.require) but are not used in v1.
func BuildBatchModule(
ctx context.Context,
runner *Runner,
packageID string,
manifest map[string]any,
rc *RunContext,
lc *libContext,
) *starlarkstruct.Module {
var running atomic.Bool
return MakeModule("batch", starlark.StringDict{
"exec": starlark.NewBuiltin("batch.exec", batchExec(ctx, &running)),
})
}
// batchExec returns the batch.exec builtin implementation.
func batchExec(
ctx context.Context,
running *atomic.Bool,
) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
return func(
thread *starlark.Thread, b *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
// Nesting guard: parent thread is blocked in wg.Wait(), so only
// a branch goroutine could re-enter. The captured builtin reference
// makes this reachable even though branches don't get fresh modules.
if !running.CompareAndSwap(false, true) {
return nil, fmt.Errorf("batch.exec: nested calls are not allowed")
}
defer running.Store(false)
var callableList *starlark.List
var timeout int = batchDefaultTimeout
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
"callables", &callableList,
"timeout?", &timeout,
); err != nil {
return nil, err
}
n := callableList.Len()
if n == 0 {
return nil, fmt.Errorf("batch.exec: callables list is empty")
}
if n > batchMaxCallables {
return nil, fmt.Errorf("batch.exec: max %d callables, got %d", batchMaxCallables, n)
}
if timeout < 1 || timeout > batchMaxTimeout {
timeout = batchDefaultTimeout
}
// Validate all elements are callable before spawning goroutines.
callables := make([]starlark.Callable, n)
for i := 0; i < n; i++ {
c, ok := callableList.Index(i).(starlark.Callable)
if !ok {
return nil, fmt.Errorf("batch.exec: element %d is %s, not callable",
i, callableList.Index(i).Type())
}
callables[i] = c
}
// Execute concurrently.
results := make([]starlark.Value, n)
errors := make([]starlark.Value, n)
var wg sync.WaitGroup
for i, callable := range callables {
wg.Add(1)
go func(idx int, fn starlark.Callable) {
defer wg.Done()
branchCtx, cancel := context.WithTimeout(ctx,
time.Duration(timeout)*time.Second)
defer cancel()
sb := New(DefaultConfig())
val, _, callErr := sb.Call(branchCtx, fn, nil, nil)
if callErr != nil {
results[idx] = starlark.None
errors[idx] = starlark.String(callErr.Error())
} else {
results[idx] = val
errors[idx] = starlark.None
}
}(i, callable)
}
wg.Wait()
return starlark.Tuple{
starlark.NewList(results),
starlark.NewList(errors),
}, nil
}
}