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.
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/PKG-INFO +1 -1
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_api_child.py +93 -25
- lumera-0.40.3.dev2026090401/lumera/releases/_durable_event.py +116 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_worker_child.py +220 -95
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera.egg-info/PKG-INFO +1 -1
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera.egg-info/SOURCES.txt +1 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/pyproject.toml +1 -1
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/__init__.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/_live.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/_utils.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/agents.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/automations.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/email.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/exceptions.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/files.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/flags.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/function_resources.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/__init__.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/__main__.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_artifacts.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_config.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_context.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_contract.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_dependencies.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_errors.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_execution.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_jsonschema.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_registry.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_remote.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_runner.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/_runtime.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/functions/testing.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/google.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/__init__.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/bill.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/finch.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/google.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/kafka.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/netsuite.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/salesforce.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/slack.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/integrations/snowflake.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/llm.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/pb.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/__init__.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/__main__.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_archive.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_builder.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_config.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_contracts.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_errors.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_function_child.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_projection.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_runtime.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_runtime_child.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_schemas.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_target.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/sdk.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/storage.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/webhooks.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera.egg-info/dependency_links.txt +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera.egg-info/requires.txt +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera.egg-info/top_level.txt +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/setup.cfg +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_agents_live.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_automations.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_flags.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_function_resources.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_bill.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_finch.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_google.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_kafka.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_netsuite.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_salesforce.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_snowflake.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_lazy_imports.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_sdk.py +0 -0
- {lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_webhooks.py +0 -0
|
@@ -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.
|
|
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.
|
|
63
|
+
{"status": "healthy", "release_loaded": self._release is not None},
|
|
50
64
|
)
|
|
51
65
|
return
|
|
52
|
-
|
|
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
|
|
58
|
-
if self._started and self.
|
|
59
|
-
return self.
|
|
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.
|
|
64
|
-
return self.
|
|
65
|
-
|
|
66
|
-
if
|
|
67
|
-
|
|
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
|
|
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) ->
|
|
193
|
+
def _load(self) -> _LoadedRelease:
|
|
131
194
|
with self._lock:
|
|
132
|
-
if self.
|
|
133
|
-
return self.
|
|
134
|
-
self.
|
|
135
|
-
return self.
|
|
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() ->
|
|
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
|
-
|
|
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
|
-
|
|
180
|
+
return api(prefix + "/claim", payload, worker_token), None
|
|
109
181
|
except HTTPError as exc:
|
|
110
|
-
# A duplicate
|
|
111
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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(
|
|
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
|
-
"
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
"
|
|
490
|
+
event_namespace + ".execution_timing",
|
|
366
491
|
project_id=project_id,
|
|
367
492
|
execution_id=execution_id,
|
|
368
493
|
**timings,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera/releases/_function_child.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/lumera.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_netsuite.py
RENAMED
|
File without changes
|
{lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_salesforce.py
RENAMED
|
File without changes
|
{lumera-0.40.2.dev20260830 → lumera-0.40.3.dev2026090401}/tests/test_integrations_snowflake.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|