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
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
141 lines
3.8 KiB
Go
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
|
|
}
|
|
}
|