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 3b74774077
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m46s
CI/CD / test-sqlite (push) Successful in 2m58s
CI/CD / build-and-deploy (push) Successful in 1m27s
CI/CD / test-frontend (push) Successful in 5s
Feat v0.7.12 batch exec (#66)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 23:57:46 +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
}
}