databricks-agentbricks 0.2.0__py3-none-any.whl
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.
- databricks_agentbricks/__init__.py +1 -0
- databricks_agentbricks/_group.py +217 -0
- databricks_agentbricks/agent_project.py +657 -0
- databricks_agentbricks/app_resources.py +139 -0
- databricks_agentbricks/cli/__init__.py +12 -0
- databricks_agentbricks/cli/app.py +108 -0
- databricks_agentbricks/cli/app_auth.py +198 -0
- databricks_agentbricks/cli/auth.py +142 -0
- databricks_agentbricks/cli/deploy.py +954 -0
- databricks_agentbricks/cli/dev.py +310 -0
- databricks_agentbricks/cli/endpoint.py +145 -0
- databricks_agentbricks/cli/endpoint_examples.py +33 -0
- databricks_agentbricks/cli/endpoint_output.py +53 -0
- databricks_agentbricks/cli/endpoint_request.py +71 -0
- databricks_agentbricks/cli/endpoint_transport.py +148 -0
- databricks_agentbricks/cli/help.py +467 -0
- databricks_agentbricks/cli/init.py +496 -0
- databricks_agentbricks/cli/mcp.py +75 -0
- databricks_agentbricks/cli/memory.py +520 -0
- databricks_agentbricks/cli/sandbox.py +823 -0
- databricks_agentbricks/cli/sessions.py +588 -0
- databricks_agentbricks/cli/tools.py +456 -0
- databricks_agentbricks/cli/tracing.py +724 -0
- databricks_agentbricks/databricks_cli.py +38 -0
- databricks_agentbricks/errors.py +114 -0
- databricks_agentbricks/lakebase_runtime_store.py +107 -0
- databricks_agentbricks/legacy_lakebase_runtime_store.py +74 -0
- databricks_agentbricks/project_config.py +157 -0
- databricks_agentbricks/project_types.py +55 -0
- databricks_agentbricks/py.typed +0 -0
- databricks_agentbricks/render.py +349 -0
- databricks_agentbricks/templates/agent-bricks-migrate/SKILL.md +101 -0
- databricks_agentbricks/templates/agent-langgraph/.env.example +36 -0
- databricks_agentbricks/templates/agent-langgraph/.gitignore +199 -0
- databricks_agentbricks/templates/agent-langgraph/AGENTKIT_CONTRACT.md +95 -0
- databricks_agentbricks/templates/agent-langgraph/AGENTS.md +31 -0
- databricks_agentbricks/templates/agent-langgraph/CLAUDE.md +1 -0
- databricks_agentbricks/templates/agent-langgraph/README.md +141 -0
- databricks_agentbricks/templates/agent-langgraph/agent/__init__.py +0 -0
- databricks_agentbricks/templates/agent-langgraph/agent/agent.py +177 -0
- databricks_agentbricks/templates/agent-langgraph/agent/mcps.py +27 -0
- databricks_agentbricks/templates/agent-langgraph/agent/tools/__init__.py +24 -0
- databricks_agentbricks/templates/agent-langgraph/agent/tools/sample_tool.py +15 -0
- databricks_agentbricks/templates/agent-langgraph/agent/tools/send_message.py +17 -0
- databricks_agentbricks/templates/agent-langgraph/app.yaml +15 -0
- databricks_agentbricks/templates/agent-langgraph/pyproject.toml +42 -0
- databricks_agentbricks/templates/agent-langgraph/runtime/__init__.py +0 -0
- databricks_agentbricks/templates/agent-langgraph/runtime/adapter.py +144 -0
- databricks_agentbricks/templates/agent-langgraph/runtime/main.py +25 -0
- databricks_agentbricks/templates/agent-langgraph/tests/__init__.py +0 -0
- databricks_agentbricks/templates/agent-langgraph/tests/test_agent.py +211 -0
- databricks_agentbricks/templates/agent-langgraph/tests/test_runtime.py +19 -0
- databricks_agentbricks/templates/agent-langgraph/tests/test_workspace.py +28 -0
- databricks_agentbricks/templates/agent-openai/.env.example +37 -0
- databricks_agentbricks/templates/agent-openai/.gitignore +199 -0
- databricks_agentbricks/templates/agent-openai/AGENTKIT_CONTRACT.md +107 -0
- databricks_agentbricks/templates/agent-openai/AGENTS.md +76 -0
- databricks_agentbricks/templates/agent-openai/CLAUDE.md +1 -0
- databricks_agentbricks/templates/agent-openai/README.md +143 -0
- databricks_agentbricks/templates/agent-openai/agent/__init__.py +0 -0
- databricks_agentbricks/templates/agent-openai/agent/agent.py +205 -0
- databricks_agentbricks/templates/agent-openai/agent/mcps.py +28 -0
- databricks_agentbricks/templates/agent-openai/agent/tools/__init__.py +24 -0
- databricks_agentbricks/templates/agent-openai/agent/tools/sample_tool.py +15 -0
- databricks_agentbricks/templates/agent-openai/agent/tools/send_message.py +18 -0
- databricks_agentbricks/templates/agent-openai/app.yaml +15 -0
- databricks_agentbricks/templates/agent-openai/pyproject.toml +42 -0
- databricks_agentbricks/templates/agent-openai/runtime/__init__.py +0 -0
- databricks_agentbricks/templates/agent-openai/runtime/adapter.py +188 -0
- databricks_agentbricks/templates/agent-openai/runtime/main.py +25 -0
- databricks_agentbricks/templates/agent-openai/tests/__init__.py +0 -0
- databricks_agentbricks/templates/agent-openai/tests/test_agent.py +326 -0
- databricks_agentbricks/templates/agent-openai/tests/test_runtime.py +19 -0
- databricks_agentbricks/templates/agent-openai/tests/test_workspace.py +28 -0
- databricks_agentbricks/templates/custom-agent-langgraph/.env.example +1 -0
- databricks_agentbricks/templates/custom-agent-langgraph/.gitignore +11 -0
- databricks_agentbricks/templates/custom-agent-langgraph/README.md +22 -0
- databricks_agentbricks/templates/custom-agent-langgraph/agent/__init__.py +0 -0
- databricks_agentbricks/templates/custom-agent-langgraph/agent/agent.py +27 -0
- databricks_agentbricks/templates/custom-agent-langgraph/app.yaml +1 -0
- databricks_agentbricks/templates/custom-agent-langgraph/pyproject.toml +38 -0
- databricks_agentbricks/templates/custom-agent-langgraph/runtime/__init__.py +0 -0
- databricks_agentbricks/templates/custom-agent-langgraph/runtime/main.py +31 -0
- databricks_agentbricks/templates/custom-agent-langgraph/tests/test_runtime.py +29 -0
- databricks_agentbricks/templates/custom-agent-openai/.env.example +1 -0
- databricks_agentbricks/templates/custom-agent-openai/.gitignore +11 -0
- databricks_agentbricks/templates/custom-agent-openai/README.md +22 -0
- databricks_agentbricks/templates/custom-agent-openai/agent/__init__.py +0 -0
- databricks_agentbricks/templates/custom-agent-openai/agent/agent.py +29 -0
- databricks_agentbricks/templates/custom-agent-openai/app.yaml +1 -0
- databricks_agentbricks/templates/custom-agent-openai/pyproject.toml +38 -0
- databricks_agentbricks/templates/custom-agent-openai/runtime/__init__.py +0 -0
- databricks_agentbricks/templates/custom-agent-openai/runtime/main.py +31 -0
- databricks_agentbricks/templates/custom-agent-openai/tests/test_runtime.py +29 -0
- databricks_agentbricks/templates/sandbox_mcp.py +39 -0
- databricks_agentbricks/templates/sandbox_mcp_langgraph.py +39 -0
- databricks_agentbricks/templates/ui/agent-langgraph/CHAT_APP.md +49 -0
- databricks_agentbricks/templates/ui/agent-langgraph/runtime/main.py +26 -0
- databricks_agentbricks/templates/ui/agent-langgraph/runtime/ui.py +528 -0
- databricks_agentbricks/templates/ui/agent-langgraph/tests/test_demo_ui.py +542 -0
- databricks_agentbricks/templates/ui/agent-langgraph/ui/agentbricks-mark.png +0 -0
- databricks_agentbricks/templates/ui/agent-langgraph/ui/app.js +1464 -0
- databricks_agentbricks/templates/ui/agent-langgraph/ui/index.html +281 -0
- databricks_agentbricks/templates/ui/agent-langgraph/ui/styles.css +1913 -0
- databricks_agentbricks/templates/ui/agent-openai/CHAT_APP.md +54 -0
- databricks_agentbricks/templates/ui/agent-openai/runtime/main.py +26 -0
- databricks_agentbricks/templates/ui/agent-openai/runtime/ui.py +521 -0
- databricks_agentbricks/templates/ui/agent-openai/tests/test_demo_ui.py +512 -0
- databricks_agentbricks/templates/ui/agent-openai/ui/agentbricks-mark.png +0 -0
- databricks_agentbricks/templates/ui/agent-openai/ui/app.js +1428 -0
- databricks_agentbricks/templates/ui/agent-openai/ui/index.html +281 -0
- databricks_agentbricks/templates/ui/agent-openai/ui/styles.css +1913 -0
- databricks_agentbricks/theme.py +72 -0
- databricks_agentbricks-0.2.0.dist-info/METADATA +897 -0
- databricks_agentbricks-0.2.0.dist-info/RECORD +159 -0
- databricks_agentbricks-0.2.0.dist-info/WHEEL +4 -0
- databricks_agentbricks-0.2.0.dist-info/entry_points.txt +2 -0
- databricks_agentbricks-0.2.0.dist-info/licenses/NOTICE +39 -0
- databricks_agentkit/__init__.py +67 -0
- databricks_agentkit/_api_client.py +673 -0
- databricks_agentkit/_pagination.py +25 -0
- databricks_agentkit/client.py +31 -0
- databricks_agentkit/langgraph/__init__.py +98 -0
- databricks_agentkit/langgraph/genie.py +50 -0
- databricks_agentkit/langgraph/mcp.py +216 -0
- databricks_agentkit/langgraph/memory.py +67 -0
- databricks_agentkit/langgraph/session_store.py +485 -0
- databricks_agentkit/memory_store.py +371 -0
- databricks_agentkit/models.py +330 -0
- databricks_agentkit/openai/__init__.py +93 -0
- databricks_agentkit/openai/genie.py +50 -0
- databricks_agentkit/openai/mcp.py +189 -0
- databricks_agentkit/openai/memory.py +67 -0
- databricks_agentkit/openai/sessions.py +157 -0
- databricks_agentkit/py.typed +0 -0
- databricks_agentkit/runtime/ARCHITECTURE.md +64 -0
- databricks_agentkit/runtime/README.md +184 -0
- databricks_agentkit/runtime/__init__.py +69 -0
- databricks_agentkit/runtime/app.py +316 -0
- databricks_agentkit/runtime/auth.py +149 -0
- databricks_agentkit/runtime/durability/__init__.py +1 -0
- databricks_agentkit/runtime/durability/execution.py +133 -0
- databricks_agentkit/runtime/durability/heartbeat.py +64 -0
- databricks_agentkit/runtime/durability/lakebase_runtime_store.py +648 -0
- databricks_agentkit/runtime/durability/recovery.py +70 -0
- databricks_agentkit/runtime/durability/store.py +33 -0
- databricks_agentkit/runtime/execution.py +160 -0
- databricks_agentkit/runtime/genie.py +224 -0
- databricks_agentkit/runtime/mcp_auth.py +114 -0
- databricks_agentkit/runtime/model_services.py +90 -0
- databricks_agentkit/runtime/runtime.py +273 -0
- databricks_agentkit/runtime/session_store_client.py +120 -0
- databricks_agentkit/runtime/store.py +287 -0
- databricks_agentkit/runtime/tool_manifest.py +223 -0
- databricks_agentkit/runtime/tracing.py +71 -0
- databricks_agentkit/runtime/types.py +118 -0
- databricks_agentkit/runtime/workspace.py +27 -0
- databricks_agentkit/session_store.py +434 -0
- databricks_agentkit/timefmt.py +85 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
"""Lakebase-backed Runtime Store with heartbeat-based recovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from threading import Lock
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
12
|
+
|
|
13
|
+
from sqlalchemy import URL, event, text
|
|
14
|
+
from sqlalchemy.engine import RowMapping
|
|
15
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
16
|
+
|
|
17
|
+
from databricks_agentkit.runtime.durability.store import DurableRuntimeStore
|
|
18
|
+
from databricks_agentkit.runtime.store import DEFAULT_RUNTIME_STORE_SCHEMA
|
|
19
|
+
from databricks_agentkit.runtime.types import (
|
|
20
|
+
Invocation,
|
|
21
|
+
InvocationConflictError,
|
|
22
|
+
InvocationEvent,
|
|
23
|
+
InvocationStatus,
|
|
24
|
+
JsonObject,
|
|
25
|
+
JsonValue,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from databricks.sdk import WorkspaceClient
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _AsyncLakebase(Protocol):
|
|
33
|
+
engine: AsyncEngine
|
|
34
|
+
|
|
35
|
+
async def create_schema(self) -> None: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_SCHEMA_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
39
|
+
_TOKEN_CACHE_SECONDS = 15 * 60
|
|
40
|
+
_POOL_RECYCLE_SECONDS = 14 * 60
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _serialize_json_value(value: JsonValue) -> str:
|
|
44
|
+
return json.dumps(value, allow_nan=False)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validate_invocation_id(invocation_id: str) -> None:
|
|
48
|
+
if not invocation_id:
|
|
49
|
+
raise ValueError("invocation_id must not be empty")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _AppsPostgresLakebase:
|
|
53
|
+
"""SQLAlchemy connection for a Databricks Apps Postgres resource.
|
|
54
|
+
|
|
55
|
+
Apps injects the selected resource's connection coordinates through the standard ``PG*``
|
|
56
|
+
variables. The endpoint resource path is kept separately because OAuth credentials must be
|
|
57
|
+
refreshed through the Databricks Postgres API.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
endpoint: str,
|
|
64
|
+
host: str,
|
|
65
|
+
port: int,
|
|
66
|
+
database: str,
|
|
67
|
+
username: str,
|
|
68
|
+
sslmode: str,
|
|
69
|
+
workspace_client: WorkspaceClient | None,
|
|
70
|
+
schema: str,
|
|
71
|
+
) -> None:
|
|
72
|
+
if not endpoint or not host or not database or not username:
|
|
73
|
+
raise ValueError("endpoint, host, database, and username must not be empty")
|
|
74
|
+
if port <= 0:
|
|
75
|
+
raise ValueError("port must be positive")
|
|
76
|
+
|
|
77
|
+
if workspace_client is None:
|
|
78
|
+
from databricks.sdk import WorkspaceClient
|
|
79
|
+
|
|
80
|
+
workspace_client = WorkspaceClient()
|
|
81
|
+
|
|
82
|
+
self._endpoint = endpoint
|
|
83
|
+
self._workspace_client = workspace_client
|
|
84
|
+
self._schema = schema
|
|
85
|
+
self._token: str | None = None
|
|
86
|
+
self._token_time = 0.0
|
|
87
|
+
self._token_lock = Lock()
|
|
88
|
+
|
|
89
|
+
url = URL.create(
|
|
90
|
+
drivername="postgresql+psycopg",
|
|
91
|
+
username=username,
|
|
92
|
+
host=host,
|
|
93
|
+
port=port,
|
|
94
|
+
database=database,
|
|
95
|
+
)
|
|
96
|
+
self.engine: AsyncEngine = create_async_engine(
|
|
97
|
+
url,
|
|
98
|
+
pool_recycle=_POOL_RECYCLE_SECONDS,
|
|
99
|
+
pool_pre_ping=True,
|
|
100
|
+
connect_args={"sslmode": sslmode},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
@event.listens_for(self.engine.sync_engine, "do_connect")
|
|
104
|
+
def inject_token(dialect, connection_record, args, params) -> None:
|
|
105
|
+
params["password"] = self._get_token()
|
|
106
|
+
|
|
107
|
+
async def create_schema(self) -> None:
|
|
108
|
+
async with self.engine.begin() as connection:
|
|
109
|
+
await connection.execute(text(f"CREATE SCHEMA IF NOT EXISTS {self._schema}"))
|
|
110
|
+
|
|
111
|
+
def _get_token(self) -> str:
|
|
112
|
+
with self._token_lock:
|
|
113
|
+
if self._token and time.monotonic() - self._token_time < _TOKEN_CACHE_SECONDS:
|
|
114
|
+
return self._token
|
|
115
|
+
credential = self._workspace_client.postgres.generate_database_credential(
|
|
116
|
+
endpoint=self._endpoint
|
|
117
|
+
)
|
|
118
|
+
token = getattr(credential, "token", None)
|
|
119
|
+
if not token:
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
f"failed to generate a database credential for endpoint {self._endpoint!r}"
|
|
122
|
+
)
|
|
123
|
+
self._token = token
|
|
124
|
+
self._token_time = time.monotonic()
|
|
125
|
+
return token
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class LakebaseDurableRuntimeStore(DurableRuntimeStore):
|
|
129
|
+
"""Persist invocation state, attempt leases, and ordered events in Lakebase.
|
|
130
|
+
|
|
131
|
+
The ``invocations`` table is the source of truth for idempotency and lifecycle state. Conditional
|
|
132
|
+
SQL updates claim an attempt and fence completion, failure, heartbeat, and event writes by its
|
|
133
|
+
attempt number. The ``invocation_events`` table provides an ordered replay cursor. Because every
|
|
134
|
+
app replica connects to the same schema, another replica can detect a stale heartbeat, claim the
|
|
135
|
+
next attempt, and continue after process or pod loss.
|
|
136
|
+
|
|
137
|
+
This store requires a Lakebase Postgres database. ``ab deploy`` reuses or provisions a
|
|
138
|
+
dedicated app-owned database, then assigns the app its own schema. ``ab dev`` uses
|
|
139
|
+
``InMemoryRuntimeStore`` instead.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
*,
|
|
145
|
+
autoscaling_endpoint: str | None = None,
|
|
146
|
+
project: str | None = None,
|
|
147
|
+
branch: str | None = None,
|
|
148
|
+
database: str | None = None,
|
|
149
|
+
username: str | None = None,
|
|
150
|
+
workspace_client: WorkspaceClient | None = None,
|
|
151
|
+
schema: str = DEFAULT_RUNTIME_STORE_SCHEMA,
|
|
152
|
+
lakebase: _AsyncLakebase | None = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
if not _SCHEMA_NAME.fullmatch(schema):
|
|
155
|
+
raise ValueError(f"invalid Runtime Store schema name: {schema!r}")
|
|
156
|
+
|
|
157
|
+
if lakebase is None:
|
|
158
|
+
from databricks_ai_bridge.lakebase import DEFAULT_DATABASE, AsyncLakebaseSQLAlchemy
|
|
159
|
+
|
|
160
|
+
autoscaling_endpoint = autoscaling_endpoint or os.getenv(
|
|
161
|
+
"LAKEBASE_AUTOSCALING_ENDPOINT"
|
|
162
|
+
)
|
|
163
|
+
if autoscaling_endpoint is None:
|
|
164
|
+
project = project or os.getenv("LAKEBASE_AUTOSCALING_PROJECT")
|
|
165
|
+
branch = branch or os.getenv("LAKEBASE_AUTOSCALING_BRANCH")
|
|
166
|
+
lakebase = AsyncLakebaseSQLAlchemy(
|
|
167
|
+
autoscaling_endpoint=autoscaling_endpoint,
|
|
168
|
+
project=project,
|
|
169
|
+
branch=branch,
|
|
170
|
+
database=database or DEFAULT_DATABASE,
|
|
171
|
+
username=username,
|
|
172
|
+
workspace_client=workspace_client,
|
|
173
|
+
schema=schema,
|
|
174
|
+
pool_pre_ping=True,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
self._lakebase = lakebase
|
|
178
|
+
self._engine = lakebase.engine
|
|
179
|
+
self._table = f"{schema}.invocations"
|
|
180
|
+
self._events_table = f"{schema}.invocation_events"
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def from_app_resource(
|
|
184
|
+
cls,
|
|
185
|
+
*,
|
|
186
|
+
endpoint: str,
|
|
187
|
+
host: str | None = None,
|
|
188
|
+
port: int | None = None,
|
|
189
|
+
database: str | None = None,
|
|
190
|
+
username: str | None = None,
|
|
191
|
+
sslmode: str | None = None,
|
|
192
|
+
workspace_client: WorkspaceClient | None = None,
|
|
193
|
+
schema: str = DEFAULT_RUNTIME_STORE_SCHEMA,
|
|
194
|
+
) -> "LakebaseDurableRuntimeStore":
|
|
195
|
+
"""Use connection coordinates injected for a Databricks Apps Postgres resource."""
|
|
196
|
+
if not _SCHEMA_NAME.fullmatch(schema):
|
|
197
|
+
raise ValueError(f"invalid Runtime Store schema name: {schema!r}")
|
|
198
|
+
host = host or os.getenv("PGHOST")
|
|
199
|
+
database = database or os.getenv("PGDATABASE")
|
|
200
|
+
username = username or os.getenv("PGUSER")
|
|
201
|
+
if port is None:
|
|
202
|
+
raw_port = os.getenv("PGPORT")
|
|
203
|
+
try:
|
|
204
|
+
port = int(raw_port or "")
|
|
205
|
+
except ValueError as exc:
|
|
206
|
+
raise RuntimeError("PGPORT must be an integer") from exc
|
|
207
|
+
missing = [
|
|
208
|
+
name
|
|
209
|
+
for name, value in {
|
|
210
|
+
"PGHOST": host,
|
|
211
|
+
"PGPORT": port,
|
|
212
|
+
"PGDATABASE": database,
|
|
213
|
+
"PGUSER": username,
|
|
214
|
+
}.items()
|
|
215
|
+
if not value
|
|
216
|
+
]
|
|
217
|
+
if missing:
|
|
218
|
+
raise RuntimeError(
|
|
219
|
+
"Databricks Apps Postgres resource is missing: " + ", ".join(missing)
|
|
220
|
+
)
|
|
221
|
+
assert host is not None
|
|
222
|
+
assert port is not None
|
|
223
|
+
assert database is not None
|
|
224
|
+
assert username is not None
|
|
225
|
+
lakebase = _AppsPostgresLakebase(
|
|
226
|
+
endpoint=endpoint,
|
|
227
|
+
host=host,
|
|
228
|
+
port=port,
|
|
229
|
+
database=database,
|
|
230
|
+
username=username,
|
|
231
|
+
sslmode=sslmode or os.getenv("PGSSLMODE", "require"),
|
|
232
|
+
workspace_client=workspace_client,
|
|
233
|
+
schema=schema,
|
|
234
|
+
)
|
|
235
|
+
return cls(schema=schema, lakebase=lakebase)
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def from_managed_runtime_store(
|
|
239
|
+
cls,
|
|
240
|
+
*,
|
|
241
|
+
branch: str,
|
|
242
|
+
database: str,
|
|
243
|
+
username: str,
|
|
244
|
+
workspace_client: WorkspaceClient | None = None,
|
|
245
|
+
schema: str = DEFAULT_RUNTIME_STORE_SCHEMA,
|
|
246
|
+
) -> "LakebaseDurableRuntimeStore":
|
|
247
|
+
"""Connect using the backend coordinates returned by the Runtime Store API."""
|
|
248
|
+
return cls(
|
|
249
|
+
branch=branch,
|
|
250
|
+
database=database,
|
|
251
|
+
username=username,
|
|
252
|
+
workspace_client=workspace_client,
|
|
253
|
+
schema=schema,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
async def initialize(self) -> None:
|
|
257
|
+
await self._lakebase.create_schema()
|
|
258
|
+
async with self._engine.begin() as connection:
|
|
259
|
+
await connection.execute(
|
|
260
|
+
text(
|
|
261
|
+
f"""
|
|
262
|
+
CREATE TABLE IF NOT EXISTS {self._table} (
|
|
263
|
+
invocation_id TEXT PRIMARY KEY,
|
|
264
|
+
status TEXT NOT NULL,
|
|
265
|
+
attempt INTEGER NOT NULL DEFAULT 0,
|
|
266
|
+
heartbeat_at TIMESTAMPTZ,
|
|
267
|
+
request JSONB NOT NULL,
|
|
268
|
+
response JSONB,
|
|
269
|
+
CHECK (status IN ('QUEUED', 'ACTIVE', 'COMPLETED', 'FAILED'))
|
|
270
|
+
)
|
|
271
|
+
"""
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
await connection.execute(
|
|
275
|
+
text(
|
|
276
|
+
f"""
|
|
277
|
+
CREATE TABLE IF NOT EXISTS {self._events_table} (
|
|
278
|
+
sequence_number BIGSERIAL PRIMARY KEY,
|
|
279
|
+
invocation_id TEXT NOT NULL
|
|
280
|
+
REFERENCES {self._table}(invocation_id) ON DELETE CASCADE,
|
|
281
|
+
attempt INTEGER NOT NULL,
|
|
282
|
+
event JSONB NOT NULL,
|
|
283
|
+
CHECK (jsonb_typeof(event) = 'object')
|
|
284
|
+
)
|
|
285
|
+
"""
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
await connection.execute(
|
|
289
|
+
text(
|
|
290
|
+
f"""
|
|
291
|
+
CREATE INDEX IF NOT EXISTS invocation_events_replay_idx
|
|
292
|
+
ON {self._events_table} (invocation_id, sequence_number)
|
|
293
|
+
"""
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
await connection.execute(
|
|
297
|
+
text(
|
|
298
|
+
f"""
|
|
299
|
+
CREATE INDEX IF NOT EXISTS invocations_recovery_idx
|
|
300
|
+
ON {self._table} (status, heartbeat_at)
|
|
301
|
+
WHERE status IN ('QUEUED', 'ACTIVE')
|
|
302
|
+
"""
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
async def close(self) -> None:
|
|
307
|
+
await self._engine.dispose()
|
|
308
|
+
|
|
309
|
+
async def accept(self, invocation_id: str, request: JsonValue) -> Invocation:
|
|
310
|
+
_validate_invocation_id(invocation_id)
|
|
311
|
+
serialized_request = _serialize_json_value(request)
|
|
312
|
+
async with self._engine.begin() as connection:
|
|
313
|
+
await connection.execute(
|
|
314
|
+
text(
|
|
315
|
+
f"""
|
|
316
|
+
INSERT INTO {self._table} (invocation_id, status, request)
|
|
317
|
+
VALUES (:invocation_id, 'QUEUED', CAST(:request AS JSONB))
|
|
318
|
+
ON CONFLICT (invocation_id) DO NOTHING
|
|
319
|
+
"""
|
|
320
|
+
),
|
|
321
|
+
{"invocation_id": invocation_id, "request": serialized_request},
|
|
322
|
+
)
|
|
323
|
+
row = (
|
|
324
|
+
(
|
|
325
|
+
await connection.execute(
|
|
326
|
+
text(
|
|
327
|
+
f"""
|
|
328
|
+
SELECT invocation_id, status, attempt,
|
|
329
|
+
request::TEXT AS request_json,
|
|
330
|
+
response::TEXT AS response_json
|
|
331
|
+
FROM {self._table}
|
|
332
|
+
WHERE invocation_id=:invocation_id
|
|
333
|
+
"""
|
|
334
|
+
),
|
|
335
|
+
{"invocation_id": invocation_id},
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
.mappings()
|
|
339
|
+
.one()
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
state = self._to_invocation(row)
|
|
343
|
+
if state.request != request:
|
|
344
|
+
raise InvocationConflictError(
|
|
345
|
+
f"invocation {invocation_id!r} was already accepted with a different request"
|
|
346
|
+
)
|
|
347
|
+
return state
|
|
348
|
+
|
|
349
|
+
async def get(self, invocation_id: str) -> Invocation | None:
|
|
350
|
+
_validate_invocation_id(invocation_id)
|
|
351
|
+
async with self._engine.connect() as connection:
|
|
352
|
+
row = (
|
|
353
|
+
(
|
|
354
|
+
await connection.execute(
|
|
355
|
+
text(
|
|
356
|
+
f"""
|
|
357
|
+
SELECT invocation_id, status, attempt,
|
|
358
|
+
request::TEXT AS request_json,
|
|
359
|
+
response::TEXT AS response_json
|
|
360
|
+
FROM {self._table}
|
|
361
|
+
WHERE invocation_id=:invocation_id
|
|
362
|
+
"""
|
|
363
|
+
),
|
|
364
|
+
{"invocation_id": invocation_id},
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
.mappings()
|
|
368
|
+
.one_or_none()
|
|
369
|
+
)
|
|
370
|
+
return self._to_invocation(row) if row is not None else None
|
|
371
|
+
|
|
372
|
+
async def queued_invocation_ids(self) -> list[str]:
|
|
373
|
+
async with self._engine.connect() as connection:
|
|
374
|
+
rows = (
|
|
375
|
+
(
|
|
376
|
+
await connection.execute(
|
|
377
|
+
text(
|
|
378
|
+
f"""
|
|
379
|
+
SELECT invocation_id
|
|
380
|
+
FROM {self._table}
|
|
381
|
+
WHERE status='QUEUED'
|
|
382
|
+
ORDER BY invocation_id
|
|
383
|
+
"""
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
.scalars()
|
|
388
|
+
.all()
|
|
389
|
+
)
|
|
390
|
+
return list(rows)
|
|
391
|
+
|
|
392
|
+
async def stale_invocation_ids(self, stale_seconds: float) -> list[str]:
|
|
393
|
+
async with self._engine.connect() as connection:
|
|
394
|
+
rows = (
|
|
395
|
+
(
|
|
396
|
+
await connection.execute(
|
|
397
|
+
text(
|
|
398
|
+
f"""
|
|
399
|
+
SELECT invocation_id
|
|
400
|
+
FROM {self._table}
|
|
401
|
+
WHERE status='ACTIVE' AND (
|
|
402
|
+
heartbeat_at IS NULL
|
|
403
|
+
OR heartbeat_at < NOW() - (:stale * INTERVAL '1 second')
|
|
404
|
+
)
|
|
405
|
+
ORDER BY heartbeat_at NULLS FIRST
|
|
406
|
+
"""
|
|
407
|
+
),
|
|
408
|
+
{"stale": stale_seconds},
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
.scalars()
|
|
412
|
+
.all()
|
|
413
|
+
)
|
|
414
|
+
return list(rows)
|
|
415
|
+
|
|
416
|
+
async def claim(self, invocation_id: str) -> Invocation | None:
|
|
417
|
+
"""Claim a newly queued invocation for its first attempt."""
|
|
418
|
+
return await self._claim(invocation_id, stale_seconds=None)
|
|
419
|
+
|
|
420
|
+
async def claim_recoverable(
|
|
421
|
+
self,
|
|
422
|
+
invocation_id: str,
|
|
423
|
+
stale_seconds: float,
|
|
424
|
+
) -> Invocation | None:
|
|
425
|
+
"""Replace a stale active attempt."""
|
|
426
|
+
return await self._claim(invocation_id, stale_seconds=stale_seconds)
|
|
427
|
+
|
|
428
|
+
async def _claim(
|
|
429
|
+
self,
|
|
430
|
+
invocation_id: str,
|
|
431
|
+
*,
|
|
432
|
+
stale_seconds: float | None,
|
|
433
|
+
) -> Invocation | None:
|
|
434
|
+
_validate_invocation_id(invocation_id)
|
|
435
|
+
eligibility = "status='QUEUED'"
|
|
436
|
+
parameters: dict[str, str | float] = {"invocation_id": invocation_id}
|
|
437
|
+
if stale_seconds is not None:
|
|
438
|
+
eligibility = """
|
|
439
|
+
status='ACTIVE' AND (
|
|
440
|
+
heartbeat_at IS NULL
|
|
441
|
+
OR heartbeat_at < NOW() - (:stale * INTERVAL '1 second')
|
|
442
|
+
)
|
|
443
|
+
"""
|
|
444
|
+
parameters["stale"] = stale_seconds
|
|
445
|
+
async with self._engine.begin() as connection:
|
|
446
|
+
row = (
|
|
447
|
+
(
|
|
448
|
+
await connection.execute(
|
|
449
|
+
text(
|
|
450
|
+
f"""
|
|
451
|
+
UPDATE {self._table}
|
|
452
|
+
SET status='ACTIVE', attempt=attempt+1, heartbeat_at=NOW()
|
|
453
|
+
WHERE invocation_id=:invocation_id
|
|
454
|
+
AND ({eligibility})
|
|
455
|
+
RETURNING invocation_id, status, attempt,
|
|
456
|
+
request::TEXT AS request_json,
|
|
457
|
+
response::TEXT AS response_json
|
|
458
|
+
"""
|
|
459
|
+
),
|
|
460
|
+
parameters,
|
|
461
|
+
)
|
|
462
|
+
)
|
|
463
|
+
.mappings()
|
|
464
|
+
.one_or_none()
|
|
465
|
+
)
|
|
466
|
+
if row is not None:
|
|
467
|
+
await connection.execute(
|
|
468
|
+
text(
|
|
469
|
+
f"""
|
|
470
|
+
INSERT INTO {self._events_table} (invocation_id, attempt, event)
|
|
471
|
+
VALUES (:invocation_id, :attempt, CAST(:event AS JSONB))
|
|
472
|
+
"""
|
|
473
|
+
),
|
|
474
|
+
{
|
|
475
|
+
"invocation_id": invocation_id,
|
|
476
|
+
"attempt": int(row["attempt"]),
|
|
477
|
+
"event": _serialize_json_value({"type": "run.started"}),
|
|
478
|
+
},
|
|
479
|
+
)
|
|
480
|
+
return self._to_invocation(row) if row is not None else None
|
|
481
|
+
|
|
482
|
+
async def heartbeat(self, invocation_id: str, attempt: int) -> bool:
|
|
483
|
+
_validate_invocation_id(invocation_id)
|
|
484
|
+
async with self._engine.begin() as connection:
|
|
485
|
+
result = await connection.execute(
|
|
486
|
+
text(
|
|
487
|
+
f"""
|
|
488
|
+
UPDATE {self._table}
|
|
489
|
+
SET heartbeat_at=NOW()
|
|
490
|
+
WHERE invocation_id=:invocation_id
|
|
491
|
+
AND attempt=:attempt
|
|
492
|
+
AND status='ACTIVE'
|
|
493
|
+
"""
|
|
494
|
+
),
|
|
495
|
+
{"invocation_id": invocation_id, "attempt": attempt},
|
|
496
|
+
)
|
|
497
|
+
return result.rowcount == 1
|
|
498
|
+
|
|
499
|
+
async def complete(
|
|
500
|
+
self,
|
|
501
|
+
invocation_id: str,
|
|
502
|
+
attempt: int,
|
|
503
|
+
response: JsonValue,
|
|
504
|
+
) -> bool:
|
|
505
|
+
_validate_invocation_id(invocation_id)
|
|
506
|
+
serialized_response = _serialize_json_value(response)
|
|
507
|
+
async with self._engine.begin() as connection:
|
|
508
|
+
result = await connection.execute(
|
|
509
|
+
text(
|
|
510
|
+
f"""
|
|
511
|
+
UPDATE {self._table}
|
|
512
|
+
SET status='COMPLETED', response=CAST(:response AS JSONB)
|
|
513
|
+
WHERE invocation_id=:invocation_id
|
|
514
|
+
AND attempt=:attempt
|
|
515
|
+
AND status='ACTIVE'
|
|
516
|
+
"""
|
|
517
|
+
),
|
|
518
|
+
{
|
|
519
|
+
"invocation_id": invocation_id,
|
|
520
|
+
"attempt": attempt,
|
|
521
|
+
"response": serialized_response,
|
|
522
|
+
},
|
|
523
|
+
)
|
|
524
|
+
if result.rowcount == 1:
|
|
525
|
+
await connection.execute(
|
|
526
|
+
text(
|
|
527
|
+
f"""
|
|
528
|
+
INSERT INTO {self._events_table} (invocation_id, attempt, event)
|
|
529
|
+
VALUES (:invocation_id, :attempt, CAST(:event AS JSONB))
|
|
530
|
+
"""
|
|
531
|
+
),
|
|
532
|
+
{
|
|
533
|
+
"invocation_id": invocation_id,
|
|
534
|
+
"attempt": attempt,
|
|
535
|
+
"event": _serialize_json_value({"type": "run.completed"}),
|
|
536
|
+
},
|
|
537
|
+
)
|
|
538
|
+
return result.rowcount == 1
|
|
539
|
+
|
|
540
|
+
async def fail(self, invocation_id: str, attempt: int) -> bool:
|
|
541
|
+
_validate_invocation_id(invocation_id)
|
|
542
|
+
async with self._engine.begin() as connection:
|
|
543
|
+
result = await connection.execute(
|
|
544
|
+
text(
|
|
545
|
+
f"""
|
|
546
|
+
UPDATE {self._table}
|
|
547
|
+
SET status='FAILED'
|
|
548
|
+
WHERE invocation_id=:invocation_id
|
|
549
|
+
AND attempt=:attempt
|
|
550
|
+
AND status='ACTIVE'
|
|
551
|
+
"""
|
|
552
|
+
),
|
|
553
|
+
{"invocation_id": invocation_id, "attempt": attempt},
|
|
554
|
+
)
|
|
555
|
+
if result.rowcount == 1:
|
|
556
|
+
await connection.execute(
|
|
557
|
+
text(
|
|
558
|
+
f"""
|
|
559
|
+
INSERT INTO {self._events_table} (invocation_id, attempt, event)
|
|
560
|
+
VALUES (:invocation_id, :attempt, CAST(:event AS JSONB))
|
|
561
|
+
"""
|
|
562
|
+
),
|
|
563
|
+
{
|
|
564
|
+
"invocation_id": invocation_id,
|
|
565
|
+
"attempt": attempt,
|
|
566
|
+
"event": _serialize_json_value({"type": "run.failed"}),
|
|
567
|
+
},
|
|
568
|
+
)
|
|
569
|
+
return result.rowcount == 1
|
|
570
|
+
|
|
571
|
+
async def append_event(
|
|
572
|
+
self,
|
|
573
|
+
invocation_id: str,
|
|
574
|
+
attempt: int,
|
|
575
|
+
event: JsonObject,
|
|
576
|
+
) -> int | None:
|
|
577
|
+
"""Append an event only while the caller owns the active attempt."""
|
|
578
|
+
_validate_invocation_id(invocation_id)
|
|
579
|
+
serialized_event = _serialize_json_value(event)
|
|
580
|
+
async with self._engine.begin() as connection:
|
|
581
|
+
result = await connection.execute(
|
|
582
|
+
text(
|
|
583
|
+
f"""
|
|
584
|
+
INSERT INTO {self._events_table} (invocation_id, attempt, event)
|
|
585
|
+
SELECT :invocation_id, :attempt, CAST(:event AS JSONB)
|
|
586
|
+
WHERE EXISTS (
|
|
587
|
+
SELECT 1
|
|
588
|
+
FROM {self._table}
|
|
589
|
+
WHERE invocation_id=:invocation_id
|
|
590
|
+
AND attempt=:attempt
|
|
591
|
+
AND status='ACTIVE'
|
|
592
|
+
)
|
|
593
|
+
RETURNING sequence_number
|
|
594
|
+
"""
|
|
595
|
+
),
|
|
596
|
+
{
|
|
597
|
+
"invocation_id": invocation_id,
|
|
598
|
+
"attempt": attempt,
|
|
599
|
+
"event": serialized_event,
|
|
600
|
+
},
|
|
601
|
+
)
|
|
602
|
+
sequence_number = result.scalar_one_or_none()
|
|
603
|
+
return int(sequence_number) if sequence_number is not None else None
|
|
604
|
+
|
|
605
|
+
async def events(
|
|
606
|
+
self,
|
|
607
|
+
invocation_id: str,
|
|
608
|
+
after_sequence: int | None = None,
|
|
609
|
+
) -> list[InvocationEvent]:
|
|
610
|
+
"""Return ordered events for one invocation after an optional cursor."""
|
|
611
|
+
_validate_invocation_id(invocation_id)
|
|
612
|
+
async with self._engine.connect() as connection:
|
|
613
|
+
result = await connection.execute(
|
|
614
|
+
text(
|
|
615
|
+
f"""
|
|
616
|
+
SELECT sequence_number, invocation_id, attempt,
|
|
617
|
+
event::TEXT AS event_json
|
|
618
|
+
FROM {self._events_table}
|
|
619
|
+
WHERE invocation_id=:invocation_id
|
|
620
|
+
AND (:after_sequence IS NULL OR sequence_number > :after_sequence)
|
|
621
|
+
ORDER BY sequence_number
|
|
622
|
+
"""
|
|
623
|
+
),
|
|
624
|
+
{
|
|
625
|
+
"invocation_id": invocation_id,
|
|
626
|
+
"after_sequence": after_sequence,
|
|
627
|
+
},
|
|
628
|
+
)
|
|
629
|
+
rows = result.mappings().all()
|
|
630
|
+
return [
|
|
631
|
+
InvocationEvent(
|
|
632
|
+
sequence_number=int(row["sequence_number"]),
|
|
633
|
+
invocation_id=str(row["invocation_id"]),
|
|
634
|
+
attempt=int(row["attempt"]),
|
|
635
|
+
event=json.loads(row["event_json"]),
|
|
636
|
+
)
|
|
637
|
+
for row in rows
|
|
638
|
+
]
|
|
639
|
+
|
|
640
|
+
@staticmethod
|
|
641
|
+
def _to_invocation(row: Mapping[str, Any] | RowMapping) -> Invocation:
|
|
642
|
+
return Invocation(
|
|
643
|
+
invocation_id=str(row["invocation_id"]),
|
|
644
|
+
status=InvocationStatus(str(row["status"])),
|
|
645
|
+
attempt=int(row["attempt"]),
|
|
646
|
+
request=json.loads(row["request_json"]),
|
|
647
|
+
response=json.loads(row["response_json"]) if row["response_json"] else None,
|
|
648
|
+
)
|