From 562471e5df61d79abd8431c7bcabe08f1c53873a Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Tue, 6 May 2025 23:36:32 -0400 Subject: [PATCH 01/26] Implements Spawn and FunctionCAll --- modal-go/function.go | 43 +++++++++++++++++++++++++++++++++------ modal-go/function_call.go | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 modal-go/function_call.go diff --git a/modal-go/function.go b/modal-go/function.go index 3c9373a..22636cf 100644 --- a/modal-go/function.go +++ b/modal-go/function.go @@ -81,8 +81,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 and creates a function call. +func (f *Function) functionCallHelper(args []any, kwargs map[string]any, invocationType pb.FunctionCallInvocationType) (*pb.FunctionMapResponse, error) { payload, err := pickleSerialize(args, kwargs) if err != nil { return nil, err @@ -115,16 +115,32 @@ 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 functionMapResponse, nil +} + +// Execute a single input into a remote Function. +func (f *Function) Remote(args []any, kwargs map[string]any) (any, error) { + invocationType := pb.FunctionCallInvocationType_FUNCTION_CALL_INVOCATION_TYPE_SYNC + functionMapResponse, err := f.functionCallHelper(args, kwargs, invocationType) + if err != nil { + return nil, fmt.Errorf("FunctionMap error: %v", err) + } + + functionCallId := functionMapResponse.GetFunctionCallId() + return pollForOutput(functionCallId) +} + +func pollForOutput(ctx context.Context, functionCallId string) (any, error) { for { - response, err := client.FunctionGetOutputs(f.ctx, pb.FunctionGetOutputsRequest_builder{ - FunctionCallId: functionMapResponse.GetFunctionCallId(), + response, err := client.FunctionGetOutputs(ctx, pb.FunctionGetOutputsRequest_builder{ + FunctionCallId: functionCallId, MaxValues: 1, Timeout: 55, LastEntryId: "0-0", @@ -139,11 +155,26 @@ func (f *Function) Remote(args []any, kwargs map[string]any) (any, error) { // 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()) } } } +// Spawn a single input into a remote function. +func (f *Function) Spawn(args []any, kwargs map[string]any) (*FunctionCall, error) { + invocationType := pb.FunctionCallInvocationType_FUNCTION_CALL_INVOCATION_TYPE_ASYNC + functionMapResponse, err := f.functionCallHelper(args, kwargs, invocationType) + if err != nil { + return nil, fmt.Errorf("FunctionMap error: %v", err) + } + functionCall := FunctionCall{ + FunctionCallId: functionMapResponse.GetFunctionCallId(), + function: f, + ctx: f.ctx, + } + return &functionCall, nil +} + // processResult processes the result from an invocation. func processResult(ctx context.Context, result *pb.GenericResult, dataFormat pb.DataFormat) (any, error) { if result == nil { diff --git a/modal-go/function_call.go b/modal-go/function_call.go new file mode 100644 index 0000000..5475e4c --- /dev/null +++ b/modal-go/function_call.go @@ -0,0 +1,43 @@ +package modal + +import ( + "context" + "fmt" + + pb "github.com/modal-labs/libmodal/modal-go/proto/modal_proto" +) + +// FunctionCall references a Modal function call. +type FunctionCall struct { + FunctionCallId string + function *Function + ctx context.Context +} + +// Gets the ouptut for a FunctionCall +func (fc *FunctionCall) Get() (any, error) { + return pollForOutput(fc.ctx, fc.FunctionCallId) +} + +// Cancel a FunctionCall +func (fc *FunctionCall) Cancel(terminateContainers bool) error { + _, err := client.FunctionCallCancel(fc.ctx, pb.FunctionCallCancelRequest_builder{ + FunctionCallId: fc.FunctionCallId, + TerminateContainers: terminateContainers, + }.Build()) + if err != nil { + return fmt.Errorf("FunctionCallCancel failed: %v", err) + } + + return nil +} + +// Lookup a FunctionCall +func FunctionCallLookup(ctx context.Context, functionCallId string) (FunctionCall, error) { + ctx = clientContext(ctx) + functionCall := FunctionCall{ + FunctionCallId: functionCallId, + ctx: ctx, + } + return functionCall, nil +} From 7067075e904aa5f42492556268dcee0d7ed8349c Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Tue, 6 May 2025 23:42:35 -0400 Subject: [PATCH 02/26] Add test --- modal-go/function.go | 2 +- modal-go/function_call.go | 4 +-- modal-go/test/function_call_test.go | 38 +++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 modal-go/test/function_call_test.go diff --git a/modal-go/function.go b/modal-go/function.go index 22636cf..6dcf206 100644 --- a/modal-go/function.go +++ b/modal-go/function.go @@ -134,7 +134,7 @@ func (f *Function) Remote(args []any, kwargs map[string]any) (any, error) { } functionCallId := functionMapResponse.GetFunctionCallId() - return pollForOutput(functionCallId) + return pollForOutput(f.ctx, functionCallId) } func pollForOutput(ctx context.Context, functionCallId string) (any, error) { diff --git a/modal-go/function_call.go b/modal-go/function_call.go index 5475e4c..4999ba8 100644 --- a/modal-go/function_call.go +++ b/modal-go/function_call.go @@ -33,11 +33,11 @@ func (fc *FunctionCall) Cancel(terminateContainers bool) error { } // Lookup a FunctionCall -func FunctionCallLookup(ctx context.Context, functionCallId string) (FunctionCall, error) { +func FunctionCallLookup(ctx context.Context, functionCallId string) (*FunctionCall, error) { ctx = clientContext(ctx) functionCall := FunctionCall{ FunctionCallId: functionCallId, ctx: ctx, } - return functionCall, nil + return &functionCall, nil } diff --git a/modal-go/test/function_call_test.go b/modal-go/test/function_call_test.go new file mode 100644 index 0000000..b332fbb --- /dev/null +++ b/modal-go/test/function_call_test.go @@ -0,0 +1,38 @@ +package test + +import ( + "context" + "testing" + + "github.com/modal-labs/libmodal/modal-go" + "github.com/onsi/gomega" +) + +func TestFunctionSpawn(t *testing.T) { + t.Parallel() + g := gomega.NewWithT(t) + + function, err := modal.FunctionLookup( + context.Background(), + "libmodal-test-support", "echo_string", modal.LookupOptions{}, + ) + g.Expect(err).ShouldNot(gomega.HaveOccurred()) + + // Call function using spawn + functionCall, err := function.Spawn(nil, map[string]any{"s": "hello"}) + g.Expect(err).ShouldNot(gomega.HaveOccurred()) + + // Get input later + result, err := functionCall.Get() + g.Expect(err).ShouldNot(gomega.HaveOccurred()) + g.Expect(result).Should(gomega.Equal("output: hello")) + + // Create FunctionCall instance and get output again + functionCall, err = modal.FunctionCallLookup(context.Background(), functionCall.FunctionCallId) + g.Expect(err).ShouldNot(gomega.HaveOccurred()) + + result, err = functionCall.Get() + g.Expect(err).ShouldNot(gomega.HaveOccurred()) + g.Expect(result).Should(gomega.Equal("output: hello")) + +} From 6da14af481dbeec719c46a69ddcd01cacf342883 Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Wed, 7 May 2025 09:42:23 -0400 Subject: [PATCH 03/26] Adds test for cancel --- modal-go/test/function_call_test.go | 19 +++++++++++++++++++ test-support/libmodal_test_support.py | 4 ++++ 2 files changed, 23 insertions(+) diff --git a/modal-go/test/function_call_test.go b/modal-go/test/function_call_test.go index b332fbb..ce67d5d 100644 --- a/modal-go/test/function_call_test.go +++ b/modal-go/test/function_call_test.go @@ -35,4 +35,23 @@ func TestFunctionSpawn(t *testing.T) { g.Expect(err).ShouldNot(gomega.HaveOccurred()) g.Expect(result).Should(gomega.Equal("output: hello")) + // Get function that + functionSleep, err := modal.FunctionLookup( + context.Background(), + "libmodal-test-support", "sleep", modal.LookupOptions{}, + ) + g.Expect(err).ShouldNot(gomega.HaveOccurred()) + + functionCall, err = functionSleep.Spawn(nil, map[string]any{"t": 5}) + g.Expect(err).ShouldNot(gomega.HaveOccurred()) + + // Cancel function call + terminateContainers := false // leave test containers running + err = functionCall.Cancel(terminateContainers) + g.Expect(err).ShouldNot(gomega.HaveOccurred()) + + // Attempting to get cancelled input fails + _, err = functionCall.Get() + g.Expect(err).Should(gomega.HaveOccurred()) + } diff --git a/test-support/libmodal_test_support.py b/test-support/libmodal_test_support.py index c551cd4..6dd64e4 100644 --- a/test-support/libmodal_test_support.py +++ b/test-support/libmodal_test_support.py @@ -1,4 +1,5 @@ import modal +import time app = modal.App("libmodal-test-support") @@ -8,6 +9,9 @@ def echo_string(s: str) -> str: return "output: " + s +@app.function(min_containers=1) +def sleep(t: int) -> None: + time.sleep(t) @app.function(min_containers=1) def bytelength(buf: bytes) -> int: From f2b7224c0dc9420bbf1aa689b363faca6d618249 Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Wed, 7 May 2025 10:28:51 -0400 Subject: [PATCH 04/26] Removes function reference --- modal-go/function.go | 1 - modal-go/function_call.go | 25 +++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/modal-go/function.go b/modal-go/function.go index 6dcf206..d7f1bab 100644 --- a/modal-go/function.go +++ b/modal-go/function.go @@ -169,7 +169,6 @@ func (f *Function) Spawn(args []any, kwargs map[string]any) (*FunctionCall, erro } functionCall := FunctionCall{ FunctionCallId: functionMapResponse.GetFunctionCallId(), - function: f, ctx: f.ctx, } return &functionCall, nil diff --git a/modal-go/function_call.go b/modal-go/function_call.go index 4999ba8..debbc32 100644 --- a/modal-go/function_call.go +++ b/modal-go/function_call.go @@ -7,10 +7,11 @@ import ( pb "github.com/modal-labs/libmodal/modal-go/proto/modal_proto" ) -// FunctionCall references a Modal function call. +// FunctionCall references a Modal Function Call. Function Calls are +// Function invocations with a given input. They can be consumed +// asynchronously (see Get()) or cancelled (see Cancel()). type FunctionCall struct { FunctionCallId string - function *Function ctx context.Context } @@ -19,6 +20,16 @@ func (fc *FunctionCall) Get() (any, error) { return pollForOutput(fc.ctx, fc.FunctionCallId) } +// Lookup a FunctionCall +func FunctionCallLookup(ctx context.Context, functionCallId string) (*FunctionCall, error) { + ctx = clientContext(ctx) + functionCall := FunctionCall{ + FunctionCallId: functionCallId, + ctx: ctx, + } + return &functionCall, nil +} + // Cancel a FunctionCall func (fc *FunctionCall) Cancel(terminateContainers bool) error { _, err := client.FunctionCallCancel(fc.ctx, pb.FunctionCallCancelRequest_builder{ @@ -31,13 +42,3 @@ func (fc *FunctionCall) Cancel(terminateContainers bool) error { return nil } - -// Lookup a FunctionCall -func FunctionCallLookup(ctx context.Context, functionCallId string) (*FunctionCall, error) { - ctx = clientContext(ctx) - functionCall := FunctionCall{ - FunctionCallId: functionCallId, - ctx: ctx, - } - return &functionCall, nil -} From e40686151ee7c7a5b417ec2061006c5a7416c26e Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Wed, 7 May 2025 10:32:48 -0400 Subject: [PATCH 05/26] Rename function --- modal-go/function.go | 5 +++-- modal-go/function_call.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/modal-go/function.go b/modal-go/function.go index d7f1bab..c37ee50 100644 --- a/modal-go/function.go +++ b/modal-go/function.go @@ -134,10 +134,11 @@ func (f *Function) Remote(args []any, kwargs map[string]any) (any, error) { } functionCallId := functionMapResponse.GetFunctionCallId() - return pollForOutput(f.ctx, functionCallId) + return pollFunctionOutput(f.ctx, functionCallId) } -func pollForOutput(ctx context.Context, functionCallId string) (any, error) { +// Poll for ouputs for a given FunctionCall ID +func pollFunctionOutput(ctx context.Context, functionCallId string) (any, error) { for { response, err := client.FunctionGetOutputs(ctx, pb.FunctionGetOutputsRequest_builder{ FunctionCallId: functionCallId, diff --git a/modal-go/function_call.go b/modal-go/function_call.go index debbc32..b51ef1b 100644 --- a/modal-go/function_call.go +++ b/modal-go/function_call.go @@ -17,7 +17,7 @@ type FunctionCall struct { // Gets the ouptut for a FunctionCall func (fc *FunctionCall) Get() (any, error) { - return pollForOutput(fc.ctx, fc.FunctionCallId) + return pollFunctionOutput(fc.ctx, fc.FunctionCallId) } // Lookup a FunctionCall From 843f3042f2c9607a9cd74ea0f960d0008b6e0e8e Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Wed, 7 May 2025 10:39:16 -0400 Subject: [PATCH 06/26] Adds Functioncall example --- modal-go/examples/function-spawn/main.go | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 modal-go/examples/function-spawn/main.go diff --git a/modal-go/examples/function-spawn/main.go b/modal-go/examples/function-spawn/main.go new file mode 100644 index 0000000..fa28683 --- /dev/null +++ b/modal-go/examples/function-spawn/main.go @@ -0,0 +1,31 @@ +// This example calls a function defined in `libmodal_test_support.py`. + +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 { + log.Fatalf("Failed to lookup function: %v", err) + } + + fc, err := echo.Spawn(nil, map[string]any{"s": "Hello world!"}) + if err != nil { + log.Fatalf("Failed to spawn function: %v", err) + } + + ret, err := fc.Get() + if err != nil { + log.Fatalf("Failed to get function results: %v", err) + } + fmt.Printf("%s\n", ret) +} From 6c5a371676dbc01c026034bcd6a937236b0ec96a Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Wed, 7 May 2025 10:39:57 -0400 Subject: [PATCH 07/26] Link new example --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 178cb91..7eb9799 100644 --- a/README.md +++ b/README.md @@ -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) From 977db4969e64146feb2b969dbc978d4555ca477d Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Wed, 7 May 2025 10:46:28 -0400 Subject: [PATCH 08/26] Changes helper function name --- modal-go/function.go | 18 +++++++++--------- modal-go/function_call.go | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/modal-go/function.go b/modal-go/function.go index c37ee50..1f03b41 100644 --- a/modal-go/function.go +++ b/modal-go/function.go @@ -81,8 +81,8 @@ func pickleDeserialize(buffer []byte) (any, error) { return result, nil } -// Serializes inputs and creates a function call. -func (f *Function) functionCallHelper(args []any, kwargs map[string]any, invocationType pb.FunctionCallInvocationType) (*pb.FunctionMapResponse, 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 @@ -122,26 +122,26 @@ func (f *Function) functionCallHelper(args []any, kwargs map[string]any, invocat return nil, fmt.Errorf("FunctionMap error: %v", err) } - return functionMapResponse, nil + functionCallId := functionMapResponse.GetFunctionCallId() + return &functionCallId, nil } // Execute a single input into a remote Function. func (f *Function) Remote(args []any, kwargs map[string]any) (any, error) { invocationType := pb.FunctionCallInvocationType_FUNCTION_CALL_INVOCATION_TYPE_SYNC - functionMapResponse, err := f.functionCallHelper(args, kwargs, invocationType) + functionCallId, err := f.execFunctionCall(args, kwargs, invocationType) if err != nil { return nil, fmt.Errorf("FunctionMap error: %v", err) } - functionCallId := functionMapResponse.GetFunctionCallId() return pollFunctionOutput(f.ctx, functionCallId) } // Poll for ouputs for a given FunctionCall ID -func pollFunctionOutput(ctx context.Context, functionCallId string) (any, error) { +func pollFunctionOutput(ctx context.Context, functionCallId *string) (any, error) { for { response, err := client.FunctionGetOutputs(ctx, pb.FunctionGetOutputsRequest_builder{ - FunctionCallId: functionCallId, + FunctionCallId: *functionCallId, MaxValues: 1, Timeout: 55, LastEntryId: "0-0", @@ -164,12 +164,12 @@ func pollFunctionOutput(ctx context.Context, functionCallId string) (any, error) // Spawn a single input into a remote function. func (f *Function) Spawn(args []any, kwargs map[string]any) (*FunctionCall, error) { invocationType := pb.FunctionCallInvocationType_FUNCTION_CALL_INVOCATION_TYPE_ASYNC - functionMapResponse, err := f.functionCallHelper(args, kwargs, invocationType) + functionCallId, err := f.execFunctionCall(args, kwargs, invocationType) if err != nil { return nil, fmt.Errorf("FunctionMap error: %v", err) } functionCall := FunctionCall{ - FunctionCallId: functionMapResponse.GetFunctionCallId(), + FunctionCallId: *functionCallId, ctx: f.ctx, } return &functionCall, nil diff --git a/modal-go/function_call.go b/modal-go/function_call.go index b51ef1b..4f0cf4f 100644 --- a/modal-go/function_call.go +++ b/modal-go/function_call.go @@ -17,7 +17,7 @@ type FunctionCall struct { // Gets the ouptut for a FunctionCall func (fc *FunctionCall) Get() (any, error) { - return pollFunctionOutput(fc.ctx, fc.FunctionCallId) + return pollFunctionOutput(fc.ctx, &fc.FunctionCallId) } // Lookup a FunctionCall From da55d398bcbfa36c04da2ddc73013226f2461920 Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Wed, 7 May 2025 10:48:47 -0400 Subject: [PATCH 09/26] Improve note on spawn example --- modal-go/examples/function-spawn/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modal-go/examples/function-spawn/main.go b/modal-go/examples/function-spawn/main.go index fa28683..e4a0d53 100644 --- a/modal-go/examples/function-spawn/main.go +++ b/modal-go/examples/function-spawn/main.go @@ -1,4 +1,5 @@ -// This example calls a function defined in `libmodal_test_support.py`. +// This example spawns a function defined in `libmodal_test_support.py`, and +// later gets its outputs. package main From 5c6f903babf0c825348b9abe0f94386898bb5a42 Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Wed, 7 May 2025 22:24:51 -0400 Subject: [PATCH 10/26] Review updates --- modal-go/cls.go | 4 +-- modal-go/examples/cls-call/main.go | 14 ++++---- modal-go/examples/function-call/main.go | 6 ++-- modal-go/examples/function-spawn/main.go | 6 ++-- modal-go/examples/sandbox-exec/main.go | 14 ++++---- modal-go/examples/sandbox/main.go | 12 +++---- modal-go/function.go | 14 ++++---- modal-go/function_call.go | 41 +++++++++++++++++------- modal-go/sandbox.go | 2 +- modal-go/test/function_call_test.go | 32 +++++++++++------- 10 files changed, 86 insertions(+), 59 deletions(-) diff --git a/modal-go/cls.go b/modal-go/cls.go index 7106af2..31005b4 100644 --- a/modal-go/cls.go +++ b/modal-go/cls.go @@ -51,7 +51,7 @@ func ClsLookup(ctx context.Context, appName string, name string, options LookupO parameterInfo := serviceFunction.GetHandleMetadata().GetClassParameterInfo() schema := parameterInfo.GetSchema() if len(schema) > 0 && parameterInfo.GetFormat() != pb.ClassParameterInfo_PARAM_SERIALIZATION_FORMAT_PROTO { - return nil, fmt.Errorf("unsupported parameter format: %v", parameterInfo.GetFormat()) + return nil, fmt.Errorf("unsupported parameter format: %w", parameterInfo.GetFormat()) } else { cls.schema = schema } @@ -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 diff --git a/modal-go/examples/cls-call/main.go b/modal-go/examples/cls-call/main.go index e147596..6882233 100644 --- a/modal-go/examples/cls-call/main.go +++ b/modal-go/examples/cls-call/main.go @@ -18,30 +18,30 @@ func main() { "libmodal-test-support", "EchoCls", modal.LookupOptions{}, ) if err != nil { - log.Fatalf("Failed to lookup Cls: %v", err) + log.Fatalf("Failed to lookup Cls: %w", err) } instance, err := cls.Instance(nil) if err != nil { - log.Fatalf("Failed to create Cls instance: %v", err) + log.Fatalf("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) + log.Fatalf("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) + log.Fatalf("Failed to call Cls method: %w", err) } - log.Printf("%v\n", result) + log.Printf("%w\n", 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) + log.Fatalf("Failed to call Cls method: %w", err) } - log.Printf("%v\n", result) + log.Printf("%w\n", result) } diff --git a/modal-go/examples/function-call/main.go b/modal-go/examples/function-call/main.go index acf314e..2127c72 100644 --- a/modal-go/examples/function-call/main.go +++ b/modal-go/examples/function-call/main.go @@ -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) + log.Fatalf("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) + log.Fatalf("Failed to call function: %w", err) } fmt.Printf("%s\n", 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) + log.Fatalf("Failed to call function with kwargs: %w", err) } log.Printf("%s\n", ret) } diff --git a/modal-go/examples/function-spawn/main.go b/modal-go/examples/function-spawn/main.go index e4a0d53..044e09c 100644 --- a/modal-go/examples/function-spawn/main.go +++ b/modal-go/examples/function-spawn/main.go @@ -16,17 +16,17 @@ 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) + log.Fatalf("Failed to lookup function: %w", err) } fc, err := echo.Spawn(nil, map[string]any{"s": "Hello world!"}) if err != nil { - log.Fatalf("Failed to spawn function: %v", err) + log.Fatalf("Failed to spawn function: %w", err) } ret, err := fc.Get() if err != nil { - log.Fatalf("Failed to get function results: %v", err) + log.Fatalf("Failed to get function results: %w", err) } fmt.Printf("%s\n", ret) } diff --git a/modal-go/examples/sandbox-exec/main.go b/modal-go/examples/sandbox-exec/main.go index e46e42b..f00dd0e 100644 --- a/modal-go/examples/sandbox-exec/main.go +++ b/modal-go/examples/sandbox-exec/main.go @@ -13,17 +13,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) + log.Fatalf("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) + log.Fatalf("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) + log.Fatalf("Failed to create sandbox: %w", err) } log.Println("Started sandbox:", sb.SandboxId) defer sb.Terminate() @@ -47,22 +47,22 @@ for i in range(50000): }, ) if err != nil { - log.Fatalf("Failed to execute command in sandbox: %v", err) + log.Fatalf("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) + log.Fatalf("Failed to read stdout: %w", err) } contentStderr, err := io.ReadAll(p.Stderr) if err != nil { - log.Fatalf("Failed to read stderr: %v", err) + log.Fatalf("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) + log.Fatalf("Failed to wait for process completion: %w", err) } log.Println("Return code:", returnCode) diff --git a/modal-go/examples/sandbox/main.go b/modal-go/examples/sandbox/main.go index 7877038..fc6a1f7 100644 --- a/modal-go/examples/sandbox/main.go +++ b/modal-go/examples/sandbox/main.go @@ -13,34 +13,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) + log.Fatalf("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) + log.Fatalf("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) + log.Fatalf("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) + log.Fatalf("Failed to write to sandbox stdin: %w", err) } err = sb.Stdin.Close() if err != nil { - log.Fatalf("Failed to close sandbox stdin: %v", err) + log.Fatalf("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) + log.Fatalf("Failed to read from sandbox stdout: %w", err) } log.Printf("output: %s\n", string(output)) diff --git a/modal-go/function.go b/modal-go/function.go index 1f03b41..e3ce12f 100644 --- a/modal-go/function.go +++ b/modal-go/function.go @@ -119,7 +119,7 @@ func (f *Function) execFunctionCall(args []any, kwargs map[string]any, invocatio 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() @@ -131,17 +131,17 @@ 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, fmt.Errorf("FunctionMap error: %v", err) + return nil, err } - return pollFunctionOutput(f.ctx, functionCallId) + return pollFunctionOutput(f.ctx, *functionCallId) } // Poll for ouputs for a given FunctionCall ID -func pollFunctionOutput(ctx context.Context, functionCallId *string) (any, error) { +func pollFunctionOutput(ctx context.Context, functionCallId string) (any, error) { for { response, err := client.FunctionGetOutputs(ctx, pb.FunctionGetOutputsRequest_builder{ - FunctionCallId: *functionCallId, + FunctionCallId: functionCallId, MaxValues: 1, Timeout: 55, LastEntryId: "0-0", @@ -149,7 +149,7 @@ func pollFunctionOutput(ctx context.Context, functionCallId *string) (any, error RequestedAt: timeNow(), }.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 @@ -166,7 +166,7 @@ func (f *Function) Spawn(args []any, kwargs map[string]any) (*FunctionCall, erro invocationType := pb.FunctionCallInvocationType_FUNCTION_CALL_INVOCATION_TYPE_ASYNC functionCallId, err := f.execFunctionCall(args, kwargs, invocationType) if err != nil { - return nil, fmt.Errorf("FunctionMap error: %v", err) + return nil, err } functionCall := FunctionCall{ FunctionCallId: *functionCallId, diff --git a/modal-go/function_call.go b/modal-go/function_call.go index 4f0cf4f..d6e2fc2 100644 --- a/modal-go/function_call.go +++ b/modal-go/function_call.go @@ -3,6 +3,7 @@ package modal import ( "context" "fmt" + "time" pb "github.com/modal-labs/libmodal/modal-go/proto/modal_proto" ) @@ -15,13 +16,8 @@ type FunctionCall struct { ctx context.Context } -// Gets the ouptut for a FunctionCall -func (fc *FunctionCall) Get() (any, error) { - return pollFunctionOutput(fc.ctx, &fc.FunctionCallId) -} - -// Lookup a FunctionCall -func FunctionCallLookup(ctx context.Context, functionCallId string) (*FunctionCall, error) { +// FunctionCallFromId looks up a FunctionCall. +func FunctionCallFromId(ctx context.Context, functionCallId string) (*FunctionCall, error) { ctx = clientContext(ctx) functionCall := FunctionCall{ FunctionCallId: functionCallId, @@ -30,14 +26,37 @@ func FunctionCallLookup(ctx context.Context, functionCallId string) (*FunctionCa return &functionCall, nil } -// Cancel a FunctionCall -func (fc *FunctionCall) Cancel(terminateContainers bool) error { +// GetOptions are options for getting outputs from Function Calls. +type GetOptions struct { + Timeout time.Duration +} + +// Get waits for the output of a FunctionCall. +// If timeout > 0, the operation will be cancelled after the specified duration. +func (fc *FunctionCall) Get(options GetOptions) (any, error) { + ctx := fc.ctx + if options.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(fc.ctx, options.Timeout) + defer cancel() + } + + return pollFunctionOutput(ctx, fc.FunctionCallId) +} + +// CancelOptions are options for cancelling Function Calls. +type CancelOptions struct { + TerminateContainers bool +} + +// Cancel cancels a FunctionCall. +func (fc *FunctionCall) Cancel(options CancelOptions) error { _, err := client.FunctionCallCancel(fc.ctx, pb.FunctionCallCancelRequest_builder{ FunctionCallId: fc.FunctionCallId, - TerminateContainers: terminateContainers, + TerminateContainers: options.TerminateContainers, }.Build()) if err != nil { - return fmt.Errorf("FunctionCallCancel failed: %v", err) + return fmt.Errorf("FunctionCallCancel failed: %w", err) } return nil diff --git a/modal-go/sandbox.go b/modal-go/sandbox.go index a9d0310..ece0218 100644 --- a/modal-go/sandbox.go +++ b/modal-go/sandbox.go @@ -76,7 +76,7 @@ func (sb *Sandbox) ensureTaskId() error { return fmt.Errorf("Sandbox %s does not have a task ID, it may not be running", sb.SandboxId) } if resp.GetTaskResult() != nil { - return fmt.Errorf("Sandbox %s has already completed with result: %v", sb.SandboxId, resp.GetTaskResult()) + return fmt.Errorf("Sandbox %s has already completed with result: %w", sb.SandboxId, resp.GetTaskResult()) } sb.taskId = resp.GetTaskId() } diff --git a/modal-go/test/function_call_test.go b/modal-go/test/function_call_test.go index ce67d5d..531dd92 100644 --- a/modal-go/test/function_call_test.go +++ b/modal-go/test/function_call_test.go @@ -3,6 +3,7 @@ package test import ( "context" "testing" + "time" "github.com/modal-labs/libmodal/modal-go" "github.com/onsi/gomega" @@ -18,24 +19,24 @@ func TestFunctionSpawn(t *testing.T) { ) g.Expect(err).ShouldNot(gomega.HaveOccurred()) - // Call function using spawn + // Call function using spawn. functionCall, err := function.Spawn(nil, map[string]any{"s": "hello"}) g.Expect(err).ShouldNot(gomega.HaveOccurred()) - // Get input later - result, err := functionCall.Get() + // Get input later. + result, err := functionCall.Get(modal.GetOptions{}) g.Expect(err).ShouldNot(gomega.HaveOccurred()) g.Expect(result).Should(gomega.Equal("output: hello")) - // Create FunctionCall instance and get output again - functionCall, err = modal.FunctionCallLookup(context.Background(), functionCall.FunctionCallId) + // Create FunctionCall instance and get output again. + functionCall, err = modal.FunctionCallFromId(context.Background(), functionCall.FunctionCallId) g.Expect(err).ShouldNot(gomega.HaveOccurred()) - result, err = functionCall.Get() + result, err = functionCall.Get(modal.GetOptions{}) g.Expect(err).ShouldNot(gomega.HaveOccurred()) g.Expect(result).Should(gomega.Equal("output: hello")) - // Get function that + // Looking function that takes a long time to complete. functionSleep, err := modal.FunctionLookup( context.Background(), "libmodal-test-support", "sleep", modal.LookupOptions{}, @@ -45,13 +46,20 @@ func TestFunctionSpawn(t *testing.T) { functionCall, err = functionSleep.Spawn(nil, map[string]any{"t": 5}) g.Expect(err).ShouldNot(gomega.HaveOccurred()) - // Cancel function call - terminateContainers := false // leave test containers running - err = functionCall.Cancel(terminateContainers) + // Cancel function call. + err = functionCall.Cancel(modal.CancelOptions{}) g.Expect(err).ShouldNot(gomega.HaveOccurred()) - // Attempting to get cancelled input fails - _, err = functionCall.Get() + // Attempting to get the outputs for a cancelled function call + // is expected to return an error. + _, err = functionCall.Get(modal.GetOptions{}) g.Expect(err).Should(gomega.HaveOccurred()) + // Spawn function with long running input. + functionCall, err = functionSleep.Spawn(nil, map[string]any{"t": 5}) + g.Expect(err).ShouldNot(gomega.HaveOccurred()) + + // Get is now expected to timeout. + _, err = functionCall.Get(modal.GetOptions{Timeout: 10 * time.Millisecond}) + g.Expect(err).Should(gomega.HaveOccurred()) } From 8791c312b78770549db277f8745d92ac3081a2c2 Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Wed, 7 May 2025 22:30:39 -0400 Subject: [PATCH 11/26] Improve comments --- modal-go/test/function_call_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modal-go/test/function_call_test.go b/modal-go/test/function_call_test.go index 531dd92..6397d1d 100644 --- a/modal-go/test/function_call_test.go +++ b/modal-go/test/function_call_test.go @@ -23,7 +23,7 @@ func TestFunctionSpawn(t *testing.T) { functionCall, err := function.Spawn(nil, map[string]any{"s": "hello"}) g.Expect(err).ShouldNot(gomega.HaveOccurred()) - // Get input later. + // Get outputs. result, err := functionCall.Get(modal.GetOptions{}) g.Expect(err).ShouldNot(gomega.HaveOccurred()) g.Expect(result).Should(gomega.Equal("output: hello")) @@ -50,7 +50,7 @@ func TestFunctionSpawn(t *testing.T) { err = functionCall.Cancel(modal.CancelOptions{}) g.Expect(err).ShouldNot(gomega.HaveOccurred()) - // Attempting to get the outputs for a cancelled function call + // Attempting to get outputs for a cancelled function call // is expected to return an error. _, err = functionCall.Get(modal.GetOptions{}) g.Expect(err).Should(gomega.HaveOccurred()) From b3e164f4bf25c56c8f41c4094c95c215cb64419d Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Wed, 7 May 2025 22:33:12 -0400 Subject: [PATCH 12/26] Makes options clearer --- modal-go/examples/function-spawn/main.go | 2 +- modal-go/function_call.go | 12 ++++++------ modal-go/test/function_call_test.go | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/modal-go/examples/function-spawn/main.go b/modal-go/examples/function-spawn/main.go index 044e09c..ccab42b 100644 --- a/modal-go/examples/function-spawn/main.go +++ b/modal-go/examples/function-spawn/main.go @@ -24,7 +24,7 @@ func main() { log.Fatalf("Failed to spawn function: %w", err) } - ret, err := fc.Get() + ret, err := fc.Get(modal.FunctionCallGetOptions{}) if err != nil { log.Fatalf("Failed to get function results: %w", err) } diff --git a/modal-go/function_call.go b/modal-go/function_call.go index d6e2fc2..b1ffc02 100644 --- a/modal-go/function_call.go +++ b/modal-go/function_call.go @@ -26,14 +26,14 @@ func FunctionCallFromId(ctx context.Context, functionCallId string) (*FunctionCa return &functionCall, nil } -// GetOptions are options for getting outputs from Function Calls. -type GetOptions struct { +// FunctionCallGetOptions are options for getting outputs from Function Calls. +type FunctionCallGetOptions struct { Timeout time.Duration } // Get waits for the output of a FunctionCall. // If timeout > 0, the operation will be cancelled after the specified duration. -func (fc *FunctionCall) Get(options GetOptions) (any, error) { +func (fc *FunctionCall) Get(options FunctionCallGetOptions) (any, error) { ctx := fc.ctx if options.Timeout > 0 { var cancel context.CancelFunc @@ -44,13 +44,13 @@ func (fc *FunctionCall) Get(options GetOptions) (any, error) { return pollFunctionOutput(ctx, fc.FunctionCallId) } -// CancelOptions are options for cancelling Function Calls. -type CancelOptions struct { +// FunctionCallCancelOptions are options for cancelling Function Calls. +type FunctionCallCancelOptions struct { TerminateContainers bool } // Cancel cancels a FunctionCall. -func (fc *FunctionCall) Cancel(options CancelOptions) error { +func (fc *FunctionCall) Cancel(options FunctionCallCancelOptions) error { _, err := client.FunctionCallCancel(fc.ctx, pb.FunctionCallCancelRequest_builder{ FunctionCallId: fc.FunctionCallId, TerminateContainers: options.TerminateContainers, diff --git a/modal-go/test/function_call_test.go b/modal-go/test/function_call_test.go index 6397d1d..fc6a590 100644 --- a/modal-go/test/function_call_test.go +++ b/modal-go/test/function_call_test.go @@ -24,7 +24,7 @@ func TestFunctionSpawn(t *testing.T) { g.Expect(err).ShouldNot(gomega.HaveOccurred()) // Get outputs. - result, err := functionCall.Get(modal.GetOptions{}) + result, err := functionCall.Get(modal.FunctionCallGetOptions{}) g.Expect(err).ShouldNot(gomega.HaveOccurred()) g.Expect(result).Should(gomega.Equal("output: hello")) @@ -32,7 +32,7 @@ func TestFunctionSpawn(t *testing.T) { functionCall, err = modal.FunctionCallFromId(context.Background(), functionCall.FunctionCallId) g.Expect(err).ShouldNot(gomega.HaveOccurred()) - result, err = functionCall.Get(modal.GetOptions{}) + result, err = functionCall.Get(modal.FunctionCallGetOptions{}) g.Expect(err).ShouldNot(gomega.HaveOccurred()) g.Expect(result).Should(gomega.Equal("output: hello")) @@ -47,12 +47,12 @@ func TestFunctionSpawn(t *testing.T) { g.Expect(err).ShouldNot(gomega.HaveOccurred()) // Cancel function call. - err = functionCall.Cancel(modal.CancelOptions{}) + err = functionCall.Cancel(modal.FunctionCallCancelOptions{}) g.Expect(err).ShouldNot(gomega.HaveOccurred()) // Attempting to get outputs for a cancelled function call // is expected to return an error. - _, err = functionCall.Get(modal.GetOptions{}) + _, err = functionCall.Get(modal.FunctionCallGetOptions{}) g.Expect(err).Should(gomega.HaveOccurred()) // Spawn function with long running input. @@ -60,6 +60,6 @@ func TestFunctionSpawn(t *testing.T) { g.Expect(err).ShouldNot(gomega.HaveOccurred()) // Get is now expected to timeout. - _, err = functionCall.Get(modal.GetOptions{Timeout: 10 * time.Millisecond}) + _, err = functionCall.Get(modal.FunctionCallGetOptions{Timeout: 10 * time.Millisecond}) g.Expect(err).Should(gomega.HaveOccurred()) } From 276805e2beedf9a0def459d9c47fc814c3a80d0a Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Fri, 16 May 2025 14:24:05 -0400 Subject: [PATCH 13/26] Implement spawn in TypeScript --- modal-js/examples/function-spawn.ts | 10 +++++ modal-js/src/function.ts | 60 +++++++++++++++++++++-------- modal-js/src/function_call.ts | 57 +++++++++++++++++++++++++++ modal-js/test/function_call.test.ts | 35 +++++++++++++++++ 4 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 modal-js/examples/function-spawn.ts create mode 100644 modal-js/src/function_call.ts create mode 100644 modal-js/test/function_call.test.ts diff --git a/modal-js/examples/function-spawn.ts b/modal-js/examples/function-spawn.ts new file mode 100644 index 0000000..64eedea --- /dev/null +++ b/modal-js/examples/function-spawn.ts @@ -0,0 +1,10 @@ +// This example calls a function defined in `libmodal_test_support.py`. + +import { Function_ } from "modal"; + +const echo = await Function_.lookup("libmodal-test-support", "echo_string"); + +// Spawn the function with kwargs. +const functionCall = await echo.spawn([], { s: "Hello world!" }); +const ret = await functionCall.get(); +console.log(ret); diff --git a/modal-js/src/function.ts b/modal-js/src/function.ts index 687bac8..5c4d6a6 100644 --- a/modal-js/src/function.ts +++ b/modal-js/src/function.ts @@ -13,6 +13,7 @@ import { } from "../proto/modal_proto/api"; import { LookupOptions } from "./app"; import { client } from "./client"; +import { FunctionCall } from "./function_call"; import { environmentName } from "./config"; import { InternalFailure, @@ -65,6 +66,32 @@ export class Function_ { args: any[] = [], kwargs: Record = {}, ): Promise { + const functionCallId = await this.execFunctionCall( + args, + kwargs, + FunctionCallInvocationType.FUNCTION_CALL_INVOCATION_TYPE_SYNC, + ); + return await pollFunctionOutput(functionCallId); + } + + // Spawn a single input into a remote function. + async spawn( + args: any[] = [], + kwargs: Record = {}, + ): Promise { + const functionCallId = await this.execFunctionCall( + args, + kwargs, + FunctionCallInvocationType.FUNCTION_CALL_INVOCATION_TYPE_SYNC, + ); + return new FunctionCall(functionCallId); + } + + async execFunctionCall( + args: any[] = [], + kwargs: Record = {}, + invocationType: FunctionCallInvocationType = FunctionCallInvocationType.FUNCTION_CALL_INVOCATION_TYPE_SYNC, + ): Promise { const payload = dumps([args, kwargs]); let argsBlobId: string | undefined = undefined; @@ -76,8 +103,7 @@ export class Function_ { const functionMapResponse = await client.functionMap({ functionId: this.functionId, functionCallType: FunctionCallType.FUNCTION_CALL_TYPE_UNARY, - functionCallInvocationType: - FunctionCallInvocationType.FUNCTION_CALL_INVOCATION_TYPE_SYNC, + functionCallInvocationType: invocationType, pipelinedInputs: [ { idx: 0, @@ -91,20 +117,24 @@ export class Function_ { ], }); - while (true) { - const response = await client.functionGetOutputs({ - functionCallId: functionMapResponse.functionCallId, - maxValues: 1, - timeout: 55, - lastEntryId: "0-0", - clearOnSuccess: true, - requestedAt: timeNow(), - }); + return functionMapResponse.functionCallId; + } +} + +export async function pollFunctionOutput(functionCallId: string): Promise { + while (true) { + const response = await client.functionGetOutputs({ + functionCallId: functionCallId, + maxValues: 1, + timeout: 55, + lastEntryId: "0-0", + clearOnSuccess: true, + requestedAt: timeNow(), + }); - const outputs = response.outputs; - if (outputs.length > 0) { - return await processResult(outputs[0].result, outputs[0].dataFormat); - } + const outputs = response.outputs; + if (outputs.length > 0) { + return await processResult(outputs[0].result, outputs[0].dataFormat); } } } diff --git a/modal-js/src/function_call.ts b/modal-js/src/function_call.ts new file mode 100644 index 0000000..cc67686 --- /dev/null +++ b/modal-js/src/function_call.ts @@ -0,0 +1,57 @@ +// Manage existing Function Calls (look-ups, polling for output, cancellation). + +import { client } from "./client"; +import { pollFunctionOutput } from "./function"; +import { TimeoutError } from "./errors"; + +export type FunctionCallGetOptions = { + timeout?: number; // in seconds +}; + +export type FunctionCallCancelOptions = { + terminateContainers?: boolean; +}; + +export class FunctionCall { + readonly functionCallId: string; + + constructor(functionCallId: string) { + this.functionCallId = functionCallId; + } + + async get(options: FunctionCallGetOptions = {}): Promise { + const timeout = options.timeout; + + if (!timeout) return await pollFunctionOutput(this.functionCallId); + + return new Promise(async (resolve, reject) => { + const timer = setTimeout( + () => reject(new TimeoutError(`timeout after ${timeout}s`)), + timeout * 1_000, + ); + + await pollFunctionOutput(this.functionCallId) + .then((result) => { + clearTimeout(timer); + resolve(result); + }) + .catch((err) => { + clearTimeout(timer); + reject(err); + }); + }); + } + + async cancel(options: FunctionCallCancelOptions = {}) { + await client.functionCallCancel({ + functionCallId: this.functionCallId, + terminateContainers: options.terminateContainers, + }); + } +} + +async function functionCallFromId( + functionCallId: string, +): Promise { + return new FunctionCall(functionCallId); +} diff --git a/modal-js/test/function_call.test.ts b/modal-js/test/function_call.test.ts new file mode 100644 index 0000000..c5785c1 --- /dev/null +++ b/modal-js/test/function_call.test.ts @@ -0,0 +1,35 @@ +import { Function_, TimeoutError } from "modal"; +import { expect, test } from "vitest"; + +test("FunctionSpawn", async () => { + const function_ = await Function_.lookup( + "libmodal-test-support", + "echo_string", + ); + + // Spawn function with kwargs. + var functionCall = await function_.spawn([], { s: "hello" }); + expect(functionCall.functionCallId).toBeDefined(); + + // Get results after spawn. + var resultKwargs = await functionCall.get(); + expect(resultKwargs).toBe("output: hello"); + + // Try the same again; results should still be available. + resultKwargs = await functionCall.get(); + expect(resultKwargs).toBe("output: hello"); + + // Looking function that takes a long time to complete. + const functionSleep_ = await Function_.lookup( + "libmodal-test-support", + "sleep", + ); + + // Spawn function with long running input. + functionCall = await functionSleep_.spawn([], { t: 5 }); + expect(functionCall.functionCallId).toBeDefined(); + + // Get is now expected to timeout. + const promise = functionCall.get({ timeout: 1 / 100 }); + await expect(promise).rejects.toThrowError(TimeoutError); +}); From a7e753f0e8ff09a29cbd3958a6371be6f7e99518 Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Fri, 16 May 2025 14:26:32 -0400 Subject: [PATCH 14/26] Adds comments --- modal-js/test/function_call.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modal-js/test/function_call.test.ts b/modal-js/test/function_call.test.ts index c5785c1..5bac170 100644 --- a/modal-js/test/function_call.test.ts +++ b/modal-js/test/function_call.test.ts @@ -15,21 +15,21 @@ test("FunctionSpawn", async () => { var resultKwargs = await functionCall.get(); expect(resultKwargs).toBe("output: hello"); - // Try the same again; results should still be available. + // Try the same again; same results should still be available. resultKwargs = await functionCall.get(); expect(resultKwargs).toBe("output: hello"); - // Looking function that takes a long time to complete. + // Lookup function that takes a long time to complete. const functionSleep_ = await Function_.lookup( "libmodal-test-support", "sleep", ); - // Spawn function with long running input. + // Spawn with long running input. functionCall = await functionSleep_.spawn([], { t: 5 }); expect(functionCall.functionCallId).toBeDefined(); - // Get is now expected to timeout. + // Getting outputs with timeout raises error. const promise = functionCall.get({ timeout: 1 / 100 }); await expect(promise).rejects.toThrowError(TimeoutError); }); From c31cff9d3622cd985737a81cc5485749648ffe1a Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Fri, 16 May 2025 14:29:13 -0400 Subject: [PATCH 15/26] Improve comments --- modal-js/src/function_call.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modal-js/src/function_call.ts b/modal-js/src/function_call.ts index cc67686..37da37c 100644 --- a/modal-js/src/function_call.ts +++ b/modal-js/src/function_call.ts @@ -12,6 +12,10 @@ export type FunctionCallCancelOptions = { terminateContainers?: boolean; }; +/** Represents a Modal FunctionCall, Function Calls are +Function invocations with a given input. They can be consumed +asynchronously (see get()) or cancelled (see cancel()). +*/ export class FunctionCall { readonly functionCallId: string; @@ -19,6 +23,7 @@ export class FunctionCall { this.functionCallId = functionCallId; } + // Get output for a FunctionCall ID. async get(options: FunctionCallGetOptions = {}): Promise { const timeout = options.timeout; @@ -42,6 +47,7 @@ export class FunctionCall { }); } + // Cancel ongoing FunctionCall. async cancel(options: FunctionCallCancelOptions = {}) { await client.functionCallCancel({ functionCallId: this.functionCallId, From 2f38805eef34c46f08e12840e70d505a63cace0e Mon Sep 17 00:00:00 2001 From: Luis Capelo <953118+luiscape@users.noreply.github.com> Date: Fri, 16 May 2025 14:31:56 -0400 Subject: [PATCH 16/26] Update modal-go/function.go Co-authored-by: Eric Zhang --- modal-go/function.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modal-go/function.go b/modal-go/function.go index e3ce12f..5bd0593 100644 --- a/modal-go/function.go +++ b/modal-go/function.go @@ -126,7 +126,7 @@ func (f *Function) execFunctionCall(args []any, kwargs map[string]any, invocatio return &functionCallId, nil } -// Execute a single input into a remote Function. +// 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) From de26b1544c219baf321e159915d44f8f9b6291b1 Mon Sep 17 00:00:00 2001 From: Luis Capelo <953118+luiscape@users.noreply.github.com> Date: Fri, 16 May 2025 14:32:03 -0400 Subject: [PATCH 17/26] Update modal-go/function.go Co-authored-by: Eric Zhang --- modal-go/function.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modal-go/function.go b/modal-go/function.go index 5bd0593..96080f6 100644 --- a/modal-go/function.go +++ b/modal-go/function.go @@ -161,7 +161,7 @@ func pollFunctionOutput(ctx context.Context, functionCallId string) (any, error) } } -// Spawn a single input into a remote function. +// 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) From 72b506859b3c5759391895d4a9a38814772fe46f Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Fri, 16 May 2025 14:43:54 -0400 Subject: [PATCH 18/26] Updates error handling --- modal-go/cls.go | 2 +- modal-go/examples/cls-call/main.go | 15 ++++++++------- modal-go/examples/function-call/main.go | 10 +++++----- modal-go/examples/function-spawn/main.go | 8 ++++---- modal-go/examples/sandbox-exec/main.go | 15 ++++++++------- modal-go/examples/sandbox/main.go | 13 +++++++------ 6 files changed, 33 insertions(+), 30 deletions(-) diff --git a/modal-go/cls.go b/modal-go/cls.go index 31005b4..a3108f5 100644 --- a/modal-go/cls.go +++ b/modal-go/cls.go @@ -51,7 +51,7 @@ func ClsLookup(ctx context.Context, appName string, name string, options LookupO parameterInfo := serviceFunction.GetHandleMetadata().GetClassParameterInfo() schema := parameterInfo.GetSchema() if len(schema) > 0 && parameterInfo.GetFormat() != pb.ClassParameterInfo_PARAM_SERIALIZATION_FORMAT_PROTO { - return nil, fmt.Errorf("unsupported parameter format: %w", parameterInfo.GetFormat()) + return nil, fmt.Errorf("unsupported parameter format: %v", parameterInfo.GetFormat()) } else { cls.schema = schema } diff --git a/modal-go/examples/cls-call/main.go b/modal-go/examples/cls-call/main.go index 6882233..0b02a00 100644 --- a/modal-go/examples/cls-call/main.go +++ b/modal-go/examples/cls-call/main.go @@ -4,6 +4,7 @@ package main import ( "context" + "fmt" "log" "github.com/modal-labs/libmodal/modal-go" @@ -18,30 +19,30 @@ func main() { "libmodal-test-support", "EchoCls", modal.LookupOptions{}, ) if err != nil { - log.Fatalf("Failed to lookup Cls: %w", err) + fmt.Errorf("Failed to lookup Cls: %w", err) } instance, err := cls.Instance(nil) if err != nil { - log.Fatalf("Failed to create Cls instance: %w", 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: %w", 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: %w", err) + fmt.Errorf("Failed to call Cls method: %w", err) } - log.Printf("%w\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: %w", err) + fmt.Errorf("Failed to call Cls method: %w", err) } - log.Printf("%w\n", result) + log.Println("Response:", result) } diff --git a/modal-go/examples/function-call/main.go b/modal-go/examples/function-call/main.go index 2127c72..59145de 100644 --- a/modal-go/examples/function-call/main.go +++ b/modal-go/examples/function-call/main.go @@ -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: %w", 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: %w", 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: %w", err) + fmt.Errorf("Failed to call function with kwargs: %w", err) } - log.Printf("%s\n", ret) + log.Println("Response:", ret) } diff --git a/modal-go/examples/function-spawn/main.go b/modal-go/examples/function-spawn/main.go index ccab42b..9c79f86 100644 --- a/modal-go/examples/function-spawn/main.go +++ b/modal-go/examples/function-spawn/main.go @@ -16,17 +16,17 @@ func main() { echo, err := modal.FunctionLookup(ctx, "libmodal-test-support", "echo_string", modal.LookupOptions{}) if err != nil { - log.Fatalf("Failed to lookup function: %w", err) + fmt.Errorf("Failed to lookup function: %w", err) } fc, err := echo.Spawn(nil, map[string]any{"s": "Hello world!"}) if err != nil { - log.Fatalf("Failed to spawn function: %w", err) + fmt.Errorf("Failed to spawn function: %w", err) } ret, err := fc.Get(modal.FunctionCallGetOptions{}) if err != nil { - log.Fatalf("Failed to get function results: %w", err) + fmt.Errorf("Failed to get function results: %w", err) } - fmt.Printf("%s\n", ret) + log.Println("Response:", ret) } diff --git a/modal-go/examples/sandbox-exec/main.go b/modal-go/examples/sandbox-exec/main.go index f00dd0e..753c3e2 100644 --- a/modal-go/examples/sandbox-exec/main.go +++ b/modal-go/examples/sandbox-exec/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "io" "log" @@ -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: %w", 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: %w", 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: %w", err) + fmt.Errorf("Failed to create sandbox: %w", err) } log.Println("Started sandbox:", sb.SandboxId) defer sb.Terminate() @@ -47,22 +48,22 @@ for i in range(50000): }, ) if err != nil { - log.Fatalf("Failed to execute command in sandbox: %w", 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: %w", err) + fmt.Errorf("Failed to read stdout: %w", err) } contentStderr, err := io.ReadAll(p.Stderr) if err != nil { - log.Fatalf("Failed to read stderr: %w", 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: %w", err) + fmt.Errorf("Failed to wait for process completion: %w", err) } log.Println("Return code:", returnCode) diff --git a/modal-go/examples/sandbox/main.go b/modal-go/examples/sandbox/main.go index fc6a1f7..8420b0a 100644 --- a/modal-go/examples/sandbox/main.go +++ b/modal-go/examples/sandbox/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "io" "log" @@ -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: %w", 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: %w", 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: %w", 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: %w", 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: %w", 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: %w", err) + fmt.Errorf("Failed to read from sandbox stdout: %w", err) } log.Printf("output: %s\n", string(output)) From d5fd355488b65a2a5a8f5883fa0686a6556e438a Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Fri, 16 May 2025 14:45:39 -0400 Subject: [PATCH 19/26] Fix formatting --- modal-go/sandbox.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modal-go/sandbox.go b/modal-go/sandbox.go index ece0218..a9d0310 100644 --- a/modal-go/sandbox.go +++ b/modal-go/sandbox.go @@ -76,7 +76,7 @@ func (sb *Sandbox) ensureTaskId() error { return fmt.Errorf("Sandbox %s does not have a task ID, it may not be running", sb.SandboxId) } if resp.GetTaskResult() != nil { - return fmt.Errorf("Sandbox %s has already completed with result: %w", sb.SandboxId, resp.GetTaskResult()) + return fmt.Errorf("Sandbox %s has already completed with result: %v", sb.SandboxId, resp.GetTaskResult()) } sb.taskId = resp.GetTaskId() } From 55913ba171b15506e165ce0f977ea0bd815a9b19 Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Sun, 25 May 2025 16:58:44 -0400 Subject: [PATCH 20/26] Adds timeout correctly --- modal-go/function.go | 33 +++++++++++++++++++++++++---- modal-go/function_call.go | 17 +++++++++------ modal-go/test/function_call_test.go | 3 +-- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/modal-go/function.go b/modal-go/function.go index d277fde..6874a0e 100644 --- a/modal-go/function.go +++ b/modal-go/function.go @@ -21,7 +21,10 @@ import ( ) // From: modal/_utils/blob_utils.py -const maxObjectSizeBytes = 2 * 1024 * 1024 // 2 MiB +const maxObjectSizeBytes int = 2 * 1024 * 1024 // 2 MiB + +// From: modal-client/modal/_utils/function_utils.py +const OutputsTimeout time.Duration = time.Second * 55 func timeNow() float64 { return float64(time.Now().UnixNano()) / 1e9 @@ -134,16 +137,25 @@ func (f *Function) Remote(args []any, kwargs map[string]any) (any, error) { return nil, err } - return pollFunctionOutput(f.ctx, *functionCallId) + return pollFunctionOutput(f.ctx, *functionCallId, OutputsTimeout) } // Poll for ouputs for a given FunctionCall ID -func pollFunctionOutput(ctx context.Context, functionCallId string) (any, error) { +func pollFunctionOutput(ctx context.Context, functionCallId string, timeout time.Duration) (any, error) { + startTime := time.Now() + + // Calculate initial backend timeout + pollTimeout := minTimeout(OutputsTimeout, timeout) for { + // 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(), @@ -158,6 +170,19 @@ func pollFunctionOutput(ctx context.Context, functionCallId string) (any, error) if len(outputs) > 0 { return processResult(ctx, outputs[0].GetResult(), outputs[0].GetDataFormat()) } + + // Check if we've exceeded the total timeout + 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) + + // Update backend timeout for next poll + pollTimeout = minTimeout(OutputsTimeout, remainingTime) } } diff --git a/modal-go/function_call.go b/modal-go/function_call.go index b1ffc02..fed5bcc 100644 --- a/modal-go/function_call.go +++ b/modal-go/function_call.go @@ -28,20 +28,23 @@ func FunctionCallFromId(ctx context.Context, functionCallId string) (*FunctionCa // FunctionCallGetOptions are options for getting outputs from Function Calls. type FunctionCallGetOptions struct { - Timeout time.Duration + Timeout int // in seconds } // Get waits for the output of a FunctionCall. // If timeout > 0, the operation will be cancelled after the specified duration. func (fc *FunctionCall) Get(options FunctionCallGetOptions) (any, error) { ctx := fc.ctx - if options.Timeout > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(fc.ctx, options.Timeout) - defer cancel() - } + timeoutSeconds := time.Duration(options.Timeout) * time.Second + return pollFunctionOutput(ctx, fc.FunctionCallId, timeoutSeconds) +} - return pollFunctionOutput(ctx, fc.FunctionCallId) +// Helper function to find the minimum of two float32 values +func minTimeout(a, b time.Duration) time.Duration { + if a < b { + return a + } + return b } // FunctionCallCancelOptions are options for cancelling Function Calls. diff --git a/modal-go/test/function_call_test.go b/modal-go/test/function_call_test.go index fc6a590..b0ff55f 100644 --- a/modal-go/test/function_call_test.go +++ b/modal-go/test/function_call_test.go @@ -3,7 +3,6 @@ package test import ( "context" "testing" - "time" "github.com/modal-labs/libmodal/modal-go" "github.com/onsi/gomega" @@ -60,6 +59,6 @@ func TestFunctionSpawn(t *testing.T) { g.Expect(err).ShouldNot(gomega.HaveOccurred()) // Get is now expected to timeout. - _, err = functionCall.Get(modal.FunctionCallGetOptions{Timeout: 10 * time.Millisecond}) + _, err = functionCall.Get(modal.FunctionCallGetOptions{Timeout: 1}) g.Expect(err).Should(gomega.HaveOccurred()) } From 71c091e32f66d0dea1afad2e3ff35a4fa44f7ab6 Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Sun, 25 May 2025 16:59:14 -0400 Subject: [PATCH 21/26] Updates client --- modal-client | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modal-client b/modal-client index ed035f6..2bd3ea0 160000 --- a/modal-client +++ b/modal-client @@ -1 +1 @@ -Subproject commit ed035f67f92a8f4e1e2b82e9727b2f8477ed0248 +Subproject commit 2bd3ea0798bf98f63744ea700141aac2e9f7bed1 From c56ba6989e171b8e752c05e50cdd9e7d1339a882 Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Sun, 25 May 2025 17:12:33 -0400 Subject: [PATCH 22/26] Update timeout in TS --- modal-js/src/function.ts | 57 ++++++++++++++++++++++------- modal-js/src/function_call.ts | 24 ++---------- modal-js/test/function_call.test.ts | 2 +- 3 files changed, 47 insertions(+), 36 deletions(-) diff --git a/modal-js/src/function.ts b/modal-js/src/function.ts index 5608059..02eae4b 100644 --- a/modal-js/src/function.ts +++ b/modal-js/src/function.ts @@ -27,6 +27,9 @@ import { ClientError, Status } from "nice-grpc"; // From: modal/_utils/blob_utils.py const maxObjectSizeBytes = 2 * 1024 * 1024; // 2 MiB +// From: modal-client/modal/_utils/function_utils.py +export const outputsTimeout = 55; // in seconds + function timeNow() { return Date.now() / 1e3; } @@ -71,7 +74,7 @@ export class Function_ { kwargs, FunctionCallInvocationType.FUNCTION_CALL_INVOCATION_TYPE_SYNC, ); - return await pollFunctionOutput(functionCallId); + return await pollFunctionOutput(functionCallId, outputsTimeout); } // Spawn a single input into a remote function. @@ -121,24 +124,50 @@ export class Function_ { } } -export async function pollFunctionOutput(functionCallId: string): Promise { +export async function pollFunctionOutput( + functionCallId: string, + timeout: number, +): Promise { + const startTime = Date.now(); + + // Calculate initial backend timeout + let pollTimeoutSeconds = Math.min(outputsTimeout, timeout); + while (true) { - const response = await client.functionGetOutputs({ - functionCallId: functionCallId, - maxValues: 1, - timeout: 55, - lastEntryId: "0-0", - clearOnSuccess: true, - requestedAt: timeNow(), - }); + try { + const response = await client.functionGetOutputs({ + functionCallId: functionCallId, + maxValues: 1, + timeout: pollTimeoutSeconds, + lastEntryId: "0-0", + clearOnSuccess: true, + requestedAt: timeNow(), + }); - const outputs = response.outputs; - if (outputs.length > 0) { - return await processResult(outputs[0].result, outputs[0].dataFormat); + const outputs = response.outputs; + if (outputs.length > 0) { + return await processResult(outputs[0].result, outputs[0].dataFormat); + } + + // Small delay to avoid hammering the backend + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Check if we've exceeded the total timeout + const elapsed = timeNow() - startTime; + const remaining = timeout - elapsed; + + if (remaining <= 0) { + const message = `Timeout exceeded: ${timeout.toFixed(1)}s`; + throw new FunctionTimeoutError(message); + } + + // Update backend timeout for next poll + pollTimeoutSeconds = Math.min(outputsTimeout, remaining); + } catch (error) { + throw error; } } } - async function processResult( result: GenericResult | undefined, dataFormat: DataFormat, diff --git a/modal-js/src/function_call.ts b/modal-js/src/function_call.ts index 37da37c..860ed88 100644 --- a/modal-js/src/function_call.ts +++ b/modal-js/src/function_call.ts @@ -1,7 +1,7 @@ // Manage existing Function Calls (look-ups, polling for output, cancellation). import { client } from "./client"; -import { pollFunctionOutput } from "./function"; +import { pollFunctionOutput, outputsTimeout } from "./function"; import { TimeoutError } from "./errors"; export type FunctionCallGetOptions = { @@ -25,26 +25,8 @@ export class FunctionCall { // Get output for a FunctionCall ID. async get(options: FunctionCallGetOptions = {}): Promise { - const timeout = options.timeout; - - if (!timeout) return await pollFunctionOutput(this.functionCallId); - - return new Promise(async (resolve, reject) => { - const timer = setTimeout( - () => reject(new TimeoutError(`timeout after ${timeout}s`)), - timeout * 1_000, - ); - - await pollFunctionOutput(this.functionCallId) - .then((result) => { - clearTimeout(timer); - resolve(result); - }) - .catch((err) => { - clearTimeout(timer); - reject(err); - }); - }); + const timeout = options.timeout || outputsTimeout; + return await pollFunctionOutput(this.functionCallId, timeout); } // Cancel ongoing FunctionCall. diff --git a/modal-js/test/function_call.test.ts b/modal-js/test/function_call.test.ts index 5bac170..0226de6 100644 --- a/modal-js/test/function_call.test.ts +++ b/modal-js/test/function_call.test.ts @@ -30,6 +30,6 @@ test("FunctionSpawn", async () => { expect(functionCall.functionCallId).toBeDefined(); // Getting outputs with timeout raises error. - const promise = functionCall.get({ timeout: 1 / 100 }); + const promise = functionCall.get({ timeout: 1 }); await expect(promise).rejects.toThrowError(TimeoutError); }); From 643ae357666f7b6b879d1ca08d90686ba5a0c2c5 Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Sun, 25 May 2025 17:21:02 -0400 Subject: [PATCH 23/26] Make it a time.Duration --- modal-go/function_call.go | 4 ++-- modal-go/test/function_call_test.go | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/modal-go/function_call.go b/modal-go/function_call.go index fed5bcc..bb0dda4 100644 --- a/modal-go/function_call.go +++ b/modal-go/function_call.go @@ -28,14 +28,14 @@ func FunctionCallFromId(ctx context.Context, functionCallId string) (*FunctionCa // FunctionCallGetOptions are options for getting outputs from Function Calls. type FunctionCallGetOptions struct { - Timeout int // in seconds + Timeout time.Duration } // Get waits for the output of a FunctionCall. // If timeout > 0, the operation will be cancelled after the specified duration. func (fc *FunctionCall) Get(options FunctionCallGetOptions) (any, error) { ctx := fc.ctx - timeoutSeconds := time.Duration(options.Timeout) * time.Second + timeoutSeconds := time.Duration(options.Timeout) return pollFunctionOutput(ctx, fc.FunctionCallId, timeoutSeconds) } diff --git a/modal-go/test/function_call_test.go b/modal-go/test/function_call_test.go index b0ff55f..5eed5f7 100644 --- a/modal-go/test/function_call_test.go +++ b/modal-go/test/function_call_test.go @@ -3,6 +3,7 @@ package test import ( "context" "testing" + "time" "github.com/modal-labs/libmodal/modal-go" "github.com/onsi/gomega" @@ -59,6 +60,6 @@ func TestFunctionSpawn(t *testing.T) { g.Expect(err).ShouldNot(gomega.HaveOccurred()) // Get is now expected to timeout. - _, err = functionCall.Get(modal.FunctionCallGetOptions{Timeout: 1}) + _, err = functionCall.Get(modal.FunctionCallGetOptions{Timeout: 1 * time.Second}) g.Expect(err).Should(gomega.HaveOccurred()) } From 2680914f968f9621a38da6685e5240f4a9b29566 Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Mon, 26 May 2025 14:45:31 -0400 Subject: [PATCH 24/26] Adds default behavior when calling output --- modal-go/function.go | 7 ++----- modal-go/function_call.go | 7 ++++++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/modal-go/function.go b/modal-go/function.go index 6874a0e..5ce0e4e 100644 --- a/modal-go/function.go +++ b/modal-go/function.go @@ -26,7 +26,7 @@ const maxObjectSizeBytes int = 2 * 1024 * 1024 // 2 MiB // From: modal-client/modal/_utils/function_utils.py const OutputsTimeout time.Duration = time.Second * 55 -func timeNow() float64 { +func timeNowSeconds() float64 { return float64(time.Now().UnixNano()) / 1e9 } @@ -158,7 +158,7 @@ func pollFunctionOutput(ctx context.Context, functionCallId string, timeout time Timeout: float32(pollTimeout.Seconds()), LastEntryId: "0-0", ClearOnSuccess: true, - RequestedAt: timeNow(), + RequestedAt: timeNowSeconds(), }.Build()) if err != nil { return nil, fmt.Errorf("FunctionGetOutputs failed: %w", err) @@ -171,7 +171,6 @@ func pollFunctionOutput(ctx context.Context, functionCallId string, timeout time return processResult(ctx, outputs[0].GetResult(), outputs[0].GetDataFormat()) } - // Check if we've exceeded the total timeout remainingTime := timeout - time.Since(startTime) if remainingTime <= 0 { m := fmt.Sprintf("Timeout exceeded: %.1fs", timeout.Seconds()) @@ -180,8 +179,6 @@ func pollFunctionOutput(ctx context.Context, functionCallId string, timeout time // Add a small delay before next poll to avoid overloading backend. time.Sleep(50 * time.Millisecond) - - // Update backend timeout for next poll pollTimeout = minTimeout(OutputsTimeout, remainingTime) } } diff --git a/modal-go/function_call.go b/modal-go/function_call.go index bb0dda4..543f3f2 100644 --- a/modal-go/function_call.go +++ b/modal-go/function_call.go @@ -35,7 +35,12 @@ type FunctionCallGetOptions struct { // If timeout > 0, the operation will be cancelled after the specified duration. func (fc *FunctionCall) Get(options FunctionCallGetOptions) (any, error) { ctx := fc.ctx - timeoutSeconds := time.Duration(options.Timeout) + + // Use default if not specified. + timeoutSeconds := options.Timeout + if options.Timeout == 0 { + timeoutSeconds = OutputsTimeout + } return pollFunctionOutput(ctx, fc.FunctionCallId, timeoutSeconds) } From e52a30b57059ae4b415121cd06954bff04f7233f Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Mon, 26 May 2025 15:14:44 -0400 Subject: [PATCH 25/26] log more test datails --- modal-js/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modal-js/package.json b/modal-js/package.json index 4249b85..ea5703b 100644 --- a/modal-js/package.json +++ b/modal-js/package.json @@ -26,7 +26,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "prepare": "scripts/gen-proto.sh", - "test": "vitest" + "test": "vitest --reporter=verbose" }, "dependencies": { "long": "^5.3.1", From 2603870f371d75bad68f4762f329472a588de007 Mon Sep 17 00:00:00 2001 From: Luis Capelo Date: Mon, 26 May 2025 15:19:40 -0400 Subject: [PATCH 26/26] Implements timeout correctly in TS --- modal-js/src/function.ts | 36 ++++++++++++++--------------- modal-js/src/function_call.ts | 6 ++--- modal-js/test/function_call.test.ts | 6 ++--- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/modal-js/src/function.ts b/modal-js/src/function.ts index 02eae4b..30754db 100644 --- a/modal-js/src/function.ts +++ b/modal-js/src/function.ts @@ -28,9 +28,9 @@ import { ClientError, Status } from "nice-grpc"; const maxObjectSizeBytes = 2 * 1024 * 1024; // 2 MiB // From: modal-client/modal/_utils/function_utils.py -export const outputsTimeout = 55; // in seconds +export const outputsTimeout = 55 * 1000; -function timeNow() { +function timeNowSeconds() { return Date.now() / 1e3; } @@ -126,22 +126,22 @@ export class Function_ { export async function pollFunctionOutput( functionCallId: string, - timeout: number, + timeout: number, // in milliseconds ): Promise { const startTime = Date.now(); // Calculate initial backend timeout - let pollTimeoutSeconds = Math.min(outputsTimeout, timeout); + let pollTimeout = Math.min(outputsTimeout, timeout); while (true) { try { const response = await client.functionGetOutputs({ functionCallId: functionCallId, maxValues: 1, - timeout: pollTimeoutSeconds, + timeout: pollTimeout / 1000, // Backend needs seconds lastEntryId: "0-0", clearOnSuccess: true, - requestedAt: timeNow(), + requestedAt: timeNowSeconds(), }); const outputs = response.outputs; @@ -149,25 +149,25 @@ export async function pollFunctionOutput( return await processResult(outputs[0].result, outputs[0].dataFormat); } - // Small delay to avoid hammering the backend - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Check if we've exceeded the total timeout - const elapsed = timeNow() - startTime; - const remaining = timeout - elapsed; - - if (remaining <= 0) { - const message = `Timeout exceeded: ${timeout.toFixed(1)}s`; + const remainingTime = timeout - (Date.now() - startTime); + if (remainingTime <= 0) { + const message = `Timeout exceeded: ${(timeout / 1000).toFixed(1)}s`; throw new FunctionTimeoutError(message); } - // Update backend timeout for next poll - pollTimeoutSeconds = Math.min(outputsTimeout, remaining); + // Add a small delay before next poll to avoid overloading backend + await new Promise((resolve) => setTimeout(resolve, 50)); + + pollTimeout = Math.min(outputsTimeout, remainingTime); } catch (error) { - throw error; + if (error instanceof FunctionTimeoutError) { + throw error; + } + throw new Error(`FunctionGetOutputs failed: ${error}`); } } } + async function processResult( result: GenericResult | undefined, dataFormat: DataFormat, diff --git a/modal-js/src/function_call.ts b/modal-js/src/function_call.ts index 860ed88..f04f1c8 100644 --- a/modal-js/src/function_call.ts +++ b/modal-js/src/function_call.ts @@ -2,10 +2,9 @@ import { client } from "./client"; import { pollFunctionOutput, outputsTimeout } from "./function"; -import { TimeoutError } from "./errors"; export type FunctionCallGetOptions = { - timeout?: number; // in seconds + timeout?: number; // in milliseconds }; export type FunctionCallCancelOptions = { @@ -38,7 +37,8 @@ export class FunctionCall { } } -async function functionCallFromId( +// functionCallFromId looks up a FunctionCall. +export async function functionCallFromId( functionCallId: string, ): Promise { return new FunctionCall(functionCallId); diff --git a/modal-js/test/function_call.test.ts b/modal-js/test/function_call.test.ts index 0226de6..ec2063f 100644 --- a/modal-js/test/function_call.test.ts +++ b/modal-js/test/function_call.test.ts @@ -1,4 +1,4 @@ -import { Function_, TimeoutError } from "modal"; +import { Function_, FunctionTimeoutError } from "modal"; import { expect, test } from "vitest"; test("FunctionSpawn", async () => { @@ -30,6 +30,6 @@ test("FunctionSpawn", async () => { expect(functionCall.functionCallId).toBeDefined(); // Getting outputs with timeout raises error. - const promise = functionCall.get({ timeout: 1 }); - await expect(promise).rejects.toThrowError(TimeoutError); + const promise = functionCall.get({ timeout: 1000 }); // 1000ms + await expect(promise).rejects.toThrowError(FunctionTimeoutError); });