Skip to content

Implements Spawn in Go and TS #12

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
May 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
562471e
Implements Spawn and FunctionCAll
luiscape May 7, 2025
7067075
Add test
luiscape May 7, 2025
6da14af
Adds test for cancel
luiscape May 7, 2025
f2b7224
Removes function reference
luiscape May 7, 2025
e406861
Rename function
luiscape May 7, 2025
843f304
Adds Functioncall example
luiscape May 7, 2025
6c5a371
Link new example
luiscape May 7, 2025
977db49
Changes helper function name
luiscape May 7, 2025
da55d39
Improve note on spawn example
luiscape May 7, 2025
5c6f903
Review updates
luiscape May 8, 2025
8791c31
Improve comments
luiscape May 8, 2025
b3e164f
Makes options clearer
luiscape May 8, 2025
924ff87
Merge branch 'main' of github.com:modal-labs/libmodal into luis/go-spawn
luiscape May 16, 2025
276805e
Implement spawn in TypeScript
luiscape May 16, 2025
a7e753f
Adds comments
luiscape May 16, 2025
c31cff9
Improve comments
luiscape May 16, 2025
2f38805
Update modal-go/function.go
luiscape May 16, 2025
de26b15
Update modal-go/function.go
luiscape May 16, 2025
72b5068
Updates error handling
luiscape May 16, 2025
d5fd355
Fix formatting
luiscape May 16, 2025
baaa02e
Merge branch 'main' of github.com:modal-labs/libmodal into luis/go-spawn
luiscape May 16, 2025
97beeec
Merge branch 'main' of github.com:modal-labs/libmodal into luis/go-spawn
luiscape May 25, 2025
55913ba
Adds timeout correctly
luiscape May 25, 2025
71c091e
Updates client
luiscape May 25, 2025
c56ba69
Update timeout in TS
luiscape May 25, 2025
643ae35
Make it a time.Duration
luiscape May 25, 2025
2680914
Adds default behavior when calling output
luiscape May 26, 2025
e52a30b
log more test datails
luiscape May 26, 2025
2603870
Implements timeout correctly in TS
luiscape May 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import "github.com/modal-labs/libmodal/modal-go"
Examples:

- [Call a deployed function](./modal-go/examples/function-call/main.go)
- [Spawn a deployed function](./modal-go/examples/function-spawn/main.go)
- [Call a deployed cls](./modal-go/examples/cls-call/main.go)
- [Create a sandbox](./modal-go/examples/sandbox/main.go)
- [Execute sandbox commands](./modal-go/examples/sandbox-exec/main.go)
Expand Down
2 changes: 1 addition & 1 deletion modal-go/cls.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ func encodeParameter(paramSpec *pb.ClassParameterSpec, value any) (*pb.ClassPara
paramValue.SetBytesValue(bytesValue)

default:
return nil, fmt.Errorf("unsupported parameter type: %v", paramType)
return nil, fmt.Errorf("unsupported parameter type: %w", paramType)
}

return paramValue, nil
Expand Down
15 changes: 8 additions & 7 deletions modal-go/examples/cls-call/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package main

import (
"context"
"fmt"
"log"

"github.com/modal-labs/libmodal/modal-go"
Expand All @@ -18,30 +19,30 @@ func main() {
"libmodal-test-support", "EchoCls", modal.LookupOptions{},
)
if err != nil {
log.Fatalf("Failed to lookup Cls: %v", err)
fmt.Errorf("Failed to lookup Cls: %w", err)
}

instance, err := cls.Instance(nil)
if err != nil {
log.Fatalf("Failed to create Cls instance: %v", err)
fmt.Errorf("Failed to create Cls instance: %w", err)
}

function, err := instance.Method("echo_string")
if err != nil {
log.Fatalf("Failed to access Cls method: %v", err)
fmt.Errorf("Failed to access Cls method: %w", err)
}

// Call the Cls function with args.
result, err := function.Remote([]any{"Hello world!"}, nil)
if err != nil {
log.Fatalf("Failed to call Cls method: %v", err)
fmt.Errorf("Failed to call Cls method: %w", err)
}
log.Printf("%v\n", result)
log.Println("Response:", result)

// Call the Cls function with kwargs.
result, err = function.Remote(nil, map[string]any{"s": "Hello world!"})
if err != nil {
log.Fatalf("Failed to call Cls method: %v", err)
fmt.Errorf("Failed to call Cls method: %w", err)
}
log.Printf("%v\n", result)
log.Println("Response:", result)
}
10 changes: 5 additions & 5 deletions modal-go/examples/function-call/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,18 @@ func main() {

echo, err := modal.FunctionLookup(ctx, "libmodal-test-support", "echo_string", modal.LookupOptions{})
if err != nil {
log.Fatalf("Failed to lookup function: %v", err)
fmt.Errorf("Failed to lookup function: %w", err)
}

ret, err := echo.Remote([]any{"Hello world!"}, nil)
if err != nil {
log.Fatalf("Failed to call function: %v", err)
fmt.Errorf("Failed to call function: %w", err)
}
fmt.Printf("%s\n", ret)
log.Println("Response:", ret)

ret, err = echo.Remote(nil, map[string]any{"s": "Hello world!"})
if err != nil {
log.Fatalf("Failed to call function with kwargs: %v", err)
fmt.Errorf("Failed to call function with kwargs: %w", err)
}
log.Printf("%s\n", ret)
log.Println("Response:", ret)
}
32 changes: 32 additions & 0 deletions modal-go/examples/function-spawn/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// This example spawns a function defined in `libmodal_test_support.py`, and
// later gets its outputs.

package main

import (
"context"
"fmt"
"log"

"github.com/modal-labs/libmodal/modal-go"
)

func main() {
ctx := context.Background()

echo, err := modal.FunctionLookup(ctx, "libmodal-test-support", "echo_string", modal.LookupOptions{})
if err != nil {
fmt.Errorf("Failed to lookup function: %w", err)
}

fc, err := echo.Spawn(nil, map[string]any{"s": "Hello world!"})
if err != nil {
fmt.Errorf("Failed to spawn function: %w", err)
}

ret, err := fc.Get(modal.FunctionCallGetOptions{})
if err != nil {
fmt.Errorf("Failed to get function results: %w", err)
}
log.Println("Response:", ret)
}
15 changes: 8 additions & 7 deletions modal-go/examples/sandbox-exec/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"fmt"
"io"
"log"

Expand All @@ -13,17 +14,17 @@ func main() {

app, err := modal.AppLookup(ctx, "libmodal-example", modal.LookupOptions{CreateIfMissing: true})
if err != nil {
log.Fatalf("Failed to lookup or create app: %v", err)
fmt.Errorf("Failed to lookup or create app: %w", err)
}

image, err := app.ImageFromRegistry("python:3.13-slim")
if err != nil {
log.Fatalf("Failed to create image from registry: %v", err)
fmt.Errorf("Failed to create image from registry: %w", err)
}

sb, err := app.CreateSandbox(image, modal.SandboxOptions{})
if err != nil {
log.Fatalf("Failed to create sandbox: %v", err)
fmt.Errorf("Failed to create sandbox: %w", err)
}
log.Println("Started sandbox:", sb.SandboxId)
defer sb.Terminate()
Expand All @@ -47,22 +48,22 @@ for i in range(50000):
},
)
if err != nil {
log.Fatalf("Failed to execute command in sandbox: %v", err)
fmt.Errorf("Failed to execute command in sandbox: %w", err)
}

contentStdout, err := io.ReadAll(p.Stdout)
if err != nil {
log.Fatalf("Failed to read stdout: %v", err)
fmt.Errorf("Failed to read stdout: %w", err)
}
contentStderr, err := io.ReadAll(p.Stderr)
if err != nil {
log.Fatalf("Failed to read stderr: %v", err)
fmt.Errorf("Failed to read stderr: %w", err)
}

log.Printf("Got %d bytes stdout and %d bytes stderr\n", len(contentStdout), len(contentStderr))
returnCode, err := p.Wait()
if err != nil {
log.Fatalf("Failed to wait for process completion: %v", err)
fmt.Errorf("Failed to wait for process completion: %w", err)
}
log.Println("Return code:", returnCode)

Expand Down
13 changes: 7 additions & 6 deletions modal-go/examples/sandbox/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"fmt"
"io"
"log"

Expand All @@ -13,34 +14,34 @@ func main() {

app, err := modal.AppLookup(ctx, "libmodal-example", modal.LookupOptions{CreateIfMissing: true})
if err != nil {
log.Fatalf("Failed to lookup or create app: %v", err)
fmt.Errorf("Failed to lookup or create app: %w", err)
}

image, err := app.ImageFromRegistry("alpine:3.21")
if err != nil {
log.Fatalf("Failed to create image from registry: %v", err)
fmt.Errorf("Failed to create image from registry: %w", err)
}

sb, err := app.CreateSandbox(image, modal.SandboxOptions{
Command: []string{"cat"},
})
if err != nil {
log.Fatalf("Failed to create sandbox: %v", err)
fmt.Errorf("Failed to create sandbox: %w", err)
}
log.Printf("sandbox: %s\n", sb.SandboxId)

_, err = sb.Stdin.Write([]byte("this is input that should be mirrored by cat"))
if err != nil {
log.Fatalf("Failed to write to sandbox stdin: %v", err)
fmt.Errorf("Failed to write to sandbox stdin: %w", err)
}
err = sb.Stdin.Close()
if err != nil {
log.Fatalf("Failed to close sandbox stdin: %v", err)
fmt.Errorf("Failed to close sandbox stdin: %w", err)
}

output, err := io.ReadAll(sb.Stdout)
if err != nil {
log.Fatalf("Failed to read from sandbox stdout: %v", err)
fmt.Errorf("Failed to read from sandbox stdout: %w", err)
}

log.Printf("output: %s\n", string(output))
Expand Down
77 changes: 65 additions & 12 deletions modal-go/function.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ import (
)

// From: modal/_utils/blob_utils.py
const maxObjectSizeBytes = 2 * 1024 * 1024 // 2 MiB
const maxObjectSizeBytes int = 2 * 1024 * 1024 // 2 MiB

func timeNow() float64 {
// From: modal-client/modal/_utils/function_utils.py
const OutputsTimeout time.Duration = time.Second * 55

func timeNowSeconds() float64 {
return float64(time.Now().UnixNano()) / 1e9
}

Expand Down Expand Up @@ -81,8 +84,8 @@ func pickleDeserialize(buffer []byte) (any, error) {
return result, nil
}

// Execute a single input into a remote Function.
func (f *Function) Remote(args []any, kwargs map[string]any) (any, error) {
// Serializes inputs, make a function call and return its ID
func (f *Function) execFunctionCall(args []any, kwargs map[string]any, invocationType pb.FunctionCallInvocationType) (*string, error) {
payload, err := pickleSerialize(args, kwargs)
if err != nil {
return nil, err
Expand Down Expand Up @@ -115,33 +118,83 @@ func (f *Function) Remote(args []any, kwargs map[string]any) (any, error) {
functionMapResponse, err := client.FunctionMap(f.ctx, pb.FunctionMapRequest_builder{
FunctionId: f.FunctionId,
FunctionCallType: pb.FunctionCallType_FUNCTION_CALL_TYPE_UNARY,
FunctionCallInvocationType: pb.FunctionCallInvocationType_FUNCTION_CALL_INVOCATION_TYPE_SYNC,
FunctionCallInvocationType: invocationType,
PipelinedInputs: functionInputs,
}.Build())
if err != nil {
return nil, fmt.Errorf("FunctionMap error: %v", err)
return nil, fmt.Errorf("FunctionMap error: %w", err)
}

functionCallId := functionMapResponse.GetFunctionCallId()
return &functionCallId, nil
}

// Remote executes a single input on a remote Function.
func (f *Function) Remote(args []any, kwargs map[string]any) (any, error) {
invocationType := pb.FunctionCallInvocationType_FUNCTION_CALL_INVOCATION_TYPE_SYNC
functionCallId, err := f.execFunctionCall(args, kwargs, invocationType)
if err != nil {
return nil, err
}

return pollFunctionOutput(f.ctx, *functionCallId, OutputsTimeout)
}

// Poll for ouputs for a given FunctionCall ID
func pollFunctionOutput(ctx context.Context, functionCallId string, timeout time.Duration) (any, error) {
startTime := time.Now()

// Calculate initial backend timeout
pollTimeout := minTimeout(OutputsTimeout, timeout)
for {
response, err := client.FunctionGetOutputs(f.ctx, pb.FunctionGetOutputsRequest_builder{
FunctionCallId: functionMapResponse.GetFunctionCallId(),
// Context might have been cancelled. Check before next poll operation.
if err := ctx.Err(); err != nil {
return nil, err
}

response, err := client.FunctionGetOutputs(ctx, pb.FunctionGetOutputsRequest_builder{
FunctionCallId: functionCallId,
MaxValues: 1,
Timeout: 55,
Timeout: float32(pollTimeout.Seconds()),
LastEntryId: "0-0",
ClearOnSuccess: true,
RequestedAt: timeNow(),
RequestedAt: timeNowSeconds(),
}.Build())
if err != nil {
return nil, fmt.Errorf("FunctionGetOutputs failed: %v", err)
return nil, fmt.Errorf("FunctionGetOutputs failed: %w", err)
}

// Output serialization may fail if any of the output items can't be deserialized
// into a supported Go type. Users are expected to serialize outputs correctly.
outputs := response.GetOutputs()
if len(outputs) > 0 {
return processResult(f.ctx, outputs[0].GetResult(), outputs[0].GetDataFormat())
return processResult(ctx, outputs[0].GetResult(), outputs[0].GetDataFormat())
}

remainingTime := timeout - time.Since(startTime)
if remainingTime <= 0 {
m := fmt.Sprintf("Timeout exceeded: %.1fs", timeout.Seconds())
return nil, FunctionTimeoutError{m}
}

// Add a small delay before next poll to avoid overloading backend.
time.Sleep(50 * time.Millisecond)
pollTimeout = minTimeout(OutputsTimeout, remainingTime)
}
}

// Spawn starts running a single input on a remote function.
func (f *Function) Spawn(args []any, kwargs map[string]any) (*FunctionCall, error) {
invocationType := pb.FunctionCallInvocationType_FUNCTION_CALL_INVOCATION_TYPE_ASYNC
functionCallId, err := f.execFunctionCall(args, kwargs, invocationType)
if err != nil {
return nil, err
}
functionCall := FunctionCall{
FunctionCallId: *functionCallId,
ctx: f.ctx,
}
return &functionCall, nil
}

// processResult processes the result from an invocation.
Expand Down
Loading