Skip to content

Commit d5e624c

Browse files
Journal codec (#229)
1 parent 2739c42 commit d5e624c

11 files changed

Lines changed: 465 additions & 24 deletions

File tree

‎python/restate/__init__.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
SendHandle,
3939
RunOptions,
4040
)
41+
from .entry_codec import JournalValueCodec, JournalValueCodecProvider
4142
from .exceptions import TerminalError, SdkInternalBaseException, is_internal_exception
4243
from .asyncio import as_completed, gather, wait_completed, select
4344

@@ -58,6 +59,7 @@ def create_test_harness(
5859
restate_image: str = "docker.io/restatedev/restate:latest",
5960
always_replay: bool = False,
6061
disable_retries: bool = False,
62+
journal_value_codec: typing.Optional[JournalValueCodec] = None,
6163
) -> typing.AsyncGenerator[HarnessEnvironment, None]:
6264
"""a dummy harness constructor to raise ImportError. Install restate-sdk[harness] to use this feature"""
6365
raise ImportError("Install restate-sdk[harness] to use this feature")
@@ -81,7 +83,9 @@ def test_harness(
8183

8284
@asynccontextmanager
8385
async def create_client(
84-
ingress: str, headers: typing.Optional[dict] = None
86+
ingress: str,
87+
headers: typing.Optional[dict] = None,
88+
journal_value_codec: typing.Optional[JournalValueCodec] = None,
8589
) -> typing.AsyncGenerator[RestateClient, None]:
8690
"""a dummy client constructor to raise ImportError. Install restate-sdk[client] to use this feature"""
8791
raise ImportError("Install restate-sdk[client] to use this feature")
@@ -105,6 +109,8 @@ async def create_client(
105109
"ScopedContext",
106110
"RunOptions",
107111
"TerminalError",
112+
"JournalValueCodec",
113+
"JournalValueCodecProvider",
108114
"app",
109115
"create_test_harness",
110116
"test_harness",

‎python/restate/client.py‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from .client_types import RestateClient, RestateClientSendHandle, RestateScopedClient, HttpError
2121

2222
from .context import HandlerType
23+
from .entry_codec import JournalValueCodec
2324
from .serde import BytesSerde, JsonSerde, Serde
2425
from .handler import handler_from_callable
2526

@@ -32,9 +33,15 @@ class Client(RestateClient):
3233
A basic client for connecting to the Restate service.
3334
"""
3435

35-
def __init__(self, client: httpx.AsyncClient, headers: typing.Optional[dict] = None):
36+
def __init__(
37+
self,
38+
client: httpx.AsyncClient,
39+
headers: typing.Optional[dict] = None,
40+
journal_codec: typing.Optional[JournalValueCodec] = None,
41+
):
3642
self.headers = headers or {}
3743
self.client = client
44+
self.journal_codec = journal_codec
3845

3946
def scope(self, scope: str) -> RestateScopedClient:
4047
return ScopedClient(self, scope)
@@ -103,6 +110,8 @@ async def do_raw_call(
103110
) -> O:
104111
"""Make an RPC call to the given handler"""
105112
parameter = input_serde.serialize(input_param)
113+
if self.journal_codec is not None:
114+
parameter = self.journal_codec.encode(parameter)
106115
if headers is not None:
107116
headers_kvs = list(headers.items())
108117
else:
@@ -124,6 +133,9 @@ async def do_raw_call(
124133
scope=scope,
125134
limit_key=limit_key,
126135
)
136+
# A send returns the invocation-id envelope, not a codec'd payload, so skip decode for it.
137+
if not send and self.journal_codec is not None:
138+
res = await self.journal_codec.decode(res)
127139
return output_serde.deserialize(res) # type: ignore
128140

129141
async def post(
@@ -471,10 +483,19 @@ async def workflow_send(
471483

472484
@asynccontextmanager
473485
async def create_client(
474-
ingress: str, headers: typing.Optional[dict] = None
486+
ingress: str,
487+
headers: typing.Optional[dict] = None,
488+
journal_value_codec: typing.Optional[JournalValueCodec] = None,
475489
) -> typing.AsyncGenerator[RestateClient, None]:
476490
"""
477491
Create a new Restate client.
492+
493+
Args:
494+
ingress: The base URL of the Restate ingress.
495+
headers: Optional default headers to send with every request.
496+
journal_value_codec: Optional journal value codec. When set, request bodies are encoded and
497+
call responses decoded through it. It must match the codec configured on the endpoint.
498+
NOTE: This is experimental and may change in future releases.
478499
"""
479500
async with httpx.AsyncClient(base_url=ingress, headers=headers, http2=True) as http_client:
480-
yield Client(http_client, headers)
501+
yield Client(http_client, headers, journal_codec=journal_value_codec)

‎python/restate/endpoint.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from restate.service import Service
1818
from restate.object import VirtualObject
1919
from restate.workflow import Workflow
20+
from restate.entry_codec import JournalValueCodec, JournalValueCodecProvider
2021

2122

2223
# disable too few methods in a class
@@ -31,6 +32,7 @@ class Endpoint:
3132
services: typing.Dict[str, typing.Union[Service, VirtualObject, Workflow]]
3233
protocol: typing.Optional[typing.Literal["bidi", "request_response"]]
3334
identity_keys: typing.List[str]
35+
journal_value_codec: typing.Optional[typing.Union[JournalValueCodec, JournalValueCodecProvider]]
3436

3537
def __init__(self):
3638
"""
@@ -44,6 +46,10 @@ def __init__(self):
4446

4547
self.identity_keys = []
4648

49+
# An optional journal value codec (or async provider building one). When set, the SDK
50+
# transforms serialized journal values through it. None means no codec is configured.
51+
self.journal_value_codec = None
52+
4753
def bind(self, *services: typing.Union[Service, VirtualObject, Workflow]):
4854
"""
4955
Bind a service to the endpoint
@@ -79,6 +85,23 @@ def identity_key(self, identity_key: str):
7985
"""Add an identity key to this endpoint."""
8086
self.identity_keys.append(identity_key)
8187

88+
def set_journal_value_codec(self, codec_or_provider: typing.Union[JournalValueCodec, JournalValueCodecProvider]):
89+
"""
90+
Set the journal value codec for this endpoint.
91+
92+
NOTE: This is experimental and may change in future releases.
93+
94+
Args:
95+
codec_or_provider: Either a :class:`JournalValueCodec` instance, or an async provider
96+
(a zero-arg callable returning an awaitable of a codec) that is invoked once and
97+
cached for the lifetime of the endpoint.
98+
99+
Returns:
100+
The updated Endpoint instance.
101+
"""
102+
self.journal_value_codec = codec_or_provider
103+
return self
104+
82105
def app(self):
83106
"""
84107
Returns the ASGI application for this endpoint.
@@ -101,6 +124,7 @@ def app(
101124
services: typing.Iterable[typing.Union[Service, VirtualObject, Workflow]],
102125
protocol: typing.Optional[typing.Literal["bidi", "request_response"]] = None,
103126
identity_keys: typing.Optional[typing.List[str]] = None,
127+
journal_value_codec: typing.Optional[typing.Union[JournalValueCodec, JournalValueCodecProvider]] = None,
104128
):
105129
"""A restate ASGI application that hosts the given services."""
106130
endpoint = Endpoint()
@@ -113,4 +137,6 @@ def app(
113137
if identity_keys:
114138
for key in identity_keys:
115139
endpoint.identity_key(key)
140+
if journal_value_codec is not None:
141+
endpoint.set_journal_value_codec(journal_value_codec)
116142
return endpoint.app()

‎python/restate/entry_codec.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
#
2+
# Copyright (c) 2023-2026 - Restate Software, Inc., Restate GmbH
3+
#
4+
# This file is part of the Restate SDK for Python,
5+
# which is released under the MIT license.
6+
#
7+
# You can find a copy of the license in file LICENSE in the root
8+
# directory of this repository or package, or at
9+
# https://github.com/restatedev/sdk-typescript/blob/main/LICENSE
10+
#
11+
"""This module contains the journal value codec interface."""
12+
13+
import abc
14+
import typing
15+
16+
# disable too few public methods
17+
# pylint: disable=R0903
18+
19+
20+
class JournalValueCodec(abc.ABC):
21+
"""
22+
Journal values codec.
23+
24+
This allows to transform journal values after being serialized, before writing them to the
25+
wire, and vice-versa. It sits *between* the ``Serde`` layer and the journal/wire.
26+
The canonical use case is to encrypt or compress everything the SDK persists to the journal.
27+
28+
Values that are passed through the codec:
29+
30+
* Handlers input and success output
31+
* ``ctx.run`` success results
32+
* Awakeables/Promise success results
33+
* State values
34+
* Call/send request parameters and call responses
35+
36+
Failures are never passed through the codec.
37+
38+
NOTE: This is preview and may change in future releases.
39+
"""
40+
41+
@abc.abstractmethod
42+
def encode(self, buf: bytes) -> bytes:
43+
"""
44+
Encodes the given buffer. This will be applied *after* serialization.
45+
46+
Args:
47+
buf: The buffer to encode. Empty byte buffers should be appropriately handled as well.
48+
49+
Returns:
50+
The encoded buffer.
51+
"""
52+
53+
@abc.abstractmethod
54+
async def decode(self, buf: bytes) -> bytes:
55+
"""
56+
Decodes the given buffer. This will be applied *before* deserialization.
57+
58+
Args:
59+
buf: The buffer to decode.
60+
61+
Returns:
62+
The decoded buffer.
63+
"""
64+
65+
66+
JournalValueCodecProvider = typing.Callable[[], typing.Awaitable[JournalValueCodec]]
67+
"""A provider that asynchronously builds a :class:`JournalValueCodec`.
68+
69+
It is invoked once, and the resulting codec is reused for the lifetime of the endpoint.
70+
This is useful to perform async setup at startup, e.g. loading an encryption key.
71+
"""

‎python/restate/handler.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from restate.retry_policy import InvocationRetryPolicy
3535

3636
from restate.context import HandlerType
37+
from restate.entry_codec import JournalValueCodec
3738
from restate.exceptions import TerminalError
3839
from restate.serde import DefaultSerde, PydanticJsonSerde, MsgspecJsonSerde, Serde, is_pydantic, Msgspec
3940
from restate.types import extract_core_type
@@ -350,11 +351,18 @@ def handler_from_callable(wrapper: HandlerType[I, O]) -> Handler[I, O]:
350351
raise ValueError("Handler not found") # pylint: disable=raise-missing-from
351352

352353

353-
async def invoke_handler(handler: Handler[I, O], ctx: Any, in_buffer: bytes) -> bytes:
354+
async def invoke_handler(
355+
handler: Handler[I, O], ctx: Any, in_buffer: bytes, journal_codec: Optional[JournalValueCodec] = None
356+
) -> bytes:
354357
"""
355358
Invoke the handler with the given context and input.
356359
"""
357360
if handler.arity == 2:
361+
if journal_codec is not None:
362+
try:
363+
in_buffer = await journal_codec.decode(in_buffer)
364+
except Exception as e:
365+
raise TerminalError(message="Failed to decode input using journal value codec", status_code=400) from e
358366
try:
359367
in_arg = handler.handler_io.input_serde.deserialize(in_buffer)
360368
except Exception as e:
@@ -363,4 +371,6 @@ async def invoke_handler(handler: Handler[I, O], ctx: Any, in_buffer: bytes) ->
363371
else:
364372
out_arg = await handler.fn(ctx) # type: ignore [call-arg]
365373
out_buffer = handler.handler_io.output_serde.serialize(out_arg)
374+
if journal_codec is not None:
375+
out_buffer = journal_codec.encode(bytes(out_buffer))
366376
return bytes(out_buffer)

‎python/restate/harness.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from hypercorn.config import Config
2222
from hypercorn.asyncio import serve
2323
from restate.client import create_client
24+
from restate.entry_codec import JournalValueCodec
2425
from restate.server_types import RestateAppT
2526
from restate.types import HarnessEnvironment
2627
from testcontainers.core.container import DockerContainer # type: ignore
@@ -338,6 +339,7 @@ async def create_test_harness(
338339
restate_image: str = "docker.io/restatedev/restate:latest",
339340
always_replay: bool = False,
340341
disable_retries: bool = False,
342+
journal_value_codec: typing.Optional[JournalValueCodec] = None,
341343
) -> typing.AsyncGenerator[HarnessEnvironment, None]:
342344
"""
343345
Creates a test harness for running Restate services together with restate-server.
@@ -360,6 +362,8 @@ async def create_test_harness(
360362
on a suspension point. This is useful to hunt non-deterministic bugs
361363
that might prevent your code to replay correctly (default is False).
362364
:param disable_retries: When True, retries are disabled (default is False).
365+
:param journal_value_codec: Optional journal value codec to configure on the ingress client, so
366+
it matches a codec configured on the endpoint under test (default is None).
363367
"""
364368
with (
365369
create_restate_container(
@@ -377,7 +381,7 @@ async def create_test_harness(
377381
msg = f"unable to register the services at {bind_address} - {res.status_code} {res.text}"
378382
raise AssertionError(msg)
379383

380-
async with create_client(runtime.ingress_url()) as client:
384+
async with create_client(runtime.ingress_url(), journal_value_codec=journal_value_codec) as client:
381385
yield HarnessEnvironment(
382386
ingress_url=runtime.ingress_url(), admin_api_url=runtime.admin_url(), client=client
383387
)

‎python/restate/server.py‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
"""This module contains the ASGI server for the restate framework."""
1212

1313
import asyncio
14+
import functools
1415
import logging
1516
import signal
16-
from typing import Dict, Set, TypedDict, Literal
17+
from typing import Dict, Optional, Set, TypedDict, Literal
1718

1819
from restate.discovery import compute_discovery_json
1920
from restate.endpoint import Endpoint
21+
from restate.entry_codec import JournalValueCodec
2022
from restate.server_context import ServerInvocationContext, DisconnectedException
2123
from restate.server_types import Receive, ReceiveChannel, RestateAppT, Scope, Send, binary_to_header, header_to_binary # pylint: disable=line-too-long
2224
from restate.vm import VMWrapper
@@ -125,7 +127,12 @@ async def send_health_check(send: Send):
125127

126128

127129
async def process_invocation_to_completion(
128-
vm: VMWrapper, handler, attempt_headers: Dict[str, str], receive: ReceiveChannel, send: Send
130+
vm: VMWrapper,
131+
handler,
132+
attempt_headers: Dict[str, str],
133+
receive: ReceiveChannel,
134+
send: Send,
135+
journal_codec: Optional[JournalValueCodec] = None,
129136
):
130137
"""Invoke the user code."""
131138
status, res_headers = vm.get_response_head()
@@ -155,7 +162,13 @@ async def process_invocation_to_completion(
155162
# ========================================
156163
invocation = vm.sys_input()
157164
context = ServerInvocationContext(
158-
vm=vm, handler=handler, invocation=invocation, attempt_headers=attempt_headers, send=send, receive=receive
165+
vm=vm,
166+
handler=handler,
167+
invocation=invocation,
168+
attempt_headers=attempt_headers,
169+
send=send,
170+
receive=receive,
171+
journal_codec=journal_codec,
159172
)
160173
try:
161174
await context.enter()
@@ -217,6 +230,27 @@ def asgi_app(endpoint: Endpoint) -> RestateAppT:
217230
active_channels: Set[ReceiveChannel] = set()
218231
sigterm_installed = False
219232

233+
# Journal value codec resolution.
234+
# A codec (or an async provider building one) may be configured on the endpoint. Whether one is
235+
# configured is known synchronously and drives the VM's payload-checks flag. An async provider
236+
# is resolved once, lazily on the first invocation, and the resulting codec is reused.
237+
codec_config = endpoint.journal_value_codec
238+
disable_payload_checks = codec_config is not None
239+
240+
# Memoize the provider's resolution. functools.cache can't wrap the async provider directly
241+
# (it would cache a coroutine, which can't be awaited twice), so we cache a sync factory that
242+
# returns the Task instead: the provider runs once and every caller awaits the same Task.
243+
@functools.cache
244+
def _codec_task() -> "asyncio.Task[JournalValueCodec]":
245+
assert callable(codec_config)
246+
return asyncio.ensure_future(codec_config())
247+
248+
async def get_codec() -> Optional[JournalValueCodec]:
249+
if codec_config is None or isinstance(codec_config, JournalValueCodec):
250+
# Nothing configured, or an already-built instance: nothing to await.
251+
return codec_config
252+
return await _codec_task()
253+
220254
def _on_sigterm() -> None:
221255
"""Notify all active receive channels of graceful shutdown."""
222256
for ch in active_channels:
@@ -284,8 +318,14 @@ async def app(scope: Scope, receive: Receive, send: Send):
284318
receive_channel = ReceiveChannel(receive)
285319
active_channels.add(receive_channel)
286320
try:
321+
journal_codec = await get_codec()
287322
await process_invocation_to_completion(
288-
VMWrapper(request_headers), handler, dict(request_headers), receive_channel, send
323+
VMWrapper(request_headers, disable_payload_checks=disable_payload_checks),
324+
handler,
325+
dict(request_headers),
326+
receive_channel,
327+
send,
328+
journal_codec=journal_codec,
289329
)
290330
finally:
291331
active_channels.discard(receive_channel)

0 commit comments

Comments
 (0)