Skip to content

Commit 8a877b8

Browse files
authored
Merge pull request #174 from restatedev/zstd
Support zstd compression in python 3.14+
2 parents bf21956 + 793a213 commit 8a877b8

3 files changed

Lines changed: 71 additions & 5 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
runs-on: ubuntu-latest
1919
strategy:
2020
matrix:
21-
python: [ "3.10", "3.11", "3.12", "3.13" ]
21+
python: [ "3.10", "3.11", "3.12", "3.13", "3.14" ]
2222
env:
2323
UV_PYTHON: ${{ matrix.python }}
2424
steps:

python/restate/aws_lambda.py

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ def request_to_receive(req: RestateLambdaRequest) -> Receive:
6262
assert req["isBase64Encoded"]
6363
body = base64.b64decode(req["body"])
6464

65+
# Decompress zstd-encoded request body
66+
headers = {k.lower(): v for k, v in req.get("headers", {}).items()}
67+
if "zstd" in headers.get("content-encoding", ""):
68+
body = zstd_decompress(body)
69+
6570
events = cast(
6671
list[HTTPRequestEvent],
6772
[
@@ -80,15 +85,19 @@ async def recv() -> HTTPRequestEvent:
8085
return recv
8186

8287

88+
RESPONSE_COMPRESSION_THRESHOLD = 3 * 1024 * 1024
89+
90+
8391
class ResponseCollector:
8492
"""
8593
Response collector from ASGI Send to Lambda
8694
"""
8795

88-
def __init__(self):
96+
def __init__(self, accept_encoding: str = ""):
8997
self.body = bytearray()
90-
self.headers = {}
98+
self.headers: dict[str, str] = {}
9199
self.status_code = 500
100+
self.accept_encoding = accept_encoding
92101

93102
async def __call__(self, message: Union[HTTPResponseStartEvent, HTTPResponseBodyEvent]) -> None:
94103
"""
@@ -105,11 +114,18 @@ def to_lambda_response(self) -> RestateLambdaResponse:
105114
"""
106115
Convert collected values to lambda response
107116
"""
117+
body: bytes | bytearray = self.body
118+
119+
# Compress response if it exceeds threshold and client accepts zstd
120+
if len(body) > RESPONSE_COMPRESSION_THRESHOLD and "zstd" in self.accept_encoding and ZSTD_AVAILABLE:
121+
body = zstd_compress(body)
122+
self.headers["content-encoding"] = "zstd"
123+
108124
return {
109125
"statusCode": self.status_code,
110126
"headers": self.headers,
111127
"isBase64Encoded": True,
112-
"body": base64.b64encode(self.body).decode(),
128+
"body": base64.b64encode(body).decode(),
113129
}
114130

115131

@@ -134,7 +150,8 @@ def lambda_handler(event: RestateLambdaRequest, _context: Any) -> RestateLambdaR
134150

135151
scope = create_scope(event)
136152
recv = request_to_receive(event)
137-
send = ResponseCollector()
153+
req_headers = {k.lower(): v for k, v in event.get("headers", {}).items()}
154+
send = ResponseCollector(accept_encoding=req_headers.get("accept-encoding", ""))
138155

139156
asgi_instance = asgi_app(scope, recv, send)
140157
asgi_task = loop.create_task(asgi_instance) # type: ignore[var-annotated, arg-type]
@@ -143,3 +160,47 @@ def lambda_handler(event: RestateLambdaRequest, _context: Any) -> RestateLambdaR
143160
return send.to_lambda_response()
144161

145162
return lambda_handler
163+
164+
165+
def _check_zstd_available() -> bool:
166+
"""Return True if zstd compression is available (Python 3.14+)."""
167+
try:
168+
import compression.zstd # type: ignore[import-not-found]
169+
170+
return compression.zstd is not None
171+
except ImportError:
172+
return False
173+
174+
175+
ZSTD_AVAILABLE = _check_zstd_available()
176+
177+
178+
def is_lambda_compression_supported():
179+
"""Return 'zstd' if running on Lambda and compression.zstd is available (Python 3.14+), else None."""
180+
if is_running_on_lambda() and ZSTD_AVAILABLE:
181+
return "zstd"
182+
return None
183+
184+
185+
def zstd_compress(data: bytes | bytearray) -> bytes:
186+
"""Compress data using zstd."""
187+
try:
188+
import compression.zstd # type: ignore[import-not-found]
189+
except ImportError as e:
190+
raise RuntimeError(
191+
"zstd compression requested but compression.zstd is not available. "
192+
"Python 3.14+ is required for zstd compression support."
193+
) from e
194+
return compression.zstd.compress(data)
195+
196+
197+
def zstd_decompress(data: bytes) -> bytes:
198+
"""Decompress zstd-compressed data."""
199+
try:
200+
import compression.zstd # type: ignore[import-not-found]
201+
except ImportError as e:
202+
raise RuntimeError(
203+
"Received zstd-compressed request but compression.zstd is not available. "
204+
"Python 3.14+ is required for zstd compression support."
205+
) from e
206+
return compression.zstd.decompress(data)

python/restate/discovery.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from restate.handler import TypeHint
3535
from restate.object import VirtualObject
3636
from restate.workflow import Workflow
37+
from restate.aws_lambda import is_lambda_compression_supported
3738

3839

3940
class ProtocolMode(Enum):
@@ -159,6 +160,7 @@ def __init__(
159160
self.minProtocolVersion = minProtocolVersion
160161
self.maxProtocolVersion = maxProtocolVersion
161162
self.services = services
163+
self.lambdaCompression = is_lambda_compression_supported()
162164

163165

164166
PROTOCOL_MODES = {"bidi": ProtocolMode.BIDI_STREAM, "request_response": ProtocolMode.REQUEST_RESPONSE}
@@ -235,6 +237,9 @@ def compute_discovery_json(
235237

236238
# Validate that new discovery fields aren't used with older protocol versions
237239
if version <= 3:
240+
# Strip lambdaCompression for older discovery versions
241+
ep.lambdaCompression = None
242+
238243
for service in ep.services:
239244
if service.retryPolicyInitialInterval is not None:
240245
raise ValueError("retryPolicyInitialInterval is only supported in discovery protocol version 4")

0 commit comments

Comments
 (0)