Skip to content

Commit e48484f

Browse files
feat(cloud): add connection state and catalog management to CloudConnection (#975)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent bdffff9 commit e48484f

7 files changed

Lines changed: 676 additions & 16 deletions

File tree

airbyte/_util/api_util.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from airbyte.constants import CLOUD_API_ROOT, CLOUD_CONFIG_API_ROOT, CLOUD_CONFIG_API_ROOT_ENV_VAR
2626
from airbyte.exceptions import (
27+
AirbyteConnectionSyncActiveError,
2728
AirbyteConnectionSyncError,
2829
AirbyteError,
2930
AirbyteMissingResourceError,
@@ -2092,6 +2093,70 @@ def get_connection_state(
20922093
)
20932094

20942095

2096+
def replace_connection_state(
2097+
connection_id: str,
2098+
connection_state_dict: dict[str, Any],
2099+
*,
2100+
api_root: str,
2101+
client_id: SecretString | None,
2102+
client_secret: SecretString | None,
2103+
bearer_token: SecretString | None,
2104+
) -> dict[str, Any]:
2105+
"""Replace the state for a connection.
2106+
2107+
Uses the Config API endpoint: POST /v1/state/create_or_update_safe
2108+
2109+
Returns HTTP 423 if a sync is currently running, preventing state
2110+
corruption from concurrent modifications.
2111+
2112+
Important: This endpoint replaces the ENTIRE connection state. It does not
2113+
perform per-stream deduplication or merging on the backend. Callers that need
2114+
to update a single stream must first fetch the current state, merge the desired
2115+
stream change into the full state object, and then send the complete state back.
2116+
See ``CloudConnection.set_stream_state()`` for this fetch-modify-push pattern.
2117+
2118+
The provided ``connection_id`` is injected into both the outer request
2119+
wrapper and the inner ``connection_state`` payload to ensure consistency.
2120+
2121+
Args:
2122+
connection_id: The connection ID to update state for.
2123+
connection_state_dict: The full ConnectionState dict to set. Must include:
2124+
- stateType: "global", "stream", or "legacy"
2125+
- One of: state (legacy), streamState (stream), globalState (global)
2126+
All streams must be included; any stream omitted will have its state dropped.
2127+
api_root: The API root URL.
2128+
client_id: OAuth client ID.
2129+
client_secret: OAuth client secret.
2130+
bearer_token: Bearer token for authentication (alternative to client credentials).
2131+
2132+
Returns:
2133+
Dictionary containing the updated ConnectionState object.
2134+
"""
2135+
try:
2136+
return _make_config_api_request(
2137+
path="/state/create_or_update_safe",
2138+
json={
2139+
"connectionId": connection_id,
2140+
"connectionState": {
2141+
**connection_state_dict,
2142+
"connectionId": connection_id,
2143+
},
2144+
},
2145+
api_root=api_root,
2146+
client_id=client_id,
2147+
client_secret=client_secret,
2148+
bearer_token=bearer_token,
2149+
)
2150+
except AirbyteError as ex:
2151+
if ex.context and ex.context.get("status_code") == HTTPStatus.LOCKED:
2152+
raise AirbyteConnectionSyncActiveError(
2153+
message="Cannot update connection state while a sync is running.",
2154+
connection_id=connection_id,
2155+
guidance="Wait for the current sync to complete before updating state.",
2156+
) from ex
2157+
raise
2158+
2159+
20952160
def get_connection_catalog(
20962161
connection_id: str,
20972162
*,
@@ -2127,6 +2192,49 @@ def get_connection_catalog(
21272192
)
21282193

21292194

2195+
def replace_connection_catalog(
2196+
connection_id: str,
2197+
configured_catalog_dict: dict[str, Any],
2198+
*,
2199+
api_root: str,
2200+
client_id: SecretString | None,
2201+
client_secret: SecretString | None,
2202+
bearer_token: SecretString | None,
2203+
) -> dict[str, Any]:
2204+
"""Replace the configured catalog for a connection.
2205+
2206+
Uses the Config API endpoint: POST /v1/web_backend/connections/update
2207+
2208+
This is a patch-style update that replaces the connection's entire syncCatalog
2209+
with the provided catalog. All other connection settings remain unchanged.
2210+
2211+
Args:
2212+
connection_id: The connection ID to update catalog for.
2213+
configured_catalog_dict: The configured catalog dict (``{"streams": [...]}``) to set.
2214+
api_root: The API root URL.
2215+
client_id: OAuth client ID.
2216+
client_secret: OAuth client secret.
2217+
bearer_token: Bearer token for authentication (alternative to client credentials).
2218+
2219+
Returns:
2220+
Dictionary containing the updated WebBackendConnectionRead response.
2221+
"""
2222+
return _make_config_api_request(
2223+
path="/web_backend/connections/update",
2224+
json={
2225+
"connectionId": connection_id,
2226+
"syncCatalog": configured_catalog_dict,
2227+
# Resets are destructive and cause customer-side data outage.
2228+
# If a reset is desired, caller will need to decide & manage.
2229+
"skipReset": True,
2230+
},
2231+
api_root=api_root,
2232+
client_id=client_id,
2233+
client_secret=client_secret,
2234+
bearer_token=bearer_token,
2235+
)
2236+
2237+
21302238
def get_organization_info(
21312239
organization_id: str,
21322240
*,

airbyte/cloud/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,13 @@
6363
# Submodules imported here for documentation reasons: https://github.com/mitmproxy/pdoc/issues/757
6464
if TYPE_CHECKING:
6565
# ruff: noqa: TC004
66-
from airbyte.cloud import client_config, connections, constants, sync_results, workspaces
66+
from airbyte.cloud import (
67+
client_config,
68+
connections,
69+
constants,
70+
sync_results,
71+
workspaces,
72+
)
6773

6874

6975
__all__ = [

airbyte/cloud/_connection_state.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
2+
"""Pydantic models for connection state management.
3+
4+
These models represent the state of an Airbyte connection, which tracks sync progress
5+
for incremental syncs. The state can be one of several types:
6+
- stream: Per-stream state
7+
- global: Global state with optional per-stream states
8+
- legacy: Legacy state blob
9+
- not_set: No state has been set yet
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from typing import Any, Literal
15+
16+
from pydantic import BaseModel, ConfigDict, Field
17+
18+
19+
class StreamDescriptor(BaseModel):
20+
"""Descriptor for a single stream within a connection state."""
21+
22+
name: str = Field(description="The stream name")
23+
namespace: str | None = Field(
24+
default=None,
25+
description="The stream namespace (optional)",
26+
)
27+
28+
29+
class StreamState(BaseModel):
30+
"""State for a single stream."""
31+
32+
model_config = ConfigDict(populate_by_name=True)
33+
34+
stream_descriptor: StreamDescriptor = Field(
35+
alias="streamDescriptor",
36+
description="The stream descriptor (name and namespace)",
37+
)
38+
stream_state: dict[str, Any] | None = Field(
39+
default=None,
40+
alias="streamState",
41+
description="The state blob for this stream",
42+
)
43+
44+
45+
class GlobalState(BaseModel):
46+
"""Global state containing shared state and per-stream states."""
47+
48+
model_config = ConfigDict(populate_by_name=True)
49+
50+
shared_state: dict[str, Any] | None = Field(
51+
default=None,
52+
alias="sharedState",
53+
description="Shared state across all streams",
54+
)
55+
stream_states: list[StreamState] = Field(
56+
default_factory=list,
57+
alias="streamStates",
58+
description="Per-stream states within the global state",
59+
)
60+
61+
62+
class ConnectionStateResponse(BaseModel):
63+
"""Response model for connection state operations.
64+
65+
Represents the state of a connection, which can be one of:
66+
- stream: Per-stream state (streamState field populated)
67+
- global: Global state with optional per-stream states (globalState field populated)
68+
- legacy: Legacy state blob (state field populated)
69+
- not_set: No state has been set yet
70+
"""
71+
72+
model_config = ConfigDict(populate_by_name=True)
73+
74+
state_type: Literal["global", "stream", "legacy", "not_set"] = Field(
75+
alias="stateType",
76+
description="The type of state: global, stream, legacy, or not_set",
77+
)
78+
connection_id: str = Field(
79+
alias="connectionId",
80+
description="The connection ID (UUID)",
81+
)
82+
state: dict[str, Any] | None = Field(
83+
default=None,
84+
description="Legacy state blob (populated when stateType is 'legacy')",
85+
)
86+
stream_state: list[StreamState] | None = Field(
87+
default=None,
88+
alias="streamState",
89+
description="Per-stream states (populated when stateType is 'stream')",
90+
)
91+
global_state: GlobalState | None = Field(
92+
default=None,
93+
alias="globalState",
94+
description="Global state (populated when stateType is 'global')",
95+
)
96+
97+
def __str__(self) -> str:
98+
"""Return a string representation of the connection state."""
99+
stream_count = 0
100+
if self.stream_state:
101+
stream_count = len(self.stream_state)
102+
elif self.global_state and self.global_state.stream_states:
103+
stream_count = len(self.global_state.stream_states)
104+
return (
105+
f"Connection {self.connection_id}: "
106+
f"stateType={self.state_type}, streams={stream_count}"
107+
)
108+
109+
110+
def _match_stream(
111+
stream: StreamState,
112+
stream_name: str,
113+
stream_namespace: str | None = None,
114+
) -> bool:
115+
"""Check if a StreamState matches the given name and namespace.
116+
117+
Namespace matching treats None and "" as equivalent (both mean "no namespace").
118+
There is no wildcard behavior: the caller must match the actual namespace.
119+
"""
120+
if stream.stream_descriptor.name != stream_name:
121+
return False
122+
return (stream.stream_descriptor.namespace or None) == (stream_namespace or None)
123+
124+
125+
def _get_stream_list(
126+
state: ConnectionStateResponse,
127+
) -> list[StreamState]:
128+
"""Extract the stream list from a ConnectionStateResponse regardless of state type."""
129+
if state.state_type == "stream" and state.stream_state:
130+
return state.stream_state
131+
if state.state_type == "global" and state.global_state:
132+
return state.global_state.stream_states
133+
return []

0 commit comments

Comments
 (0)