diff --git a/api-contracts/v1/workflows.proto b/api-contracts/v1/workflows.proto index f3f7a236f4..dd7f8aceee 100644 --- a/api-contracts/v1/workflows.proto +++ b/api-contracts/v1/workflows.proto @@ -115,9 +115,15 @@ message CreateWorkflowVersionRequest { optional IdempotencyConfig idempotency = 15; // (optional) idempotency configuration for the workflow } +enum IdempotencyMethod { + TTL = 0; + STATUS = 1; +} + message IdempotencyConfig { string expression = 1; // a CEL expression for determining the idempotency key for workflow runs - int64 ttl_ms = 2; // time-to-live for idempotency keys in milliseconds + int64 ttl_ms = 2; // time-to-live for idempotency keys in milliseconds. if the method is `STATUS`, this is a "fallback" - the longest the key can live before it's evicted + optional IdempotencyMethod method = 3; // the method to use for idempotency, defaults to TTL } message IdempotencyCollisionError { diff --git a/cmd/hatchet-migrate/migrate/migrations/20260719000944_v1_0_132.sql b/cmd/hatchet-migrate/migrate/migrations/20260719000944_v1_0_132.sql new file mode 100644 index 0000000000..9c37a52955 --- /dev/null +++ b/cmd/hatchet-migrate/migrate/migrations/20260719000944_v1_0_132.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TYPE idempotency_method AS ENUM ('TTL', 'STATUS'); + +ALTER TABLE "WorkflowVersion" +ADD COLUMN "idempotencyMethod" idempotency_method; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TYPE idempotency_method; +ALTER TABLE "WorkflowVersion" +DROP COLUMN "idempotencyMethod"; +-- +goose StatementEnd diff --git a/examples/go/idempotency/worker.go b/examples/go/idempotency/worker.go index 4d8f4d7c61..f3d56125e4 100644 --- a/examples/go/idempotency/worker.go +++ b/examples/go/idempotency/worker.go @@ -30,3 +30,20 @@ func IdempotentTask(client *hatchet.Client) *hatchet.StandaloneTask { }), ) } + +// > status-based-idempotency +func IdempotentStatusBasedTask(client *hatchet.Client) *hatchet.StandaloneTask { + return client.NewStandaloneTask( + "idempotent-status-based-task", + func(ctx hatchet.Context, input IdempotencyInput) (*IdempotencyOutput, error) { + return &IdempotencyOutput{ + Result: fmt.Sprintf("Hello, world from task %s", input.ID), + }, nil + }, + hatchet.WithWorkflowIdempotency(hatchet.IdempotencyConfig{ + Expression: "input.id", + Method: hatchet.IdempotencyMethodStatus, + TTL: 10 * time.Second, + }), + ) +} diff --git a/examples/python/cron/cron_input.py b/examples/python/cron/cron_input.py index bcd8c1ef52..1e355bbdbe 100644 --- a/examples/python/cron/cron_input.py +++ b/examples/python/cron/cron_input.py @@ -15,7 +15,6 @@ class CronInput(BaseModel): @hatchet.task( - name="CronInputWorkflow", input_validator=CronInput, on_crons=["* * * * *"], cron_input=CronInput(name="Hatchet"), diff --git a/examples/python/idempotency/worker.py b/examples/python/idempotency/worker.py index 75abe3d927..cfc034954e 100644 --- a/examples/python/idempotency/worker.py +++ b/examples/python/idempotency/worker.py @@ -1,6 +1,13 @@ -from hatchet_sdk import Context, Hatchet, TTLBasedIdempotencyConfig +from hatchet_sdk import ( + Context, + Hatchet, + TTLBasedIdempotencyConfig, + StatusBasedIdempotencyConfig, +) from datetime import timedelta from pydantic import BaseModel +import asyncio +from typing import Literal hatchet = Hatchet() @@ -11,6 +18,7 @@ class IdempotencyInput(BaseModel): id: str + desired_status: Literal["success", "cancel", "fail"] = "success" @hatchet.task( @@ -39,10 +47,80 @@ async def idempotent_task_short_window( return {"result": f"Hello, world from task {input.id}"} +# > status_based_idempotency +@hatchet.task( + idempotency=StatusBasedIdempotencyConfig( + key_expression="input.id", fallback_ttl=timedelta(seconds=10) + ), + input_validator=IdempotencyInput, +) +async def idempotent_status_based_task( + input: IdempotencyInput, + ctx: Context, +) -> dict[str, str]: + if input.desired_status == "success": + await asyncio.sleep(2) + return {"result": f"Hello, world from task {input.id}"} + + if input.desired_status == "fail": + await asyncio.sleep(2) + raise Exception(f"Task {input.id} failed as requested.") + + if input.desired_status == "cancel": + await asyncio.sleep(1) + await ctx.aio_cancel() + for _ in range(10): + await asyncio.sleep(1) + + raise Exception(f"Task {input.id} should have been cancelled, but was not.") + + + + +# > status_based_idempotency_with_retries +@hatchet.task( + idempotency=StatusBasedIdempotencyConfig( + key_expression="input.id", fallback_ttl=timedelta(seconds=30) + ), + input_validator=IdempotencyInput, + retries=3, +) +async def idempotent_status_based_task_with_retries( + input: IdempotencyInput, + ctx: Context, +) -> dict[str, str]: + if input.desired_status == "fail": + await asyncio.sleep(2) + raise Exception(f"Task {input.id} failed as requested.") + + if ctx.retry_count == 0: + await asyncio.sleep(2) + raise Exception(f"Task {input.id} failed as requested (attempt 1).") + + if input.desired_status == "success": + await asyncio.sleep(2) + return {"result": f"Hello, world from task {input.id}"} + + if input.desired_status == "cancel": + await asyncio.sleep(1) + await ctx.aio_cancel() + for _ in range(10): + await asyncio.sleep(1) + + raise Exception(f"Task {input.id} should have been cancelled, but was not.") + + + + def main() -> None: worker = hatchet.worker( "test-worker", - workflows=[idempotent_task], + workflows=[ + idempotent_task, + idempotent_task_short_window, + idempotent_status_based_task, + idempotent_status_based_task_with_retries, + ], ) worker.start() diff --git a/examples/python/worker.py b/examples/python/worker.py index 4e18a96a90..efe91fc8fd 100644 --- a/examples/python/worker.py +++ b/examples/python/worker.py @@ -110,7 +110,12 @@ durable_parent_child_key_bug, child_child_key_bug, ) -from examples.idempotency.worker import idempotent_task, idempotent_task_short_window +from examples.idempotency.worker import ( + idempotent_task, + idempotent_task_short_window, + idempotent_status_based_task, + idempotent_status_based_task_with_retries, +) from examples.bug_tests.durable_spawn_index_collision.worker import ( durable_spawn_index_collision, spawn_index_child_a, @@ -218,6 +223,8 @@ def main() -> None: durable_spawn_many_dags, idempotent_task, idempotent_task_short_window, + idempotent_status_based_task, + idempotent_status_based_task_with_retries, error_raising_durable_parent, error_raising_task, ], diff --git a/examples/ruby/idempotency/worker.rb b/examples/ruby/idempotency/worker.rb index 42a5ccc7d4..33ca82d67d 100644 --- a/examples/ruby/idempotency/worker.rb +++ b/examples/ruby/idempotency/worker.rb @@ -21,8 +21,19 @@ { 'result' => "Hello from task #{input['id']}" } end +# > status-based-idempotency +IDEMPOTENT_STATUS_BASED_TASK = HATCHET.task( + name: 'ruby-e2e-idempotent-status-based-task', + idempotency: Hatchet::StatusBasedIdempotencyConfig.new(expression: 'input.id', fallback_ttl_ms: 10_000) +) do |input, _ctx| + { 'result' => "Hello from task #{input['id']}" } +end + def main - worker = HATCHET.worker('idempotency-worker', workflows: [IDEMPOTENT_TASK, IDEMPOTENT_TASK_SHORT_WINDOW]) + worker = HATCHET.worker( + 'idempotency-worker', + workflows: [IDEMPOTENT_TASK, IDEMPOTENT_TASK_SHORT_WINDOW, IDEMPOTENT_STATUS_BASED_TASK] + ) worker.start end diff --git a/examples/typescript/idempotency/workflow.ts b/examples/typescript/idempotency/workflow.ts index 2f0d018cdb..2be4fded41 100644 --- a/examples/typescript/idempotency/workflow.ts +++ b/examples/typescript/idempotency/workflow.ts @@ -31,3 +31,16 @@ export const idempotentTaskShortWindow = hatchet.task status-based-idempotency +export const idempotentStatusBasedTask = hatchet.task({ + name: 'ts-e2e-idempotent-status-based-task', + idempotency: { + strategy: 'status', + expression: 'input.id', + fallbackTtlMs: 10_000, + }, + fn: async (input) => { + return { result: `Hello, world from task ${input.id}` }; + }, +}); diff --git a/frontend/docs/pages/reference/changelog/python.mdx b/frontend/docs/pages/reference/changelog/python.mdx index c2bb72f593..bad865a9c6 100644 --- a/frontend/docs/pages/reference/changelog/python.mdx +++ b/frontend/docs/pages/reference/changelog/python.mdx @@ -1,5 +1,11 @@ {/* AUTOGENERATED — do not edit. Run `task sync-changelog` to regenerate from sdks/python/CHANGELOG.md */} +## v1.36.0 - 2026-07-21 + +### Added + +- Adds support for terminal status-based idempotency keys, which are released when the task holding the key reaches a terminal state (either completed, cancelled, or having failed and exhausted all retries). + ## v1.35.1 - 2026-07-20 ### Fixed diff --git a/frontend/docs/pages/reference/changelog/ruby.mdx b/frontend/docs/pages/reference/changelog/ruby.mdx index 96ddeab82f..17002fe9aa 100644 --- a/frontend/docs/pages/reference/changelog/ruby.mdx +++ b/frontend/docs/pages/reference/changelog/ruby.mdx @@ -1,5 +1,11 @@ {/* AUTOGENERATED — do not edit. Run `task sync-changelog` to regenerate from sdks/ruby/src/CHANGELOG.md */} +## v0.5.0 - 2026-07-22 + +### Added + +- Adds support for terminal status-based idempotency keys, which are released when the task holding the key reaches a terminal state (either completed, cancelled, or having failed and exhausted all retries). + ## v0.4.0 - 2026-06-03 ### Added diff --git a/frontend/docs/pages/reference/changelog/typescript.mdx b/frontend/docs/pages/reference/changelog/typescript.mdx index 73b8055ff7..22303d009a 100644 --- a/frontend/docs/pages/reference/changelog/typescript.mdx +++ b/frontend/docs/pages/reference/changelog/typescript.mdx @@ -1,5 +1,11 @@ {/* AUTOGENERATED — do not edit. Run `task sync-changelog` to regenerate from sdks/typescript/CHANGELOG.md */} +## v1.27.0 - 2026-07-22 + +### Added + +- Adds support for terminal status-based idempotency keys, which are released when the task holding the key reaches a terminal state (either completed, cancelled, or having failed and exhausted all retries). + ## v1.26.2 - 2026-07-21 ### Added diff --git a/frontend/docs/pages/v1/idempotency.mdx b/frontend/docs/pages/v1/idempotency.mdx index b86b1afbbb..7e83fce7f1 100644 --- a/frontend/docs/pages/v1/idempotency.mdx +++ b/frontend/docs/pages/v1/idempotency.mdx @@ -7,14 +7,25 @@ import { snippets } from "@/lib/generated/snippets"; If you need to prevent more than one run of a task from occurring within a given time window, for instance because of duplicate event sends from a webhook that can trigger duplicate runs in Hatchet, you can achieve this by adding **idempotency** configuration to your workflow or standalone task. -## Define an Idempotency Key +## Types of Idempotency in Hatchet -Configuring idempotency on a workflow or standalone task requires two parameters to be set: the **expression**, which will be used to create an idempotency key from the input and additional metadata of the run that's going to be triggered, and the **TTL**, which determines how long the key should live for. +Hatchet supports two different types of idempotency behavior, which you can choose between depending on the needs of your application: + +1. **TTL-based** idempotency, which says that there can only be one run for a given idempotency key within a specified amount of time after the first run for that key is seen. +2. **Status-based** idempotency, which clears the idempotency key when the run that claimed it reaches a terminal status (success, failure, or cancellation). This acts similarly to `CANCEL_NEWEST` concurrency, where any time there's a running task holding an idempotency key, any incoming tasks with the same key will be dropped. But once the running task reaches a terminal state, a new one that's triggered can immediately claim the key once again, with no fixed wait time. + +## The Idempotency Key Expression + +Every idempotency configuration, regardless of strategy, requires an **expression**, which is a CEL expression that's evaluated against the input and additional metadata of the run that's about to be triggered to produce the idempotency key. Two runs with the same computed key are considered duplicates. The idempotency key expression must evaluate to a string. +## TTL-based Idempotency + +TTL-based idempotency requires two parameters: the **expression** described above, and a **TTL**, which determines how long the key should live for. Only one run for a given key will occur in the time window from when the first trigger comes in until the TTL expires. + @@ -30,7 +41,40 @@ Configuring idempotency on a workflow or standalone task requires two parameters -When idempotency is configured, only one run of a workflow will occur in the time window from when the first trigger comes in until the TTL expires. For instance, if you trigger a workflow at `00:00:00 UTC` (midnight) with a TTL of five minutes, and then the same workflow is triggered again with the same inputs and metadata at `00:02:00 UTC`, `00:04:00 UTC`, and `00:06:00 UTC`, only the first one (at midnight) and the final one (at `00:06:00 UTC`) will run, and then after the second run occurs, the lock on the key will be held until `00:11:00 UTC` (five minutes after the final run was triggered). +The TTL window is _sliding_: each accepted run resets the clock. For instance, if you trigger a workflow at `00:00:00 UTC` (midnight) with a TTL of five minutes, and then the same workflow is triggered again with the same inputs and metadata at `00:02:00 UTC`, `00:04:00 UTC`, and `00:06:00 UTC`, only the first one (at midnight) and the final one (at `00:06:00 UTC`) will run. After the second run occurs, the lock on the key will be held until `00:11:00 UTC` (five minutes after the final run was triggered). + +TTL-based idempotency is a good fit when you want to _debounce_ duplicate triggers over a fixed period of time, regardless of how long the run itself takes. + +## Status-based Idempotency + +Status-based idempotency keeps the idempotency key claimed only while the run that owns it is still active. Once that run reaches a terminal status (success, failure, or cancellation), the key is released immediately, and the next trigger with the same key can claim it and start a fresh run with no fixed wait time. + +This behaves similarly to `CANCEL_NEWEST` concurrency: while a run is holding the key, any incoming run with the same key is dropped (raising an idempotency collision), but once the running task finishes, a new one can immediately take its place. + +Instead of a TTL, status-based idempotency takes a **fallback TTL**. Because the key is normally released when the run reaches a terminal state, the fallback TTL exists only as a safety net: it caps the longest the key can remain claimed if, for some reason, the run never reaches a terminal status. You should generally set the fallback TTL comfortably longer than you expect the run to take. + + + + + + + + + + + + + + + + +For example, if you trigger a run at `00:00:00 UTC` that takes thirty seconds to complete, any duplicate triggers that come in before `00:00:30 UTC` will collide and be rejected. But a duplicate that arrives at `00:00:31 UTC`, just after the first run has finished, will be accepted and start a new run, because the key was released as soon as the first run reached its terminal status. + +Status-based idempotency is a good fit when you want to guarantee that only one run for a given key is _in flight_ at any moment, without imposing a cooldown after it completes. ## Handling Collisions diff --git a/internal/services/admin/v1/server.go b/internal/services/admin/v1/server.go index ebd26c136e..2e818dfd89 100644 --- a/internal/services/admin/v1/server.go +++ b/internal/services/admin/v1/server.go @@ -913,9 +913,16 @@ func getCreateWorkflowOpts(req *contracts.CreateWorkflowVersionRequest) (*v1.Cre var idempotency *v1.IdempotencyConfig if req.Idempotency != nil { + method := sqlcv1.IdempotencyMethodTTL + + if req.Idempotency.Method != nil { + method = sqlcv1.IdempotencyMethod(req.Idempotency.Method.String()) + } + idempotency = &v1.IdempotencyConfig{ Expression: req.Idempotency.Expression, TTLMs: req.Idempotency.TtlMs, + Method: method, } } diff --git a/internal/services/shared/proto/v1/workflows.pb.go b/internal/services/shared/proto/v1/workflows.pb.go index 9c98ed0ccc..123e1683c1 100644 --- a/internal/services/shared/proto/v1/workflows.pb.go +++ b/internal/services/shared/proto/v1/workflows.pb.go @@ -186,6 +186,52 @@ func (RunStatus) EnumDescriptor() ([]byte, []int) { return file_v1_workflows_proto_rawDescGZIP(), []int{2} } +type IdempotencyMethod int32 + +const ( + IdempotencyMethod_TTL IdempotencyMethod = 0 + IdempotencyMethod_STATUS IdempotencyMethod = 1 +) + +// Enum value maps for IdempotencyMethod. +var ( + IdempotencyMethod_name = map[int32]string{ + 0: "TTL", + 1: "STATUS", + } + IdempotencyMethod_value = map[string]int32{ + "TTL": 0, + "STATUS": 1, + } +) + +func (x IdempotencyMethod) Enum() *IdempotencyMethod { + p := new(IdempotencyMethod) + *p = x + return p +} + +func (x IdempotencyMethod) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (IdempotencyMethod) Descriptor() protoreflect.EnumDescriptor { + return file_v1_workflows_proto_enumTypes[3].Descriptor() +} + +func (IdempotencyMethod) Type() protoreflect.EnumType { + return &file_v1_workflows_proto_enumTypes[3] +} + +func (x IdempotencyMethod) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use IdempotencyMethod.Descriptor instead. +func (IdempotencyMethod) EnumDescriptor() ([]byte, []int) { + return file_v1_workflows_proto_rawDescGZIP(), []int{3} +} + type ConcurrencyLimitStrategy int32 const ( @@ -225,11 +271,11 @@ func (x ConcurrencyLimitStrategy) String() string { } func (ConcurrencyLimitStrategy) Descriptor() protoreflect.EnumDescriptor { - return file_v1_workflows_proto_enumTypes[3].Descriptor() + return file_v1_workflows_proto_enumTypes[4].Descriptor() } func (ConcurrencyLimitStrategy) Type() protoreflect.EnumType { - return &file_v1_workflows_proto_enumTypes[3] + return &file_v1_workflows_proto_enumTypes[4] } func (x ConcurrencyLimitStrategy) Number() protoreflect.EnumNumber { @@ -238,7 +284,7 @@ func (x ConcurrencyLimitStrategy) Number() protoreflect.EnumNumber { // Deprecated: Use ConcurrencyLimitStrategy.Descriptor instead. func (ConcurrencyLimitStrategy) EnumDescriptor() ([]byte, []int) { - return file_v1_workflows_proto_rawDescGZIP(), []int{3} + return file_v1_workflows_proto_rawDescGZIP(), []int{4} } type CancelTasksRequest struct { @@ -942,8 +988,9 @@ type IdempotencyConfig struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Expression string `protobuf:"bytes,1,opt,name=expression,proto3" json:"expression,omitempty"` // a CEL expression for determining the idempotency key for workflow runs - TtlMs int64 `protobuf:"varint,2,opt,name=ttl_ms,json=ttlMs,proto3" json:"ttl_ms,omitempty"` // time-to-live for idempotency keys in milliseconds + Expression string `protobuf:"bytes,1,opt,name=expression,proto3" json:"expression,omitempty"` // a CEL expression for determining the idempotency key for workflow runs + TtlMs int64 `protobuf:"varint,2,opt,name=ttl_ms,json=ttlMs,proto3" json:"ttl_ms,omitempty"` // time-to-live for idempotency keys in milliseconds. if the method is `STATUS`, this is a "fallback" - the longest the key can live before it's evicted + Method *IdempotencyMethod `protobuf:"varint,3,opt,name=method,proto3,enum=v1.IdempotencyMethod,oneof" json:"method,omitempty"` // the method to use for idempotency, defaults to TTL } func (x *IdempotencyConfig) Reset() { @@ -992,6 +1039,13 @@ func (x *IdempotencyConfig) GetTtlMs() int64 { return 0 } +func (x *IdempotencyConfig) GetMethod() IdempotencyMethod { + if x != nil && x.Method != nil { + return *x.Method + } + return IdempotencyMethod_TTL +} + type IdempotencyCollisionError struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1893,12 +1947,16 @@ var file_v1_workflows_proto_rawDesc = []byte{ 0x6b, 0x79, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x42, 0x0e, 0x0a, - 0x0c, 0x5f, 0x69, 0x64, 0x65, 0x6d, 0x70, 0x6f, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x22, 0x4a, 0x0a, - 0x11, 0x49, 0x64, 0x65, 0x6d, 0x70, 0x6f, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x12, 0x15, 0x0a, 0x06, 0x74, 0x74, 0x6c, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x05, 0x74, 0x74, 0x6c, 0x4d, 0x73, 0x22, 0x8f, 0x01, 0x0a, 0x19, 0x49, 0x64, + 0x0c, 0x5f, 0x69, 0x64, 0x65, 0x6d, 0x70, 0x6f, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x22, 0x89, 0x01, + 0x0a, 0x11, 0x49, 0x64, 0x65, 0x6d, 0x70, 0x6f, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x15, 0x0a, 0x06, 0x74, 0x74, 0x6c, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x05, 0x74, 0x74, 0x6c, 0x4d, 0x73, 0x12, 0x32, 0x0a, 0x06, 0x6d, 0x65, + 0x74, 0x68, 0x6f, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x76, 0x31, 0x2e, + 0x49, 0x64, 0x65, 0x6d, 0x70, 0x6f, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x4d, 0x65, 0x74, 0x68, 0x6f, + 0x64, 0x48, 0x00, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x88, 0x01, 0x01, 0x42, 0x09, + 0x0a, 0x07, 0x5f, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x22, 0x8f, 0x01, 0x0a, 0x19, 0x49, 0x64, 0x65, 0x6d, 0x70, 0x6f, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x43, 0x6f, 0x6c, 0x6c, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x37, 0x0a, 0x18, 0x65, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, @@ -2072,49 +2130,51 @@ var file_v1_workflows_proto_rawDesc = []byte{ 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x4f, 0x4d, 0x50, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0a, 0x0a, 0x06, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x03, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x45, 0x56, - 0x49, 0x43, 0x54, 0x45, 0x44, 0x10, 0x05, 0x2a, 0x7f, 0x0a, 0x18, 0x43, 0x6f, 0x6e, 0x63, 0x75, - 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x53, 0x74, 0x72, 0x61, 0x74, - 0x65, 0x67, 0x79, 0x12, 0x16, 0x0a, 0x12, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x5f, 0x49, 0x4e, - 0x5f, 0x50, 0x52, 0x4f, 0x47, 0x52, 0x45, 0x53, 0x53, 0x10, 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x44, - 0x52, 0x4f, 0x50, 0x5f, 0x4e, 0x45, 0x57, 0x45, 0x53, 0x54, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, - 0x51, 0x55, 0x45, 0x55, 0x45, 0x5f, 0x4e, 0x45, 0x57, 0x45, 0x53, 0x54, 0x10, 0x02, 0x12, 0x15, - 0x0a, 0x11, 0x47, 0x52, 0x4f, 0x55, 0x50, 0x5f, 0x52, 0x4f, 0x55, 0x4e, 0x44, 0x5f, 0x52, 0x4f, - 0x42, 0x49, 0x4e, 0x10, 0x03, 0x12, 0x11, 0x0a, 0x0d, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x5f, - 0x4e, 0x45, 0x57, 0x45, 0x53, 0x54, 0x10, 0x04, 0x32, 0xcf, 0x03, 0x0a, 0x0c, 0x41, 0x64, 0x6d, - 0x69, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x52, 0x0a, 0x0b, 0x50, 0x75, 0x74, - 0x57, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x12, 0x20, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, - 0x65, 0x61, 0x74, 0x65, 0x57, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x56, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x76, 0x31, 0x2e, - 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x57, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x56, 0x65, - 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, - 0x0b, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x16, 0x2e, 0x76, - 0x31, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, - 0x54, 0x61, 0x73, 0x6b, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, - 0x0b, 0x52, 0x65, 0x70, 0x6c, 0x61, 0x79, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x16, 0x2e, 0x76, - 0x31, 0x2e, 0x52, 0x65, 0x70, 0x6c, 0x61, 0x79, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x70, 0x6c, 0x61, 0x79, - 0x54, 0x61, 0x73, 0x6b, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, - 0x12, 0x54, 0x72, 0x69, 0x67, 0x67, 0x65, 0x72, 0x57, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, - 0x52, 0x75, 0x6e, 0x12, 0x1d, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x72, 0x69, 0x67, 0x67, 0x65, 0x72, - 0x57, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x72, 0x69, 0x67, 0x67, 0x65, 0x72, 0x57, - 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x44, 0x0a, 0x0d, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x44, 0x65, 0x74, 0x61, - 0x69, 0x6c, 0x73, 0x12, 0x18, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x44, - 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, + 0x49, 0x43, 0x54, 0x45, 0x44, 0x10, 0x05, 0x2a, 0x28, 0x0a, 0x11, 0x49, 0x64, 0x65, 0x6d, 0x70, + 0x6f, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x07, 0x0a, 0x03, + 0x54, 0x54, 0x4c, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x10, + 0x01, 0x2a, 0x7f, 0x0a, 0x18, 0x43, 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, + 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, 0x16, 0x0a, + 0x12, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x5f, 0x49, 0x4e, 0x5f, 0x50, 0x52, 0x4f, 0x47, 0x52, + 0x45, 0x53, 0x53, 0x10, 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x44, 0x52, 0x4f, 0x50, 0x5f, 0x4e, 0x45, + 0x57, 0x45, 0x53, 0x54, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x51, 0x55, 0x45, 0x55, 0x45, 0x5f, + 0x4e, 0x45, 0x57, 0x45, 0x53, 0x54, 0x10, 0x02, 0x12, 0x15, 0x0a, 0x11, 0x47, 0x52, 0x4f, 0x55, + 0x50, 0x5f, 0x52, 0x4f, 0x55, 0x4e, 0x44, 0x5f, 0x52, 0x4f, 0x42, 0x49, 0x4e, 0x10, 0x03, 0x12, + 0x11, 0x0a, 0x0d, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x5f, 0x4e, 0x45, 0x57, 0x45, 0x53, 0x54, + 0x10, 0x04, 0x32, 0xcf, 0x03, 0x0a, 0x0c, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x12, 0x52, 0x0a, 0x0b, 0x50, 0x75, 0x74, 0x57, 0x6f, 0x72, 0x6b, 0x66, 0x6c, + 0x6f, 0x77, 0x12, 0x20, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x57, 0x6f, + 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x57, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x0b, 0x43, 0x61, 0x6e, 0x63, 0x65, + 0x6c, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x16, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x61, 0x6e, 0x63, + 0x65, 0x6c, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, + 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x0b, 0x52, 0x65, 0x70, 0x6c, 0x61, + 0x79, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x16, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x70, 0x6c, + 0x61, 0x79, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, + 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x70, 0x6c, 0x61, 0x79, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x12, 0x54, 0x72, 0x69, 0x67, 0x67, + 0x65, 0x72, 0x57, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x12, 0x1d, 0x2e, + 0x76, 0x31, 0x2e, 0x54, 0x72, 0x69, 0x67, 0x67, 0x65, 0x72, 0x57, 0x6f, 0x72, 0x6b, 0x66, 0x6c, + 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x76, + 0x31, 0x2e, 0x54, 0x72, 0x69, 0x67, 0x67, 0x65, 0x72, 0x57, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, + 0x77, 0x52, 0x75, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x44, 0x0a, 0x0d, + 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x18, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x11, 0x42, 0x72, 0x61, 0x6e, - 0x63, 0x68, 0x44, 0x75, 0x72, 0x61, 0x62, 0x6c, 0x65, 0x54, 0x61, 0x73, 0x6b, 0x12, 0x1c, 0x2e, - 0x76, 0x31, 0x2e, 0x42, 0x72, 0x61, 0x6e, 0x63, 0x68, 0x44, 0x75, 0x72, 0x61, 0x62, 0x6c, 0x65, - 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x76, 0x31, - 0x2e, 0x42, 0x72, 0x61, 0x6e, 0x63, 0x68, 0x44, 0x75, 0x72, 0x61, 0x62, 0x6c, 0x65, 0x54, 0x61, - 0x73, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x42, 0x5a, 0x40, 0x67, 0x69, - 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x74, 0x63, 0x68, 0x65, 0x74, - 0x2d, 0x64, 0x65, 0x76, 0x2f, 0x68, 0x61, 0x74, 0x63, 0x68, 0x65, 0x74, 0x2f, 0x69, 0x6e, 0x74, - 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x73, - 0x68, 0x61, 0x72, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, + 0x52, 0x75, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x50, 0x0a, 0x11, 0x42, 0x72, 0x61, 0x6e, 0x63, 0x68, 0x44, 0x75, 0x72, 0x61, + 0x62, 0x6c, 0x65, 0x54, 0x61, 0x73, 0x6b, 0x12, 0x1c, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x72, 0x61, + 0x6e, 0x63, 0x68, 0x44, 0x75, 0x72, 0x61, 0x62, 0x6c, 0x65, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x72, 0x61, 0x6e, 0x63, + 0x68, 0x44, 0x75, 0x72, 0x61, 0x62, 0x6c, 0x65, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x42, 0x5a, 0x40, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, + 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x74, 0x63, 0x68, 0x65, 0x74, 0x2d, 0x64, 0x65, 0x76, 0x2f, 0x68, + 0x61, 0x74, 0x63, 0x68, 0x65, 0x74, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x2f, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -2129,86 +2189,88 @@ func file_v1_workflows_proto_rawDescGZIP() []byte { return file_v1_workflows_proto_rawDescData } -var file_v1_workflows_proto_enumTypes = make([]protoimpl.EnumInfo, 4) +var file_v1_workflows_proto_enumTypes = make([]protoimpl.EnumInfo, 5) var file_v1_workflows_proto_msgTypes = make([]protoimpl.MessageInfo, 25) var file_v1_workflows_proto_goTypes = []interface{}{ (StickyStrategy)(0), // 0: v1.StickyStrategy (RateLimitDuration)(0), // 1: v1.RateLimitDuration (RunStatus)(0), // 2: v1.RunStatus - (ConcurrencyLimitStrategy)(0), // 3: v1.ConcurrencyLimitStrategy - (*CancelTasksRequest)(nil), // 4: v1.CancelTasksRequest - (*ReplayTasksRequest)(nil), // 5: v1.ReplayTasksRequest - (*TasksFilter)(nil), // 6: v1.TasksFilter - (*CancelTasksResponse)(nil), // 7: v1.CancelTasksResponse - (*ReplayTasksResponse)(nil), // 8: v1.ReplayTasksResponse - (*TriggerWorkflowRunRequest)(nil), // 9: v1.TriggerWorkflowRunRequest - (*TriggerWorkflowRunResponse)(nil), // 10: v1.TriggerWorkflowRunResponse - (*BranchDurableTaskRequest)(nil), // 11: v1.BranchDurableTaskRequest - (*BranchDurableTaskResponse)(nil), // 12: v1.BranchDurableTaskResponse - (*CreateWorkflowVersionRequest)(nil), // 13: v1.CreateWorkflowVersionRequest - (*IdempotencyConfig)(nil), // 14: v1.IdempotencyConfig - (*IdempotencyCollisionError)(nil), // 15: v1.IdempotencyCollisionError - (*BulkTriggerIdempotencyCollisionError)(nil), // 16: v1.BulkTriggerIdempotencyCollisionError - (*DefaultFilter)(nil), // 17: v1.DefaultFilter - (*Concurrency)(nil), // 18: v1.Concurrency - (*CreateTaskOpts)(nil), // 19: v1.CreateTaskOpts - (*CreateTaskRateLimit)(nil), // 20: v1.CreateTaskRateLimit - (*CreateWorkflowVersionResponse)(nil), // 21: v1.CreateWorkflowVersionResponse - (*GetRunDetailsRequest)(nil), // 22: v1.GetRunDetailsRequest - (*TaskRunDetail)(nil), // 23: v1.TaskRunDetail - (*GetRunDetailsResponse)(nil), // 24: v1.GetRunDetailsResponse - nil, // 25: v1.TriggerWorkflowRunRequest.DesiredWorkerLabelsEntry - nil, // 26: v1.CreateTaskOpts.WorkerLabelsEntry - nil, // 27: v1.CreateTaskOpts.SlotRequestsEntry - nil, // 28: v1.GetRunDetailsResponse.TaskRunsEntry - (*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp - (*TaskConditions)(nil), // 30: v1.TaskConditions - (*DesiredWorkerLabels)(nil), // 31: v1.DesiredWorkerLabels + (IdempotencyMethod)(0), // 3: v1.IdempotencyMethod + (ConcurrencyLimitStrategy)(0), // 4: v1.ConcurrencyLimitStrategy + (*CancelTasksRequest)(nil), // 5: v1.CancelTasksRequest + (*ReplayTasksRequest)(nil), // 6: v1.ReplayTasksRequest + (*TasksFilter)(nil), // 7: v1.TasksFilter + (*CancelTasksResponse)(nil), // 8: v1.CancelTasksResponse + (*ReplayTasksResponse)(nil), // 9: v1.ReplayTasksResponse + (*TriggerWorkflowRunRequest)(nil), // 10: v1.TriggerWorkflowRunRequest + (*TriggerWorkflowRunResponse)(nil), // 11: v1.TriggerWorkflowRunResponse + (*BranchDurableTaskRequest)(nil), // 12: v1.BranchDurableTaskRequest + (*BranchDurableTaskResponse)(nil), // 13: v1.BranchDurableTaskResponse + (*CreateWorkflowVersionRequest)(nil), // 14: v1.CreateWorkflowVersionRequest + (*IdempotencyConfig)(nil), // 15: v1.IdempotencyConfig + (*IdempotencyCollisionError)(nil), // 16: v1.IdempotencyCollisionError + (*BulkTriggerIdempotencyCollisionError)(nil), // 17: v1.BulkTriggerIdempotencyCollisionError + (*DefaultFilter)(nil), // 18: v1.DefaultFilter + (*Concurrency)(nil), // 19: v1.Concurrency + (*CreateTaskOpts)(nil), // 20: v1.CreateTaskOpts + (*CreateTaskRateLimit)(nil), // 21: v1.CreateTaskRateLimit + (*CreateWorkflowVersionResponse)(nil), // 22: v1.CreateWorkflowVersionResponse + (*GetRunDetailsRequest)(nil), // 23: v1.GetRunDetailsRequest + (*TaskRunDetail)(nil), // 24: v1.TaskRunDetail + (*GetRunDetailsResponse)(nil), // 25: v1.GetRunDetailsResponse + nil, // 26: v1.TriggerWorkflowRunRequest.DesiredWorkerLabelsEntry + nil, // 27: v1.CreateTaskOpts.WorkerLabelsEntry + nil, // 28: v1.CreateTaskOpts.SlotRequestsEntry + nil, // 29: v1.GetRunDetailsResponse.TaskRunsEntry + (*timestamppb.Timestamp)(nil), // 30: google.protobuf.Timestamp + (*TaskConditions)(nil), // 31: v1.TaskConditions + (*DesiredWorkerLabels)(nil), // 32: v1.DesiredWorkerLabels } var file_v1_workflows_proto_depIdxs = []int32{ - 6, // 0: v1.CancelTasksRequest.filter:type_name -> v1.TasksFilter - 6, // 1: v1.ReplayTasksRequest.filter:type_name -> v1.TasksFilter - 29, // 2: v1.TasksFilter.since:type_name -> google.protobuf.Timestamp - 29, // 3: v1.TasksFilter.until:type_name -> google.protobuf.Timestamp - 25, // 4: v1.TriggerWorkflowRunRequest.desired_worker_labels:type_name -> v1.TriggerWorkflowRunRequest.DesiredWorkerLabelsEntry - 19, // 5: v1.CreateWorkflowVersionRequest.tasks:type_name -> v1.CreateTaskOpts - 18, // 6: v1.CreateWorkflowVersionRequest.concurrency:type_name -> v1.Concurrency - 19, // 7: v1.CreateWorkflowVersionRequest.on_failure_task:type_name -> v1.CreateTaskOpts + 7, // 0: v1.CancelTasksRequest.filter:type_name -> v1.TasksFilter + 7, // 1: v1.ReplayTasksRequest.filter:type_name -> v1.TasksFilter + 30, // 2: v1.TasksFilter.since:type_name -> google.protobuf.Timestamp + 30, // 3: v1.TasksFilter.until:type_name -> google.protobuf.Timestamp + 26, // 4: v1.TriggerWorkflowRunRequest.desired_worker_labels:type_name -> v1.TriggerWorkflowRunRequest.DesiredWorkerLabelsEntry + 20, // 5: v1.CreateWorkflowVersionRequest.tasks:type_name -> v1.CreateTaskOpts + 19, // 6: v1.CreateWorkflowVersionRequest.concurrency:type_name -> v1.Concurrency + 20, // 7: v1.CreateWorkflowVersionRequest.on_failure_task:type_name -> v1.CreateTaskOpts 0, // 8: v1.CreateWorkflowVersionRequest.sticky:type_name -> v1.StickyStrategy - 18, // 9: v1.CreateWorkflowVersionRequest.concurrency_arr:type_name -> v1.Concurrency - 17, // 10: v1.CreateWorkflowVersionRequest.default_filters:type_name -> v1.DefaultFilter - 14, // 11: v1.CreateWorkflowVersionRequest.idempotency:type_name -> v1.IdempotencyConfig - 15, // 12: v1.BulkTriggerIdempotencyCollisionError.collisions:type_name -> v1.IdempotencyCollisionError - 3, // 13: v1.Concurrency.limit_strategy:type_name -> v1.ConcurrencyLimitStrategy - 20, // 14: v1.CreateTaskOpts.rate_limits:type_name -> v1.CreateTaskRateLimit - 26, // 15: v1.CreateTaskOpts.worker_labels:type_name -> v1.CreateTaskOpts.WorkerLabelsEntry - 18, // 16: v1.CreateTaskOpts.concurrency:type_name -> v1.Concurrency - 30, // 17: v1.CreateTaskOpts.conditions:type_name -> v1.TaskConditions - 27, // 18: v1.CreateTaskOpts.slot_requests:type_name -> v1.CreateTaskOpts.SlotRequestsEntry - 1, // 19: v1.CreateTaskRateLimit.duration:type_name -> v1.RateLimitDuration - 2, // 20: v1.TaskRunDetail.status:type_name -> v1.RunStatus - 2, // 21: v1.GetRunDetailsResponse.status:type_name -> v1.RunStatus - 28, // 22: v1.GetRunDetailsResponse.task_runs:type_name -> v1.GetRunDetailsResponse.TaskRunsEntry - 31, // 23: v1.TriggerWorkflowRunRequest.DesiredWorkerLabelsEntry.value:type_name -> v1.DesiredWorkerLabels - 31, // 24: v1.CreateTaskOpts.WorkerLabelsEntry.value:type_name -> v1.DesiredWorkerLabels - 23, // 25: v1.GetRunDetailsResponse.TaskRunsEntry.value:type_name -> v1.TaskRunDetail - 13, // 26: v1.AdminService.PutWorkflow:input_type -> v1.CreateWorkflowVersionRequest - 4, // 27: v1.AdminService.CancelTasks:input_type -> v1.CancelTasksRequest - 5, // 28: v1.AdminService.ReplayTasks:input_type -> v1.ReplayTasksRequest - 9, // 29: v1.AdminService.TriggerWorkflowRun:input_type -> v1.TriggerWorkflowRunRequest - 22, // 30: v1.AdminService.GetRunDetails:input_type -> v1.GetRunDetailsRequest - 11, // 31: v1.AdminService.BranchDurableTask:input_type -> v1.BranchDurableTaskRequest - 21, // 32: v1.AdminService.PutWorkflow:output_type -> v1.CreateWorkflowVersionResponse - 7, // 33: v1.AdminService.CancelTasks:output_type -> v1.CancelTasksResponse - 8, // 34: v1.AdminService.ReplayTasks:output_type -> v1.ReplayTasksResponse - 10, // 35: v1.AdminService.TriggerWorkflowRun:output_type -> v1.TriggerWorkflowRunResponse - 24, // 36: v1.AdminService.GetRunDetails:output_type -> v1.GetRunDetailsResponse - 12, // 37: v1.AdminService.BranchDurableTask:output_type -> v1.BranchDurableTaskResponse - 32, // [32:38] is the sub-list for method output_type - 26, // [26:32] is the sub-list for method input_type - 26, // [26:26] is the sub-list for extension type_name - 26, // [26:26] is the sub-list for extension extendee - 0, // [0:26] is the sub-list for field type_name + 19, // 9: v1.CreateWorkflowVersionRequest.concurrency_arr:type_name -> v1.Concurrency + 18, // 10: v1.CreateWorkflowVersionRequest.default_filters:type_name -> v1.DefaultFilter + 15, // 11: v1.CreateWorkflowVersionRequest.idempotency:type_name -> v1.IdempotencyConfig + 3, // 12: v1.IdempotencyConfig.method:type_name -> v1.IdempotencyMethod + 16, // 13: v1.BulkTriggerIdempotencyCollisionError.collisions:type_name -> v1.IdempotencyCollisionError + 4, // 14: v1.Concurrency.limit_strategy:type_name -> v1.ConcurrencyLimitStrategy + 21, // 15: v1.CreateTaskOpts.rate_limits:type_name -> v1.CreateTaskRateLimit + 27, // 16: v1.CreateTaskOpts.worker_labels:type_name -> v1.CreateTaskOpts.WorkerLabelsEntry + 19, // 17: v1.CreateTaskOpts.concurrency:type_name -> v1.Concurrency + 31, // 18: v1.CreateTaskOpts.conditions:type_name -> v1.TaskConditions + 28, // 19: v1.CreateTaskOpts.slot_requests:type_name -> v1.CreateTaskOpts.SlotRequestsEntry + 1, // 20: v1.CreateTaskRateLimit.duration:type_name -> v1.RateLimitDuration + 2, // 21: v1.TaskRunDetail.status:type_name -> v1.RunStatus + 2, // 22: v1.GetRunDetailsResponse.status:type_name -> v1.RunStatus + 29, // 23: v1.GetRunDetailsResponse.task_runs:type_name -> v1.GetRunDetailsResponse.TaskRunsEntry + 32, // 24: v1.TriggerWorkflowRunRequest.DesiredWorkerLabelsEntry.value:type_name -> v1.DesiredWorkerLabels + 32, // 25: v1.CreateTaskOpts.WorkerLabelsEntry.value:type_name -> v1.DesiredWorkerLabels + 24, // 26: v1.GetRunDetailsResponse.TaskRunsEntry.value:type_name -> v1.TaskRunDetail + 14, // 27: v1.AdminService.PutWorkflow:input_type -> v1.CreateWorkflowVersionRequest + 5, // 28: v1.AdminService.CancelTasks:input_type -> v1.CancelTasksRequest + 6, // 29: v1.AdminService.ReplayTasks:input_type -> v1.ReplayTasksRequest + 10, // 30: v1.AdminService.TriggerWorkflowRun:input_type -> v1.TriggerWorkflowRunRequest + 23, // 31: v1.AdminService.GetRunDetails:input_type -> v1.GetRunDetailsRequest + 12, // 32: v1.AdminService.BranchDurableTask:input_type -> v1.BranchDurableTaskRequest + 22, // 33: v1.AdminService.PutWorkflow:output_type -> v1.CreateWorkflowVersionResponse + 8, // 34: v1.AdminService.CancelTasks:output_type -> v1.CancelTasksResponse + 9, // 35: v1.AdminService.ReplayTasks:output_type -> v1.ReplayTasksResponse + 11, // 36: v1.AdminService.TriggerWorkflowRun:output_type -> v1.TriggerWorkflowRunResponse + 25, // 37: v1.AdminService.GetRunDetails:output_type -> v1.GetRunDetailsResponse + 13, // 38: v1.AdminService.BranchDurableTask:output_type -> v1.BranchDurableTaskResponse + 33, // [33:39] is the sub-list for method output_type + 27, // [27:33] is the sub-list for method input_type + 27, // [27:27] is the sub-list for extension type_name + 27, // [27:27] is the sub-list for extension extendee + 0, // [0:27] is the sub-list for field type_name } func init() { file_v1_workflows_proto_init() } @@ -2477,6 +2539,7 @@ func file_v1_workflows_proto_init() { file_v1_workflows_proto_msgTypes[2].OneofWrappers = []interface{}{} file_v1_workflows_proto_msgTypes[5].OneofWrappers = []interface{}{} file_v1_workflows_proto_msgTypes[9].OneofWrappers = []interface{}{} + file_v1_workflows_proto_msgTypes[10].OneofWrappers = []interface{}{} file_v1_workflows_proto_msgTypes[13].OneofWrappers = []interface{}{} file_v1_workflows_proto_msgTypes[14].OneofWrappers = []interface{}{} file_v1_workflows_proto_msgTypes[15].OneofWrappers = []interface{}{} @@ -2487,7 +2550,7 @@ func file_v1_workflows_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_v1_workflows_proto_rawDesc, - NumEnums: 4, + NumEnums: 5, NumMessages: 25, NumExtensions: 0, NumServices: 1, diff --git a/pkg/client/create/tasks.go b/pkg/client/create/tasks.go index 558dbaa850..1593e54ac5 100644 --- a/pkg/client/create/tasks.go +++ b/pkg/client/create/tasks.go @@ -8,13 +8,29 @@ import ( "github.com/hatchet-dev/hatchet/pkg/client/types" ) +// IdempotencyMethod determines how the lifetime of an idempotency key is managed. +type IdempotencyMethod string + +const ( + // IdempotencyMethodTTL evicts the idempotency key after a fixed time-to-live window. + IdempotencyMethodTTL IdempotencyMethod = "TTL" + + // IdempotencyMethodStatus keeps the idempotency key alive until the associated run + // reaches a terminal status. TTL acts as a fallback that caps how long the key can live. + IdempotencyMethodStatus IdempotencyMethod = "STATUS" +) + // IdempotencyConfig configures idempotency behavior for a workflow. type IdempotencyConfig struct { // Expression is a CEL expression evaluated against the workflow input to produce an idempotency key. Expression string // TTL is the duration during which duplicate runs with the same key are rejected. + // When Method is STATUS, this acts as a fallback: the longest the key can live before it's evicted. TTL time.Duration + + // Method determines how the idempotency key's lifetime is managed. Defaults to TTL. + Method IdempotencyMethod } // TaskDefaults defines default configuration values for tasks within a workflow. diff --git a/pkg/repository/sqlcv1/idempotency-keys.sql b/pkg/repository/sqlcv1/idempotency-keys.sql index a8ff5e53f8..7dda8a5768 100644 --- a/pkg/repository/sqlcv1/idempotency-keys.sql +++ b/pkg/repository/sqlcv1/idempotency-keys.sql @@ -62,3 +62,36 @@ SELECT FROM inputs i LEFT JOIN claims c USING (key) ; + +-- name: ReleaseIdempotencyKeys :exec +-- !! IMPORTANT: this only gets called when a task reaches a terminal state (exhausted all retries, completed, etc.) +-- which means we want to evict any idempotency keys that still are live and tied to the task at this point +WITH input AS ( + SELECT + UNNEST(@taskIds::BIGINT[]) AS task_id, + UNNEST(@taskInsertedAts::TIMESTAMPTZ[]) AS task_inserted_at, + UNNEST(@taskRetryCounts::INTEGER[]) AS retry_count +), relevant_tasks AS ( + SELECT t.tenant_id, t.idempotency_key + FROM v1_task t + JOIN "WorkflowVersion" wv ON t.workflow_version_id = wv.id + WHERE + (t.id, t.inserted_at, t.retry_count) IN (SELECT task_id, task_inserted_at, retry_count FROM input) + AND t.idempotency_key IS NOT NULL + AND wv."idempotencyMethod" = 'STATUS' +), keys_to_release AS ( + SELECT * + FROM v1_idempotency_key + WHERE (tenant_id, key) IN ( + SELECT tenant_id, idempotency_key + FROM relevant_tasks + ) + ORDER BY tenant_id, key + FOR UPDATE +) + +DELETE FROM v1_idempotency_key k +WHERE (tenant_id, key) IN ( + SELECT tenant_id, key + FROM keys_to_release +); diff --git a/pkg/repository/sqlcv1/idempotency-keys.sql.go b/pkg/repository/sqlcv1/idempotency-keys.sql.go index f806e63972..33004a0796 100644 --- a/pkg/repository/sqlcv1/idempotency-keys.sql.go +++ b/pkg/repository/sqlcv1/idempotency-keys.sql.go @@ -125,3 +125,48 @@ func (q *Queries) CleanUpExpiredIdempotencyKeys(ctx context.Context, db DBTX, te _, err := db.Exec(ctx, cleanUpExpiredIdempotencyKeys, tenantid) return err } + +const releaseIdempotencyKeys = `-- name: ReleaseIdempotencyKeys :exec +WITH input AS ( + SELECT + UNNEST($1::BIGINT[]) AS task_id, + UNNEST($2::TIMESTAMPTZ[]) AS task_inserted_at, + UNNEST($3::INTEGER[]) AS retry_count +), relevant_tasks AS ( + SELECT t.tenant_id, t.idempotency_key + FROM v1_task t + JOIN "WorkflowVersion" wv ON t.workflow_version_id = wv.id + WHERE + (t.id, t.inserted_at, t.retry_count) IN (SELECT task_id, task_inserted_at, retry_count FROM input) + AND t.idempotency_key IS NOT NULL + AND wv."idempotencyMethod" = 'STATUS' +), keys_to_release AS ( + SELECT tenant_id, key, expires_at, claimed_by_external_id, inserted_at, updated_at + FROM v1_idempotency_key + WHERE (tenant_id, key) IN ( + SELECT tenant_id, idempotency_key + FROM relevant_tasks + ) + ORDER BY tenant_id, key + FOR UPDATE +) + +DELETE FROM v1_idempotency_key k +WHERE (tenant_id, key) IN ( + SELECT tenant_id, key + FROM keys_to_release +) +` + +type ReleaseIdempotencyKeysParams struct { + Taskids []int64 `json:"taskids"` + Taskinsertedats []pgtype.Timestamptz `json:"taskinsertedats"` + Taskretrycounts []int32 `json:"taskretrycounts"` +} + +// !! IMPORTANT: this only gets called when a task reaches a terminal state (exhausted all retries, completed, etc.) +// which means we want to evict any idempotency keys that still are live and tied to the task at this point +func (q *Queries) ReleaseIdempotencyKeys(ctx context.Context, db DBTX, arg ReleaseIdempotencyKeysParams) error { + _, err := db.Exec(ctx, releaseIdempotencyKeys, arg.Taskids, arg.Taskinsertedats, arg.Taskretrycounts) + return err +} diff --git a/pkg/repository/sqlcv1/models.go b/pkg/repository/sqlcv1/models.go index 65139aa7de..d2c727be3e 100644 --- a/pkg/repository/sqlcv1/models.go +++ b/pkg/repository/sqlcv1/models.go @@ -58,6 +58,48 @@ func (ns NullConcurrencyLimitStrategy) Value() (driver.Value, error) { return string(ns.ConcurrencyLimitStrategy), nil } +type IdempotencyMethod string + +const ( + IdempotencyMethodTTL IdempotencyMethod = "TTL" + IdempotencyMethodSTATUS IdempotencyMethod = "STATUS" +) + +func (e *IdempotencyMethod) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = IdempotencyMethod(s) + case string: + *e = IdempotencyMethod(s) + default: + return fmt.Errorf("unsupported scan type for IdempotencyMethod: %T", src) + } + return nil +} + +type NullIdempotencyMethod struct { + IdempotencyMethod IdempotencyMethod `json:"idempotency_method"` + Valid bool `json:"valid"` // Valid is true if IdempotencyMethod is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullIdempotencyMethod) Scan(value interface{}) error { + if value == nil { + ns.IdempotencyMethod, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.IdempotencyMethod.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullIdempotencyMethod) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.IdempotencyMethod), nil +} + type InternalQueue string const ( @@ -4004,21 +4046,22 @@ type WorkflowTriggers struct { } type WorkflowVersion struct { - ID uuid.UUID `json:"id"` - CreatedAt pgtype.Timestamp `json:"createdAt"` - UpdatedAt pgtype.Timestamp `json:"updatedAt"` - DeletedAt pgtype.Timestamp `json:"deletedAt"` - Version pgtype.Text `json:"version"` - Order int64 `json:"order"` - WorkflowId uuid.UUID `json:"workflowId"` - Checksum string `json:"checksum"` - ScheduleTimeout string `json:"scheduleTimeout"` - OnFailureJobId *uuid.UUID `json:"onFailureJobId"` - Sticky NullStickyStrategy `json:"sticky"` - Kind WorkflowKind `json:"kind"` - DefaultPriority pgtype.Int4 `json:"defaultPriority"` - CreateWorkflowVersionOpts []byte `json:"createWorkflowVersionOpts"` - InputJsonSchema []byte `json:"inputJsonSchema"` - IdempotencyKeyExpression pgtype.Text `json:"idempotencyKeyExpression"` - IdempotencyKeyTtlMs pgtype.Int8 `json:"idempotencyKeyTtlMs"` + ID uuid.UUID `json:"id"` + CreatedAt pgtype.Timestamp `json:"createdAt"` + UpdatedAt pgtype.Timestamp `json:"updatedAt"` + DeletedAt pgtype.Timestamp `json:"deletedAt"` + Version pgtype.Text `json:"version"` + Order int64 `json:"order"` + WorkflowId uuid.UUID `json:"workflowId"` + Checksum string `json:"checksum"` + ScheduleTimeout string `json:"scheduleTimeout"` + OnFailureJobId *uuid.UUID `json:"onFailureJobId"` + Sticky NullStickyStrategy `json:"sticky"` + Kind WorkflowKind `json:"kind"` + DefaultPriority pgtype.Int4 `json:"defaultPriority"` + CreateWorkflowVersionOpts []byte `json:"createWorkflowVersionOpts"` + InputJsonSchema []byte `json:"inputJsonSchema"` + IdempotencyKeyExpression pgtype.Text `json:"idempotencyKeyExpression"` + IdempotencyKeyTtlMs pgtype.Int8 `json:"idempotencyKeyTtlMs"` + IdempotencyMethod NullIdempotencyMethod `json:"idempotencyMethod"` } diff --git a/pkg/repository/sqlcv1/tasks-overwrite.go b/pkg/repository/sqlcv1/tasks-overwrite.go index 9830e16d25..12ec3888c4 100644 --- a/pkg/repository/sqlcv1/tasks-overwrite.go +++ b/pkg/repository/sqlcv1/tasks-overwrite.go @@ -750,7 +750,8 @@ SELECT r.worker_id, i.retry_count::int AS retry_count, t.retry_count = i.retry_count AS is_current_retry, - t.concurrency_strategy_ids + t.concurrency_strategy_ids, + t.idempotency_key FROM v1_task t JOIN @@ -776,6 +777,7 @@ type ReleaseTasksRow struct { RetryCount int32 `json:"retry_count"` IsCurrentRetry bool `json:"is_current_retry"` ConcurrencyStrategyIds []int64 `json:"concurrency_strategy_ids"` + IdempotencyKey pgtype.Text `json:"idempotency_key"` } func (q *Queries) ReleaseTasks(ctx context.Context, db DBTX, arg ReleaseTasksParams) ([]*ReleaseTasksRow, error) { @@ -805,6 +807,7 @@ func (q *Queries) ReleaseTasks(ctx context.Context, db DBTX, arg ReleaseTasksPar &i.RetryCount, &i.IsCurrentRetry, &i.ConcurrencyStrategyIds, + &i.IdempotencyKey, ); err != nil { errCh <- err close(rowsCh) @@ -818,6 +821,7 @@ func (q *Queries) ReleaseTasks(ctx context.Context, db DBTX, arg ReleaseTasksPar close(errCh) return nil }) + batch.Queue(releaseRetryQueueItems, vals...) batch.Queue(releaseQueueItems, vals...) batch.Queue(lockParentConcurrencySlots, vals...) diff --git a/pkg/repository/sqlcv1/workflows.sql b/pkg/repository/sqlcv1/workflows.sql index a5cee618d4..d4911aed22 100644 --- a/pkg/repository/sqlcv1/workflows.sql +++ b/pkg/repository/sqlcv1/workflows.sql @@ -123,7 +123,8 @@ INSERT INTO "WorkflowVersion" ( "createWorkflowVersionOpts", "inputJsonSchema", "idempotencyKeyExpression", - "idempotencyKeyTtlMs" + "idempotencyKeyTtlMs", + "idempotencyMethod" ) VALUES ( @id::uuid, coalesce(sqlc.narg('createdAt')::timestamp, CURRENT_TIMESTAMP), @@ -140,7 +141,8 @@ INSERT INTO "WorkflowVersion" ( sqlc.narg('createWorkflowVersionOpts')::jsonb, sqlc.narg('inputJsonSchema')::jsonb, sqlc.narg('idempotencyKeyExpression')::text, - sqlc.narg('idempotencyKeyTtlMs')::bigint + sqlc.narg('idempotencyKeyTtlMs')::bigint, + sqlc.narg('idempotencyMethod')::idempotency_method ) RETURNING *; -- name: CreateJob :one diff --git a/pkg/repository/sqlcv1/workflows.sql.go b/pkg/repository/sqlcv1/workflows.sql.go index 43f2946d8b..25823dd7d5 100644 --- a/pkg/repository/sqlcv1/workflows.sql.go +++ b/pkg/repository/sqlcv1/workflows.sql.go @@ -920,7 +920,8 @@ INSERT INTO "WorkflowVersion" ( "createWorkflowVersionOpts", "inputJsonSchema", "idempotencyKeyExpression", - "idempotencyKeyTtlMs" + "idempotencyKeyTtlMs", + "idempotencyMethod" ) VALUES ( $1::uuid, coalesce($2::timestamp, CURRENT_TIMESTAMP), @@ -937,25 +938,27 @@ INSERT INTO "WorkflowVersion" ( $11::jsonb, $12::jsonb, $13::text, - $14::bigint -) RETURNING id, "createdAt", "updatedAt", "deletedAt", version, "order", "workflowId", checksum, "scheduleTimeout", "onFailureJobId", sticky, kind, "defaultPriority", "createWorkflowVersionOpts", "inputJsonSchema", "idempotencyKeyExpression", "idempotencyKeyTtlMs" + $14::bigint, + $15::idempotency_method +) RETURNING id, "createdAt", "updatedAt", "deletedAt", version, "order", "workflowId", checksum, "scheduleTimeout", "onFailureJobId", sticky, kind, "defaultPriority", "createWorkflowVersionOpts", "inputJsonSchema", "idempotencyKeyExpression", "idempotencyKeyTtlMs", "idempotencyMethod" ` type CreateWorkflowVersionParams struct { - ID uuid.UUID `json:"id"` - CreatedAt pgtype.Timestamp `json:"createdAt"` - UpdatedAt pgtype.Timestamp `json:"updatedAt"` - Deletedat pgtype.Timestamp `json:"deletedat"` - Checksum string `json:"checksum"` - Version pgtype.Text `json:"version"` - Workflowid uuid.UUID `json:"workflowid"` - Sticky NullStickyStrategy `json:"sticky"` - Kind NullWorkflowKind `json:"kind"` - DefaultPriority pgtype.Int4 `json:"defaultPriority"` - CreateWorkflowVersionOpts []byte `json:"createWorkflowVersionOpts"` - InputJsonSchema []byte `json:"inputJsonSchema"` - IdempotencyKeyExpression pgtype.Text `json:"idempotencyKeyExpression"` - IdempotencyKeyTtlMs pgtype.Int8 `json:"idempotencyKeyTtlMs"` + ID uuid.UUID `json:"id"` + CreatedAt pgtype.Timestamp `json:"createdAt"` + UpdatedAt pgtype.Timestamp `json:"updatedAt"` + Deletedat pgtype.Timestamp `json:"deletedat"` + Checksum string `json:"checksum"` + Version pgtype.Text `json:"version"` + Workflowid uuid.UUID `json:"workflowid"` + Sticky NullStickyStrategy `json:"sticky"` + Kind NullWorkflowKind `json:"kind"` + DefaultPriority pgtype.Int4 `json:"defaultPriority"` + CreateWorkflowVersionOpts []byte `json:"createWorkflowVersionOpts"` + InputJsonSchema []byte `json:"inputJsonSchema"` + IdempotencyKeyExpression pgtype.Text `json:"idempotencyKeyExpression"` + IdempotencyKeyTtlMs pgtype.Int8 `json:"idempotencyKeyTtlMs"` + IdempotencyMethod NullIdempotencyMethod `json:"idempotencyMethod"` } func (q *Queries) CreateWorkflowVersion(ctx context.Context, db DBTX, arg CreateWorkflowVersionParams) (*WorkflowVersion, error) { @@ -974,6 +977,7 @@ func (q *Queries) CreateWorkflowVersion(ctx context.Context, db DBTX, arg Create arg.InputJsonSchema, arg.IdempotencyKeyExpression, arg.IdempotencyKeyTtlMs, + arg.IdempotencyMethod, ) var i WorkflowVersion err := row.Scan( @@ -994,6 +998,7 @@ func (q *Queries) CreateWorkflowVersion(ctx context.Context, db DBTX, arg Create &i.InputJsonSchema, &i.IdempotencyKeyExpression, &i.IdempotencyKeyTtlMs, + &i.IdempotencyMethod, ) return &i, err } @@ -1247,7 +1252,7 @@ func (q *Queries) GetWorkflowShape(ctx context.Context, db DBTX, workflowversion const getWorkflowVersionById = `-- name: GetWorkflowVersionById :one SELECT - wv.id, wv."createdAt", wv."updatedAt", wv."deletedAt", wv.version, wv."order", wv."workflowId", wv.checksum, wv."scheduleTimeout", wv."onFailureJobId", wv.sticky, wv.kind, wv."defaultPriority", wv."createWorkflowVersionOpts", wv."inputJsonSchema", wv."idempotencyKeyExpression", wv."idempotencyKeyTtlMs", + wv.id, wv."createdAt", wv."updatedAt", wv."deletedAt", wv.version, wv."order", wv."workflowId", wv.checksum, wv."scheduleTimeout", wv."onFailureJobId", wv.sticky, wv.kind, wv."defaultPriority", wv."createWorkflowVersionOpts", wv."inputJsonSchema", wv."idempotencyKeyExpression", wv."idempotencyKeyTtlMs", wv."idempotencyMethod", w.id, w."createdAt", w."updatedAt", w."deletedAt", w."tenantId", w.name, w.description, w."isPaused" FROM "WorkflowVersion" as wv @@ -1284,6 +1289,7 @@ func (q *Queries) GetWorkflowVersionById(ctx context.Context, db DBTX, id uuid.U &i.WorkflowVersion.InputJsonSchema, &i.WorkflowVersion.IdempotencyKeyExpression, &i.WorkflowVersion.IdempotencyKeyTtlMs, + &i.WorkflowVersion.IdempotencyMethod, &i.Workflow.ID, &i.Workflow.CreatedAt, &i.Workflow.UpdatedAt, @@ -1372,7 +1378,7 @@ func (q *Queries) GetWorkflowVersionEventTriggerRefs(ctx context.Context, db DBT const getWorkflowVersionForEngine = `-- name: GetWorkflowVersionForEngine :many SELECT - workflowversions.id, workflowversions."createdAt", workflowversions."updatedAt", workflowversions."deletedAt", workflowversions.version, workflowversions."order", workflowversions."workflowId", workflowversions.checksum, workflowversions."scheduleTimeout", workflowversions."onFailureJobId", workflowversions.sticky, workflowversions.kind, workflowversions."defaultPriority", workflowversions."createWorkflowVersionOpts", workflowversions."inputJsonSchema", workflowversions."idempotencyKeyExpression", workflowversions."idempotencyKeyTtlMs", + workflowversions.id, workflowversions."createdAt", workflowversions."updatedAt", workflowversions."deletedAt", workflowversions.version, workflowversions."order", workflowversions."workflowId", workflowversions.checksum, workflowversions."scheduleTimeout", workflowversions."onFailureJobId", workflowversions.sticky, workflowversions.kind, workflowversions."defaultPriority", workflowversions."createWorkflowVersionOpts", workflowversions."inputJsonSchema", workflowversions."idempotencyKeyExpression", workflowversions."idempotencyKeyTtlMs", workflowversions."idempotencyMethod", w."name" as "workflowName", wc."limitStrategy" as "concurrencyLimitStrategy", wc."maxRuns" as "concurrencyMaxRuns", @@ -1432,6 +1438,7 @@ func (q *Queries) GetWorkflowVersionForEngine(ctx context.Context, db DBTX, arg &i.WorkflowVersion.InputJsonSchema, &i.WorkflowVersion.IdempotencyKeyExpression, &i.WorkflowVersion.IdempotencyKeyTtlMs, + &i.WorkflowVersion.IdempotencyMethod, &i.WorkflowName, &i.ConcurrencyLimitStrategy, &i.ConcurrencyMaxRuns, @@ -1498,7 +1505,7 @@ const linkOnFailureJob = `-- name: LinkOnFailureJob :one UPDATE "WorkflowVersion" SET "onFailureJobId" = $1::uuid WHERE "id" = $2::uuid -RETURNING id, "createdAt", "updatedAt", "deletedAt", version, "order", "workflowId", checksum, "scheduleTimeout", "onFailureJobId", sticky, kind, "defaultPriority", "createWorkflowVersionOpts", "inputJsonSchema", "idempotencyKeyExpression", "idempotencyKeyTtlMs" +RETURNING id, "createdAt", "updatedAt", "deletedAt", version, "order", "workflowId", checksum, "scheduleTimeout", "onFailureJobId", sticky, kind, "defaultPriority", "createWorkflowVersionOpts", "inputJsonSchema", "idempotencyKeyExpression", "idempotencyKeyTtlMs", "idempotencyMethod" ` type LinkOnFailureJobParams struct { @@ -1527,6 +1534,7 @@ func (q *Queries) LinkOnFailureJob(ctx context.Context, db DBTX, arg LinkOnFailu &i.InputJsonSchema, &i.IdempotencyKeyExpression, &i.IdempotencyKeyTtlMs, + &i.IdempotencyMethod, ) return &i, err } diff --git a/pkg/repository/task.go b/pkg/repository/task.go index 4d80a86d07..02b0888e2b 100644 --- a/pkg/repository/task.go +++ b/pkg/repository/task.go @@ -2953,6 +2953,35 @@ func (r *sharedRepository) createTaskEventsAfterRelease( ) } +type ReleaseIdempotencyKeysOpt struct { + *TaskIdInsertedAtRetryCount + EventType sqlcv1.V1TaskEventType +} + +func (r *sharedRepository) releaseIdempotencyKeysForStatusPolicies( + ctx context.Context, + dbtx sqlcv1.DBTX, + opts []ReleaseIdempotencyKeysOpt, +) error { + // !! IMPORTANT: this only gets called when a task reaches a terminal state (exhausted all retries, completed, etc.) + // which means we want to evict any idempotency keys that still are live and tied to the task at this point + taskIds := make([]int64, len(opts)) + taskInsertedAts := make([]pgtype.Timestamptz, len(opts)) + taskRetryCounts := make([]int32, len(opts)) + + for i, opt := range opts { + taskIds[i] = opt.Id + taskInsertedAts[i] = opt.InsertedAt + taskRetryCounts[i] = opt.RetryCount + } + + return r.queries.ReleaseIdempotencyKeys(ctx, dbtx, sqlcv1.ReleaseIdempotencyKeysParams{ + Taskids: taskIds, + Taskinsertedats: taskInsertedAts, + Taskretrycounts: taskRetryCounts, + }) +} + func (r *sharedRepository) createTaskEvents( ctx context.Context, dbtx sqlcv1.DBTX, @@ -2978,6 +3007,7 @@ func (r *sharedRepository) createTaskEvents( internalTaskEvents := make([]InternalTaskEvent, len(tasks)) externalIdToData := make(map[uuid.UUID][]byte, len(tasks)) + releaseIdempotencyKeysOpts := make([]ReleaseIdempotencyKeysOpt, len(tasks)) for i, task := range tasks { taskIds[i] = task.Id @@ -3009,6 +3039,15 @@ func (r *sharedRepository) createTaskEvents( EventKey: eventKeys[i], Data: eventDatas[i], } + + releaseIdempotencyKeysOpts[i] = ReleaseIdempotencyKeysOpt{ + TaskIdInsertedAtRetryCount: &TaskIdInsertedAtRetryCount{ + Id: task.Id, + InsertedAt: task.InsertedAt, + RetryCount: task.RetryCount, + }, + EventType: eventTypes[i], + } } taskEvents, err := r.queries.CreateTaskEvents(ctx, dbtx, sqlcv1.CreateTaskEventsParams{ @@ -3026,6 +3065,10 @@ func (r *sharedRepository) createTaskEvents( return nil, err } + if err := r.releaseIdempotencyKeysForStatusPolicies(ctx, dbtx, releaseIdempotencyKeysOpts); err != nil { + return nil, fmt.Errorf("failed to release idempotency keys: %w", err) + } + storePayloadOpts := make([]StorePayloadOpts, len(taskEvents)) for i, taskEvent := range taskEvents { diff --git a/pkg/repository/workflow.go b/pkg/repository/workflow.go index e0bbda6e4f..d84d58d304 100644 --- a/pkg/repository/workflow.go +++ b/pkg/repository/workflow.go @@ -61,8 +61,9 @@ type CreateWorkflowVersionOpts struct { } type IdempotencyConfig struct { - Expression string `json:"expression" validate:"required,celworkflowrunstr"` - TTLMs int64 `json:"ttlMs" validate:"required,min=1"` + Expression string `json:"expression" validate:"required,celworkflowrunstr"` + TTLMs int64 `json:"ttlMs" validate:"required,min=1"` + Method sqlcv1.IdempotencyMethod `json:"method" validate:"required,oneof=TTL STATUS"` } type CreateConcurrencyOpts struct { @@ -436,11 +437,16 @@ func (r *workflowRepository) createWorkflowVersionTxs(ctx context.Context, tx sq if opts.Idempotency != nil { idempotency := *opts.Idempotency + createParams.IdempotencyKeyExpression = pgtype.Text{ String: idempotency.Expression, Valid: true, } createParams.IdempotencyKeyTtlMs = sqlchelpers.ToBigInt(&idempotency.TTLMs) + createParams.IdempotencyMethod = sqlcv1.NullIdempotencyMethod{ + IdempotencyMethod: idempotency.Method, + Valid: true, + } } if opts.DefaultPriority != nil { diff --git a/sdks/go/examples/idempotency/worker.go b/sdks/go/examples/idempotency/worker.go index 030e6af6c0..2063da0984 100644 --- a/sdks/go/examples/idempotency/worker.go +++ b/sdks/go/examples/idempotency/worker.go @@ -32,3 +32,22 @@ func IdempotentTask(client *hatchet.Client) *hatchet.StandaloneTask { } // !! + +// > status-based-idempotency +func IdempotentStatusBasedTask(client *hatchet.Client) *hatchet.StandaloneTask { + return client.NewStandaloneTask( + "idempotent-status-based-task", + func(ctx hatchet.Context, input IdempotencyInput) (*IdempotencyOutput, error) { + return &IdempotencyOutput{ + Result: fmt.Sprintf("Hello, world from task %s", input.ID), + }, nil + }, + hatchet.WithWorkflowIdempotency(hatchet.IdempotencyConfig{ + Expression: "input.id", + Method: hatchet.IdempotencyMethodStatus, + TTL: 10 * time.Second, + }), + ) +} + +// !! diff --git a/sdks/go/internal/declaration.go b/sdks/go/internal/declaration.go index b0efb82ff9..a50bfa5dd6 100644 --- a/sdks/go/internal/declaration.go +++ b/sdks/go/internal/declaration.go @@ -672,9 +672,15 @@ func (w *workflowDeclarationImpl[I, O]) Dump() (*contracts.CreateWorkflowVersion } if w.Idempotency != nil { + method := contracts.IdempotencyMethod_TTL + if w.Idempotency.Method == create.IdempotencyMethodStatus { + method = contracts.IdempotencyMethod_STATUS + } + req.Idempotency = &contracts.IdempotencyConfig{ Expression: w.Idempotency.Expression, TtlMs: w.Idempotency.TTL.Milliseconds(), + Method: &method, } } diff --git a/sdks/go/workflow.go b/sdks/go/workflow.go index b498005f6e..41cf31a217 100644 --- a/sdks/go/workflow.go +++ b/sdks/go/workflow.go @@ -149,15 +149,33 @@ func (w *Workflow) GetName() string { // WorkflowOption configures a workflow instance. type WorkflowOption func(*workflowConfig) +// IdempotencyMethod determines how the lifetime of an idempotency key is managed. +type IdempotencyMethod = create.IdempotencyMethod + +const ( + // IdempotencyMethodTTL evicts the idempotency key after a fixed time-to-live window. + IdempotencyMethodTTL = create.IdempotencyMethodTTL + + // IdempotencyMethodStatus keeps the idempotency key alive until the associated run + // reaches a terminal status. TTL acts as a fallback that caps how long the key can live. + IdempotencyMethodStatus = create.IdempotencyMethodStatus +) + // IdempotencyConfig configures idempotency behavior for a workflow or standalone task. -// When set, runs triggered with the same computed key within the TTL window return an -// IdempotencyCollisionError instead of creating a new run. +// When set, runs triggered with the same computed key return an IdempotencyCollisionError +// instead of creating a new run. The Method controls how long the key lives: TTL evicts +// after a fixed window, while STATUS keeps the key until the run reaches a terminal status +// (using TTL as a fallback cap). type IdempotencyConfig struct { // Expression is a CEL expression evaluated against the workflow input to produce an idempotency key. Expression string // TTL is the duration during which duplicate runs with the same key are rejected. + // When Method is STATUS, this acts as a fallback: the longest the key can live before it's evicted. TTL time.Duration + + // Method determines how the idempotency key's lifetime is managed. Defaults to TTL. + Method IdempotencyMethod } type workflowConfig struct { @@ -293,6 +311,7 @@ func newWorkflow(name string, v0Client v0Client.Client, options ...WorkflowOptio createOpts.Idempotency = &create.IdempotencyConfig{ Expression: config.idempotency.Expression, TTL: config.idempotency.TTL, + Method: config.idempotency.Method, } } diff --git a/sdks/python/CHANGELOG.md b/sdks/python/CHANGELOG.md index 834fb88237..2442af19ef 100644 --- a/sdks/python/CHANGELOG.md +++ b/sdks/python/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Hatchet's Python SDK will be documented in this changelog The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.36.0] - 2026-07-21 + +### Added + +- Adds support for terminal status-based idempotency keys, which are released when the task holding the key reaches a terminal state (either completed, cancelled, or having failed and exhausted all retries). + ## [1.35.1] - 2026-07-20 ### Fixed diff --git a/sdks/python/examples/cron/cron_input.py b/sdks/python/examples/cron/cron_input.py index 90343197b3..142d763ddc 100644 --- a/sdks/python/examples/cron/cron_input.py +++ b/sdks/python/examples/cron/cron_input.py @@ -15,7 +15,6 @@ class CronInput(BaseModel): @hatchet.task( - name="CronInputWorkflow", input_validator=CronInput, on_crons=["* * * * *"], cron_input=CronInput(name="Hatchet"), diff --git a/sdks/python/examples/cron/test_cron_input.py b/sdks/python/examples/cron/test_cron_input.py index 50c5d45e92..520d466d24 100644 --- a/sdks/python/examples/cron/test_cron_input.py +++ b/sdks/python/examples/cron/test_cron_input.py @@ -1,17 +1,38 @@ import pytest +from typing import cast -from examples.cron.cron_input import CronInput, cron_input_example_send_greeting +from hatchet_sdk import Hatchet + +from examples.cron.cron_input import cron_input_example_send_greeting + + +async def test_cron_workflow_has_input_on_proto() -> None: + proto = cron_input_example_send_greeting.to_proto() + assert proto.HasField("cron_input") @pytest.mark.asyncio(loop_scope="session") -async def test_cron_input_workflow_running_options() -> None: - input = CronInput(name="Hatchet") +async def test_cron_input_workflow_running_options(hatchet: Hatchet) -> None: + with hatchet.cron.client() as client: + cron = await hatchet.cron.aio_list( + workflow_id=cron_input_example_send_greeting.id + ) - result = cron_input_example_send_greeting.run(input) - aio_result = await cron_input_example_send_greeting.aio_run(input) + assert cron.rows is not None + assert len(cron.rows) == 1 + cron_id = cron.rows[0].metadata.id - for r in (result, aio_result): - assert r == {"message": "Hello, Hatchet!"} + trigger_res = hatchet.cron._wa(client).workflow_cron_trigger( + tenant=hatchet.tenant_id, + cron_workflow=cron_id, + ) - proto = cron_input_example_send_greeting.to_proto() - assert proto.HasField("cron_input") + ref = hatchet.runs.get_run_ref(trigger_res.external_id) + res = cast( + dict[str, str], + (await ref.aio_result()).get("cron_input_example_send_greeting"), + ) + + assert res == {"message": "Hello, Hatchet!"} + + await hatchet.workflows.aio_delete(cron_input_example_send_greeting.id) diff --git a/sdks/python/examples/idempotency/test_idempotency.py b/sdks/python/examples/idempotency/test_idempotency.py index b331fe7b48..5ab7a25c6a 100644 --- a/sdks/python/examples/idempotency/test_idempotency.py +++ b/sdks/python/examples/idempotency/test_idempotency.py @@ -3,6 +3,8 @@ from examples.idempotency.worker import ( idempotent_task, idempotent_task_short_window, + idempotent_status_based_task, + idempotent_status_based_task_with_retries, IdempotencyInput, EVENT_KEY, ) @@ -18,6 +20,7 @@ from datetime import timedelta, datetime, timezone import asyncio from typing import cast +from time import time @pytest.mark.asyncio(loop_scope="session") @@ -60,6 +63,220 @@ async def test_idempotency_keys_prevent_duplicate_runs_direct_trigger( assert "hello" in result["result"].lower() +@pytest.mark.asyncio(loop_scope="session") +async def test_idempotency_status_based( + hatchet: Hatchet, +) -> None: + start = time() + test_run_id = str(uuid4()) + ref1 = await idempotent_status_based_task.aio_run( + input=IdempotencyInput(id=test_run_id), + wait_for_result=False, + additional_metadata={"test_run_id": test_run_id}, + ) + + with pytest.raises(IdempotencyCollisionError) as exc_info: + await idempotent_status_based_task.aio_run( + input=IdempotencyInput(id=test_run_id), wait_for_result=False + ) + + assert exc_info.value.existing_run_external_id == ref1.workflow_run_id + + res1 = await ref1.aio_result() + + assert time() - start >= 2 + assert ( + time() - start < 10 + ), "The task should have completed within the TTL window so we can test that the status-based idempotency is working." + + ref2 = await idempotent_status_based_task.aio_run( + input=IdempotencyInput(id=test_run_id), + wait_for_result=False, + additional_metadata={"test_run_id": test_run_id}, + ) + res2 = await ref2.aio_result() + + assert ( + res1 == res2 + ), "The result of the second run should be the same as the first run." + assert ( + time() - start >= 4 + ), "The second run should have waited for the first run to complete before returning the result." + assert ( + time() - start < 10 + ), "The second run should have completed within the TTL window so we can test that the status-based idempotency is working." + + runs: V1TaskSummaryList | None = None + + for _ in range(15): + runs = await hatchet.runs.aio_list( + since=datetime.now(timezone.utc) - timedelta(minutes=5), + additional_metadata={"test_run_id": test_run_id}, + ) + + if len(runs.rows) == 0: + await asyncio.sleep(1) + continue + + break + + assert runs is not None + assert len(runs.rows) == 2 + assert {r.metadata.id for r in runs.rows} == { + ref1.workflow_run_id, + ref2.workflow_run_id, + } + + result1 = await ref1.aio_result() + assert "hello" in result1["result"].lower() + + assert result1 == res2 + + +@pytest.mark.asyncio(loop_scope="session") +async def test_idempotency_status_based_failure( + hatchet: Hatchet, +) -> None: + start = time() + test_run_id = str(uuid4()) + ref1 = await idempotent_status_based_task.aio_run( + input=IdempotencyInput(id=test_run_id, desired_status="fail"), + wait_for_result=False, + additional_metadata={"test_run_id": test_run_id}, + ) + + with pytest.raises(IdempotencyCollisionError) as exc_info: + await idempotent_status_based_task.aio_run( + input=IdempotencyInput(id=test_run_id), wait_for_result=False + ) + + assert exc_info.value.existing_run_external_id == ref1.workflow_run_id + + with pytest.raises(Exception) as exc_info_2: + await ref1.aio_result() + + assert "failed as requested" in str(exc_info_2.value).lower() + assert ref1.workflow_run_id in str(exc_info_2.value).lower() + + assert time() - start >= 2 + assert ( + time() - start < 10 + ), "The task should have finished within the TTL window so we can test that the status-based idempotency is working." + + ref2 = await idempotent_status_based_task.aio_run( + input=IdempotencyInput(id=test_run_id, desired_status="fail"), + wait_for_result=False, + additional_metadata={"test_run_id": test_run_id}, + ) + + with pytest.raises(Exception) as exc_info_2: + await ref2.aio_result() + + assert "failed as requested" in str(exc_info_2.value).lower() + assert ref2.workflow_run_id in str(exc_info_2.value).lower() + + assert ( + time() - start >= 4 + ), "The second run should have waited for the first run to complete before returning the result." + assert ( + time() - start < 10 + ), "The second run should have completed within the TTL window so we can test that the status-based idempotency is working." + + runs: V1TaskSummaryList | None = None + + for _ in range(15): + runs = await hatchet.runs.aio_list( + since=datetime.now(timezone.utc) - timedelta(minutes=5), + additional_metadata={"test_run_id": test_run_id}, + ) + + if len(runs.rows) == 0: + await asyncio.sleep(1) + continue + + break + + assert runs is not None + assert len(runs.rows) == 2 + assert {r.metadata.id for r in runs.rows} == { + ref1.workflow_run_id, + ref2.workflow_run_id, + } + + +@pytest.mark.asyncio(loop_scope="session") +async def test_idempotency_status_based_cancel( + hatchet: Hatchet, +) -> None: + start = time() + test_run_id = str(uuid4()) + ref1 = await idempotent_status_based_task.aio_run( + input=IdempotencyInput(id=test_run_id, desired_status="cancel"), + wait_for_result=False, + additional_metadata={"test_run_id": test_run_id}, + ) + + with pytest.raises(IdempotencyCollisionError) as exc_info: + await idempotent_status_based_task.aio_run( + input=IdempotencyInput(id=test_run_id), wait_for_result=False + ) + + assert exc_info.value.existing_run_external_id == ref1.workflow_run_id + + await ref1.aio_result() + + details = await hatchet.runs.aio_get_details(ref1.workflow_run_id) + + assert details.status == RunStatus.CANCELLED + assert details.external_id == ref1.workflow_run_id + + assert time() - start >= 1 + assert ( + time() - start < 10 + ), "The task should have finished within the TTL window so we can test that the status-based idempotency is working." + + ref2 = await idempotent_status_based_task.aio_run( + input=IdempotencyInput(id=test_run_id, desired_status="cancel"), + wait_for_result=False, + additional_metadata={"test_run_id": test_run_id}, + ) + + await ref2.aio_result() + + details = await hatchet.runs.aio_get_details(ref1.workflow_run_id) + + assert details.status == RunStatus.CANCELLED + assert details.external_id == ref1.workflow_run_id + + assert ( + time() - start >= 2 + ), "The second run should have waited for the first run to complete before returning the result." + assert ( + time() - start < 10 + ), "The second run should have completed within the TTL window so we can test that the status-based idempotency is working." + + runs: V1TaskSummaryList | None = None + + for _ in range(15): + runs = await hatchet.runs.aio_list( + since=datetime.now(timezone.utc) - timedelta(minutes=5), + additional_metadata={"test_run_id": test_run_id}, + ) + + if len(runs.rows) == 0: + await asyncio.sleep(1) + continue + + break + + assert runs is not None + assert len(runs.rows) == 2 + assert {r.metadata.id for r in runs.rows} == { + ref1.workflow_run_id, + ref2.workflow_run_id, + } + + @pytest.mark.asyncio(loop_scope="session") async def test_idempotency_keys_prevent_duplicate_runs_bulk_trigger( hatchet: Hatchet, @@ -170,6 +387,8 @@ async def test_idempotency_keys_prevent_duplicate_runs_event_trigger( assert runs is not None assert len(runs.rows) == 1 + await asyncio.sleep(1) + details = await hatchet.event.aio_list( event_ids=[e1.event_id, e2.event_id], ) @@ -194,3 +413,166 @@ async def test_idempotency_keys_prevent_duplicate_runs_event_trigger( continue assert run_details.status == RunStatus.COMPLETED + + +async def _wait_for_retry_count( + hatchet: Hatchet, test_run_id: str, min_retry_count: int +) -> None: + for _ in range(30): + runs = await hatchet.runs.aio_list( + since=datetime.now(timezone.utc) - timedelta(minutes=5), + additional_metadata={"test_run_id": test_run_id}, + ) + + if runs.rows and (runs.rows[0].retry_count or 0) >= min_retry_count: + return + + await asyncio.sleep(1) + + pytest.fail(f"Timed out waiting for run to reach retry_count >= {min_retry_count}.") + + +@pytest.mark.asyncio(loop_scope="session") +async def test_idempotency_status_based_key_held_across_all_retries_until_exhausted( + hatchet: Hatchet, +) -> None: + test_run_id = str(uuid4()) + ref1 = await idempotent_status_based_task_with_retries.aio_run( + input=IdempotencyInput(id=test_run_id, desired_status="fail"), + wait_for_result=False, + additional_metadata={"test_run_id": test_run_id}, + ) + + with pytest.raises(IdempotencyCollisionError) as exc_info: + await idempotent_status_based_task_with_retries.aio_run( + input=IdempotencyInput(id=test_run_id), wait_for_result=False + ) + + assert exc_info.value.existing_run_external_id == ref1.workflow_run_id + + await _wait_for_retry_count(hatchet, test_run_id, min_retry_count=1) + + with pytest.raises(IdempotencyCollisionError) as exc_info_mid_retry: + await idempotent_status_based_task_with_retries.aio_run( + input=IdempotencyInput(id=test_run_id), wait_for_result=False + ) + + assert exc_info_mid_retry.value.existing_run_external_id == ref1.workflow_run_id + + await _wait_for_retry_count(hatchet, test_run_id, min_retry_count=2) + + with pytest.raises(IdempotencyCollisionError) as exc_info_late_retry: + await idempotent_status_based_task_with_retries.aio_run( + input=IdempotencyInput(id=test_run_id), wait_for_result=False + ) + + assert exc_info_late_retry.value.existing_run_external_id == ref1.workflow_run_id + + with pytest.raises(Exception) as exc_info_final: + await ref1.aio_result() + + assert "failed as requested" in str(exc_info_final.value).lower() + + details = await hatchet.runs.aio_get_details(ref1.workflow_run_id) + assert details.status == RunStatus.FAILED + + ref2 = await idempotent_status_based_task_with_retries.aio_run( + input=IdempotencyInput(id=test_run_id, desired_status="fail"), + wait_for_result=False, + additional_metadata={"test_run_id": test_run_id}, + ) + assert ref2.workflow_run_id != ref1.workflow_run_id + + with pytest.raises(Exception) as exc_info_ref2: + await ref2.aio_result() + + assert "failed as requested" in str(exc_info_ref2.value).lower() + + +@pytest.mark.asyncio(loop_scope="session") +async def test_idempotency_status_based_key_released_immediately_on_success_after_retry( + hatchet: Hatchet, +) -> None: + test_run_id = str(uuid4()) + ref1 = await idempotent_status_based_task_with_retries.aio_run( + input=IdempotencyInput(id=test_run_id, desired_status="success"), + wait_for_result=False, + additional_metadata={"test_run_id": test_run_id}, + ) + + with pytest.raises(IdempotencyCollisionError) as exc_info: + await idempotent_status_based_task_with_retries.aio_run( + input=IdempotencyInput(id=test_run_id), wait_for_result=False + ) + + assert exc_info.value.existing_run_external_id == ref1.workflow_run_id + + await _wait_for_retry_count(hatchet, test_run_id, min_retry_count=1) + + with pytest.raises(IdempotencyCollisionError) as exc_info_mid_retry: + await idempotent_status_based_task_with_retries.aio_run( + input=IdempotencyInput(id=test_run_id), wait_for_result=False + ) + + assert exc_info_mid_retry.value.existing_run_external_id == ref1.workflow_run_id + + result = await ref1.aio_result() + assert "hello" in result["result"].lower() + + details = await hatchet.runs.aio_get_details(ref1.workflow_run_id) + assert details.status == RunStatus.COMPLETED + + ref2 = await idempotent_status_based_task_with_retries.aio_run( + input=IdempotencyInput(id=test_run_id, desired_status="success"), + wait_for_result=False, + additional_metadata={"test_run_id": test_run_id}, + ) + assert ref2.workflow_run_id != ref1.workflow_run_id + + result2 = await ref2.aio_result() + assert "hello" in result2["result"].lower() + + +@pytest.mark.asyncio(loop_scope="session") +async def test_idempotency_status_based_key_released_immediately_on_cancel_after_retry( + hatchet: Hatchet, +) -> None: + test_run_id = str(uuid4()) + ref1 = await idempotent_status_based_task_with_retries.aio_run( + input=IdempotencyInput(id=test_run_id, desired_status="cancel"), + wait_for_result=False, + additional_metadata={"test_run_id": test_run_id}, + ) + + with pytest.raises(IdempotencyCollisionError) as exc_info: + await idempotent_status_based_task_with_retries.aio_run( + input=IdempotencyInput(id=test_run_id), wait_for_result=False + ) + + assert exc_info.value.existing_run_external_id == ref1.workflow_run_id + + await _wait_for_retry_count(hatchet, test_run_id, min_retry_count=1) + + with pytest.raises(IdempotencyCollisionError) as exc_info_mid_retry: + await idempotent_status_based_task_with_retries.aio_run( + input=IdempotencyInput(id=test_run_id), wait_for_result=False + ) + + assert exc_info_mid_retry.value.existing_run_external_id == ref1.workflow_run_id + + await ref1.aio_result() + + details = await hatchet.runs.aio_get_details(ref1.workflow_run_id) + assert details.status == RunStatus.CANCELLED + + ref2 = await idempotent_status_based_task_with_retries.aio_run( + input=IdempotencyInput(id=test_run_id, desired_status="cancel"), + wait_for_result=False, + additional_metadata={"test_run_id": test_run_id}, + ) + assert ref2.workflow_run_id != ref1.workflow_run_id + + await ref2.aio_result() + + details2 = await hatchet.runs.aio_get_details(ref2.workflow_run_id) + assert details2.status == RunStatus.CANCELLED diff --git a/sdks/python/examples/idempotency/worker.py b/sdks/python/examples/idempotency/worker.py index 3cd3437d98..dd21270638 100644 --- a/sdks/python/examples/idempotency/worker.py +++ b/sdks/python/examples/idempotency/worker.py @@ -1,6 +1,13 @@ -from hatchet_sdk import Context, Hatchet, TTLBasedIdempotencyConfig +from hatchet_sdk import ( + Context, + Hatchet, + TTLBasedIdempotencyConfig, + StatusBasedIdempotencyConfig, +) from datetime import timedelta from pydantic import BaseModel +import asyncio +from typing import Literal hatchet = Hatchet() @@ -11,6 +18,7 @@ class IdempotencyInput(BaseModel): id: str + desired_status: Literal["success", "cancel", "fail"] = "success" @hatchet.task( @@ -40,10 +48,82 @@ async def idempotent_task_short_window( return {"result": f"Hello, world from task {input.id}"} +# > status_based_idempotency +@hatchet.task( + idempotency=StatusBasedIdempotencyConfig( + key_expression="input.id", fallback_ttl=timedelta(seconds=10) + ), + input_validator=IdempotencyInput, +) +async def idempotent_status_based_task( + input: IdempotencyInput, + ctx: Context, +) -> dict[str, str]: + if input.desired_status == "success": + await asyncio.sleep(2) + return {"result": f"Hello, world from task {input.id}"} + + if input.desired_status == "fail": + await asyncio.sleep(2) + raise Exception(f"Task {input.id} failed as requested.") + + if input.desired_status == "cancel": + await asyncio.sleep(1) + await ctx.aio_cancel() + for _ in range(10): + await asyncio.sleep(1) + + raise Exception(f"Task {input.id} should have been cancelled, but was not.") + + +# !! + + +# > status_based_idempotency_with_retries +@hatchet.task( + idempotency=StatusBasedIdempotencyConfig( + key_expression="input.id", fallback_ttl=timedelta(seconds=30) + ), + input_validator=IdempotencyInput, + retries=3, +) +async def idempotent_status_based_task_with_retries( + input: IdempotencyInput, + ctx: Context, +) -> dict[str, str]: + if input.desired_status == "fail": + await asyncio.sleep(2) + raise Exception(f"Task {input.id} failed as requested.") + + if ctx.retry_count == 0: + await asyncio.sleep(2) + raise Exception(f"Task {input.id} failed as requested (attempt 1).") + + if input.desired_status == "success": + await asyncio.sleep(2) + return {"result": f"Hello, world from task {input.id}"} + + if input.desired_status == "cancel": + await asyncio.sleep(1) + await ctx.aio_cancel() + for _ in range(10): + await asyncio.sleep(1) + + raise Exception(f"Task {input.id} should have been cancelled, but was not.") + + +# !! + + def main() -> None: worker = hatchet.worker( "test-worker", - workflows=[idempotent_task], + workflows=[ + idempotent_task, + idempotent_task_short_window, + idempotent_status_based_task, + idempotent_status_based_task_with_retries, + ], ) worker.start() diff --git a/sdks/python/examples/worker.py b/sdks/python/examples/worker.py index 4e18a96a90..efe91fc8fd 100644 --- a/sdks/python/examples/worker.py +++ b/sdks/python/examples/worker.py @@ -110,7 +110,12 @@ durable_parent_child_key_bug, child_child_key_bug, ) -from examples.idempotency.worker import idempotent_task, idempotent_task_short_window +from examples.idempotency.worker import ( + idempotent_task, + idempotent_task_short_window, + idempotent_status_based_task, + idempotent_status_based_task_with_retries, +) from examples.bug_tests.durable_spawn_index_collision.worker import ( durable_spawn_index_collision, spawn_index_child_a, @@ -218,6 +223,8 @@ def main() -> None: durable_spawn_many_dags, idempotent_task, idempotent_task_short_window, + idempotent_status_based_task, + idempotent_status_based_task_with_retries, error_raising_durable_parent, error_raising_task, ], diff --git a/sdks/python/hatchet_sdk/__init__.py b/sdks/python/hatchet_sdk/__init__.py index e1df6d15fa..1125606da2 100644 --- a/sdks/python/hatchet_sdk/__init__.py +++ b/sdks/python/hatchet_sdk/__init__.py @@ -169,7 +169,10 @@ ConcurrencyExpression, ConcurrencyLimitStrategy, ) -from hatchet_sdk.types.idempotency import TTLBasedIdempotencyConfig +from hatchet_sdk.types.idempotency import ( + StatusBasedIdempotencyConfig, + TTLBasedIdempotencyConfig, +) from hatchet_sdk.types.labels import ( DesiredWorkerLabel, WorkerLabel, @@ -277,6 +280,7 @@ "ScheduleTriggerWorkflowOptions", "SleepCondition", "SlotType", + "StatusBasedIdempotencyConfig", "StepRun", "StepRunDiff", "StepRunEventType", diff --git a/sdks/python/hatchet_sdk/contracts/v1/workflows_pb2.py b/sdks/python/hatchet_sdk/contracts/v1/workflows_pb2.py index 9ada351972..9da6322007 100644 --- a/sdks/python/hatchet_sdk/contracts/v1/workflows_pb2.py +++ b/sdks/python/hatchet_sdk/contracts/v1/workflows_pb2.py @@ -27,7 +27,7 @@ from hatchet_sdk.contracts.v1.shared import trigger_pb2 as v1_dot_shared_dot_trigger__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x12v1/workflows.proto\x12\x02v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x19v1/shared/condition.proto\x1a\x17v1/shared/trigger.proto\"[\n\x12\x43\x61ncelTasksRequest\x12\x14\n\x0c\x65xternal_ids\x18\x01 \x03(\t\x12$\n\x06\x66ilter\x18\x02 \x01(\x0b\x32\x0f.v1.TasksFilterH\x00\x88\x01\x01\x42\t\n\x07_filter\"[\n\x12ReplayTasksRequest\x12\x14\n\x0c\x65xternal_ids\x18\x01 \x03(\t\x12$\n\x06\x66ilter\x18\x02 \x01(\x0b\x32\x0f.v1.TasksFilterH\x00\x88\x01\x01\x42\t\n\x07_filter\"\xb7\x01\n\x0bTasksFilter\x12\x10\n\x08statuses\x18\x01 \x03(\t\x12)\n\x05since\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12.\n\x05until\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x00\x88\x01\x01\x12\x14\n\x0cworkflow_ids\x18\x04 \x03(\t\x12\x1b\n\x13\x61\x64\x64itional_metadata\x18\x05 \x03(\tB\x08\n\x06_until\".\n\x13\x43\x61ncelTasksResponse\x12\x17\n\x0f\x63\x61ncelled_tasks\x18\x01 \x03(\t\"-\n\x13ReplayTasksResponse\x12\x16\n\x0ereplayed_tasks\x18\x01 \x03(\t\"\xae\x02\n\x19TriggerWorkflowRunRequest\x12\x15\n\rworkflow_name\x18\x01 \x01(\t\x12\r\n\x05input\x18\x02 \x01(\x0c\x12\x1b\n\x13\x61\x64\x64itional_metadata\x18\x03 \x01(\x0c\x12\x15\n\x08priority\x18\x04 \x01(\x05H\x00\x88\x01\x01\x12U\n\x15\x64\x65sired_worker_labels\x18\x05 \x03(\x0b\x32\x36.v1.TriggerWorkflowRunRequest.DesiredWorkerLabelsEntry\x1aS\n\x18\x44\x65siredWorkerLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12&\n\x05value\x18\x02 \x01(\x0b\x32\x17.v1.DesiredWorkerLabels:\x02\x38\x01\x42\x0b\n\t_priority\"1\n\x1aTriggerWorkflowRunResponse\x12\x13\n\x0b\x65xternal_id\x18\x01 \x01(\t\"X\n\x18\x42ranchDurableTaskRequest\x12\x18\n\x10task_external_id\x18\x01 \x01(\t\x12\x0f\n\x07node_id\x18\x02 \x01(\x03\x12\x11\n\tbranch_id\x18\x03 \x01(\x03\"Y\n\x19\x42ranchDurableTaskResponse\x12\x18\n\x10task_external_id\x18\x01 \x01(\t\x12\x0f\n\x07node_id\x18\x02 \x01(\x03\x12\x11\n\tbranch_id\x18\x03 \x01(\x03\"\xed\x04\n\x1c\x43reateWorkflowVersionRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t\x12\x16\n\x0e\x65vent_triggers\x18\x04 \x03(\t\x12\x15\n\rcron_triggers\x18\x05 \x03(\t\x12!\n\x05tasks\x18\x06 \x03(\x0b\x32\x12.v1.CreateTaskOpts\x12$\n\x0b\x63oncurrency\x18\x07 \x01(\x0b\x32\x0f.v1.Concurrency\x12\x17\n\ncron_input\x18\x08 \x01(\tH\x00\x88\x01\x01\x12\x30\n\x0fon_failure_task\x18\t \x01(\x0b\x32\x12.v1.CreateTaskOptsH\x01\x88\x01\x01\x12\'\n\x06sticky\x18\n \x01(\x0e\x32\x12.v1.StickyStrategyH\x02\x88\x01\x01\x12\x1d\n\x10\x64\x65\x66\x61ult_priority\x18\x0b \x01(\x05H\x03\x88\x01\x01\x12(\n\x0f\x63oncurrency_arr\x18\x0c \x03(\x0b\x32\x0f.v1.Concurrency\x12*\n\x0f\x64\x65\x66\x61ult_filters\x18\r \x03(\x0b\x32\x11.v1.DefaultFilter\x12\x1e\n\x11input_json_schema\x18\x0e \x01(\x0cH\x04\x88\x01\x01\x12/\n\x0bidempotency\x18\x0f \x01(\x0b\x32\x15.v1.IdempotencyConfigH\x05\x88\x01\x01\x42\r\n\x0b_cron_inputB\x12\n\x10_on_failure_taskB\t\n\x07_stickyB\x13\n\x11_default_priorityB\x14\n\x12_input_json_schemaB\x0e\n\x0c_idempotency\"7\n\x11IdempotencyConfig\x12\x12\n\nexpression\x18\x01 \x01(\t\x12\x0e\n\x06ttl_ms\x18\x02 \x01(\x03\"`\n\x19IdempotencyCollisionError\x12 \n\x18\x65xisting_run_external_id\x18\x01 \x01(\t\x12!\n\x19\x63olliding_run_external_id\x18\x02 \x01(\t\"\x87\x01\n$BulkTriggerIdempotencyCollisionError\x12,\n$successful_workflow_run_external_ids\x18\x01 \x03(\t\x12\x31\n\ncollisions\x18\x02 \x03(\x0b\x32\x1d.v1.IdempotencyCollisionError\"T\n\rDefaultFilter\x12\x12\n\nexpression\x18\x01 \x01(\t\x12\r\n\x05scope\x18\x02 \x01(\t\x12\x14\n\x07payload\x18\x03 \x01(\x0cH\x00\x88\x01\x01\x42\n\n\x08_payload\"\x93\x01\n\x0b\x43oncurrency\x12\x12\n\nexpression\x18\x01 \x01(\t\x12\x15\n\x08max_runs\x18\x02 \x01(\x05H\x00\x88\x01\x01\x12\x39\n\x0elimit_strategy\x18\x03 \x01(\x0e\x32\x1c.v1.ConcurrencyLimitStrategyH\x01\x88\x01\x01\x42\x0b\n\t_max_runsB\x11\n\x0f_limit_strategy\"\xb7\x05\n\x0e\x43reateTaskOpts\x12\x13\n\x0breadable_id\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x63tion\x18\x02 \x01(\t\x12\x0f\n\x07timeout\x18\x03 \x01(\t\x12\x0e\n\x06inputs\x18\x04 \x01(\t\x12\x0f\n\x07parents\x18\x05 \x03(\t\x12\x0f\n\x07retries\x18\x06 \x01(\x05\x12,\n\x0brate_limits\x18\x07 \x03(\x0b\x32\x17.v1.CreateTaskRateLimit\x12;\n\rworker_labels\x18\x08 \x03(\x0b\x32$.v1.CreateTaskOpts.WorkerLabelsEntry\x12\x1b\n\x0e\x62\x61\x63koff_factor\x18\t \x01(\x02H\x00\x88\x01\x01\x12 \n\x13\x62\x61\x63koff_max_seconds\x18\n \x01(\x05H\x01\x88\x01\x01\x12$\n\x0b\x63oncurrency\x18\x0b \x03(\x0b\x32\x0f.v1.Concurrency\x12+\n\nconditions\x18\x0c \x01(\x0b\x32\x12.v1.TaskConditionsH\x02\x88\x01\x01\x12\x1d\n\x10schedule_timeout\x18\r \x01(\tH\x03\x88\x01\x01\x12\x12\n\nis_durable\x18\x0e \x01(\x08\x12;\n\rslot_requests\x18\x0f \x03(\x0b\x32$.v1.CreateTaskOpts.SlotRequestsEntry\x1aL\n\x11WorkerLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12&\n\x05value\x18\x02 \x01(\x0b\x32\x17.v1.DesiredWorkerLabels:\x02\x38\x01\x1a\x33\n\x11SlotRequestsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x42\x11\n\x0f_backoff_factorB\x16\n\x14_backoff_max_secondsB\r\n\x0b_conditionsB\x13\n\x11_schedule_timeout\"\xfd\x01\n\x13\x43reateTaskRateLimit\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x12\n\x05units\x18\x02 \x01(\x05H\x00\x88\x01\x01\x12\x15\n\x08key_expr\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x17\n\nunits_expr\x18\x04 \x01(\tH\x02\x88\x01\x01\x12\x1e\n\x11limit_values_expr\x18\x05 \x01(\tH\x03\x88\x01\x01\x12,\n\x08\x64uration\x18\x06 \x01(\x0e\x32\x15.v1.RateLimitDurationH\x04\x88\x01\x01\x42\x08\n\x06_unitsB\x0b\n\t_key_exprB\r\n\x0b_units_exprB\x14\n\x12_limit_values_exprB\x0b\n\t_duration\"@\n\x1d\x43reateWorkflowVersionResponse\x12\n\n\x02id\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\"+\n\x14GetRunDetailsRequest\x12\x13\n\x0b\x65xternal_id\x18\x01 \x01(\t\"\xaa\x01\n\rTaskRunDetail\x12\x13\n\x0b\x65xternal_id\x18\x01 \x01(\t\x12\x1d\n\x06status\x18\x02 \x01(\x0e\x32\r.v1.RunStatus\x12\x12\n\x05\x65rror\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06output\x18\x04 \x01(\x0cH\x01\x88\x01\x01\x12\x13\n\x0breadable_id\x18\x05 \x01(\t\x12\x12\n\nis_evicted\x18\x06 \x01(\x08\x42\x08\n\x06_errorB\t\n\x07_output\"\x84\x02\n\x15GetRunDetailsResponse\x12\r\n\x05input\x18\x01 \x01(\x0c\x12\x1d\n\x06status\x18\x02 \x01(\x0e\x32\r.v1.RunStatus\x12:\n\ttask_runs\x18\x03 \x03(\x0b\x32\'.v1.GetRunDetailsResponse.TaskRunsEntry\x12\x0c\n\x04\x64one\x18\x04 \x01(\x08\x12\x1b\n\x13\x61\x64\x64itional_metadata\x18\x05 \x01(\x0c\x12\x12\n\nis_evicted\x18\x06 \x01(\x08\x1a\x42\n\rTaskRunsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.v1.TaskRunDetail:\x02\x38\x01*$\n\x0eStickyStrategy\x12\x08\n\x04SOFT\x10\x00\x12\x08\n\x04HARD\x10\x01*]\n\x11RateLimitDuration\x12\n\n\x06SECOND\x10\x00\x12\n\n\x06MINUTE\x10\x01\x12\x08\n\x04HOUR\x10\x02\x12\x07\n\x03\x44\x41Y\x10\x03\x12\x08\n\x04WEEK\x10\x04\x12\t\n\x05MONTH\x10\x05\x12\x08\n\x04YEAR\x10\x06*[\n\tRunStatus\x12\n\n\x06QUEUED\x10\x00\x12\x0b\n\x07RUNNING\x10\x01\x12\r\n\tCOMPLETED\x10\x02\x12\n\n\x06\x46\x41ILED\x10\x03\x12\r\n\tCANCELLED\x10\x04\x12\x0b\n\x07\x45VICTED\x10\x05*\x7f\n\x18\x43oncurrencyLimitStrategy\x12\x16\n\x12\x43\x41NCEL_IN_PROGRESS\x10\x00\x12\x0f\n\x0b\x44ROP_NEWEST\x10\x01\x12\x10\n\x0cQUEUE_NEWEST\x10\x02\x12\x15\n\x11GROUP_ROUND_ROBIN\x10\x03\x12\x11\n\rCANCEL_NEWEST\x10\x04\x32\xcf\x03\n\x0c\x41\x64minService\x12R\n\x0bPutWorkflow\x12 .v1.CreateWorkflowVersionRequest\x1a!.v1.CreateWorkflowVersionResponse\x12>\n\x0b\x43\x61ncelTasks\x12\x16.v1.CancelTasksRequest\x1a\x17.v1.CancelTasksResponse\x12>\n\x0bReplayTasks\x12\x16.v1.ReplayTasksRequest\x1a\x17.v1.ReplayTasksResponse\x12S\n\x12TriggerWorkflowRun\x12\x1d.v1.TriggerWorkflowRunRequest\x1a\x1e.v1.TriggerWorkflowRunResponse\x12\x44\n\rGetRunDetails\x12\x18.v1.GetRunDetailsRequest\x1a\x19.v1.GetRunDetailsResponse\x12P\n\x11\x42ranchDurableTask\x12\x1c.v1.BranchDurableTaskRequest\x1a\x1d.v1.BranchDurableTaskResponseBBZ@github.com/hatchet-dev/hatchet/internal/services/shared/proto/v1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x12v1/workflows.proto\x12\x02v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x19v1/shared/condition.proto\x1a\x17v1/shared/trigger.proto\"[\n\x12\x43\x61ncelTasksRequest\x12\x14\n\x0c\x65xternal_ids\x18\x01 \x03(\t\x12$\n\x06\x66ilter\x18\x02 \x01(\x0b\x32\x0f.v1.TasksFilterH\x00\x88\x01\x01\x42\t\n\x07_filter\"[\n\x12ReplayTasksRequest\x12\x14\n\x0c\x65xternal_ids\x18\x01 \x03(\t\x12$\n\x06\x66ilter\x18\x02 \x01(\x0b\x32\x0f.v1.TasksFilterH\x00\x88\x01\x01\x42\t\n\x07_filter\"\xb7\x01\n\x0bTasksFilter\x12\x10\n\x08statuses\x18\x01 \x03(\t\x12)\n\x05since\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12.\n\x05until\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x00\x88\x01\x01\x12\x14\n\x0cworkflow_ids\x18\x04 \x03(\t\x12\x1b\n\x13\x61\x64\x64itional_metadata\x18\x05 \x03(\tB\x08\n\x06_until\".\n\x13\x43\x61ncelTasksResponse\x12\x17\n\x0f\x63\x61ncelled_tasks\x18\x01 \x03(\t\"-\n\x13ReplayTasksResponse\x12\x16\n\x0ereplayed_tasks\x18\x01 \x03(\t\"\xae\x02\n\x19TriggerWorkflowRunRequest\x12\x15\n\rworkflow_name\x18\x01 \x01(\t\x12\r\n\x05input\x18\x02 \x01(\x0c\x12\x1b\n\x13\x61\x64\x64itional_metadata\x18\x03 \x01(\x0c\x12\x15\n\x08priority\x18\x04 \x01(\x05H\x00\x88\x01\x01\x12U\n\x15\x64\x65sired_worker_labels\x18\x05 \x03(\x0b\x32\x36.v1.TriggerWorkflowRunRequest.DesiredWorkerLabelsEntry\x1aS\n\x18\x44\x65siredWorkerLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12&\n\x05value\x18\x02 \x01(\x0b\x32\x17.v1.DesiredWorkerLabels:\x02\x38\x01\x42\x0b\n\t_priority\"1\n\x1aTriggerWorkflowRunResponse\x12\x13\n\x0b\x65xternal_id\x18\x01 \x01(\t\"X\n\x18\x42ranchDurableTaskRequest\x12\x18\n\x10task_external_id\x18\x01 \x01(\t\x12\x0f\n\x07node_id\x18\x02 \x01(\x03\x12\x11\n\tbranch_id\x18\x03 \x01(\x03\"Y\n\x19\x42ranchDurableTaskResponse\x12\x18\n\x10task_external_id\x18\x01 \x01(\t\x12\x0f\n\x07node_id\x18\x02 \x01(\x03\x12\x11\n\tbranch_id\x18\x03 \x01(\x03\"\xed\x04\n\x1c\x43reateWorkflowVersionRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t\x12\x16\n\x0e\x65vent_triggers\x18\x04 \x03(\t\x12\x15\n\rcron_triggers\x18\x05 \x03(\t\x12!\n\x05tasks\x18\x06 \x03(\x0b\x32\x12.v1.CreateTaskOpts\x12$\n\x0b\x63oncurrency\x18\x07 \x01(\x0b\x32\x0f.v1.Concurrency\x12\x17\n\ncron_input\x18\x08 \x01(\tH\x00\x88\x01\x01\x12\x30\n\x0fon_failure_task\x18\t \x01(\x0b\x32\x12.v1.CreateTaskOptsH\x01\x88\x01\x01\x12\'\n\x06sticky\x18\n \x01(\x0e\x32\x12.v1.StickyStrategyH\x02\x88\x01\x01\x12\x1d\n\x10\x64\x65\x66\x61ult_priority\x18\x0b \x01(\x05H\x03\x88\x01\x01\x12(\n\x0f\x63oncurrency_arr\x18\x0c \x03(\x0b\x32\x0f.v1.Concurrency\x12*\n\x0f\x64\x65\x66\x61ult_filters\x18\r \x03(\x0b\x32\x11.v1.DefaultFilter\x12\x1e\n\x11input_json_schema\x18\x0e \x01(\x0cH\x04\x88\x01\x01\x12/\n\x0bidempotency\x18\x0f \x01(\x0b\x32\x15.v1.IdempotencyConfigH\x05\x88\x01\x01\x42\r\n\x0b_cron_inputB\x12\n\x10_on_failure_taskB\t\n\x07_stickyB\x13\n\x11_default_priorityB\x14\n\x12_input_json_schemaB\x0e\n\x0c_idempotency\"n\n\x11IdempotencyConfig\x12\x12\n\nexpression\x18\x01 \x01(\t\x12\x0e\n\x06ttl_ms\x18\x02 \x01(\x03\x12*\n\x06method\x18\x03 \x01(\x0e\x32\x15.v1.IdempotencyMethodH\x00\x88\x01\x01\x42\t\n\x07_method\"`\n\x19IdempotencyCollisionError\x12 \n\x18\x65xisting_run_external_id\x18\x01 \x01(\t\x12!\n\x19\x63olliding_run_external_id\x18\x02 \x01(\t\"\x87\x01\n$BulkTriggerIdempotencyCollisionError\x12,\n$successful_workflow_run_external_ids\x18\x01 \x03(\t\x12\x31\n\ncollisions\x18\x02 \x03(\x0b\x32\x1d.v1.IdempotencyCollisionError\"T\n\rDefaultFilter\x12\x12\n\nexpression\x18\x01 \x01(\t\x12\r\n\x05scope\x18\x02 \x01(\t\x12\x14\n\x07payload\x18\x03 \x01(\x0cH\x00\x88\x01\x01\x42\n\n\x08_payload\"\x93\x01\n\x0b\x43oncurrency\x12\x12\n\nexpression\x18\x01 \x01(\t\x12\x15\n\x08max_runs\x18\x02 \x01(\x05H\x00\x88\x01\x01\x12\x39\n\x0elimit_strategy\x18\x03 \x01(\x0e\x32\x1c.v1.ConcurrencyLimitStrategyH\x01\x88\x01\x01\x42\x0b\n\t_max_runsB\x11\n\x0f_limit_strategy\"\xb7\x05\n\x0e\x43reateTaskOpts\x12\x13\n\x0breadable_id\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x63tion\x18\x02 \x01(\t\x12\x0f\n\x07timeout\x18\x03 \x01(\t\x12\x0e\n\x06inputs\x18\x04 \x01(\t\x12\x0f\n\x07parents\x18\x05 \x03(\t\x12\x0f\n\x07retries\x18\x06 \x01(\x05\x12,\n\x0brate_limits\x18\x07 \x03(\x0b\x32\x17.v1.CreateTaskRateLimit\x12;\n\rworker_labels\x18\x08 \x03(\x0b\x32$.v1.CreateTaskOpts.WorkerLabelsEntry\x12\x1b\n\x0e\x62\x61\x63koff_factor\x18\t \x01(\x02H\x00\x88\x01\x01\x12 \n\x13\x62\x61\x63koff_max_seconds\x18\n \x01(\x05H\x01\x88\x01\x01\x12$\n\x0b\x63oncurrency\x18\x0b \x03(\x0b\x32\x0f.v1.Concurrency\x12+\n\nconditions\x18\x0c \x01(\x0b\x32\x12.v1.TaskConditionsH\x02\x88\x01\x01\x12\x1d\n\x10schedule_timeout\x18\r \x01(\tH\x03\x88\x01\x01\x12\x12\n\nis_durable\x18\x0e \x01(\x08\x12;\n\rslot_requests\x18\x0f \x03(\x0b\x32$.v1.CreateTaskOpts.SlotRequestsEntry\x1aL\n\x11WorkerLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12&\n\x05value\x18\x02 \x01(\x0b\x32\x17.v1.DesiredWorkerLabels:\x02\x38\x01\x1a\x33\n\x11SlotRequestsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x42\x11\n\x0f_backoff_factorB\x16\n\x14_backoff_max_secondsB\r\n\x0b_conditionsB\x13\n\x11_schedule_timeout\"\xfd\x01\n\x13\x43reateTaskRateLimit\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x12\n\x05units\x18\x02 \x01(\x05H\x00\x88\x01\x01\x12\x15\n\x08key_expr\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x17\n\nunits_expr\x18\x04 \x01(\tH\x02\x88\x01\x01\x12\x1e\n\x11limit_values_expr\x18\x05 \x01(\tH\x03\x88\x01\x01\x12,\n\x08\x64uration\x18\x06 \x01(\x0e\x32\x15.v1.RateLimitDurationH\x04\x88\x01\x01\x42\x08\n\x06_unitsB\x0b\n\t_key_exprB\r\n\x0b_units_exprB\x14\n\x12_limit_values_exprB\x0b\n\t_duration\"@\n\x1d\x43reateWorkflowVersionResponse\x12\n\n\x02id\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\"+\n\x14GetRunDetailsRequest\x12\x13\n\x0b\x65xternal_id\x18\x01 \x01(\t\"\xaa\x01\n\rTaskRunDetail\x12\x13\n\x0b\x65xternal_id\x18\x01 \x01(\t\x12\x1d\n\x06status\x18\x02 \x01(\x0e\x32\r.v1.RunStatus\x12\x12\n\x05\x65rror\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06output\x18\x04 \x01(\x0cH\x01\x88\x01\x01\x12\x13\n\x0breadable_id\x18\x05 \x01(\t\x12\x12\n\nis_evicted\x18\x06 \x01(\x08\x42\x08\n\x06_errorB\t\n\x07_output\"\x84\x02\n\x15GetRunDetailsResponse\x12\r\n\x05input\x18\x01 \x01(\x0c\x12\x1d\n\x06status\x18\x02 \x01(\x0e\x32\r.v1.RunStatus\x12:\n\ttask_runs\x18\x03 \x03(\x0b\x32\'.v1.GetRunDetailsResponse.TaskRunsEntry\x12\x0c\n\x04\x64one\x18\x04 \x01(\x08\x12\x1b\n\x13\x61\x64\x64itional_metadata\x18\x05 \x01(\x0c\x12\x12\n\nis_evicted\x18\x06 \x01(\x08\x1a\x42\n\rTaskRunsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.v1.TaskRunDetail:\x02\x38\x01*$\n\x0eStickyStrategy\x12\x08\n\x04SOFT\x10\x00\x12\x08\n\x04HARD\x10\x01*]\n\x11RateLimitDuration\x12\n\n\x06SECOND\x10\x00\x12\n\n\x06MINUTE\x10\x01\x12\x08\n\x04HOUR\x10\x02\x12\x07\n\x03\x44\x41Y\x10\x03\x12\x08\n\x04WEEK\x10\x04\x12\t\n\x05MONTH\x10\x05\x12\x08\n\x04YEAR\x10\x06*[\n\tRunStatus\x12\n\n\x06QUEUED\x10\x00\x12\x0b\n\x07RUNNING\x10\x01\x12\r\n\tCOMPLETED\x10\x02\x12\n\n\x06\x46\x41ILED\x10\x03\x12\r\n\tCANCELLED\x10\x04\x12\x0b\n\x07\x45VICTED\x10\x05*(\n\x11IdempotencyMethod\x12\x07\n\x03TTL\x10\x00\x12\n\n\x06STATUS\x10\x01*\x7f\n\x18\x43oncurrencyLimitStrategy\x12\x16\n\x12\x43\x41NCEL_IN_PROGRESS\x10\x00\x12\x0f\n\x0b\x44ROP_NEWEST\x10\x01\x12\x10\n\x0cQUEUE_NEWEST\x10\x02\x12\x15\n\x11GROUP_ROUND_ROBIN\x10\x03\x12\x11\n\rCANCEL_NEWEST\x10\x04\x32\xcf\x03\n\x0c\x41\x64minService\x12R\n\x0bPutWorkflow\x12 .v1.CreateWorkflowVersionRequest\x1a!.v1.CreateWorkflowVersionResponse\x12>\n\x0b\x43\x61ncelTasks\x12\x16.v1.CancelTasksRequest\x1a\x17.v1.CancelTasksResponse\x12>\n\x0bReplayTasks\x12\x16.v1.ReplayTasksRequest\x1a\x17.v1.ReplayTasksResponse\x12S\n\x12TriggerWorkflowRun\x12\x1d.v1.TriggerWorkflowRunRequest\x1a\x1e.v1.TriggerWorkflowRunResponse\x12\x44\n\rGetRunDetails\x12\x18.v1.GetRunDetailsRequest\x1a\x19.v1.GetRunDetailsResponse\x12P\n\x11\x42ranchDurableTask\x12\x1c.v1.BranchDurableTaskRequest\x1a\x1d.v1.BranchDurableTaskResponseBBZ@github.com/hatchet-dev/hatchet/internal/services/shared/proto/v1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -43,14 +43,16 @@ _globals['_CREATETASKOPTS_SLOTREQUESTSENTRY']._serialized_options = b'8\001' _globals['_GETRUNDETAILSRESPONSE_TASKRUNSENTRY']._loaded_options = None _globals['_GETRUNDETAILSRESPONSE_TASKRUNSENTRY']._serialized_options = b'8\001' - _globals['_STICKYSTRATEGY']._serialized_start=3769 - _globals['_STICKYSTRATEGY']._serialized_end=3805 - _globals['_RATELIMITDURATION']._serialized_start=3807 - _globals['_RATELIMITDURATION']._serialized_end=3900 - _globals['_RUNSTATUS']._serialized_start=3902 - _globals['_RUNSTATUS']._serialized_end=3993 - _globals['_CONCURRENCYLIMITSTRATEGY']._serialized_start=3995 - _globals['_CONCURRENCYLIMITSTRATEGY']._serialized_end=4122 + _globals['_STICKYSTRATEGY']._serialized_start=3824 + _globals['_STICKYSTRATEGY']._serialized_end=3860 + _globals['_RATELIMITDURATION']._serialized_start=3862 + _globals['_RATELIMITDURATION']._serialized_end=3955 + _globals['_RUNSTATUS']._serialized_start=3957 + _globals['_RUNSTATUS']._serialized_end=4048 + _globals['_IDEMPOTENCYMETHOD']._serialized_start=4050 + _globals['_IDEMPOTENCYMETHOD']._serialized_end=4090 + _globals['_CONCURRENCYLIMITSTRATEGY']._serialized_start=4092 + _globals['_CONCURRENCYLIMITSTRATEGY']._serialized_end=4219 _globals['_CANCELTASKSREQUEST']._serialized_start=111 _globals['_CANCELTASKSREQUEST']._serialized_end=202 _globals['_REPLAYTASKSREQUEST']._serialized_start=204 @@ -74,33 +76,33 @@ _globals['_CREATEWORKFLOWVERSIONREQUEST']._serialized_start=1116 _globals['_CREATEWORKFLOWVERSIONREQUEST']._serialized_end=1737 _globals['_IDEMPOTENCYCONFIG']._serialized_start=1739 - _globals['_IDEMPOTENCYCONFIG']._serialized_end=1794 - _globals['_IDEMPOTENCYCOLLISIONERROR']._serialized_start=1796 - _globals['_IDEMPOTENCYCOLLISIONERROR']._serialized_end=1892 - _globals['_BULKTRIGGERIDEMPOTENCYCOLLISIONERROR']._serialized_start=1895 - _globals['_BULKTRIGGERIDEMPOTENCYCOLLISIONERROR']._serialized_end=2030 - _globals['_DEFAULTFILTER']._serialized_start=2032 - _globals['_DEFAULTFILTER']._serialized_end=2116 - _globals['_CONCURRENCY']._serialized_start=2119 - _globals['_CONCURRENCY']._serialized_end=2266 - _globals['_CREATETASKOPTS']._serialized_start=2269 - _globals['_CREATETASKOPTS']._serialized_end=2964 - _globals['_CREATETASKOPTS_WORKERLABELSENTRY']._serialized_start=2756 - _globals['_CREATETASKOPTS_WORKERLABELSENTRY']._serialized_end=2832 - _globals['_CREATETASKOPTS_SLOTREQUESTSENTRY']._serialized_start=2834 - _globals['_CREATETASKOPTS_SLOTREQUESTSENTRY']._serialized_end=2885 - _globals['_CREATETASKRATELIMIT']._serialized_start=2967 - _globals['_CREATETASKRATELIMIT']._serialized_end=3220 - _globals['_CREATEWORKFLOWVERSIONRESPONSE']._serialized_start=3222 - _globals['_CREATEWORKFLOWVERSIONRESPONSE']._serialized_end=3286 - _globals['_GETRUNDETAILSREQUEST']._serialized_start=3288 - _globals['_GETRUNDETAILSREQUEST']._serialized_end=3331 - _globals['_TASKRUNDETAIL']._serialized_start=3334 - _globals['_TASKRUNDETAIL']._serialized_end=3504 - _globals['_GETRUNDETAILSRESPONSE']._serialized_start=3507 - _globals['_GETRUNDETAILSRESPONSE']._serialized_end=3767 - _globals['_GETRUNDETAILSRESPONSE_TASKRUNSENTRY']._serialized_start=3701 - _globals['_GETRUNDETAILSRESPONSE_TASKRUNSENTRY']._serialized_end=3767 - _globals['_ADMINSERVICE']._serialized_start=4125 - _globals['_ADMINSERVICE']._serialized_end=4588 + _globals['_IDEMPOTENCYCONFIG']._serialized_end=1849 + _globals['_IDEMPOTENCYCOLLISIONERROR']._serialized_start=1851 + _globals['_IDEMPOTENCYCOLLISIONERROR']._serialized_end=1947 + _globals['_BULKTRIGGERIDEMPOTENCYCOLLISIONERROR']._serialized_start=1950 + _globals['_BULKTRIGGERIDEMPOTENCYCOLLISIONERROR']._serialized_end=2085 + _globals['_DEFAULTFILTER']._serialized_start=2087 + _globals['_DEFAULTFILTER']._serialized_end=2171 + _globals['_CONCURRENCY']._serialized_start=2174 + _globals['_CONCURRENCY']._serialized_end=2321 + _globals['_CREATETASKOPTS']._serialized_start=2324 + _globals['_CREATETASKOPTS']._serialized_end=3019 + _globals['_CREATETASKOPTS_WORKERLABELSENTRY']._serialized_start=2811 + _globals['_CREATETASKOPTS_WORKERLABELSENTRY']._serialized_end=2887 + _globals['_CREATETASKOPTS_SLOTREQUESTSENTRY']._serialized_start=2889 + _globals['_CREATETASKOPTS_SLOTREQUESTSENTRY']._serialized_end=2940 + _globals['_CREATETASKRATELIMIT']._serialized_start=3022 + _globals['_CREATETASKRATELIMIT']._serialized_end=3275 + _globals['_CREATEWORKFLOWVERSIONRESPONSE']._serialized_start=3277 + _globals['_CREATEWORKFLOWVERSIONRESPONSE']._serialized_end=3341 + _globals['_GETRUNDETAILSREQUEST']._serialized_start=3343 + _globals['_GETRUNDETAILSREQUEST']._serialized_end=3386 + _globals['_TASKRUNDETAIL']._serialized_start=3389 + _globals['_TASKRUNDETAIL']._serialized_end=3559 + _globals['_GETRUNDETAILSRESPONSE']._serialized_start=3562 + _globals['_GETRUNDETAILSRESPONSE']._serialized_end=3822 + _globals['_GETRUNDETAILSRESPONSE_TASKRUNSENTRY']._serialized_start=3756 + _globals['_GETRUNDETAILSRESPONSE_TASKRUNSENTRY']._serialized_end=3822 + _globals['_ADMINSERVICE']._serialized_start=4222 + _globals['_ADMINSERVICE']._serialized_end=4685 # @@protoc_insertion_point(module_scope) diff --git a/sdks/python/hatchet_sdk/contracts/v1/workflows_pb2.pyi b/sdks/python/hatchet_sdk/contracts/v1/workflows_pb2.pyi index 63d553dd9c..da9e637054 100644 --- a/sdks/python/hatchet_sdk/contracts/v1/workflows_pb2.pyi +++ b/sdks/python/hatchet_sdk/contracts/v1/workflows_pb2.pyi @@ -36,6 +36,11 @@ class RunStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): CANCELLED: _ClassVar[RunStatus] EVICTED: _ClassVar[RunStatus] +class IdempotencyMethod(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + TTL: _ClassVar[IdempotencyMethod] + STATUS: _ClassVar[IdempotencyMethod] + class ConcurrencyLimitStrategy(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () CANCEL_IN_PROGRESS: _ClassVar[ConcurrencyLimitStrategy] @@ -58,6 +63,8 @@ COMPLETED: RunStatus FAILED: RunStatus CANCELLED: RunStatus EVICTED: RunStatus +TTL: IdempotencyMethod +STATUS: IdempotencyMethod CANCEL_IN_PROGRESS: ConcurrencyLimitStrategy DROP_NEWEST: ConcurrencyLimitStrategy QUEUE_NEWEST: ConcurrencyLimitStrategy @@ -188,12 +195,14 @@ class CreateWorkflowVersionRequest(_message.Message): def __init__(self, name: _Optional[str] = ..., description: _Optional[str] = ..., version: _Optional[str] = ..., event_triggers: _Optional[_Iterable[str]] = ..., cron_triggers: _Optional[_Iterable[str]] = ..., tasks: _Optional[_Iterable[_Union[CreateTaskOpts, _Mapping]]] = ..., concurrency: _Optional[_Union[Concurrency, _Mapping]] = ..., cron_input: _Optional[str] = ..., on_failure_task: _Optional[_Union[CreateTaskOpts, _Mapping]] = ..., sticky: _Optional[_Union[StickyStrategy, str]] = ..., default_priority: _Optional[int] = ..., concurrency_arr: _Optional[_Iterable[_Union[Concurrency, _Mapping]]] = ..., default_filters: _Optional[_Iterable[_Union[DefaultFilter, _Mapping]]] = ..., input_json_schema: _Optional[bytes] = ..., idempotency: _Optional[_Union[IdempotencyConfig, _Mapping]] = ...) -> None: ... class IdempotencyConfig(_message.Message): - __slots__ = ("expression", "ttl_ms") + __slots__ = ("expression", "ttl_ms", "method") EXPRESSION_FIELD_NUMBER: _ClassVar[int] TTL_MS_FIELD_NUMBER: _ClassVar[int] + METHOD_FIELD_NUMBER: _ClassVar[int] expression: str ttl_ms: int - def __init__(self, expression: _Optional[str] = ..., ttl_ms: _Optional[int] = ...) -> None: ... + method: IdempotencyMethod + def __init__(self, expression: _Optional[str] = ..., ttl_ms: _Optional[int] = ..., method: _Optional[_Union[IdempotencyMethod, str]] = ...) -> None: ... class IdempotencyCollisionError(_message.Message): __slots__ = ("existing_run_external_id", "colliding_run_external_id") diff --git a/sdks/python/hatchet_sdk/hatchet.py b/sdks/python/hatchet_sdk/hatchet.py index 8a1d997e42..55e9b84bbb 100644 --- a/sdks/python/hatchet_sdk/hatchet.py +++ b/sdks/python/hatchet_sdk/hatchet.py @@ -41,7 +41,10 @@ ) from hatchet_sdk.runnables.workflow import BaseWorkflow, Standalone, Workflow from hatchet_sdk.types.concurrency import ConcurrencyExpression -from hatchet_sdk.types.idempotency import TTLBasedIdempotencyConfig +from hatchet_sdk.types.idempotency import ( + StatusBasedIdempotencyConfig, + TTLBasedIdempotencyConfig, +) from hatchet_sdk.types.labels import DesiredWorkerLabel from hatchet_sdk.types.priority import Priority, _warn_if_int_priority from hatchet_sdk.types.rate_limit import RateLimit @@ -280,7 +283,9 @@ def workflow( task_defaults: TaskDefaults = TaskDefaults(), default_filters: list[DefaultFilter] | None = None, default_additional_metadata: JSONSerializableMapping | None = None, - idempotency: TTLBasedIdempotencyConfig | None = None, + idempotency: ( + TTLBasedIdempotencyConfig | StatusBasedIdempotencyConfig | None + ) = None, ) -> Workflow[EmptyModel]: ... @overload @@ -302,7 +307,9 @@ def workflow( task_defaults: TaskDefaults = TaskDefaults(), default_filters: list[DefaultFilter] | None = None, default_additional_metadata: JSONSerializableMapping | None = None, - idempotency: TTLBasedIdempotencyConfig | None = None, + idempotency: ( + TTLBasedIdempotencyConfig | StatusBasedIdempotencyConfig | None + ) = None, ) -> Workflow[TWorkflowInput]: ... def workflow( @@ -323,7 +330,9 @@ def workflow( task_defaults: TaskDefaults = TaskDefaults(), default_filters: list[DefaultFilter] | None = None, default_additional_metadata: JSONSerializableMapping | None = None, - idempotency: TTLBasedIdempotencyConfig | None = None, + idempotency: ( + TTLBasedIdempotencyConfig | StatusBasedIdempotencyConfig | None + ) = None, ) -> Workflow[EmptyModel] | Workflow[TWorkflowInput]: """ Define a Hatchet workflow, which can then declare `task`s and be `run`, `schedule`d, and so on. @@ -409,7 +418,9 @@ def task( default_filters: list[DefaultFilter] | None = None, default_additional_metadata: JSONSerializableMapping | None = None, slot_cost: int | None = None, - idempotency: TTLBasedIdempotencyConfig | None = None, + idempotency: ( + TTLBasedIdempotencyConfig | StatusBasedIdempotencyConfig | None + ) = None, ) -> Callable[ [Callable[Concatenate[EmptyModel, Context, P], R | CoroutineLike[R]]], Standalone[EmptyModel, R], @@ -443,7 +454,9 @@ def task( default_filters: list[DefaultFilter] | None = None, default_additional_metadata: JSONSerializableMapping | None = None, slot_cost: int | None = None, - idempotency: TTLBasedIdempotencyConfig | None = None, + idempotency: ( + TTLBasedIdempotencyConfig | StatusBasedIdempotencyConfig | None + ) = None, ) -> Callable[ [Callable[Concatenate[TWorkflowInput, Context, P], R | CoroutineLike[R]]], Standalone[TWorkflowInput, R], @@ -476,7 +489,9 @@ def task( default_filters: list[DefaultFilter] | None = None, default_additional_metadata: JSONSerializableMapping | None = None, slot_cost: int | None = None, - idempotency: TTLBasedIdempotencyConfig | None = None, + idempotency: ( + TTLBasedIdempotencyConfig | StatusBasedIdempotencyConfig | None + ) = None, ) -> ( Callable[ [Callable[Concatenate[EmptyModel, Context, P], R | CoroutineLike[R]]], @@ -622,7 +637,9 @@ def durable_task( default_filters: list[DefaultFilter] | None = None, default_additional_metadata: JSONSerializableMapping | None = None, eviction_policy: EvictionPolicy | None = DEFAULT_DURABLE_TASK_EVICTION_POLICY, - idempotency: TTLBasedIdempotencyConfig | None = None, + idempotency: ( + TTLBasedIdempotencyConfig | StatusBasedIdempotencyConfig | None + ) = None, ) -> Callable[ [Callable[Concatenate[EmptyModel, DurableContext, P], R | CoroutineLike[R]]], Standalone[EmptyModel, R], @@ -656,7 +673,9 @@ def durable_task( default_filters: list[DefaultFilter] | None = None, default_additional_metadata: JSONSerializableMapping | None = None, eviction_policy: EvictionPolicy | None = DEFAULT_DURABLE_TASK_EVICTION_POLICY, - idempotency: TTLBasedIdempotencyConfig | None = None, + idempotency: ( + TTLBasedIdempotencyConfig | StatusBasedIdempotencyConfig | None + ) = None, ) -> Callable[ [ Callable[ @@ -693,7 +712,9 @@ def durable_task( default_filters: list[DefaultFilter] | None = None, default_additional_metadata: JSONSerializableMapping | None = None, eviction_policy: EvictionPolicy | None = DEFAULT_DURABLE_TASK_EVICTION_POLICY, - idempotency: TTLBasedIdempotencyConfig | None = None, + idempotency: ( + TTLBasedIdempotencyConfig | StatusBasedIdempotencyConfig | None + ) = None, ) -> ( Callable[ [ diff --git a/sdks/python/hatchet_sdk/runnables/types.py b/sdks/python/hatchet_sdk/runnables/types.py index bf7978aca5..e412ad2a16 100644 --- a/sdks/python/hatchet_sdk/runnables/types.py +++ b/sdks/python/hatchet_sdk/runnables/types.py @@ -18,7 +18,10 @@ from hatchet_sdk.types.concurrency import ( ConcurrencyExpression, ) -from hatchet_sdk.types.idempotency import TTLBasedIdempotencyConfig +from hatchet_sdk.types.idempotency import ( + StatusBasedIdempotencyConfig, + TTLBasedIdempotencyConfig, +) from hatchet_sdk.types.priority import Priority from hatchet_sdk.types.sticky import StickyStrategy from hatchet_sdk.utils.timedelta_to_expression import Duration @@ -106,7 +109,7 @@ class WorkflowConfig(BaseModel): concurrency: int | ConcurrencyExpression | list[ConcurrencyExpression] | None = None input_validator: TypeAdapter[TaskPayloadForInternalUse] default_priority: int | Priority | None = None - idempotency: TTLBasedIdempotencyConfig | None = None + idempotency: TTLBasedIdempotencyConfig | StatusBasedIdempotencyConfig | None = None task_defaults: TaskDefaults = TaskDefaults() default_filters: list[DefaultFilter] = Field(default_factory=list) diff --git a/sdks/python/hatchet_sdk/types/idempotency.py b/sdks/python/hatchet_sdk/types/idempotency.py index b4dcfd5223..3c9e24c009 100644 --- a/sdks/python/hatchet_sdk/types/idempotency.py +++ b/sdks/python/hatchet_sdk/types/idempotency.py @@ -1,11 +1,21 @@ from abc import ABC, abstractmethod from datetime import timedelta +from enum import Enum from pydantic import BaseModel from hatchet_sdk.contracts.v1.workflows_pb2 import ( IdempotencyConfig as IdempotencyConfigProto, ) +from hatchet_sdk.contracts.v1.workflows_pb2 import ( + IdempotencyMethod as IdempotencyMethodProto, +) +from hatchet_sdk.utils.proto_enums import convert_python_enum_to_proto + + +class IdempotencyMethod(Enum): + TTL = "TTL" + STATUS = "STATUS" class BaseIdemotencyConfig(BaseModel, ABC): @@ -22,4 +32,20 @@ def to_proto(self) -> IdempotencyConfigProto: return IdempotencyConfigProto( expression=self.key_expression, ttl_ms=int(self.ttl.total_seconds() * 1000), + method=convert_python_enum_to_proto( # type: ignore[arg-type] + IdempotencyMethod.TTL, IdempotencyMethodProto + ), + ) + + +class StatusBasedIdempotencyConfig(BaseIdemotencyConfig): + fallback_ttl: timedelta + + def to_proto(self) -> IdempotencyConfigProto: + return IdempotencyConfigProto( + expression=self.key_expression, + ttl_ms=int(self.fallback_ttl.total_seconds() * 1000), + method=convert_python_enum_to_proto( # type: ignore[arg-type] + IdempotencyMethod.STATUS, IdempotencyMethodProto + ), ) diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 4969cd9aa4..44cd0260a7 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hatchet-sdk" -version = "1.35.1" +version = "1.36.0" description = "This is the official Python SDK for Hatchet, a distributed, fault-tolerant task queue. The SDK allows you to easily integrate Hatchet's task scheduling and workflow orchestration capabilities into your Python applications." readme = "README.md" license = { text = "MIT" } diff --git a/sdks/ruby/examples/Gemfile.lock b/sdks/ruby/examples/Gemfile.lock index fad28eafdd..8042afc654 100644 --- a/sdks/ruby/examples/Gemfile.lock +++ b/sdks/ruby/examples/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: ../src specs: - hatchet-sdk (0.4.0) + hatchet-sdk (0.5.0) concurrent-ruby (>= 1.1) faraday (~> 2.0) faraday-multipart diff --git a/sdks/ruby/examples/idempotency/worker.rb b/sdks/ruby/examples/idempotency/worker.rb index 9e7ce45340..f1fd09d7e6 100644 --- a/sdks/ruby/examples/idempotency/worker.rb +++ b/sdks/ruby/examples/idempotency/worker.rb @@ -22,8 +22,20 @@ end # !! +# > status-based-idempotency +IDEMPOTENT_STATUS_BASED_TASK = HATCHET.task( + name: 'ruby-e2e-idempotent-status-based-task', + idempotency: Hatchet::StatusBasedIdempotencyConfig.new(expression: 'input.id', fallback_ttl_ms: 10_000) +) do |input, _ctx| + { 'result' => "Hello from task #{input['id']}" } +end +# !! + def main - worker = HATCHET.worker('idempotency-worker', workflows: [IDEMPOTENT_TASK, IDEMPOTENT_TASK_SHORT_WINDOW]) + worker = HATCHET.worker( + 'idempotency-worker', + workflows: [IDEMPOTENT_TASK, IDEMPOTENT_TASK_SHORT_WINDOW, IDEMPOTENT_STATUS_BASED_TASK] + ) worker.start end diff --git a/sdks/ruby/src/CHANGELOG.md b/sdks/ruby/src/CHANGELOG.md index 8995bb6fda..6137a4f424 100644 --- a/sdks/ruby/src/CHANGELOG.md +++ b/sdks/ruby/src/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Hatchet's Ruby SDK will be documented in this changelog. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-07-22 + +### Added + +- Adds support for terminal status-based idempotency keys, which are released when the task holding the key reaches a terminal state (either completed, cancelled, or having failed and exhausted all retries). + ## [0.4.0] - 2026-06-03 ### Added diff --git a/sdks/ruby/src/Gemfile.lock b/sdks/ruby/src/Gemfile.lock index 444def408d..fd12f9360f 100644 --- a/sdks/ruby/src/Gemfile.lock +++ b/sdks/ruby/src/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - hatchet-sdk (0.4.0) + hatchet-sdk (0.5.0) concurrent-ruby (>= 1.1) faraday (~> 2.0) faraday-multipart diff --git a/sdks/ruby/src/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/workflow_version_idempotency.rb b/sdks/ruby/src/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/workflow_version_idempotency.rb new file mode 100644 index 0000000000..e637bea440 --- /dev/null +++ b/sdks/ruby/src/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/workflow_version_idempotency.rb @@ -0,0 +1,265 @@ +=begin +#Hatchet API + +#The Hatchet API + +The version of the OpenAPI document: 1.0.0 + +Generated by: https://openapi-generator.tech +Generator version: 7.14.0 + +=end + +require 'date' +require 'time' + +module HatchetSdkRest + class WorkflowVersionIdempotency + # The CEL expression used to compute the idempotency key. + attr_accessor :expression + + # The time-to-live of the idempotency key, in milliseconds. + attr_accessor :ttl_ms + + # Attribute mapping from ruby-style variable name to JSON key. + def self.attribute_map + { + :'expression' => :'expression', + :'ttl_ms' => :'ttlMs' + } + end + + # Returns attribute mapping this model knows about + def self.acceptable_attribute_map + attribute_map + end + + # Returns all the JSON keys this model knows about + def self.acceptable_attributes + acceptable_attribute_map.values + end + + # Attribute type mapping. + def self.openapi_types + { + :'expression' => :'String', + :'ttl_ms' => :'Integer' + } + end + + # List of attributes with nullable: true + def self.openapi_nullable + Set.new([ + ]) + end + + # Initializes the object + # @param [Hash] attributes Model attributes in the form of hash + def initialize(attributes = {}) + if (!attributes.is_a?(Hash)) + fail ArgumentError, "The input argument (attributes) must be a hash in `HatchetSdkRest::WorkflowVersionIdempotency` initialize method" + end + + # check to see if the attribute exists and convert string to symbol for hash key + acceptable_attribute_map = self.class.acceptable_attribute_map + attributes = attributes.each_with_object({}) { |(k, v), h| + if (!acceptable_attribute_map.key?(k.to_sym)) + fail ArgumentError, "`#{k}` is not a valid attribute in `HatchetSdkRest::WorkflowVersionIdempotency`. Please check the name to make sure it's valid. List of attributes: " + acceptable_attribute_map.keys.inspect + end + h[k.to_sym] = v + } + + if attributes.key?(:'expression') + self.expression = attributes[:'expression'] + else + self.expression = nil + end + + if attributes.key?(:'ttl_ms') + self.ttl_ms = attributes[:'ttl_ms'] + else + self.ttl_ms = nil + end + end + + # Show invalid properties with the reasons. Usually used together with valid? + # @return Array for valid properties with the reasons + def list_invalid_properties + warn '[DEPRECATED] the `list_invalid_properties` method is obsolete' + invalid_properties = Array.new + if @expression.nil? + invalid_properties.push('invalid value for "expression", expression cannot be nil.') + end + + if @ttl_ms.nil? + invalid_properties.push('invalid value for "ttl_ms", ttl_ms cannot be nil.') + end + + invalid_properties + end + + # Check to see if the all the properties in the model are valid + # @return true if the model is valid + def valid? + warn '[DEPRECATED] the `valid?` method is obsolete' + return false if @expression.nil? + return false if @ttl_ms.nil? + true + end + + # Custom attribute writer method with validation + # @param [Object] expression Value to be assigned + def expression=(expression) + if expression.nil? + fail ArgumentError, 'expression cannot be nil' + end + + @expression = expression + end + + # Custom attribute writer method with validation + # @param [Object] ttl_ms Value to be assigned + def ttl_ms=(ttl_ms) + if ttl_ms.nil? + fail ArgumentError, 'ttl_ms cannot be nil' + end + + @ttl_ms = ttl_ms + end + + # Checks equality by comparing each attribute. + # @param [Object] Object to be compared + def ==(o) + return true if self.equal?(o) + self.class == o.class && + expression == o.expression && + ttl_ms == o.ttl_ms + end + + # @see the `==` method + # @param [Object] Object to be compared + def eql?(o) + self == o + end + + # Calculates hash code according to all attributes. + # @return [Integer] Hash code + def hash + [expression, ttl_ms].hash + end + + # Builds the object from hash + # @param [Hash] attributes Model attributes in the form of hash + # @return [Object] Returns the model itself + def self.build_from_hash(attributes) + return nil unless attributes.is_a?(Hash) + attributes = attributes.transform_keys(&:to_sym) + transformed_hash = {} + openapi_types.each_pair do |key, type| + if attributes.key?(attribute_map[key]) && attributes[attribute_map[key]].nil? + transformed_hash["#{key}"] = nil + elsif type =~ /\AArray<(.*)>/i + # check to ensure the input is an array given that the attribute + # is documented as an array but the input is not + if attributes[attribute_map[key]].is_a?(Array) + transformed_hash["#{key}"] = attributes[attribute_map[key]].map { |v| _deserialize($1, v) } + end + elsif !attributes[attribute_map[key]].nil? + transformed_hash["#{key}"] = _deserialize(type, attributes[attribute_map[key]]) + end + end + new(transformed_hash) + end + + # Deserializes the data based on type + # @param string type Data type + # @param string value Value to be deserialized + # @return [Object] Deserialized data + def self._deserialize(type, value) + case type.to_sym + when :Time + Time.parse(value) + when :Date + Date.parse(value) + when :String + value.to_s + when :Integer + value.to_i + when :Float + value.to_f + when :Boolean + if value.to_s =~ /\A(true|t|yes|y|1)\z/i + true + else + false + end + when :Object + # generic object (usually a Hash), return directly + value + when /\AArray<(?.+)>\z/ + inner_type = Regexp.last_match[:inner_type] + value.map { |v| _deserialize(inner_type, v) } + when /\AHash<(?.+?), (?.+)>\z/ + k_type = Regexp.last_match[:k_type] + v_type = Regexp.last_match[:v_type] + {}.tap do |hash| + value.each do |k, v| + hash[_deserialize(k_type, k)] = _deserialize(v_type, v) + end + end + else # model + # models (e.g. Pet) or oneOf + klass = HatchetSdkRest.const_get(type) + klass.respond_to?(:openapi_any_of) || klass.respond_to?(:openapi_one_of) ? klass.build(value) : klass.build_from_hash(value) + end + end + + # Returns the string representation of the object + # @return [String] String presentation of the object + def to_s + to_hash.to_s + end + + # to_body is an alias to to_hash (backward compatibility) + # @return [Hash] Returns the object in the form of hash + def to_body + to_hash + end + + # Returns the object in the form of hash + # @return [Hash] Returns the object in the form of hash + def to_hash + hash = {} + self.class.attribute_map.each_pair do |attr, param| + value = self.send(attr) + if value.nil? + is_nullable = self.class.openapi_nullable.include?(attr) + next if !is_nullable || (is_nullable && !instance_variable_defined?(:"@#{attr}")) + end + + hash[param] = _to_hash(value) + end + hash + end + + # Outputs non-array value in the form of hash + # For object, use to_hash. Otherwise, just return the value + # @param [Object] value Any valid value + # @return [Hash] Returns the value in the form of hash + def _to_hash(value) + if value.is_a?(Array) + value.compact.map { |v| _to_hash(v) } + elsif value.is_a?(Hash) + {}.tap do |hash| + value.each { |k, v| hash[k] = _to_hash(v) } + end + elsif value.respond_to? :to_hash + value.to_hash + else + value + end + end + + end + +end diff --git a/sdks/ruby/src/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/workflow_version_task.rb b/sdks/ruby/src/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/workflow_version_task.rb new file mode 100644 index 0000000000..fc4ab1b238 --- /dev/null +++ b/sdks/ruby/src/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/workflow_version_task.rb @@ -0,0 +1,393 @@ +=begin +#Hatchet API + +#The Hatchet API + +The version of the OpenAPI document: 1.0.0 + +Generated by: https://openapi-generator.tech +Generator version: 7.14.0 + +=end + +require 'date' +require 'time' + +module HatchetSdkRest + class WorkflowVersionTask + # The readable id of the task. + attr_accessor :readable_id + + # The action id of the task. + attr_accessor :action + + # The readable ids of the tasks that this task depends on (its DAG parents). + attr_accessor :parents + + # The number of retries for the task. + attr_accessor :retries + + # The execution timeout of the task. + attr_accessor :timeout + + # The scheduling timeout of the task. + attr_accessor :schedule_timeout + + # The retry backoff factor for the task. + attr_accessor :retry_backoff_factor + + # The maximum retry backoff, in seconds. + attr_accessor :retry_backoff_max_seconds + + # Whether the task is durable. + attr_accessor :is_durable + + attr_accessor :rate_limits + + attr_accessor :desired_worker_labels + + # Attribute mapping from ruby-style variable name to JSON key. + def self.attribute_map + { + :'readable_id' => :'readableId', + :'action' => :'action', + :'parents' => :'parents', + :'retries' => :'retries', + :'timeout' => :'timeout', + :'schedule_timeout' => :'scheduleTimeout', + :'retry_backoff_factor' => :'retryBackoffFactor', + :'retry_backoff_max_seconds' => :'retryBackoffMaxSeconds', + :'is_durable' => :'isDurable', + :'rate_limits' => :'rateLimits', + :'desired_worker_labels' => :'desiredWorkerLabels' + } + end + + # Returns attribute mapping this model knows about + def self.acceptable_attribute_map + attribute_map + end + + # Returns all the JSON keys this model knows about + def self.acceptable_attributes + acceptable_attribute_map.values + end + + # Attribute type mapping. + def self.openapi_types + { + :'readable_id' => :'String', + :'action' => :'String', + :'parents' => :'Array', + :'retries' => :'Integer', + :'timeout' => :'String', + :'schedule_timeout' => :'String', + :'retry_backoff_factor' => :'Float', + :'retry_backoff_max_seconds' => :'Integer', + :'is_durable' => :'Boolean', + :'rate_limits' => :'Array', + :'desired_worker_labels' => :'Array' + } + end + + # List of attributes with nullable: true + def self.openapi_nullable + Set.new([ + ]) + end + + # Initializes the object + # @param [Hash] attributes Model attributes in the form of hash + def initialize(attributes = {}) + if (!attributes.is_a?(Hash)) + fail ArgumentError, "The input argument (attributes) must be a hash in `HatchetSdkRest::WorkflowVersionTask` initialize method" + end + + # check to see if the attribute exists and convert string to symbol for hash key + acceptable_attribute_map = self.class.acceptable_attribute_map + attributes = attributes.each_with_object({}) { |(k, v), h| + if (!acceptable_attribute_map.key?(k.to_sym)) + fail ArgumentError, "`#{k}` is not a valid attribute in `HatchetSdkRest::WorkflowVersionTask`. Please check the name to make sure it's valid. List of attributes: " + acceptable_attribute_map.keys.inspect + end + h[k.to_sym] = v + } + + if attributes.key?(:'readable_id') + self.readable_id = attributes[:'readable_id'] + else + self.readable_id = nil + end + + if attributes.key?(:'action') + self.action = attributes[:'action'] + else + self.action = nil + end + + if attributes.key?(:'parents') + if (value = attributes[:'parents']).is_a?(Array) + self.parents = value + end + else + self.parents = nil + end + + if attributes.key?(:'retries') + self.retries = attributes[:'retries'] + else + self.retries = nil + end + + if attributes.key?(:'timeout') + self.timeout = attributes[:'timeout'] + end + + if attributes.key?(:'schedule_timeout') + self.schedule_timeout = attributes[:'schedule_timeout'] + end + + if attributes.key?(:'retry_backoff_factor') + self.retry_backoff_factor = attributes[:'retry_backoff_factor'] + end + + if attributes.key?(:'retry_backoff_max_seconds') + self.retry_backoff_max_seconds = attributes[:'retry_backoff_max_seconds'] + end + + if attributes.key?(:'is_durable') + self.is_durable = attributes[:'is_durable'] + end + + if attributes.key?(:'rate_limits') + if (value = attributes[:'rate_limits']).is_a?(Array) + self.rate_limits = value + end + end + + if attributes.key?(:'desired_worker_labels') + if (value = attributes[:'desired_worker_labels']).is_a?(Array) + self.desired_worker_labels = value + end + end + end + + # Show invalid properties with the reasons. Usually used together with valid? + # @return Array for valid properties with the reasons + def list_invalid_properties + warn '[DEPRECATED] the `list_invalid_properties` method is obsolete' + invalid_properties = Array.new + if @readable_id.nil? + invalid_properties.push('invalid value for "readable_id", readable_id cannot be nil.') + end + + if @action.nil? + invalid_properties.push('invalid value for "action", action cannot be nil.') + end + + if @parents.nil? + invalid_properties.push('invalid value for "parents", parents cannot be nil.') + end + + if @retries.nil? + invalid_properties.push('invalid value for "retries", retries cannot be nil.') + end + + invalid_properties + end + + # Check to see if the all the properties in the model are valid + # @return true if the model is valid + def valid? + warn '[DEPRECATED] the `valid?` method is obsolete' + return false if @readable_id.nil? + return false if @action.nil? + return false if @parents.nil? + return false if @retries.nil? + true + end + + # Custom attribute writer method with validation + # @param [Object] readable_id Value to be assigned + def readable_id=(readable_id) + if readable_id.nil? + fail ArgumentError, 'readable_id cannot be nil' + end + + @readable_id = readable_id + end + + # Custom attribute writer method with validation + # @param [Object] action Value to be assigned + def action=(action) + if action.nil? + fail ArgumentError, 'action cannot be nil' + end + + @action = action + end + + # Custom attribute writer method with validation + # @param [Object] parents Value to be assigned + def parents=(parents) + if parents.nil? + fail ArgumentError, 'parents cannot be nil' + end + + @parents = parents + end + + # Custom attribute writer method with validation + # @param [Object] retries Value to be assigned + def retries=(retries) + if retries.nil? + fail ArgumentError, 'retries cannot be nil' + end + + @retries = retries + end + + # Checks equality by comparing each attribute. + # @param [Object] Object to be compared + def ==(o) + return true if self.equal?(o) + self.class == o.class && + readable_id == o.readable_id && + action == o.action && + parents == o.parents && + retries == o.retries && + timeout == o.timeout && + schedule_timeout == o.schedule_timeout && + retry_backoff_factor == o.retry_backoff_factor && + retry_backoff_max_seconds == o.retry_backoff_max_seconds && + is_durable == o.is_durable && + rate_limits == o.rate_limits && + desired_worker_labels == o.desired_worker_labels + end + + # @see the `==` method + # @param [Object] Object to be compared + def eql?(o) + self == o + end + + # Calculates hash code according to all attributes. + # @return [Integer] Hash code + def hash + [readable_id, action, parents, retries, timeout, schedule_timeout, retry_backoff_factor, retry_backoff_max_seconds, is_durable, rate_limits, desired_worker_labels].hash + end + + # Builds the object from hash + # @param [Hash] attributes Model attributes in the form of hash + # @return [Object] Returns the model itself + def self.build_from_hash(attributes) + return nil unless attributes.is_a?(Hash) + attributes = attributes.transform_keys(&:to_sym) + transformed_hash = {} + openapi_types.each_pair do |key, type| + if attributes.key?(attribute_map[key]) && attributes[attribute_map[key]].nil? + transformed_hash["#{key}"] = nil + elsif type =~ /\AArray<(.*)>/i + # check to ensure the input is an array given that the attribute + # is documented as an array but the input is not + if attributes[attribute_map[key]].is_a?(Array) + transformed_hash["#{key}"] = attributes[attribute_map[key]].map { |v| _deserialize($1, v) } + end + elsif !attributes[attribute_map[key]].nil? + transformed_hash["#{key}"] = _deserialize(type, attributes[attribute_map[key]]) + end + end + new(transformed_hash) + end + + # Deserializes the data based on type + # @param string type Data type + # @param string value Value to be deserialized + # @return [Object] Deserialized data + def self._deserialize(type, value) + case type.to_sym + when :Time + Time.parse(value) + when :Date + Date.parse(value) + when :String + value.to_s + when :Integer + value.to_i + when :Float + value.to_f + when :Boolean + if value.to_s =~ /\A(true|t|yes|y|1)\z/i + true + else + false + end + when :Object + # generic object (usually a Hash), return directly + value + when /\AArray<(?.+)>\z/ + inner_type = Regexp.last_match[:inner_type] + value.map { |v| _deserialize(inner_type, v) } + when /\AHash<(?.+?), (?.+)>\z/ + k_type = Regexp.last_match[:k_type] + v_type = Regexp.last_match[:v_type] + {}.tap do |hash| + value.each do |k, v| + hash[_deserialize(k_type, k)] = _deserialize(v_type, v) + end + end + else # model + # models (e.g. Pet) or oneOf + klass = HatchetSdkRest.const_get(type) + klass.respond_to?(:openapi_any_of) || klass.respond_to?(:openapi_one_of) ? klass.build(value) : klass.build_from_hash(value) + end + end + + # Returns the string representation of the object + # @return [String] String presentation of the object + def to_s + to_hash.to_s + end + + # to_body is an alias to to_hash (backward compatibility) + # @return [Hash] Returns the object in the form of hash + def to_body + to_hash + end + + # Returns the object in the form of hash + # @return [Hash] Returns the object in the form of hash + def to_hash + hash = {} + self.class.attribute_map.each_pair do |attr, param| + value = self.send(attr) + if value.nil? + is_nullable = self.class.openapi_nullable.include?(attr) + next if !is_nullable || (is_nullable && !instance_variable_defined?(:"@#{attr}")) + end + + hash[param] = _to_hash(value) + end + hash + end + + # Outputs non-array value in the form of hash + # For object, use to_hash. Otherwise, just return the value + # @param [Object] value Any valid value + # @return [Hash] Returns the value in the form of hash + def _to_hash(value) + if value.is_a?(Array) + value.compact.map { |v| _to_hash(v) } + elsif value.is_a?(Hash) + {}.tap do |hash| + value.each { |k, v| hash[k] = _to_hash(v) } + end + elsif value.respond_to? :to_hash + value.to_hash + else + value + end + end + + end + +end diff --git a/sdks/ruby/src/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/workflow_version_task_desired_worker_label.rb b/sdks/ruby/src/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/workflow_version_task_desired_worker_label.rb new file mode 100644 index 0000000000..b63f150839 --- /dev/null +++ b/sdks/ruby/src/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/workflow_version_task_desired_worker_label.rb @@ -0,0 +1,288 @@ +=begin +#Hatchet API + +#The Hatchet API + +The version of the OpenAPI document: 1.0.0 + +Generated by: https://openapi-generator.tech +Generator version: 7.14.0 + +=end + +require 'date' +require 'time' + +module HatchetSdkRest + class WorkflowVersionTaskDesiredWorkerLabel + # The worker label key. + attr_accessor :key + + # The string value of the label. + attr_accessor :str_value + + # The integer value of the label. + attr_accessor :int_value + + # Whether the label is required for scheduling. + attr_accessor :required + + # The scheduling weight of the label. + attr_accessor :weight + + # The comparator used when matching the label. + attr_accessor :comparator + + # Attribute mapping from ruby-style variable name to JSON key. + def self.attribute_map + { + :'key' => :'key', + :'str_value' => :'strValue', + :'int_value' => :'intValue', + :'required' => :'required', + :'weight' => :'weight', + :'comparator' => :'comparator' + } + end + + # Returns attribute mapping this model knows about + def self.acceptable_attribute_map + attribute_map + end + + # Returns all the JSON keys this model knows about + def self.acceptable_attributes + acceptable_attribute_map.values + end + + # Attribute type mapping. + def self.openapi_types + { + :'key' => :'String', + :'str_value' => :'String', + :'int_value' => :'Integer', + :'required' => :'Boolean', + :'weight' => :'Integer', + :'comparator' => :'String' + } + end + + # List of attributes with nullable: true + def self.openapi_nullable + Set.new([ + ]) + end + + # Initializes the object + # @param [Hash] attributes Model attributes in the form of hash + def initialize(attributes = {}) + if (!attributes.is_a?(Hash)) + fail ArgumentError, "The input argument (attributes) must be a hash in `HatchetSdkRest::WorkflowVersionTaskDesiredWorkerLabel` initialize method" + end + + # check to see if the attribute exists and convert string to symbol for hash key + acceptable_attribute_map = self.class.acceptable_attribute_map + attributes = attributes.each_with_object({}) { |(k, v), h| + if (!acceptable_attribute_map.key?(k.to_sym)) + fail ArgumentError, "`#{k}` is not a valid attribute in `HatchetSdkRest::WorkflowVersionTaskDesiredWorkerLabel`. Please check the name to make sure it's valid. List of attributes: " + acceptable_attribute_map.keys.inspect + end + h[k.to_sym] = v + } + + if attributes.key?(:'key') + self.key = attributes[:'key'] + else + self.key = nil + end + + if attributes.key?(:'str_value') + self.str_value = attributes[:'str_value'] + end + + if attributes.key?(:'int_value') + self.int_value = attributes[:'int_value'] + end + + if attributes.key?(:'required') + self.required = attributes[:'required'] + end + + if attributes.key?(:'weight') + self.weight = attributes[:'weight'] + end + + if attributes.key?(:'comparator') + self.comparator = attributes[:'comparator'] + end + end + + # Show invalid properties with the reasons. Usually used together with valid? + # @return Array for valid properties with the reasons + def list_invalid_properties + warn '[DEPRECATED] the `list_invalid_properties` method is obsolete' + invalid_properties = Array.new + if @key.nil? + invalid_properties.push('invalid value for "key", key cannot be nil.') + end + + invalid_properties + end + + # Check to see if the all the properties in the model are valid + # @return true if the model is valid + def valid? + warn '[DEPRECATED] the `valid?` method is obsolete' + return false if @key.nil? + true + end + + # Custom attribute writer method with validation + # @param [Object] key Value to be assigned + def key=(key) + if key.nil? + fail ArgumentError, 'key cannot be nil' + end + + @key = key + end + + # Checks equality by comparing each attribute. + # @param [Object] Object to be compared + def ==(o) + return true if self.equal?(o) + self.class == o.class && + key == o.key && + str_value == o.str_value && + int_value == o.int_value && + required == o.required && + weight == o.weight && + comparator == o.comparator + end + + # @see the `==` method + # @param [Object] Object to be compared + def eql?(o) + self == o + end + + # Calculates hash code according to all attributes. + # @return [Integer] Hash code + def hash + [key, str_value, int_value, required, weight, comparator].hash + end + + # Builds the object from hash + # @param [Hash] attributes Model attributes in the form of hash + # @return [Object] Returns the model itself + def self.build_from_hash(attributes) + return nil unless attributes.is_a?(Hash) + attributes = attributes.transform_keys(&:to_sym) + transformed_hash = {} + openapi_types.each_pair do |key, type| + if attributes.key?(attribute_map[key]) && attributes[attribute_map[key]].nil? + transformed_hash["#{key}"] = nil + elsif type =~ /\AArray<(.*)>/i + # check to ensure the input is an array given that the attribute + # is documented as an array but the input is not + if attributes[attribute_map[key]].is_a?(Array) + transformed_hash["#{key}"] = attributes[attribute_map[key]].map { |v| _deserialize($1, v) } + end + elsif !attributes[attribute_map[key]].nil? + transformed_hash["#{key}"] = _deserialize(type, attributes[attribute_map[key]]) + end + end + new(transformed_hash) + end + + # Deserializes the data based on type + # @param string type Data type + # @param string value Value to be deserialized + # @return [Object] Deserialized data + def self._deserialize(type, value) + case type.to_sym + when :Time + Time.parse(value) + when :Date + Date.parse(value) + when :String + value.to_s + when :Integer + value.to_i + when :Float + value.to_f + when :Boolean + if value.to_s =~ /\A(true|t|yes|y|1)\z/i + true + else + false + end + when :Object + # generic object (usually a Hash), return directly + value + when /\AArray<(?.+)>\z/ + inner_type = Regexp.last_match[:inner_type] + value.map { |v| _deserialize(inner_type, v) } + when /\AHash<(?.+?), (?.+)>\z/ + k_type = Regexp.last_match[:k_type] + v_type = Regexp.last_match[:v_type] + {}.tap do |hash| + value.each do |k, v| + hash[_deserialize(k_type, k)] = _deserialize(v_type, v) + end + end + else # model + # models (e.g. Pet) or oneOf + klass = HatchetSdkRest.const_get(type) + klass.respond_to?(:openapi_any_of) || klass.respond_to?(:openapi_one_of) ? klass.build(value) : klass.build_from_hash(value) + end + end + + # Returns the string representation of the object + # @return [String] String presentation of the object + def to_s + to_hash.to_s + end + + # to_body is an alias to to_hash (backward compatibility) + # @return [Hash] Returns the object in the form of hash + def to_body + to_hash + end + + # Returns the object in the form of hash + # @return [Hash] Returns the object in the form of hash + def to_hash + hash = {} + self.class.attribute_map.each_pair do |attr, param| + value = self.send(attr) + if value.nil? + is_nullable = self.class.openapi_nullable.include?(attr) + next if !is_nullable || (is_nullable && !instance_variable_defined?(:"@#{attr}")) + end + + hash[param] = _to_hash(value) + end + hash + end + + # Outputs non-array value in the form of hash + # For object, use to_hash. Otherwise, just return the value + # @param [Object] value Any valid value + # @return [Hash] Returns the value in the form of hash + def _to_hash(value) + if value.is_a?(Array) + value.compact.map { |v| _to_hash(v) } + elsif value.is_a?(Hash) + {}.tap do |hash| + value.each { |k, v| hash[k] = _to_hash(v) } + end + elsif value.respond_to? :to_hash + value.to_hash + else + value + end + end + + end + +end diff --git a/sdks/ruby/src/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/workflow_version_task_rate_limit.rb b/sdks/ruby/src/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/workflow_version_task_rate_limit.rb new file mode 100644 index 0000000000..a9336e8ac9 --- /dev/null +++ b/sdks/ruby/src/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/workflow_version_task_rate_limit.rb @@ -0,0 +1,271 @@ +=begin +#Hatchet API + +#The Hatchet API + +The version of the OpenAPI document: 1.0.0 + +Generated by: https://openapi-generator.tech +Generator version: 7.14.0 + +=end + +require 'date' +require 'time' + +module HatchetSdkRest + class WorkflowVersionTaskRateLimit + # The static rate limit key. + attr_accessor :key + + # A CEL expression that computes the rate limit key. + attr_accessor :key_expression + + # The static number of units to consume. + attr_accessor :units + + # A CEL expression that computes the number of units to consume. + attr_accessor :units_expression + + # A CEL expression that computes a dynamic limit value. + attr_accessor :limit_expression + + # The rate limit window duration. + attr_accessor :duration + + # Attribute mapping from ruby-style variable name to JSON key. + def self.attribute_map + { + :'key' => :'key', + :'key_expression' => :'keyExpression', + :'units' => :'units', + :'units_expression' => :'unitsExpression', + :'limit_expression' => :'limitExpression', + :'duration' => :'duration' + } + end + + # Returns attribute mapping this model knows about + def self.acceptable_attribute_map + attribute_map + end + + # Returns all the JSON keys this model knows about + def self.acceptable_attributes + acceptable_attribute_map.values + end + + # Attribute type mapping. + def self.openapi_types + { + :'key' => :'String', + :'key_expression' => :'String', + :'units' => :'Integer', + :'units_expression' => :'String', + :'limit_expression' => :'String', + :'duration' => :'String' + } + end + + # List of attributes with nullable: true + def self.openapi_nullable + Set.new([ + ]) + end + + # Initializes the object + # @param [Hash] attributes Model attributes in the form of hash + def initialize(attributes = {}) + if (!attributes.is_a?(Hash)) + fail ArgumentError, "The input argument (attributes) must be a hash in `HatchetSdkRest::WorkflowVersionTaskRateLimit` initialize method" + end + + # check to see if the attribute exists and convert string to symbol for hash key + acceptable_attribute_map = self.class.acceptable_attribute_map + attributes = attributes.each_with_object({}) { |(k, v), h| + if (!acceptable_attribute_map.key?(k.to_sym)) + fail ArgumentError, "`#{k}` is not a valid attribute in `HatchetSdkRest::WorkflowVersionTaskRateLimit`. Please check the name to make sure it's valid. List of attributes: " + acceptable_attribute_map.keys.inspect + end + h[k.to_sym] = v + } + + if attributes.key?(:'key') + self.key = attributes[:'key'] + end + + if attributes.key?(:'key_expression') + self.key_expression = attributes[:'key_expression'] + end + + if attributes.key?(:'units') + self.units = attributes[:'units'] + end + + if attributes.key?(:'units_expression') + self.units_expression = attributes[:'units_expression'] + end + + if attributes.key?(:'limit_expression') + self.limit_expression = attributes[:'limit_expression'] + end + + if attributes.key?(:'duration') + self.duration = attributes[:'duration'] + end + end + + # Show invalid properties with the reasons. Usually used together with valid? + # @return Array for valid properties with the reasons + def list_invalid_properties + warn '[DEPRECATED] the `list_invalid_properties` method is obsolete' + invalid_properties = Array.new + invalid_properties + end + + # Check to see if the all the properties in the model are valid + # @return true if the model is valid + def valid? + warn '[DEPRECATED] the `valid?` method is obsolete' + true + end + + # Checks equality by comparing each attribute. + # @param [Object] Object to be compared + def ==(o) + return true if self.equal?(o) + self.class == o.class && + key == o.key && + key_expression == o.key_expression && + units == o.units && + units_expression == o.units_expression && + limit_expression == o.limit_expression && + duration == o.duration + end + + # @see the `==` method + # @param [Object] Object to be compared + def eql?(o) + self == o + end + + # Calculates hash code according to all attributes. + # @return [Integer] Hash code + def hash + [key, key_expression, units, units_expression, limit_expression, duration].hash + end + + # Builds the object from hash + # @param [Hash] attributes Model attributes in the form of hash + # @return [Object] Returns the model itself + def self.build_from_hash(attributes) + return nil unless attributes.is_a?(Hash) + attributes = attributes.transform_keys(&:to_sym) + transformed_hash = {} + openapi_types.each_pair do |key, type| + if attributes.key?(attribute_map[key]) && attributes[attribute_map[key]].nil? + transformed_hash["#{key}"] = nil + elsif type =~ /\AArray<(.*)>/i + # check to ensure the input is an array given that the attribute + # is documented as an array but the input is not + if attributes[attribute_map[key]].is_a?(Array) + transformed_hash["#{key}"] = attributes[attribute_map[key]].map { |v| _deserialize($1, v) } + end + elsif !attributes[attribute_map[key]].nil? + transformed_hash["#{key}"] = _deserialize(type, attributes[attribute_map[key]]) + end + end + new(transformed_hash) + end + + # Deserializes the data based on type + # @param string type Data type + # @param string value Value to be deserialized + # @return [Object] Deserialized data + def self._deserialize(type, value) + case type.to_sym + when :Time + Time.parse(value) + when :Date + Date.parse(value) + when :String + value.to_s + when :Integer + value.to_i + when :Float + value.to_f + when :Boolean + if value.to_s =~ /\A(true|t|yes|y|1)\z/i + true + else + false + end + when :Object + # generic object (usually a Hash), return directly + value + when /\AArray<(?.+)>\z/ + inner_type = Regexp.last_match[:inner_type] + value.map { |v| _deserialize(inner_type, v) } + when /\AHash<(?.+?), (?.+)>\z/ + k_type = Regexp.last_match[:k_type] + v_type = Regexp.last_match[:v_type] + {}.tap do |hash| + value.each do |k, v| + hash[_deserialize(k_type, k)] = _deserialize(v_type, v) + end + end + else # model + # models (e.g. Pet) or oneOf + klass = HatchetSdkRest.const_get(type) + klass.respond_to?(:openapi_any_of) || klass.respond_to?(:openapi_one_of) ? klass.build(value) : klass.build_from_hash(value) + end + end + + # Returns the string representation of the object + # @return [String] String presentation of the object + def to_s + to_hash.to_s + end + + # to_body is an alias to to_hash (backward compatibility) + # @return [Hash] Returns the object in the form of hash + def to_body + to_hash + end + + # Returns the object in the form of hash + # @return [Hash] Returns the object in the form of hash + def to_hash + hash = {} + self.class.attribute_map.each_pair do |attr, param| + value = self.send(attr) + if value.nil? + is_nullable = self.class.openapi_nullable.include?(attr) + next if !is_nullable || (is_nullable && !instance_variable_defined?(:"@#{attr}")) + end + + hash[param] = _to_hash(value) + end + hash + end + + # Outputs non-array value in the form of hash + # For object, use to_hash. Otherwise, just return the value + # @param [Object] value Any valid value + # @return [Hash] Returns the value in the form of hash + def _to_hash(value) + if value.is_a?(Array) + value.compact.map { |v| _to_hash(v) } + elsif value.is_a?(Hash) + {}.tap do |hash| + value.each { |k, v| hash[k] = _to_hash(v) } + end + elsif value.respond_to? :to_hash + value.to_hash + else + value + end + end + + end + +end diff --git a/sdks/ruby/src/lib/hatchet/contracts/v1/workflows_pb.rb b/sdks/ruby/src/lib/hatchet/contracts/v1/workflows_pb.rb index 5a11b5afcc..4d8e51170b 100644 --- a/sdks/ruby/src/lib/hatchet/contracts/v1/workflows_pb.rb +++ b/sdks/ruby/src/lib/hatchet/contracts/v1/workflows_pb.rb @@ -9,7 +9,7 @@ require 'v1/shared/trigger_pb' -descriptor_data = "\n\x12v1/workflows.proto\x12\x02v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x19v1/shared/condition.proto\x1a\x17v1/shared/trigger.proto\"[\n\x12\x43\x61ncelTasksRequest\x12\x14\n\x0c\x65xternal_ids\x18\x01 \x03(\t\x12$\n\x06\x66ilter\x18\x02 \x01(\x0b\x32\x0f.v1.TasksFilterH\x00\x88\x01\x01\x42\t\n\x07_filter\"[\n\x12ReplayTasksRequest\x12\x14\n\x0c\x65xternal_ids\x18\x01 \x03(\t\x12$\n\x06\x66ilter\x18\x02 \x01(\x0b\x32\x0f.v1.TasksFilterH\x00\x88\x01\x01\x42\t\n\x07_filter\"\xb7\x01\n\x0bTasksFilter\x12\x10\n\x08statuses\x18\x01 \x03(\t\x12)\n\x05since\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12.\n\x05until\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x00\x88\x01\x01\x12\x14\n\x0cworkflow_ids\x18\x04 \x03(\t\x12\x1b\n\x13\x61\x64\x64itional_metadata\x18\x05 \x03(\tB\x08\n\x06_until\".\n\x13\x43\x61ncelTasksResponse\x12\x17\n\x0f\x63\x61ncelled_tasks\x18\x01 \x03(\t\"-\n\x13ReplayTasksResponse\x12\x16\n\x0ereplayed_tasks\x18\x01 \x03(\t\"\xae\x02\n\x19TriggerWorkflowRunRequest\x12\x15\n\rworkflow_name\x18\x01 \x01(\t\x12\r\n\x05input\x18\x02 \x01(\x0c\x12\x1b\n\x13\x61\x64\x64itional_metadata\x18\x03 \x01(\x0c\x12\x15\n\x08priority\x18\x04 \x01(\x05H\x00\x88\x01\x01\x12U\n\x15\x64\x65sired_worker_labels\x18\x05 \x03(\x0b\x32\x36.v1.TriggerWorkflowRunRequest.DesiredWorkerLabelsEntry\x1aS\n\x18\x44\x65siredWorkerLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12&\n\x05value\x18\x02 \x01(\x0b\x32\x17.v1.DesiredWorkerLabels:\x02\x38\x01\x42\x0b\n\t_priority\"1\n\x1aTriggerWorkflowRunResponse\x12\x13\n\x0b\x65xternal_id\x18\x01 \x01(\t\"X\n\x18\x42ranchDurableTaskRequest\x12\x18\n\x10task_external_id\x18\x01 \x01(\t\x12\x0f\n\x07node_id\x18\x02 \x01(\x03\x12\x11\n\tbranch_id\x18\x03 \x01(\x03\"Y\n\x19\x42ranchDurableTaskResponse\x12\x18\n\x10task_external_id\x18\x01 \x01(\t\x12\x0f\n\x07node_id\x18\x02 \x01(\x03\x12\x11\n\tbranch_id\x18\x03 \x01(\x03\"\xed\x04\n\x1c\x43reateWorkflowVersionRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t\x12\x16\n\x0e\x65vent_triggers\x18\x04 \x03(\t\x12\x15\n\rcron_triggers\x18\x05 \x03(\t\x12!\n\x05tasks\x18\x06 \x03(\x0b\x32\x12.v1.CreateTaskOpts\x12$\n\x0b\x63oncurrency\x18\x07 \x01(\x0b\x32\x0f.v1.Concurrency\x12\x17\n\ncron_input\x18\x08 \x01(\tH\x00\x88\x01\x01\x12\x30\n\x0fon_failure_task\x18\t \x01(\x0b\x32\x12.v1.CreateTaskOptsH\x01\x88\x01\x01\x12\'\n\x06sticky\x18\n \x01(\x0e\x32\x12.v1.StickyStrategyH\x02\x88\x01\x01\x12\x1d\n\x10\x64\x65\x66\x61ult_priority\x18\x0b \x01(\x05H\x03\x88\x01\x01\x12(\n\x0f\x63oncurrency_arr\x18\x0c \x03(\x0b\x32\x0f.v1.Concurrency\x12*\n\x0f\x64\x65\x66\x61ult_filters\x18\r \x03(\x0b\x32\x11.v1.DefaultFilter\x12\x1e\n\x11input_json_schema\x18\x0e \x01(\x0cH\x04\x88\x01\x01\x12/\n\x0bidempotency\x18\x0f \x01(\x0b\x32\x15.v1.IdempotencyConfigH\x05\x88\x01\x01\x42\r\n\x0b_cron_inputB\x12\n\x10_on_failure_taskB\t\n\x07_stickyB\x13\n\x11_default_priorityB\x14\n\x12_input_json_schemaB\x0e\n\x0c_idempotency\"7\n\x11IdempotencyConfig\x12\x12\n\nexpression\x18\x01 \x01(\t\x12\x0e\n\x06ttl_ms\x18\x02 \x01(\x03\"`\n\x19IdempotencyCollisionError\x12 \n\x18\x65xisting_run_external_id\x18\x01 \x01(\t\x12!\n\x19\x63olliding_run_external_id\x18\x02 \x01(\t\"\x87\x01\n$BulkTriggerIdempotencyCollisionError\x12,\n$successful_workflow_run_external_ids\x18\x01 \x03(\t\x12\x31\n\ncollisions\x18\x02 \x03(\x0b\x32\x1d.v1.IdempotencyCollisionError\"T\n\rDefaultFilter\x12\x12\n\nexpression\x18\x01 \x01(\t\x12\r\n\x05scope\x18\x02 \x01(\t\x12\x14\n\x07payload\x18\x03 \x01(\x0cH\x00\x88\x01\x01\x42\n\n\x08_payload\"\x93\x01\n\x0b\x43oncurrency\x12\x12\n\nexpression\x18\x01 \x01(\t\x12\x15\n\x08max_runs\x18\x02 \x01(\x05H\x00\x88\x01\x01\x12\x39\n\x0elimit_strategy\x18\x03 \x01(\x0e\x32\x1c.v1.ConcurrencyLimitStrategyH\x01\x88\x01\x01\x42\x0b\n\t_max_runsB\x11\n\x0f_limit_strategy\"\xb7\x05\n\x0e\x43reateTaskOpts\x12\x13\n\x0breadable_id\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x63tion\x18\x02 \x01(\t\x12\x0f\n\x07timeout\x18\x03 \x01(\t\x12\x0e\n\x06inputs\x18\x04 \x01(\t\x12\x0f\n\x07parents\x18\x05 \x03(\t\x12\x0f\n\x07retries\x18\x06 \x01(\x05\x12,\n\x0brate_limits\x18\x07 \x03(\x0b\x32\x17.v1.CreateTaskRateLimit\x12;\n\rworker_labels\x18\x08 \x03(\x0b\x32$.v1.CreateTaskOpts.WorkerLabelsEntry\x12\x1b\n\x0e\x62\x61\x63koff_factor\x18\t \x01(\x02H\x00\x88\x01\x01\x12 \n\x13\x62\x61\x63koff_max_seconds\x18\n \x01(\x05H\x01\x88\x01\x01\x12$\n\x0b\x63oncurrency\x18\x0b \x03(\x0b\x32\x0f.v1.Concurrency\x12+\n\nconditions\x18\x0c \x01(\x0b\x32\x12.v1.TaskConditionsH\x02\x88\x01\x01\x12\x1d\n\x10schedule_timeout\x18\r \x01(\tH\x03\x88\x01\x01\x12\x12\n\nis_durable\x18\x0e \x01(\x08\x12;\n\rslot_requests\x18\x0f \x03(\x0b\x32$.v1.CreateTaskOpts.SlotRequestsEntry\x1aL\n\x11WorkerLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12&\n\x05value\x18\x02 \x01(\x0b\x32\x17.v1.DesiredWorkerLabels:\x02\x38\x01\x1a\x33\n\x11SlotRequestsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x42\x11\n\x0f_backoff_factorB\x16\n\x14_backoff_max_secondsB\r\n\x0b_conditionsB\x13\n\x11_schedule_timeout\"\xfd\x01\n\x13\x43reateTaskRateLimit\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x12\n\x05units\x18\x02 \x01(\x05H\x00\x88\x01\x01\x12\x15\n\x08key_expr\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x17\n\nunits_expr\x18\x04 \x01(\tH\x02\x88\x01\x01\x12\x1e\n\x11limit_values_expr\x18\x05 \x01(\tH\x03\x88\x01\x01\x12,\n\x08\x64uration\x18\x06 \x01(\x0e\x32\x15.v1.RateLimitDurationH\x04\x88\x01\x01\x42\x08\n\x06_unitsB\x0b\n\t_key_exprB\r\n\x0b_units_exprB\x14\n\x12_limit_values_exprB\x0b\n\t_duration\"@\n\x1d\x43reateWorkflowVersionResponse\x12\n\n\x02id\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\"+\n\x14GetRunDetailsRequest\x12\x13\n\x0b\x65xternal_id\x18\x01 \x01(\t\"\xaa\x01\n\rTaskRunDetail\x12\x13\n\x0b\x65xternal_id\x18\x01 \x01(\t\x12\x1d\n\x06status\x18\x02 \x01(\x0e\x32\r.v1.RunStatus\x12\x12\n\x05\x65rror\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06output\x18\x04 \x01(\x0cH\x01\x88\x01\x01\x12\x13\n\x0breadable_id\x18\x05 \x01(\t\x12\x12\n\nis_evicted\x18\x06 \x01(\x08\x42\x08\n\x06_errorB\t\n\x07_output\"\x84\x02\n\x15GetRunDetailsResponse\x12\r\n\x05input\x18\x01 \x01(\x0c\x12\x1d\n\x06status\x18\x02 \x01(\x0e\x32\r.v1.RunStatus\x12:\n\ttask_runs\x18\x03 \x03(\x0b\x32\'.v1.GetRunDetailsResponse.TaskRunsEntry\x12\x0c\n\x04\x64one\x18\x04 \x01(\x08\x12\x1b\n\x13\x61\x64\x64itional_metadata\x18\x05 \x01(\x0c\x12\x12\n\nis_evicted\x18\x06 \x01(\x08\x1a\x42\n\rTaskRunsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.v1.TaskRunDetail:\x02\x38\x01*$\n\x0eStickyStrategy\x12\x08\n\x04SOFT\x10\x00\x12\x08\n\x04HARD\x10\x01*]\n\x11RateLimitDuration\x12\n\n\x06SECOND\x10\x00\x12\n\n\x06MINUTE\x10\x01\x12\x08\n\x04HOUR\x10\x02\x12\x07\n\x03\x44\x41Y\x10\x03\x12\x08\n\x04WEEK\x10\x04\x12\t\n\x05MONTH\x10\x05\x12\x08\n\x04YEAR\x10\x06*[\n\tRunStatus\x12\n\n\x06QUEUED\x10\x00\x12\x0b\n\x07RUNNING\x10\x01\x12\r\n\tCOMPLETED\x10\x02\x12\n\n\x06\x46\x41ILED\x10\x03\x12\r\n\tCANCELLED\x10\x04\x12\x0b\n\x07\x45VICTED\x10\x05*\x7f\n\x18\x43oncurrencyLimitStrategy\x12\x16\n\x12\x43\x41NCEL_IN_PROGRESS\x10\x00\x12\x0f\n\x0b\x44ROP_NEWEST\x10\x01\x12\x10\n\x0cQUEUE_NEWEST\x10\x02\x12\x15\n\x11GROUP_ROUND_ROBIN\x10\x03\x12\x11\n\rCANCEL_NEWEST\x10\x04\x32\xcf\x03\n\x0c\x41\x64minService\x12R\n\x0bPutWorkflow\x12 .v1.CreateWorkflowVersionRequest\x1a!.v1.CreateWorkflowVersionResponse\x12>\n\x0b\x43\x61ncelTasks\x12\x16.v1.CancelTasksRequest\x1a\x17.v1.CancelTasksResponse\x12>\n\x0bReplayTasks\x12\x16.v1.ReplayTasksRequest\x1a\x17.v1.ReplayTasksResponse\x12S\n\x12TriggerWorkflowRun\x12\x1d.v1.TriggerWorkflowRunRequest\x1a\x1e.v1.TriggerWorkflowRunResponse\x12\x44\n\rGetRunDetails\x12\x18.v1.GetRunDetailsRequest\x1a\x19.v1.GetRunDetailsResponse\x12P\n\x11\x42ranchDurableTask\x12\x1c.v1.BranchDurableTaskRequest\x1a\x1d.v1.BranchDurableTaskResponseBBZ@github.com/hatchet-dev/hatchet/internal/services/shared/proto/v1b\x06proto3" +descriptor_data = "\n\x12v1/workflows.proto\x12\x02v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x19v1/shared/condition.proto\x1a\x17v1/shared/trigger.proto\"[\n\x12\x43\x61ncelTasksRequest\x12\x14\n\x0c\x65xternal_ids\x18\x01 \x03(\t\x12$\n\x06\x66ilter\x18\x02 \x01(\x0b\x32\x0f.v1.TasksFilterH\x00\x88\x01\x01\x42\t\n\x07_filter\"[\n\x12ReplayTasksRequest\x12\x14\n\x0c\x65xternal_ids\x18\x01 \x03(\t\x12$\n\x06\x66ilter\x18\x02 \x01(\x0b\x32\x0f.v1.TasksFilterH\x00\x88\x01\x01\x42\t\n\x07_filter\"\xb7\x01\n\x0bTasksFilter\x12\x10\n\x08statuses\x18\x01 \x03(\t\x12)\n\x05since\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12.\n\x05until\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x00\x88\x01\x01\x12\x14\n\x0cworkflow_ids\x18\x04 \x03(\t\x12\x1b\n\x13\x61\x64\x64itional_metadata\x18\x05 \x03(\tB\x08\n\x06_until\".\n\x13\x43\x61ncelTasksResponse\x12\x17\n\x0f\x63\x61ncelled_tasks\x18\x01 \x03(\t\"-\n\x13ReplayTasksResponse\x12\x16\n\x0ereplayed_tasks\x18\x01 \x03(\t\"\xae\x02\n\x19TriggerWorkflowRunRequest\x12\x15\n\rworkflow_name\x18\x01 \x01(\t\x12\r\n\x05input\x18\x02 \x01(\x0c\x12\x1b\n\x13\x61\x64\x64itional_metadata\x18\x03 \x01(\x0c\x12\x15\n\x08priority\x18\x04 \x01(\x05H\x00\x88\x01\x01\x12U\n\x15\x64\x65sired_worker_labels\x18\x05 \x03(\x0b\x32\x36.v1.TriggerWorkflowRunRequest.DesiredWorkerLabelsEntry\x1aS\n\x18\x44\x65siredWorkerLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12&\n\x05value\x18\x02 \x01(\x0b\x32\x17.v1.DesiredWorkerLabels:\x02\x38\x01\x42\x0b\n\t_priority\"1\n\x1aTriggerWorkflowRunResponse\x12\x13\n\x0b\x65xternal_id\x18\x01 \x01(\t\"X\n\x18\x42ranchDurableTaskRequest\x12\x18\n\x10task_external_id\x18\x01 \x01(\t\x12\x0f\n\x07node_id\x18\x02 \x01(\x03\x12\x11\n\tbranch_id\x18\x03 \x01(\x03\"Y\n\x19\x42ranchDurableTaskResponse\x12\x18\n\x10task_external_id\x18\x01 \x01(\t\x12\x0f\n\x07node_id\x18\x02 \x01(\x03\x12\x11\n\tbranch_id\x18\x03 \x01(\x03\"\xed\x04\n\x1c\x43reateWorkflowVersionRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t\x12\x16\n\x0e\x65vent_triggers\x18\x04 \x03(\t\x12\x15\n\rcron_triggers\x18\x05 \x03(\t\x12!\n\x05tasks\x18\x06 \x03(\x0b\x32\x12.v1.CreateTaskOpts\x12$\n\x0b\x63oncurrency\x18\x07 \x01(\x0b\x32\x0f.v1.Concurrency\x12\x17\n\ncron_input\x18\x08 \x01(\tH\x00\x88\x01\x01\x12\x30\n\x0fon_failure_task\x18\t \x01(\x0b\x32\x12.v1.CreateTaskOptsH\x01\x88\x01\x01\x12\'\n\x06sticky\x18\n \x01(\x0e\x32\x12.v1.StickyStrategyH\x02\x88\x01\x01\x12\x1d\n\x10\x64\x65\x66\x61ult_priority\x18\x0b \x01(\x05H\x03\x88\x01\x01\x12(\n\x0f\x63oncurrency_arr\x18\x0c \x03(\x0b\x32\x0f.v1.Concurrency\x12*\n\x0f\x64\x65\x66\x61ult_filters\x18\r \x03(\x0b\x32\x11.v1.DefaultFilter\x12\x1e\n\x11input_json_schema\x18\x0e \x01(\x0cH\x04\x88\x01\x01\x12/\n\x0bidempotency\x18\x0f \x01(\x0b\x32\x15.v1.IdempotencyConfigH\x05\x88\x01\x01\x42\r\n\x0b_cron_inputB\x12\n\x10_on_failure_taskB\t\n\x07_stickyB\x13\n\x11_default_priorityB\x14\n\x12_input_json_schemaB\x0e\n\x0c_idempotency\"n\n\x11IdempotencyConfig\x12\x12\n\nexpression\x18\x01 \x01(\t\x12\x0e\n\x06ttl_ms\x18\x02 \x01(\x03\x12*\n\x06method\x18\x03 \x01(\x0e\x32\x15.v1.IdempotencyMethodH\x00\x88\x01\x01\x42\t\n\x07_method\"`\n\x19IdempotencyCollisionError\x12 \n\x18\x65xisting_run_external_id\x18\x01 \x01(\t\x12!\n\x19\x63olliding_run_external_id\x18\x02 \x01(\t\"\x87\x01\n$BulkTriggerIdempotencyCollisionError\x12,\n$successful_workflow_run_external_ids\x18\x01 \x03(\t\x12\x31\n\ncollisions\x18\x02 \x03(\x0b\x32\x1d.v1.IdempotencyCollisionError\"T\n\rDefaultFilter\x12\x12\n\nexpression\x18\x01 \x01(\t\x12\r\n\x05scope\x18\x02 \x01(\t\x12\x14\n\x07payload\x18\x03 \x01(\x0cH\x00\x88\x01\x01\x42\n\n\x08_payload\"\x93\x01\n\x0b\x43oncurrency\x12\x12\n\nexpression\x18\x01 \x01(\t\x12\x15\n\x08max_runs\x18\x02 \x01(\x05H\x00\x88\x01\x01\x12\x39\n\x0elimit_strategy\x18\x03 \x01(\x0e\x32\x1c.v1.ConcurrencyLimitStrategyH\x01\x88\x01\x01\x42\x0b\n\t_max_runsB\x11\n\x0f_limit_strategy\"\xb7\x05\n\x0e\x43reateTaskOpts\x12\x13\n\x0breadable_id\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x63tion\x18\x02 \x01(\t\x12\x0f\n\x07timeout\x18\x03 \x01(\t\x12\x0e\n\x06inputs\x18\x04 \x01(\t\x12\x0f\n\x07parents\x18\x05 \x03(\t\x12\x0f\n\x07retries\x18\x06 \x01(\x05\x12,\n\x0brate_limits\x18\x07 \x03(\x0b\x32\x17.v1.CreateTaskRateLimit\x12;\n\rworker_labels\x18\x08 \x03(\x0b\x32$.v1.CreateTaskOpts.WorkerLabelsEntry\x12\x1b\n\x0e\x62\x61\x63koff_factor\x18\t \x01(\x02H\x00\x88\x01\x01\x12 \n\x13\x62\x61\x63koff_max_seconds\x18\n \x01(\x05H\x01\x88\x01\x01\x12$\n\x0b\x63oncurrency\x18\x0b \x03(\x0b\x32\x0f.v1.Concurrency\x12+\n\nconditions\x18\x0c \x01(\x0b\x32\x12.v1.TaskConditionsH\x02\x88\x01\x01\x12\x1d\n\x10schedule_timeout\x18\r \x01(\tH\x03\x88\x01\x01\x12\x12\n\nis_durable\x18\x0e \x01(\x08\x12;\n\rslot_requests\x18\x0f \x03(\x0b\x32$.v1.CreateTaskOpts.SlotRequestsEntry\x1aL\n\x11WorkerLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12&\n\x05value\x18\x02 \x01(\x0b\x32\x17.v1.DesiredWorkerLabels:\x02\x38\x01\x1a\x33\n\x11SlotRequestsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x42\x11\n\x0f_backoff_factorB\x16\n\x14_backoff_max_secondsB\r\n\x0b_conditionsB\x13\n\x11_schedule_timeout\"\xfd\x01\n\x13\x43reateTaskRateLimit\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x12\n\x05units\x18\x02 \x01(\x05H\x00\x88\x01\x01\x12\x15\n\x08key_expr\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x17\n\nunits_expr\x18\x04 \x01(\tH\x02\x88\x01\x01\x12\x1e\n\x11limit_values_expr\x18\x05 \x01(\tH\x03\x88\x01\x01\x12,\n\x08\x64uration\x18\x06 \x01(\x0e\x32\x15.v1.RateLimitDurationH\x04\x88\x01\x01\x42\x08\n\x06_unitsB\x0b\n\t_key_exprB\r\n\x0b_units_exprB\x14\n\x12_limit_values_exprB\x0b\n\t_duration\"@\n\x1d\x43reateWorkflowVersionResponse\x12\n\n\x02id\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\"+\n\x14GetRunDetailsRequest\x12\x13\n\x0b\x65xternal_id\x18\x01 \x01(\t\"\xaa\x01\n\rTaskRunDetail\x12\x13\n\x0b\x65xternal_id\x18\x01 \x01(\t\x12\x1d\n\x06status\x18\x02 \x01(\x0e\x32\r.v1.RunStatus\x12\x12\n\x05\x65rror\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06output\x18\x04 \x01(\x0cH\x01\x88\x01\x01\x12\x13\n\x0breadable_id\x18\x05 \x01(\t\x12\x12\n\nis_evicted\x18\x06 \x01(\x08\x42\x08\n\x06_errorB\t\n\x07_output\"\x84\x02\n\x15GetRunDetailsResponse\x12\r\n\x05input\x18\x01 \x01(\x0c\x12\x1d\n\x06status\x18\x02 \x01(\x0e\x32\r.v1.RunStatus\x12:\n\ttask_runs\x18\x03 \x03(\x0b\x32\'.v1.GetRunDetailsResponse.TaskRunsEntry\x12\x0c\n\x04\x64one\x18\x04 \x01(\x08\x12\x1b\n\x13\x61\x64\x64itional_metadata\x18\x05 \x01(\x0c\x12\x12\n\nis_evicted\x18\x06 \x01(\x08\x1a\x42\n\rTaskRunsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.v1.TaskRunDetail:\x02\x38\x01*$\n\x0eStickyStrategy\x12\x08\n\x04SOFT\x10\x00\x12\x08\n\x04HARD\x10\x01*]\n\x11RateLimitDuration\x12\n\n\x06SECOND\x10\x00\x12\n\n\x06MINUTE\x10\x01\x12\x08\n\x04HOUR\x10\x02\x12\x07\n\x03\x44\x41Y\x10\x03\x12\x08\n\x04WEEK\x10\x04\x12\t\n\x05MONTH\x10\x05\x12\x08\n\x04YEAR\x10\x06*[\n\tRunStatus\x12\n\n\x06QUEUED\x10\x00\x12\x0b\n\x07RUNNING\x10\x01\x12\r\n\tCOMPLETED\x10\x02\x12\n\n\x06\x46\x41ILED\x10\x03\x12\r\n\tCANCELLED\x10\x04\x12\x0b\n\x07\x45VICTED\x10\x05*(\n\x11IdempotencyMethod\x12\x07\n\x03TTL\x10\x00\x12\n\n\x06STATUS\x10\x01*\x7f\n\x18\x43oncurrencyLimitStrategy\x12\x16\n\x12\x43\x41NCEL_IN_PROGRESS\x10\x00\x12\x0f\n\x0b\x44ROP_NEWEST\x10\x01\x12\x10\n\x0cQUEUE_NEWEST\x10\x02\x12\x15\n\x11GROUP_ROUND_ROBIN\x10\x03\x12\x11\n\rCANCEL_NEWEST\x10\x04\x32\xcf\x03\n\x0c\x41\x64minService\x12R\n\x0bPutWorkflow\x12 .v1.CreateWorkflowVersionRequest\x1a!.v1.CreateWorkflowVersionResponse\x12>\n\x0b\x43\x61ncelTasks\x12\x16.v1.CancelTasksRequest\x1a\x17.v1.CancelTasksResponse\x12>\n\x0bReplayTasks\x12\x16.v1.ReplayTasksRequest\x1a\x17.v1.ReplayTasksResponse\x12S\n\x12TriggerWorkflowRun\x12\x1d.v1.TriggerWorkflowRunRequest\x1a\x1e.v1.TriggerWorkflowRunResponse\x12\x44\n\rGetRunDetails\x12\x18.v1.GetRunDetailsRequest\x1a\x19.v1.GetRunDetailsResponse\x12P\n\x11\x42ranchDurableTask\x12\x1c.v1.BranchDurableTaskRequest\x1a\x1d.v1.BranchDurableTaskResponseBBZ@github.com/hatchet-dev/hatchet/internal/services/shared/proto/v1b\x06proto3" pool = ::Google::Protobuf::DescriptorPool.generated_pool pool.add_serialized_file(descriptor_data) @@ -39,5 +39,6 @@ module V1 StickyStrategy = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("v1.StickyStrategy").enummodule RateLimitDuration = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("v1.RateLimitDuration").enummodule RunStatus = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("v1.RunStatus").enummodule + IdempotencyMethod = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("v1.IdempotencyMethod").enummodule ConcurrencyLimitStrategy = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("v1.ConcurrencyLimitStrategy").enummodule end diff --git a/sdks/ruby/src/lib/hatchet/idempotency.rb b/sdks/ruby/src/lib/hatchet/idempotency.rb index f2acdb444c..227f36a701 100644 --- a/sdks/ruby/src/lib/hatchet/idempotency.rb +++ b/sdks/ruby/src/lib/hatchet/idempotency.rb @@ -21,7 +21,41 @@ def initialize(expression:, ttl_ms:) # @return [V1::IdempotencyConfig] def to_proto - ::V1::IdempotencyConfig.new(expression: @expression, ttl_ms: @ttl_ms) + ::V1::IdempotencyConfig.new( + expression: @expression, + ttl_ms: @ttl_ms, + method: ::V1::IdempotencyMethod::TTL, + ) + end + end + + # Status-based idempotency: keeps the idempotency key alive until the associated run + # reaches a terminal status. +fallback_ttl_ms+ caps how long the key can live before + # it's evicted, even if the run has not reached a terminal status. + # + # @example + # Hatchet::StatusBasedIdempotencyConfig.new(expression: "input.id", fallback_ttl_ms: 10_000) + class StatusBasedIdempotencyConfig + # @return [String] CEL expression evaluated against workflow input + attr_reader :expression + + # @return [Integer] Fallback TTL in milliseconds; the longest the key can live + attr_reader :fallback_ttl_ms + + # @param expression [String] CEL expression to derive the idempotency key + # @param fallback_ttl_ms [Integer] Fallback TTL for the idempotency key in milliseconds + def initialize(expression:, fallback_ttl_ms:) + @expression = expression + @fallback_ttl_ms = fallback_ttl_ms + end + + # @return [V1::IdempotencyConfig] + def to_proto + ::V1::IdempotencyConfig.new( + expression: @expression, + ttl_ms: @fallback_ttl_ms, + method: ::V1::IdempotencyMethod::STATUS, + ) end end end diff --git a/sdks/ruby/src/lib/hatchet/version.rb b/sdks/ruby/src/lib/hatchet/version.rb index c09c6fb6e2..3daa310396 100644 --- a/sdks/ruby/src/lib/hatchet/version.rb +++ b/sdks/ruby/src/lib/hatchet/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Hatchet - VERSION = "0.4.0" + VERSION = "0.5.0" end diff --git a/sdks/ruby/src/lib/hatchet/workflow.rb b/sdks/ruby/src/lib/hatchet/workflow.rb index 6e5ece1055..6dccb01995 100644 --- a/sdks/ruby/src/lib/hatchet/workflow.rb +++ b/sdks/ruby/src/lib/hatchet/workflow.rb @@ -46,7 +46,7 @@ class Workflow # @return [Task, nil] The on_success task attr_reader :on_success - # @return [Hatchet::TTLBasedIdempotencyConfig, nil] Idempotency configuration + # @return [Hatchet::TTLBasedIdempotencyConfig, Hatchet::StatusBasedIdempotencyConfig, nil] Idempotency configuration attr_reader :idempotency # @return [String, nil] The workflow ID writer (set after registration) @@ -60,7 +60,7 @@ class Workflow # @param task_defaults [Hash, nil] Default task settings # @param default_filters [Array] Default filters # @param sticky [Symbol, nil] Sticky strategy - # @param idempotency [Hatchet::TTLBasedIdempotencyConfig, nil] Idempotency configuration + # @param idempotency [Hatchet::TTLBasedIdempotencyConfig, Hatchet::StatusBasedIdempotencyConfig, nil] Idempotency configuration # @param client [Hatchet::Client, nil] The client def initialize( name:, diff --git a/sdks/ruby/src/sig/hatchet/idempotency.rbs b/sdks/ruby/src/sig/hatchet/idempotency.rbs index 896369111a..0092848ecb 100644 --- a/sdks/ruby/src/sig/hatchet/idempotency.rbs +++ b/sdks/ruby/src/sig/hatchet/idempotency.rbs @@ -6,4 +6,12 @@ module Hatchet def initialize: (expression: String, ttl_ms: Integer) -> void def to_proto: () -> untyped end + + class StatusBasedIdempotencyConfig + attr_reader expression: String + attr_reader fallback_ttl_ms: Integer + + def initialize: (expression: String, fallback_ttl_ms: Integer) -> void + def to_proto: () -> untyped + end end diff --git a/sdks/typescript/CHANGELOG.md b/sdks/typescript/CHANGELOG.md index 2ef07485e0..c085a508e1 100644 --- a/sdks/typescript/CHANGELOG.md +++ b/sdks/typescript/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to Hatchet's TypeScript SDK will be documented in this chang The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.27.0] - 2026-07-22 + +### Added + +- Adds support for terminal status-based idempotency keys, which are released when the task holding the key reaches a terminal state (either completed, cancelled, or having failed and exhausted all retries). + + ## [1.26.2] - 2026-07-21 ### Added diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index bd08ed6579..248c12eb1f 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@hatchet-dev/typescript-sdk", - "version": "1.26.2", + "version": "1.27.0", "description": "Background task orchestration & visibility for developers", "types": "dist/index.d.ts", "files": [ diff --git a/sdks/typescript/src/protoc/v1/workflows.ts b/sdks/typescript/src/protoc/v1/workflows.ts index fd958a427f..d19aab155d 100644 --- a/sdks/typescript/src/protoc/v1/workflows.ts +++ b/sdks/typescript/src/protoc/v1/workflows.ts @@ -219,6 +219,39 @@ export function concurrencyLimitStrategyToJSON(object: ConcurrencyLimitStrategy) } } +export enum IdempotencyMethod { + TTL = 0, + STATUS = 1, + UNRECOGNIZED = -1, +} + +export function idempotencyMethodFromJSON(object: any): IdempotencyMethod { + switch (object) { + case 0: + case 'TTL': + return IdempotencyMethod.TTL; + case 1: + case 'STATUS': + return IdempotencyMethod.STATUS; + case -1: + case 'UNRECOGNIZED': + default: + return IdempotencyMethod.UNRECOGNIZED; + } +} + +export function idempotencyMethodToJSON(object: IdempotencyMethod): string { + switch (object) { + case IdempotencyMethod.TTL: + return 'TTL'; + case IdempotencyMethod.STATUS: + return 'STATUS'; + case IdempotencyMethod.UNRECOGNIZED: + default: + return 'UNRECOGNIZED'; + } +} + export interface CancelTasksRequest { /** a list of external UUIDs */ externalIds: string[]; @@ -319,8 +352,10 @@ export interface CreateWorkflowVersionRequest { export interface IdempotencyConfig { /** a CEL expression for determining the idempotency key for workflow runs */ expression: string; - /** time-to-live for idempotency keys in milliseconds */ + /** time-to-live for idempotency keys in milliseconds. if the method is `STATUS`, this is a "fallback" - the longest the key can live before it's evicted */ ttlMs: number; + /** the method to use for idempotency, defaults to TTL */ + method?: IdempotencyMethod | undefined; } export interface IdempotencyCollisionError { @@ -1802,7 +1837,7 @@ export const CreateWorkflowVersionRequest: MessageFns = { @@ -1813,6 +1848,9 @@ export const IdempotencyConfig: MessageFns = { if (message.ttlMs !== 0) { writer.uint32(16).int64(message.ttlMs); } + if (message.method !== undefined) { + writer.uint32(24).int32(message.method); + } return writer; }, @@ -1839,6 +1877,14 @@ export const IdempotencyConfig: MessageFns = { message.ttlMs = longToNumber(reader.int64()); continue; } + case 3: { + if (tag !== 24) { + break; + } + + message.method = reader.int32() as any; + continue; + } } if ((tag & 7) === 4 || tag === 0) { break; @@ -1856,6 +1902,7 @@ export const IdempotencyConfig: MessageFns = { : isSet(object.ttl_ms) ? globalThis.Number(object.ttl_ms) : 0, + method: isSet(object.method) ? idempotencyMethodFromJSON(object.method) : undefined, }; }, @@ -1867,6 +1914,9 @@ export const IdempotencyConfig: MessageFns = { if (message.ttlMs !== 0) { obj.ttlMs = Math.round(message.ttlMs); } + if (message.method !== undefined) { + obj.method = idempotencyMethodToJSON(message.method); + } return obj; }, @@ -1877,6 +1927,7 @@ export const IdempotencyConfig: MessageFns = { const message = createBaseIdempotencyConfig(); message.expression = object.expression ?? ''; message.ttlMs = object.ttlMs ?? 0; + message.method = object.method ?? undefined; return message; }, }; diff --git a/sdks/typescript/src/v1/client/worker/worker-internal.ts b/sdks/typescript/src/v1/client/worker/worker-internal.ts index 5cb4f74b28..170f579ac5 100644 --- a/sdks/typescript/src/v1/client/worker/worker-internal.ts +++ b/sdks/typescript/src/v1/client/worker/worker-internal.ts @@ -16,7 +16,7 @@ import HatchetPromise, { CancellationReason } from '@util/hatchet-promise/hatche import { CreateStepRateLimit, StickyStrategy } from '@hatchet/protoc/workflows'; import { actionMap, Logger, taskRunLog } from '@hatchet/util/logger'; import { BaseWorkflowDeclaration, WorkflowDefinition, HatchetClient } from '@hatchet/v1'; -import { CreateTaskOpts } from '@hatchet/protoc/v1/workflows'; +import { CreateTaskOpts, IdempotencyMethod } from '@hatchet/protoc/v1/workflows'; import { CreateOnFailureTaskOpts, CreateOnSuccessTaskOpts, @@ -423,7 +423,14 @@ export class InternalWorker { idempotency: workflow.idempotency ? { expression: workflow.idempotency.expression, - ttlMs: workflow.idempotency.ttlMs, + ttlMs: + workflow.idempotency.strategy === 'status' + ? workflow.idempotency.fallbackTtlMs + : workflow.idempotency.ttlMs, + method: + workflow.idempotency.strategy === 'status' + ? IdempotencyMethod.STATUS + : IdempotencyMethod.TTL, } : undefined, }); diff --git a/sdks/typescript/src/v1/examples/idempotency/workflow.ts b/sdks/typescript/src/v1/examples/idempotency/workflow.ts index fa5e5a5155..87bbb5990d 100644 --- a/sdks/typescript/src/v1/examples/idempotency/workflow.ts +++ b/sdks/typescript/src/v1/examples/idempotency/workflow.ts @@ -32,3 +32,17 @@ export const idempotentTaskShortWindow = hatchet.task status-based-idempotency +export const idempotentStatusBasedTask = hatchet.task({ + name: 'ts-e2e-idempotent-status-based-task', + idempotency: { + strategy: 'status', + expression: 'input.id', + fallbackTtlMs: 10_000, + }, + fn: async (input) => { + return { result: `Hello, world from task ${input.id}` }; + }, +}); +// !! diff --git a/sdks/typescript/src/v1/task.ts b/sdks/typescript/src/v1/task.ts index 65459dc470..448ef287f4 100644 --- a/sdks/typescript/src/v1/task.ts +++ b/sdks/typescript/src/v1/task.ts @@ -65,10 +65,24 @@ export type TTLBasedIdempotencyConfig = BaseIdempotencyConfig & { ttlMs: number; }; +/** + * Status-based idempotency: keeps the idempotency key alive until the associated run + * reaches a terminal status. `fallbackTtlMs` caps how long the key can live before it's evicted. + */ +export type StatusBasedIdempotencyConfig = BaseIdempotencyConfig & { + strategy: 'status'; + + /** + * Fallback time-to-live (in milliseconds): the longest the idempotency key can live + * before it's evicted, even if the run has not reached a terminal status. + */ + fallbackTtlMs: number; +}; + /** * Union of all supported idempotency configurations. */ -export type IdempotencyConfig = TTLBasedIdempotencyConfig; +export type IdempotencyConfig = TTLBasedIdempotencyConfig | StatusBasedIdempotencyConfig; export class NonRetryableError extends Error { constructor(message?: string) { diff --git a/sql/schema/v0.sql b/sql/schema/v0.sql index 64ba887e87..377e58e1c6 100644 --- a/sql/schema/v0.sql +++ b/sql/schema/v0.sql @@ -1070,6 +1070,8 @@ CREATE TABLE CONSTRAINT "WorkflowTriggers_pkey" PRIMARY KEY ("id") ); +CREATE TYPE idempotency_method AS ENUM ('TTL', 'STATUS'); + -- CreateTable CREATE TABLE "WorkflowVersion" ( @@ -1090,6 +1092,7 @@ CREATE TABLE "inputJsonSchema" JSONB, "idempotencyKeyExpression" TEXT, "idempotencyKeyTtlMs" BIGINT, + "idempotencyMethod" idempotency_method, CONSTRAINT "WorkflowVersion_pkey" PRIMARY KEY ("id") );