Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions features/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
eager_activity_non_remote_activities_worker "github.com/temporalio/features/features/eager_activity/non_remote_activities_worker"
eager_workflow_successful_start "github.com/temporalio/features/features/eager_workflow/successful_start"
nexus_sync_success "github.com/temporalio/features/features/nexus/sync_success"
query_oversized_result_external_storage "github.com/temporalio/features/features/query/oversized_result_external_storage"
query_successful_query "github.com/temporalio/features/features/query/successful_query"
query_timeout_due_to_no_active_workers "github.com/temporalio/features/features/query/timeout_due_to_no_active_workers"
query_unexpected_arguments "github.com/temporalio/features/features/query/unexpected_arguments"
Expand Down Expand Up @@ -96,6 +97,7 @@ func init() {
eager_activity_non_remote_activities_worker.Feature,
eager_workflow_successful_start.Feature,
nexus_sync_success.Feature,
query_oversized_result_external_storage.Feature,
query_successful_query.Feature,
query_timeout_due_to_no_active_workers.Feature,
query_unexpected_arguments.Feature,
Expand Down
9 changes: 9 additions & 0 deletions features/query/oversized_result_external_storage/README.md
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.

Copy link
Copy Markdown
Contributor

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.

11 changes: 11 additions & 0 deletions features/query/oversized_result_external_storage/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"go": {
"minVersion": "1.48.0"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"minVersion": "1.48.0"
"minVersion": "v1.48.0"

},
"py": {
"minVersion": "1.24.0"
},
"ts": {
"minVersion": "1.21.0"
}
}
142 changes: 142 additions & 0 deletions features/query/oversized_result_external_storage/feature.go
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

storage is package scoped so if this is run multiple times due to variants or any future fanout or sharing across tests, these assertions won't hold. Probably need to snapshot the storage counts before executing the query, snapshot again after, and make sure the difference is what we expect (likely should be 1 for store and retrieve).

return fmt.Errorf("query result did not use external storage")
}

if err := r.Client.SignalWorkflow(ctx, run.GetID(), run.GetRunID(), finishSignal, nil); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be done in a finally to make sure we let the workflow close if an assertion is failed earlier. Not in a defer because CheckResultDefault waits for the run result and would deadlock.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe make a func (d *memoryDriver) calls() (int, int) func so we get both values under the same lock to avoid tearing.

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
}
103 changes: 103 additions & 0 deletions features/query/oversized_result_external_storage/feature.py
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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is deprecated. Use DataConverter.default.

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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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,
)
63 changes: 63 additions & 0 deletions features/query/oversized_result_external_storage/feature.ts
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);
},
});
Loading
Loading