-
Notifications
You must be signed in to change notification settings - Fork 28
External storage: Test oversized query result storage #879
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| # Oversized query result with external storage | ||
|
|
||
| An oversized query result succeeds when external storage is configured. The | ||
| SDK must offload the result before applying the server payload-size limit. | ||
|
|
||
| The query returns 3 MiB, above the server's default 2 MiB error limit. The | ||
| checker verifies the value and confirms that storage and retrieval occurred. | ||
|
|
||
| Go, Python, and TypeScript currently expose functional external storage. | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,11 @@ | ||||||
| { | ||||||
| "go": { | ||||||
| "minVersion": "1.48.0" | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This only works for Go and not for any other language. See https://github.com/temporalio/features/blob/main/harness/go/cmd/run.go#L103. Probably either need to add harness support or do runtime skips. I'm okay with the latter on this for now. I can follow up with general config support.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| }, | ||||||
| "py": { | ||||||
| "minVersion": "1.24.0" | ||||||
| }, | ||||||
| "ts": { | ||||||
| "minVersion": "1.21.0" | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| package oversized_result_external_storage | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "strings" | ||
| "sync" | ||
|
|
||
| "github.com/temporalio/features/harness/go/harness" | ||
| commonpb "go.temporal.io/api/common/v1" | ||
| "go.temporal.io/sdk/client" | ||
| "go.temporal.io/sdk/converter" | ||
| "go.temporal.io/sdk/workflow" | ||
| "google.golang.org/protobuf/proto" | ||
| ) | ||
|
|
||
| const ( | ||
| queryName = "oversized-result" | ||
| finishSignal = "finish" | ||
| driverName = "query-result-memory" | ||
| // Exceed the server limit so the SDK must offload the result. | ||
| resultSize = 3 * 1024 * 1024 | ||
| storageThreshold = 1024 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The default threshold is 256 KiB, which is much lower than the result size for each of these tests. Probably do not need to specify the threshold explicitly. |
||
| ) | ||
|
|
||
| var storage = newMemoryDriver() | ||
|
|
||
| var Feature = harness.Feature{ | ||
| Workflows: Workflow, | ||
| ClientOptions: client.Options{ | ||
| ExternalStorage: converter.ExternalStorage{ | ||
| Drivers: []converter.StorageDriver{storage}, | ||
| PayloadSizeThreshold: storageThreshold, | ||
| }, | ||
| }, | ||
| CheckResult: checkResult, | ||
| } | ||
|
|
||
| func Workflow(ctx workflow.Context) error { | ||
| result := strings.Repeat("a", resultSize) | ||
| if err := workflow.SetQueryHandler(ctx, queryName, func() (string, error) { | ||
| return result, nil | ||
| }); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| workflow.GetSignalChannel(ctx, finishSignal).Receive(ctx, nil) | ||
| return nil | ||
| } | ||
|
|
||
| func checkResult(ctx context.Context, r *harness.Runner, run client.WorkflowRun) error { | ||
| value, err := r.Client.QueryWorkflow(ctx, run.GetID(), run.GetRunID(), queryName) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| var result string | ||
| if err := value.Get(&result); err != nil { | ||
| return err | ||
| } | ||
| if result != strings.Repeat("a", resultSize) { | ||
| return fmt.Errorf("unexpected query result") | ||
| } | ||
| if storage.storeCalls() == 0 || storage.retrieveCalls() == 0 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| return fmt.Errorf("query result did not use external storage") | ||
| } | ||
|
|
||
| if err := r.Client.SignalWorkflow(ctx, run.GetID(), run.GetRunID(), finishSignal, nil); err != nil { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be done in a |
||
| return err | ||
| } | ||
| return r.CheckResultDefault(ctx, run) | ||
| } | ||
|
|
||
| type memoryDriver struct { | ||
| mu sync.Mutex | ||
| payloads map[string]*commonpb.Payload | ||
| nextID int | ||
| stores int | ||
| retrieves int | ||
| } | ||
|
|
||
| func newMemoryDriver() *memoryDriver { | ||
| return &memoryDriver{payloads: make(map[string]*commonpb.Payload)} | ||
| } | ||
|
|
||
| func (*memoryDriver) Name() string { | ||
| return driverName | ||
| } | ||
|
|
||
| func (*memoryDriver) Type() string { | ||
| return driverName | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we use a unique test driver name for features, in case these are run against cloud? This is going to be reported on worker heartbeat. |
||
| } | ||
|
|
||
| func (d *memoryDriver) Store( | ||
| _ converter.StorageDriverStoreContext, | ||
| payloads []*commonpb.Payload, | ||
| ) ([]converter.StorageDriverClaim, error) { | ||
| d.mu.Lock() | ||
| defer d.mu.Unlock() | ||
|
|
||
| d.stores++ | ||
| claims := make([]converter.StorageDriverClaim, len(payloads)) | ||
| for i, payload := range payloads { | ||
| key := fmt.Sprintf("payload-%d", d.nextID) | ||
| d.nextID++ | ||
| d.payloads[key] = proto.Clone(payload).(*commonpb.Payload) | ||
| claims[i] = converter.StorageDriverClaim{ClaimData: map[string]string{"key": key}} | ||
| } | ||
| return claims, nil | ||
| } | ||
|
|
||
| func (d *memoryDriver) Retrieve( | ||
| _ converter.StorageDriverRetrieveContext, | ||
| claims []converter.StorageDriverClaim, | ||
| ) ([]*commonpb.Payload, error) { | ||
| d.mu.Lock() | ||
| defer d.mu.Unlock() | ||
|
|
||
| d.retrieves++ | ||
| payloads := make([]*commonpb.Payload, len(claims)) | ||
| for i, claim := range claims { | ||
| key := claim.ClaimData["key"] | ||
| payload, ok := d.payloads[key] | ||
| if !ok { | ||
| return nil, fmt.Errorf("payload %q not found", key) | ||
| } | ||
| payloads[i] = proto.Clone(payload).(*commonpb.Payload) | ||
| } | ||
| return payloads, nil | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe make a |
||
| func (d *memoryDriver) storeCalls() int { | ||
| d.mu.Lock() | ||
| defer d.mu.Unlock() | ||
| return d.stores | ||
| } | ||
|
|
||
| func (d *memoryDriver) retrieveCalls() int { | ||
| d.mu.Lock() | ||
| defer d.mu.Unlock() | ||
| return d.retrieves | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import dataclasses | ||
| from collections.abc import Sequence | ||
|
|
||
| import temporalio.converter | ||
| from temporalio import workflow | ||
| from temporalio.api.common.v1 import Payload | ||
| from temporalio.client import WorkflowHandle | ||
| from temporalio.converter import ( | ||
| ExternalStorage, | ||
| StorageDriver, | ||
| StorageDriverClaim, | ||
| StorageDriverRetrieveContext, | ||
| StorageDriverStoreContext, | ||
| ) | ||
|
|
||
| from harness.python.feature import Runner, register_feature | ||
|
|
||
| # Exceed the server limit so the SDK must offload the result. | ||
| RESULT_SIZE = 3 * 1024 * 1024 | ||
| STORAGE_THRESHOLD = 1024 | ||
|
|
||
|
|
||
| class MemoryDriver(StorageDriver): | ||
| def __init__(self) -> None: | ||
| self.payloads: dict[str, bytes] = {} | ||
| self.stores = 0 | ||
| self.retrieves = 0 | ||
|
|
||
| def name(self) -> str: | ||
| return "query-result-memory" | ||
|
|
||
| async def store( | ||
| self, | ||
| context: StorageDriverStoreContext, | ||
| payloads: Sequence[Payload], | ||
| ) -> list[StorageDriverClaim]: | ||
| self.stores += 1 | ||
| claims = [] | ||
| for payload in payloads: | ||
| key = f"payload-{len(self.payloads)}" | ||
| self.payloads[key] = payload.SerializeToString() | ||
| claims.append(StorageDriverClaim(claim_data={"key": key})) | ||
| return claims | ||
|
|
||
| async def retrieve( | ||
| self, | ||
| context: StorageDriverRetrieveContext, | ||
| claims: Sequence[StorageDriverClaim], | ||
| ) -> list[Payload]: | ||
| self.retrieves += 1 | ||
| payloads = [] | ||
| for claim in claims: | ||
| payload = Payload() | ||
| payload.ParseFromString(self.payloads[claim.claim_data["key"]]) | ||
| payloads.append(payload) | ||
| return payloads | ||
|
|
||
|
|
||
| driver = MemoryDriver() | ||
| data_converter = dataclasses.replace( | ||
| temporalio.converter.default(), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is deprecated. Use |
||
| external_storage=ExternalStorage( | ||
| drivers=[driver], | ||
| payload_size_threshold=STORAGE_THRESHOLD, | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
| @workflow.defn | ||
| class Workflow: | ||
| def __init__(self) -> None: | ||
| self.finished = False | ||
|
|
||
| @workflow.run | ||
| async def run(self) -> None: | ||
| await workflow.wait_condition(lambda: self.finished) | ||
|
|
||
| @workflow.query | ||
| def oversized_result(self) -> str: | ||
| return "a" * RESULT_SIZE | ||
|
|
||
| @workflow.signal | ||
| def finish(self) -> None: | ||
| self.finished = True | ||
|
|
||
|
|
||
| async def check_result(_: Runner, handle: WorkflowHandle) -> None: | ||
| result = await handle.query(Workflow.oversized_result) | ||
| assert result == "a" * RESULT_SIZE | ||
| assert driver.stores > 0 | ||
| assert driver.retrieves > 0 | ||
|
|
||
| await handle.signal(Workflow.finish) | ||
| await handle.result() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Doesn't the harness do this already? |
||
|
|
||
|
|
||
| register_feature( | ||
| workflows=[Workflow], | ||
| check_result=check_result, | ||
| data_converter=data_converter, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import * as assert from 'assert'; | ||
| import { ExternalStorage, type Payload, type StorageDriver, StorageDriverClaim } from '@temporalio/common'; | ||
| import { Feature } from '@temporalio/harness'; | ||
| import * as wf from '@temporalio/workflow'; | ||
|
|
||
| // Exceed the server limit so the SDK must offload the result. | ||
| const RESULT_SIZE = 3 * 1024 * 1024; | ||
| const STORAGE_THRESHOLD = 1024; | ||
|
|
||
| const query = wf.defineQuery<string>('oversized-result'); | ||
| const finishSignal = wf.defineSignal('finish'); | ||
|
|
||
| const payloads = new Map<string, Payload>(); | ||
| let stores = 0; | ||
| let retrieves = 0; | ||
|
|
||
| const driver: StorageDriver = { | ||
| name: 'query-result-memory', | ||
| type: 'query-result-memory', | ||
| async store(_context, values) { | ||
| stores++; | ||
| return values.map((value) => { | ||
| const key = `payload-${payloads.size}`; | ||
| payloads.set(key, value); | ||
| return new StorageDriverClaim({ key }); | ||
| }); | ||
| }, | ||
| async retrieve(_context, claims) { | ||
| retrieves++; | ||
| return claims.map((claim) => { | ||
| const key = claim.claimData.key; | ||
| const value = payloads.get(key); | ||
| if (value === undefined) { | ||
| throw new Error(`Payload ${key} not found`); | ||
| } | ||
| return value; | ||
| }); | ||
| }, | ||
| }; | ||
|
|
||
| const externalStorage = new ExternalStorage({ | ||
| drivers: [driver], | ||
| payloadSizeThreshold: STORAGE_THRESHOLD, | ||
| }); | ||
|
|
||
| export async function workflow(): Promise<void> { | ||
| wf.setHandler(query, () => 'a'.repeat(RESULT_SIZE)); | ||
| await new Promise<void>((resolve) => wf.setHandler(finishSignal, resolve)); | ||
| } | ||
|
|
||
| export const feature = new Feature({ | ||
| workflow, | ||
| dataConverter: { externalStorage }, | ||
| checkResult: async (runner, handle) => { | ||
| const result = await handle.query(query); | ||
| assert.equal(result, 'a'.repeat(RESULT_SIZE)); | ||
| assert.ok(stores > 0); | ||
| assert.ok(retrieves > 0); | ||
|
|
||
| await handle.signal(finishSignal); | ||
| await runner.waitForRunResult(handle); | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably can remove this as that's demonstrated by what's implemented as tests in here.