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>
This commit is contained in:
140
server/sandbox/batch_module.go
Normal file
140
server/sandbox/batch_module.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user