lumera 0.40.2.dev20260830__tar.gz → 0.40.3.dev2026090401__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/PKG-INFO +1 -1
  2. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_api_child.py +93 -25
  3. lumera-0.40.3.dev2026090401/lumera/releases/_durable_event.py +116 -0
  4. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_worker_child.py +220 -95
  5. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera.egg-info/PKG-INFO +1 -1
  6. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera.egg-info/SOURCES.txt +1 -0
  7. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/pyproject.toml +1 -1
  8. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/__init__.py +0 -0
  9. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/_live.py +0 -0
  10. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/_utils.py +0 -0
  11. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/agents.py +0 -0
  12. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/automations.py +0 -0
  13. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/email.py +0 -0
  14. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/exceptions.py +0 -0
  15. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/files.py +0 -0
  16. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/flags.py +0 -0
  17. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/function_resources.py +0 -0
  18. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/__init__.py +0 -0
  19. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/__main__.py +0 -0
  20. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_artifacts.py +0 -0
  21. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_config.py +0 -0
  22. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_context.py +0 -0
  23. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_contract.py +0 -0
  24. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_dependencies.py +0 -0
  25. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_errors.py +0 -0
  26. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_execution.py +0 -0
  27. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_jsonschema.py +0 -0
  28. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_registry.py +0 -0
  29. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_remote.py +0 -0
  30. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_runner.py +0 -0
  31. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_runtime.py +0 -0
  32. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/testing.py +0 -0
  33. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/google.py +0 -0
  34. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/__init__.py +0 -0
  35. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/bill.py +0 -0
  36. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/finch.py +0 -0
  37. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/google.py +0 -0
  38. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/kafka.py +0 -0
  39. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/netsuite.py +0 -0
  40. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/salesforce.py +0 -0
  41. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/slack.py +0 -0
  42. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/snowflake.py +0 -0
  43. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/llm.py +0 -0
  44. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/pb.py +0 -0
  45. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/__init__.py +0 -0
  46. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/__main__.py +0 -0
  47. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_archive.py +0 -0
  48. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_builder.py +0 -0
  49. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_config.py +0 -0
  50. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_contracts.py +0 -0
  51. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_errors.py +0 -0
  52. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_function_child.py +0 -0
  53. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_projection.py +0 -0
  54. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_runtime.py +0 -0
  55. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_runtime_child.py +0 -0
  56. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_schemas.py +0 -0
  57. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_target.py +0 -0
  58. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/sdk.py +0 -0
  59. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/storage.py +0 -0
  60. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/webhooks.py +0 -0
  61. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera.egg-info/dependency_links.txt +0 -0
  62. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera.egg-info/requires.txt +0 -0
  63. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera.egg-info/top_level.txt +0 -0
  64. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/setup.cfg +0 -0
  65. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_agents_live.py +0 -0
  66. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_automations.py +0 -0
  67. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_flags.py +0 -0
  68. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_function_resources.py +0 -0
  69. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_bill.py +0 -0
  70. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_finch.py +0 -0
  71. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_google.py +0 -0
  72. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_kafka.py +0 -0
  73. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_netsuite.py +0 -0
  74. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_salesforce.py +0 -0
  75. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_snowflake.py +0 -0
  76. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_lazy_imports.py +0 -0
  77. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_sdk.py +0 -0
  78. {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_webhooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumera
3
- Version: 0.40.2.dev20260830
3
+ Version: 0.40.3.dev2026090401
4
4
  Summary: SDK for building on Lumera platform
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests
@@ -10,14 +10,28 @@ import sys
10
10
  import tempfile
11
11
  import threading
12
12
  import time
13
+ from dataclasses import dataclass
13
14
  from pathlib import Path
14
15
  from typing import Any
15
16
  from urllib.parse import urlparse
16
17
  from urllib.request import Request, urlopen
17
18
 
19
+ from ._durable_event import execute_durable_event
18
20
  from ._projection import ASGIApp, ASGIReceive, ASGISend, create_runtime_app
19
21
 
20
22
  MAX_RELEASE_BYTES = 250 * 1024 * 1024
23
+ MAX_EVENT_INPUT = 1_000_000
24
+ WARMED_DURABLE_EVENTS_FEATURE = "durable_function_events_v1"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class _LoadedRelease:
29
+ app: ASGIApp
30
+ temporary_root: Path
31
+
32
+ @property
33
+ def root(self) -> Path:
34
+ return self.temporary_root / "release"
21
35
 
22
36
 
23
37
  class LazyReleaseApp:
@@ -25,7 +39,7 @@ class LazyReleaseApp:
25
39
 
26
40
  def __init__(self) -> None:
27
41
  self._lock = threading.Lock()
28
- self._app: ASGIApp | None = None
42
+ self._release: _LoadedRelease | None = None
29
43
  self._startup_lock: asyncio.Lock | None = None
30
44
  self._started = False
31
45
  self._lifespan_input: asyncio.Queue[dict[str, object]] | None = None
@@ -46,28 +60,77 @@ class LazyReleaseApp:
46
60
  await _json_response(
47
61
  send,
48
62
  200,
49
- {"status": "healthy", "release_loaded": self._app is not None},
63
+ {"status": "healthy", "release_loaded": self._release is not None},
50
64
  )
51
65
  return
52
- app = await self._ready_app()
66
+ method = str(scope.get("method", "GET")).upper()
67
+ if scope.get("type") == "http" and method == "POST" and path == "/__lumera/events":
68
+ await self._serve_durable_event(receive, send)
69
+ return
70
+ app = (await self._ready_release()).app
53
71
  if scope.get("type") == "http" and path == "/__lumera/warm":
54
72
  scope = {**scope, "path": "/__lumera/health", "raw_path": b"/__lumera/health"}
55
73
  await app(scope, receive, send)
56
74
 
57
- async def _ready_app(self) -> ASGIApp:
58
- if self._started and self._app is not None:
59
- return self._app
75
+ async def _ready_release(self) -> _LoadedRelease:
76
+ if self._started and self._release is not None:
77
+ return self._release
60
78
  if self._startup_lock is None:
61
79
  self._startup_lock = asyncio.Lock()
62
80
  async with self._startup_lock:
63
- if self._started and self._app is not None:
64
- return self._app
65
- app = self._app
66
- if app is None:
67
- app = await asyncio.to_thread(self._load)
68
- await self._start_project_lifespan(app)
81
+ if self._started and self._release is not None:
82
+ return self._release
83
+ release = self._release
84
+ if release is None:
85
+ release = await asyncio.to_thread(self._load)
86
+ await self._start_project_lifespan(release.app)
69
87
  self._started = True
70
- return app
88
+ return release
89
+
90
+ async def _serve_durable_event(self, receive: ASGIReceive, send: ASGISend) -> None:
91
+ body = bytearray()
92
+ more = True
93
+ while more:
94
+ event = await receive()
95
+ if event.get("type") == "http.disconnect":
96
+ return
97
+ if event.get("type") != "http.request":
98
+ continue
99
+ body.extend(event.get("body", b""))
100
+ if len(body) > MAX_EVENT_INPUT:
101
+ await _json_response(send, 413, {"error": "event too large"})
102
+ return
103
+ more = bool(event.get("more_body"))
104
+
105
+ async def ready_release() -> tuple[Path, Path]:
106
+ release = await self._ready_release()
107
+ return release.root, release.temporary_root
108
+
109
+ try:
110
+ message = json.loads(body or b"{}")
111
+ if not isinstance(message, dict):
112
+ raise ValueError("event must be an object")
113
+ status, response = await execute_durable_event(
114
+ message,
115
+ ready_release=ready_release,
116
+ )
117
+ except ValueError as exc:
118
+ status, response = 400, {"error": str(exc)}
119
+ except BaseException as exc:
120
+ correlation_id = f"fn_{os.urandom(16).hex()}"
121
+ _log_event(
122
+ "functions.app.dispatch_failed",
123
+ error_type=type(exc).__name__,
124
+ correlation_id=correlation_id,
125
+ )
126
+ status, response = (
127
+ 500,
128
+ {
129
+ "error": "durable Function dispatch failed",
130
+ "correlation_id": correlation_id,
131
+ },
132
+ )
133
+ await _json_response(send, status, response)
71
134
 
72
135
  async def _start_project_lifespan(self, app: ASGIApp) -> None:
73
136
  input_queue: asyncio.Queue[dict[str, object]] = asyncio.Queue()
@@ -127,12 +190,12 @@ class LazyReleaseApp:
127
190
  await self._lifespan_task
128
191
  await send({"type": "lifespan.shutdown.complete"})
129
192
 
130
- def _load(self) -> ASGIApp:
193
+ def _load(self) -> _LoadedRelease:
131
194
  with self._lock:
132
- if self._app is not None:
133
- return self._app
134
- self._app = _load_release_app()
135
- return self._app
195
+ if self._release is not None:
196
+ return self._release
197
+ self._release = _load_release_app()
198
+ return self._release
136
199
 
137
200
 
138
201
  def _fresh_release_url(expected_digest: str) -> str:
@@ -182,7 +245,7 @@ def _download_release(url: str, destination: Path) -> int:
182
245
  return total
183
246
 
184
247
 
185
- def _load_release_app() -> ASGIApp:
248
+ def _load_release_app() -> _LoadedRelease:
186
249
  started = time.perf_counter()
187
250
  digest = os.environ.get("LUMERA_RELEASE_DIGEST", "").strip()
188
251
  if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
@@ -239,11 +302,7 @@ def _load_release_app() -> ASGIApp:
239
302
  app = create_runtime_app(
240
303
  project_app, # type: ignore[arg-type]
241
304
  manifest,
242
- health={
243
- "release_digest": digest,
244
- "runtime_abi": expected_abi,
245
- "image_digest": os.environ.get("FUNCTIONS_RUNTIME_IMAGE_DIGEST", ""),
246
- },
305
+ health=_runtime_health(digest, expected_abi),
247
306
  )
248
307
  imported_ms = _elapsed_ms(import_started)
249
308
  _log_event(
@@ -256,7 +315,16 @@ def _load_release_app() -> ASGIApp:
256
315
  imported_ms=round(imported_ms, 1),
257
316
  total_ms=round(_elapsed_ms(started), 1),
258
317
  )
259
- return app
318
+ return _LoadedRelease(app=app, temporary_root=temporary_root)
319
+
320
+
321
+ def _runtime_health(release_digest: str, runtime_abi: str) -> dict[str, object]:
322
+ return {
323
+ "release_digest": release_digest,
324
+ "runtime_abi": runtime_abi,
325
+ "image_digest": os.environ.get("FUNCTIONS_RUNTIME_IMAGE_DIGEST", ""),
326
+ "runtime_features": [WARMED_DURABLE_EVENTS_FEATURE],
327
+ }
260
328
 
261
329
 
262
330
  def _elapsed_ms(started: float) -> float:
@@ -0,0 +1,116 @@
1
+ """Durable Function execution inside a warmed project App runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from collections.abc import Awaitable, Callable
8
+ from pathlib import Path
9
+
10
+ from ._worker_child import (
11
+ _api,
12
+ _claim_execution,
13
+ _execute_claimed_existing_release,
14
+ _log_event,
15
+ )
16
+
17
+ _ReadyRelease = Callable[[], Awaitable[tuple[Path, Path]]]
18
+
19
+
20
+ async def execute_durable_event(
21
+ message: dict[str, object],
22
+ *,
23
+ ready_release: _ReadyRelease,
24
+ ) -> tuple[int, dict[str, object]]:
25
+ """Claim, then run one generation-fenced event in a killable child process."""
26
+
27
+ if message.get("event_type") != "function.execute":
28
+ return 400, {"error": "unsupported event"}
29
+ project_id = _required_string(message, "project_id")
30
+ execution_id = _required_string(message, "execution_id")
31
+ _required_string(message, "company_id")
32
+ _required_string(message, "worker_token")
33
+ release_id = _required_string(message, "release_id")
34
+ runtime_generation = _required_positive_int(message, "runtime_generation")
35
+
36
+ loaded_release = os.environ.get("LUMERA_RELEASE_ID", "").strip()
37
+ loaded_generation = os.environ.get("LUMERA_RUNTIME_GENERATION", "").strip()
38
+ if loaded_release != release_id or loaded_generation != str(runtime_generation):
39
+ _log_event(
40
+ "functions.app.stale_dispatch",
41
+ project_id=project_id,
42
+ execution_id=execution_id,
43
+ dispatch_generation=runtime_generation,
44
+ loaded_generation=loaded_generation,
45
+ )
46
+ return 409, {"error": "runtime generation changed"}
47
+
48
+ prefix = f"/internal/functions/projects/{project_id}/executions/{execution_id}"
49
+ claim, reason = await asyncio.to_thread(
50
+ _claim_execution,
51
+ prefix,
52
+ str(message["worker_token"]),
53
+ {"release_id": release_id, "runtime_generation": runtime_generation},
54
+ api=_api,
55
+ )
56
+ if claim is None:
57
+ return 202, {"accepted": False, "reason": reason or "not_claimable"}
58
+
59
+ execution = claim.get("execution")
60
+ if not isinstance(execution, dict):
61
+ raise RuntimeError("claim returned an invalid execution")
62
+ if (
63
+ str(execution.get("release_id", "")) != loaded_release
64
+ or int(execution.get("runtime_generation", 0)) != runtime_generation
65
+ ):
66
+ raise RuntimeError("claimed execution does not match the loaded App release")
67
+
68
+ try:
69
+ release_root, runtime_temporary_root = await ready_release()
70
+ return await asyncio.to_thread(
71
+ _execute_claimed_existing_release,
72
+ message,
73
+ claim,
74
+ root=release_root,
75
+ runtime_temporary_root=runtime_temporary_root,
76
+ api=_api,
77
+ )
78
+ except BaseException as exc:
79
+ _log_event(
80
+ "functions.app.batch_item_failed",
81
+ project_id=project_id,
82
+ execution_id=execution_id,
83
+ error_type=type(exc).__name__,
84
+ )
85
+ try:
86
+ await asyncio.to_thread(
87
+ _api,
88
+ prefix + "/failed",
89
+ {"lease_token": str(claim["lease_token"])},
90
+ str(message["worker_token"]),
91
+ )
92
+ except BaseException as recovery_exc:
93
+ _log_event(
94
+ "functions.app.failure_recovery_failed",
95
+ project_id=project_id,
96
+ execution_id=execution_id,
97
+ error_type=type(recovery_exc).__name__,
98
+ )
99
+ raise
100
+
101
+
102
+ def _required_string(value: dict[str, object], key: str) -> str:
103
+ result = value.get(key)
104
+ if not isinstance(result, str) or not result.strip():
105
+ raise ValueError(f"{key} is required")
106
+ return result.strip()
107
+
108
+
109
+ def _required_positive_int(value: dict[str, object], key: str) -> int:
110
+ result = value.get(key)
111
+ if isinstance(result, bool) or not isinstance(result, int) or result <= 0:
112
+ raise ValueError(f"{key} must be positive")
113
+ return result
114
+
115
+
116
+ __all__ = ["execute_durable_event"]
@@ -12,6 +12,7 @@ import sys
12
12
  import tempfile
13
13
  import threading
14
14
  import time
15
+ from collections.abc import Callable
15
16
  from http.server import BaseHTTPRequestHandler, HTTPServer
16
17
  from pathlib import Path
17
18
  from urllib.error import HTTPError
@@ -20,6 +21,9 @@ from urllib.request import Request, urlopen
20
21
 
21
22
  MAX_RELEASE_BYTES = 250 * 1024 * 1024
22
23
  MAX_CHILD_OUTPUT = 1_100_000
24
+ MAX_EXECUTION_SECONDS = 600
25
+
26
+ APICall = Callable[[str, dict[str, object], str], dict[str, object]]
23
27
 
24
28
  _WORKER_TEMP_ROOT = Path(os.environ.get("LUMERA_WORKER_TEMP_ROOT", tempfile.gettempdir()))
25
29
 
@@ -78,10 +82,19 @@ def _download_release(url: str, destination: Path) -> None:
78
82
  raise RuntimeError("release archive exceeds runtime limit")
79
83
 
80
84
 
81
- def _purge_worker_temporary_storage() -> None:
85
+ def _purge_worker_temporary_storage(*, preserve: tuple[Path, ...] = ()) -> None:
82
86
  """Remove all writable state left by a previous customer invocation."""
83
87
  _WORKER_TEMP_ROOT.mkdir(parents=True, exist_ok=True)
84
- for child in _WORKER_TEMP_ROOT.iterdir():
88
+ root = _WORKER_TEMP_ROOT.resolve()
89
+ preserved_children: set[Path] = set()
90
+ for path in preserve:
91
+ resolved = path.resolve()
92
+ if resolved == root or root not in resolved.parents:
93
+ raise RuntimeError("preserved runtime path must be inside worker temporary storage")
94
+ preserved_children.add(root / resolved.relative_to(root).parts[0])
95
+ for child in root.iterdir():
96
+ if child in preserved_children:
97
+ continue
85
98
  if child.is_symlink() or not child.is_dir():
86
99
  child.unlink(missing_ok=True)
87
100
  else:
@@ -104,36 +117,100 @@ def _execute_message(message: dict[str, object]) -> None:
104
117
  execution_id = str(message["execution_id"])
105
118
  worker_token = str(message.get("worker_token", ""))
106
119
  prefix = f"/internal/functions/projects/{project}/executions/{execution_id}"
120
+ claim, _ = _claim_execution(prefix, worker_token, {}, api=_api)
121
+ if claim is None:
122
+ return
123
+ execution = claim.get("execution")
124
+ if not isinstance(execution, dict):
125
+ raise RuntimeError("claim returned an invalid execution")
126
+ # The invocation-level finally block owns cleanup. Avoid a second
127
+ # TemporaryDirectory finalizer racing that full /tmp purge on error paths.
128
+ temporary = Path(tempfile.mkdtemp(prefix="lumera-function-worker-", dir=_WORKER_TEMP_ROOT))
129
+ archive = temporary / "release.tar.gz"
130
+ root = temporary / "release"
131
+ _download_release(str(claim["release_url"]), archive)
132
+ from lumera.releases import extract_archive
133
+
134
+ extract_archive(archive, root, expected_digest=str(claim["release_digest"]))
135
+ _execute_claimed_release(message, claim, root, api=_api, event_namespace="functions.worker")
136
+
137
+
138
+ def _execute_claimed_existing_release(
139
+ message: dict[str, object],
140
+ claim: dict[str, object],
141
+ *,
142
+ root: Path,
143
+ runtime_temporary_root: Path,
144
+ api: APICall,
145
+ ) -> tuple[int, dict[str, object]]:
146
+ """Execute an already-claimed active release with the shared process protocol."""
147
+
148
+ project = str(message["project_id"])
149
+ execution_id = str(message["execution_id"])
150
+ _purge_worker_temporary_storage(preserve=(runtime_temporary_root,))
151
+ try:
152
+ status = _execute_claimed_release(
153
+ message,
154
+ claim,
155
+ root,
156
+ api=api,
157
+ event_namespace="functions.app",
158
+ )
159
+ _log_event(
160
+ "functions.app.execution_completed",
161
+ project_id=project,
162
+ execution_id=execution_id,
163
+ status=status,
164
+ )
165
+ return 200, {"ok": True, "status": status}
166
+ finally:
167
+ # Preserve the loaded immutable release, but remove every other /tmp
168
+ # entry so Function output cannot survive into a later App invocation.
169
+ _purge_worker_temporary_storage(preserve=(runtime_temporary_root,))
170
+
171
+
172
+ def _claim_execution(
173
+ prefix: str,
174
+ worker_token: str,
175
+ payload: dict[str, object],
176
+ *,
177
+ api: APICall,
178
+ ) -> tuple[dict[str, object] | None, str | None]:
107
179
  try:
108
- claim = _api(prefix + "/claim", {}, worker_token)
180
+ return api(prefix + "/claim", payload, worker_token), None
109
181
  except HTTPError as exc:
110
- # A duplicate SQS delivery after another worker has claimed or
111
- # completed this execution is acknowledged without rerunning code.
182
+ # A duplicate delivery after another worker has claimed or completed
183
+ # this execution is acknowledged without rerunning code.
112
184
  if exc.code in {404, 409}:
113
- return
185
+ return None, "not_claimable"
114
186
  if exc.code == 429:
115
187
  try:
116
- _api(prefix + "/defer", {}, worker_token)
188
+ api(prefix + "/defer", {}, worker_token)
117
189
  except HTTPError as defer_exc:
118
190
  # Capacity can become available and a duplicate delivery can
119
191
  # claim or finish the execution between /claim and /defer.
120
192
  if defer_exc.code not in {404, 409}:
121
193
  raise
122
- return
194
+ return None, "capacity"
123
195
  raise
196
+
197
+
198
+ def _execute_claimed_release(
199
+ message: dict[str, object],
200
+ claim: dict[str, object],
201
+ root: Path,
202
+ *,
203
+ api: APICall,
204
+ event_namespace: str,
205
+ ) -> str:
206
+ project = str(message["project_id"])
207
+ execution_id = str(message["execution_id"])
208
+ worker_token = str(message.get("worker_token", ""))
209
+ prefix = f"/internal/functions/projects/{project}/executions/{execution_id}"
124
210
  lease = str(claim["lease_token"])
125
- execution = claim["execution"]
211
+ execution = claim.get("execution")
126
212
  if not isinstance(execution, dict):
127
213
  raise RuntimeError("claim returned an invalid execution")
128
- # The invocation-level finally block owns cleanup. Avoid a second
129
- # TemporaryDirectory finalizer racing that full /tmp purge on error paths.
130
- temporary = Path(tempfile.mkdtemp(prefix="lumera-function-worker-", dir=_WORKER_TEMP_ROOT))
131
- archive = temporary / "release.tar.gz"
132
- root = temporary / "release"
133
- _download_release(str(claim["release_url"]), archive)
134
- from lumera.releases import extract_archive
135
-
136
- extract_archive(archive, root, expected_digest=str(claim["release_digest"]))
137
214
  child_input = _trusted_child_input(
138
215
  execution,
139
216
  company_id=str(message["company_id"]),
@@ -141,78 +218,17 @@ def _execute_message(message: dict[str, object]) -> None:
141
218
  execution_id=execution_id,
142
219
  claim=claim,
143
220
  )
144
- process = subprocess.Popen(
145
- [
146
- sys.executable,
147
- "-I",
148
- "-m",
149
- "lumera.releases._function_child",
150
- str(root),
151
- str(execution["function_key"]),
152
- ],
153
- stdin=subprocess.PIPE,
154
- stdout=subprocess.PIPE,
155
- stderr=subprocess.DEVNULL,
156
- env=_function_environment(
157
- str(claim["function_token"]), str(execution.get("causation_id", ""))
158
- ),
159
- start_new_session=True,
160
- )
161
- try:
162
- # The child blocks reading stdin, so this is the last pre-code boundary:
163
- # extraction and spawn failures remain safely retryable.
164
- _api(prefix + "/code-started", {"lease_token": lease}, worker_token)
165
- except BaseException:
166
- _terminate_process_group(process)
167
- raise
168
- child_bytes = json.dumps(child_input, separators=(",", ":")).encode("utf-8")
169
- stdout = bytearray()
170
-
171
- def drain_stdout() -> None:
172
- assert process.stdout is not None
173
- while chunk := process.stdout.read(64 * 1024):
174
- remaining = MAX_CHILD_OUTPUT + 1 - len(stdout)
175
- if remaining > 0:
176
- stdout.extend(chunk[:remaining])
177
-
178
- stdout_reader = threading.Thread(
179
- target=drain_stdout,
180
- name="lumera-function-stdout",
181
- daemon=True,
221
+ stdout_bytes, timed_out = _run_function_child(
222
+ root=root,
223
+ function_key=str(execution["function_key"]),
224
+ child_input=child_input,
225
+ function_token=str(claim["function_token"]),
226
+ causation_id=str(execution.get("causation_id", "")),
227
+ prefix=prefix,
228
+ lease=lease,
229
+ worker_token=worker_token,
230
+ api=api,
182
231
  )
183
- stdout_reader.start()
184
- assert process.stdin is not None
185
- try:
186
- process.stdin.write(child_bytes)
187
- process.stdin.close()
188
- except BrokenPipeError:
189
- pass
190
- deadline = time.monotonic() + 600
191
- timed_out = False
192
- while True:
193
- try:
194
- process.wait(timeout=min(45, max(0.1, deadline - time.monotonic())))
195
- break
196
- except subprocess.TimeoutExpired:
197
- if time.monotonic() >= deadline:
198
- timed_out = True
199
- _terminate_process_group(process)
200
- break
201
- # A failed heartbeat means the lease was lost or cancellation was
202
- # requested. Stop code before it can continue with stale authority.
203
- try:
204
- _api(prefix + "/heartbeat", {"lease_token": lease}, worker_token)
205
- except BaseException:
206
- _terminate_process_group(process)
207
- raise
208
- # The entrypoint may have spawned descendants that inherited its stdout or
209
- # scoped token. Retire the complete session after the entrypoint exits so a
210
- # warm Lambda never carries customer processes into the next invocation.
211
- _terminate_process_group(process)
212
- stdout_reader.join(timeout=5)
213
- if stdout_reader.is_alive():
214
- raise RuntimeError("Function stdout reader did not terminate")
215
- stdout_bytes = bytes(stdout)
216
232
  if timed_out:
217
233
  envelope = {
218
234
  "ok": False,
@@ -244,7 +260,12 @@ def _execute_message(message: dict[str, object]) -> None:
244
260
  "retryable": False,
245
261
  },
246
262
  }
247
- _log_execution_timings(envelope, project_id=project, execution_id=execution_id)
263
+ _log_execution_timings(
264
+ envelope,
265
+ project_id=project,
266
+ execution_id=execution_id,
267
+ event_namespace=event_namespace,
268
+ )
248
269
  result_payload = (
249
270
  envelope.get("result") if envelope.get("ok") else {"error": envelope.get("error")}
250
271
  )
@@ -252,7 +273,7 @@ def _execute_message(message: dict[str, object]) -> None:
252
273
 
253
274
  if not envelope.get("ok"):
254
275
  _log_event(
255
- "functions.worker.execution_failed",
276
+ event_namespace + ".execution_failed",
256
277
  project_id=project,
257
278
  execution_id=execution_id,
258
279
  error_code=str(error.get("code", "")),
@@ -266,7 +287,7 @@ def _execute_message(message: dict[str, object]) -> None:
266
287
  if envelope.get("timed_out")
267
288
  else "failed"
268
289
  )
269
- uploaded = _api(
290
+ uploaded = api(
270
291
  prefix + "/result",
271
292
  {
272
293
  "lease_token": lease,
@@ -277,7 +298,7 @@ def _execute_message(message: dict[str, object]) -> None:
277
298
  },
278
299
  worker_token,
279
300
  )
280
- _api(
301
+ api(
281
302
  prefix + "/complete",
282
303
  {
283
304
  "lease_token": lease,
@@ -288,6 +309,102 @@ def _execute_message(message: dict[str, object]) -> None:
288
309
  },
289
310
  worker_token,
290
311
  )
312
+ return terminal_status
313
+
314
+
315
+ def _run_function_child(
316
+ *,
317
+ root: Path,
318
+ function_key: str,
319
+ child_input: dict[str, object],
320
+ function_token: str,
321
+ causation_id: str,
322
+ prefix: str,
323
+ lease: str,
324
+ worker_token: str,
325
+ api: APICall,
326
+ ) -> tuple[bytes, bool]:
327
+ """Run customer code in a process group that is always retired."""
328
+
329
+ _WORKER_TEMP_ROOT.mkdir(parents=True, exist_ok=True)
330
+ execution_temporary = Path(
331
+ tempfile.mkdtemp(prefix="lumera-function-execution-", dir=_WORKER_TEMP_ROOT)
332
+ )
333
+ process: subprocess.Popen[bytes] | None = None
334
+ stdout_reader: threading.Thread | None = None
335
+ stdout = bytearray()
336
+ timed_out = False
337
+ try:
338
+ process = subprocess.Popen(
339
+ [
340
+ sys.executable,
341
+ "-I",
342
+ "-m",
343
+ "lumera.releases._function_child",
344
+ str(root),
345
+ function_key,
346
+ ],
347
+ stdin=subprocess.PIPE,
348
+ stdout=subprocess.PIPE,
349
+ stderr=subprocess.DEVNULL,
350
+ cwd=execution_temporary,
351
+ env=_function_environment(
352
+ function_token,
353
+ causation_id,
354
+ temporary_directory=execution_temporary,
355
+ ),
356
+ start_new_session=True,
357
+ )
358
+ # The child blocks reading stdin, so this is the last pre-code boundary:
359
+ # setup failures remain safely retryable and the finally block kills it.
360
+ api(prefix + "/code-started", {"lease_token": lease}, worker_token)
361
+ child_bytes = json.dumps(child_input, separators=(",", ":")).encode("utf-8")
362
+
363
+ def drain_stdout() -> None:
364
+ assert process is not None and process.stdout is not None
365
+ while chunk := process.stdout.read(64 * 1024):
366
+ remaining = MAX_CHILD_OUTPUT + 1 - len(stdout)
367
+ if remaining > 0:
368
+ stdout.extend(chunk[:remaining])
369
+
370
+ stdout_reader = threading.Thread(
371
+ target=drain_stdout,
372
+ name="lumera-function-stdout",
373
+ daemon=True,
374
+ )
375
+ stdout_reader.start()
376
+ assert process.stdin is not None
377
+ try:
378
+ process.stdin.write(child_bytes)
379
+ process.stdin.close()
380
+ except BrokenPipeError:
381
+ pass
382
+ deadline = time.monotonic() + MAX_EXECUTION_SECONDS
383
+ while True:
384
+ try:
385
+ process.wait(timeout=min(45, max(0.1, deadline - time.monotonic())))
386
+ break
387
+ except subprocess.TimeoutExpired:
388
+ if time.monotonic() >= deadline:
389
+ timed_out = True
390
+ break
391
+ # A failed heartbeat means the lease was lost or cancellation
392
+ # was requested. The finally block kills code before it can
393
+ # continue with stale authority.
394
+ api(prefix + "/heartbeat", {"lease_token": lease}, worker_token)
395
+ finally:
396
+ try:
397
+ try:
398
+ if process is not None:
399
+ _terminate_process_group(process)
400
+ finally:
401
+ if stdout_reader is not None:
402
+ stdout_reader.join(timeout=5)
403
+ finally:
404
+ shutil.rmtree(execution_temporary, ignore_errors=True)
405
+ if stdout_reader is not None and stdout_reader.is_alive():
406
+ raise RuntimeError("Function stdout reader did not terminate")
407
+ return bytes(stdout), timed_out
291
408
 
292
409
 
293
410
  def _trusted_child_input(
@@ -323,13 +440,20 @@ def _trusted_child_input(
323
440
  return child_input
324
441
 
325
442
 
326
- def _function_environment(function_token: str, causation_id: str = "") -> dict[str, str]:
443
+ def _function_environment(
444
+ function_token: str,
445
+ causation_id: str = "",
446
+ *,
447
+ temporary_directory: Path | None = None,
448
+ ) -> dict[str, str]:
327
449
  if not function_token:
328
450
  raise RuntimeError("nested Function invocation token is unavailable")
329
451
  environment = {
330
452
  key: value for key in _CHILD_ENVIRONMENT_KEYS if (value := os.environ.get(key)) is not None
331
453
  }
332
454
  environment["LUMERA_TOKEN"] = function_token
455
+ if temporary_directory is not None:
456
+ environment["TMPDIR"] = str(temporary_directory)
333
457
  if causation_id:
334
458
  environment["LUMERA_CAUSATION_ID"] = causation_id
335
459
  return environment
@@ -340,6 +464,7 @@ def _log_execution_timings(
340
464
  *,
341
465
  project_id: str,
342
466
  execution_id: str,
467
+ event_namespace: str = "functions.worker",
343
468
  ) -> None:
344
469
  if not isinstance(envelope, dict) or not isinstance(envelope.get("timings_ms"), dict):
345
470
  return
@@ -362,7 +487,7 @@ def _log_execution_timings(
362
487
  }
363
488
  if timings:
364
489
  _log_event(
365
- "functions.worker.execution_timing",
490
+ event_namespace + ".execution_timing",
366
491
  project_id=project_id,
367
492
  execution_id=execution_id,
368
493
  **timings,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumera
3
- Version: 0.40.2.dev20260830
3
+ Version: 0.40.3.dev2026090401
4
4
  Summary: SDK for building on Lumera platform
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests
@@ -51,6 +51,7 @@ lumera/releases/_archive.py
51
51
  lumera/releases/_builder.py
52
52
  lumera/releases/_config.py
53
53
  lumera/releases/_contracts.py
54
+ lumera/releases/_durable_event.py
54
55
  lumera/releases/_errors.py
55
56
  lumera/releases/_function_child.py
56
57
  lumera/releases/_projection.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lumera"
3
- version = "0.40.2.dev20260830"
3
+ version = "0.40.3.dev2026090401"
4
4
  description = "SDK for building on Lumera platform"
5
5
  requires-python = ">=3.11"
6
6
  dependencies = [