Repository navigation
Conversation
a53e75c to
7a643ba
Compare
sdk-python takes its API files from Core's vendored copy. That commit adds the Stream protos and WIT from the api repo until the api Stream change merges.
The Stream package needs a newer nexgen than the existing packages, so it has its own pin until the api WITs and a nexgen release agree. The payload visitor covers every generated system Nexus package, and mypy skips the Stream package like the other generated one.
Written by `poe gen-protos` with NEX_GEN_BIN at nexgen 0.2.8 and NEX_GEN_STREAM_BIN at a build of nexgen commit bf3c49c9. Nothing in this commit is edited by hand.
The generated code calls these by name for request ids, Producers and read paging. The system Nexus serialization lookup also knows the Stream operations.
Client inherits the generated extension, so its Stream calls are the generated ones. A Store answers them in place of the server, with the conversion and the codec of the system Nexus path.
Workflow code can't do I/O, so a Worker whose client has a Stream Store calls it from an internal Local Activity. The Replayer takes the Stores too, so a replay makes the same commands.
a5d7b87 to
a92a582
Compare
Core sends the Workflow Task that dispatched a Local Activity. A write from a Local Activity needs it for its Producer epoch.
On a Store, a Workflow appends as a Local Activity Producer, and the Store refuses the Workflow Producer. Its epoch is the Workflow Task that ran the Local Activity, so the Store fences the write of a failed task.
The bridge links the Stream runtime from Core's new `temporalio-streams` crate. The Core commits add no proto that the SDK generates from.
A Store on the bridge needs the crate's runtime and its memory Store. The feature is off by default, and the build tasks and wheels turn it on.
Client, Activity and Workflow code can catch one class and match on its reason. The details carry the reason, so Workflow code rebuilds the same error from a Local Activity's failure.
Tests now run on the same Stream runtime and rules as every Store on the bridge. They check Records and Producers with the generated read, so the fake Store is gone.
Each run of a chain sends the same strict create. With one request id for the chain, the Store answers a later run's create as created and not as ALREADY_EXISTS.
| impl: ActivityInboundInterceptor = _ActivityInboundImpl(self, running_activity) | ||
| for interceptor in reversed(list(self._interceptors)): | ||
| impl = interceptor.intercept_activity(impl) | ||
| # Users see a Stream call through the Workflow interceptors, so this internal |
There was a problem hiding this comment.
A Workflow's Stream call already goes through the user's Workflow interceptors, as one system Nexus operation. This Activity is how the Worker runs that call. Without the skip, the user's Activity interceptors would also see an Activity that's not scheduled by user code. A tracing interceptor would show it as an orphan span, for example. I rewrote the comment to say that. Wdyt?
| activities = list(config.get("activities") or []) | ||
| stream_stores = client_config["stream_stores"] | ||
| if stream_stores and config.get("workflows"): | ||
| activities.append( |
There was a problem hiding this comment.
This is a bit sketchy. I follow the point though
There was a problem hiding this comment.
Probably we'd be better off with a redis plugin that does the activity + stream store.
There was a problem hiding this comment.
I see the appeal. My concern with a plugin is that a user would mention the Stores twice, once in stream_config and once in the plugin. Or the Stores move out of the client option, which the API review chose to match external storage. So the Worker still takes its Stores from its client, and the user sets them up in one place. To make it less "sketchy", the Activity and the Workflow routing sit in one module next to the Store as of 1c836c8.
| # Workflow code builds the request, so the Local Activity does only the I/O. | ||
| payload_converter, _ = self._system_nexus_converters(input) | ||
| [request] = payload_converter.to_payloads([input.input]) | ||
| activity = self._outbound_schedule_activity( |
There was a problem hiding this comment.
I suspect this should probably live inside the stream store itself.
There was a problem hiding this comment.
Most of the code in this function, likely
| ) -> _NexusOperationHandle[OutputT]: | ||
| ) -> temporalio.workflow.NexusOperationHandle[OutputT]: | ||
| options = self._stream_store_call_options | ||
| if options is not None and input.service == _STREAM_SERVICE.name: |
There was a problem hiding this comment.
Don't know why we would check options is not none.
There was a problem hiding this comment.
It meant "this Worker's client has a Store", and it wasn't obvious. The check is on the Worker's Stream config now. With none, a Stream call goes to the server like every system Nexus operation.
| select = "*musllinux*" | ||
| environment = { PATH = "$PATH:$HOME/.cargo/bin", CARGO_NET_GIT_FETCH_WITH_CLI = "true", RUSTFLAGS = "-C target-feature=-crt-static" } | ||
| environment = { PATH = "$PATH:$HOME/.cargo/bin", CARGO_NET_GIT_FETCH_WITH_CLI = "true", RUSTFLAGS = "-C target-feature=-crt-static", MATURIN_PEP517_ARGS = "--features streams-redis" } | ||
|
|
There was a problem hiding this comment.
Seems like quite a bit of shenanigans involved here, I'll ignore the pyproject until we're further along in core.
There was a problem hiding this comment.
Makes sense. Most of it is there so the dev build and the wheels link the Stream runtime while it's behind a cargo feature. The rest keeps the linters out of the Core submodule.
| if: ${{ matrix.clippyLinter }} | ||
| - run: poe build-develop | ||
| - run: poe lint | ||
| - uses: ./.github/actions/install-redis |
There was a problem hiding this comment.
Not 100% on installing (and starting, which is unclear) a redis server for all tests, but maybe
There was a problem hiding this comment.
The step installs Redis and starts one server on a port. Only the Redis Store tests use it, and they skip when its URL isn't set, as on Windows. I mentioned the step in ed82555 so that's clear at the call site. I can move those tests to their own job if you'd rather keep Redis out of the main one.
The Activity, the Local Activity call and its handle were spread over generic Worker files. One module now holds all that a Worker does for the Stream Store of its client.
A Store is the Rust runtime behind the bridge, so no user writes one in Python. Its call takes the bridge's operation name and bytes, which is not an API for users.
A reader asked why a list takes one Store. A named CI step shows why Redis is installed.
The agreed API holds the Stores and the rule that picks one in a single config, as `ExternalStorage` holds drivers and a selector. A ref names its Store, so a call on a known Stream reaches the right Store with several of them.
A test that starts its own server needs a client with Stores, as a test with `Client.connect` has. `start_local` and `start_time_skipping` give the config to the client that they build.
A Workflow's Stream call runs in the SDK's Local Activity, so the Store can be picked there by the client's rules. History keeps the ref with the Store's name, so a replay calls no selector.
A Workflow's create picks its Store only in the Local Activity, so with several Stores it could not use the options of any one Store. The config now holds one set of options for every Store.
Decoding the encoded request to find its ref decoded every Record of an append once more. The caller has the request object before it encodes it, and the Local Activity gets the ref as two strings.
An Activity that streams model tokens appends a few characters many times a second, and one Store call for each is too many. The appender writes them in batches of at most 1,000 Records.
The new Stream WIT leaves `append`, `close` and `read` out of the Workflow target and gives the Producer parameters their default. Its Rust code is the same, so the bridge needs no rebuild.
It brings the Workflow Stream without append, close and read from the generated-code branch.
The regenerated Workflow `Stream` lost the three methods, but Workflow code can still start their operations through a Nexus client, so the Worker keeps its refusal and the tests start them that way. The generator script names the nexgen commit that made the code.
The generated Workflow modules import `temporalio.workflow` at their top, and it imports them back, so a new interpreter that started with one of them failed. The parent package now loads `temporalio.workflow` last, which completes it before a generated Workflow module starts.
An Activity interceptor sees every Activity that runs on its Worker, also one that the SDK schedules. A tracing interceptor then shows the Store call under its Workflow, which is correct.
This release has no Streams on the server, so such a call could only fail late, with a message that didn't say why. It now fails at once with a `StreamConfigError` that names the fix.
A person who writes a Store for another system needs typed operations, not an operation name and bytes. Bytes now belong to the Stores on the bridge, and the hooks hand messages to a Store with no extra parse.
Worker code should only find a Store and hand it the call, not know how the Store serves Workflow code. Each Store now brings its own Activities and its own call from Workflow code, with a default Local Activity that carries the Store's name.
The crate writes its test vectors with a Rust program, so the submodule has no Python file. Its Rust code is the same, and the bridge needs no rebuild.
The submodule has no Python file any more, so pyright, pydocstyle and ruff have nothing to skip there.
A Store is an interface that a person can implement, so the docstring no longer calls it configuration.
The Workflow Task info of a Local Activity has a branch of its own below the generated code, so this change no longer shows it.
|
closed in favour of temporalio#2129 |
This PR restarts the Streams API in sdk-python on the code that nexgen generates.
The public API is now the generated code.
client.create_streamandclient.get_streamare generated methods, andtemporalio.workflowexports the generated Workflow functions. The generated files are in #382, the base of this PR, and #383 below it adds the Workflow Task info of a Local Activity. So this diff is only the hand-written Stream code. The earlier branch is kept atbackup/pr-381-streams-combined-1112.stream_config=StreamConfig(stores=[...], store_selector=...), in the shape ofExternalStorage. A Worker takes the config from its client and serves every Store in it.StreamConfigError, since this release has no Streams on the server.Streamhas noappend,closeorreadin this release. To write, a Workflow calls its own Local Activity, which appends throughactivity.client().appendalso takes an optionalsequence, and a batch that has one is written once.BufferedAppendercollects the appends of up tomax_delayinto one batch, with one write in flight.StreamStoreis a public interface with the five operations as methods, so a person can write a Store. It also owns its Worker side, withworker_activities()andcall_from_workflow().RedisStreamStorekeeps Streams in Redis, andMemoryStreamStorekeeps them in memory for tests. Both run Core'stemporalio-streamscrate through the bridge.StreamErrorwith areason. A wrong setup of the Stores raisesStreamConfigError.The Stream WIT and protos come from temporalio/api#895, through the sdk-core submodule at moetemp/sdk-rust#228
1addba05. The Stream package needs themainof nexgen (9653a0f5), which no release has yet. Sopoe gen-protostakes a second generator fromNEX_GEN_STREAM_BIN, and the other packages stay on the released pin.