Skip to content

Commit 56be54a

Browse files
eywalkerclaude
andcommitted
feat(sources): add SourceProxy, identity-preserving schema serialization, and CachedSource graceful degradation
- Add SourceProxy class that preserves content_hash, pipeline_hash, source_id, and output_schema for non-reconstructable sources, with bind/unbind delegation - Add _identity_config() to RootSource base class; all source to_config() methods now include identity hashes and serialized schemas - Implement cross-language Arrow type string parser (parse_arrow_type_string) with support for primitives and nested types (list, struct, map, recursive nesting) - Add proper serialize_schema/deserialize_schema round-trip using Arrow type strings - Update resolve_source_from_config() with fallback_to_proxy parameter - CachedSource gracefully serves cached data when inner source is SourceProxy - Add comprehensive tests for SourceProxy, CachedSource with proxy, schema serialization round-trip, and Arrow type string parsing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 93a2e76 commit 56be54a

14 files changed

Lines changed: 1005 additions & 31 deletions

src/orcapod/core/sources/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .dict_source import DictSource
99
from .list_source import ListSource
1010
from .source_registry import GLOBAL_SOURCE_REGISTRY, SourceRegistry
11+
from .source_proxy import SourceProxy
1112

1213
__all__ = [
1314
"RootSource",
@@ -20,5 +21,6 @@
2021
"DictSource",
2122
"ListSource",
2223
"SourceRegistry",
24+
"SourceProxy",
2325
"GLOBAL_SOURCE_REGISTRY",
2426
]

src/orcapod/core/sources/arrow_table_source.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from collections.abc import Collection
4-
from typing import TYPE_CHECKING, Any, Self
4+
from typing import TYPE_CHECKING, Any
55

66
from orcapod.core.sources.base import RootSource
77
from orcapod.core.sources.stream_builder import SourceStreamBuilder
@@ -53,10 +53,11 @@ def to_config(self) -> dict[str, Any]:
5353
"source_type": "arrow_table",
5454
"tag_columns": list(self._tag_columns),
5555
"source_id": self.source_id,
56+
**self._identity_config(),
5657
}
5758

5859
@classmethod
59-
def from_config(cls, config: dict[str, Any]) -> Self:
60+
def from_config(cls, config: dict[str, Any]) -> ArrowTableSource:
6061
"""Not supported — ArrowTableSource cannot be reconstructed from config.
6162
6263
Raises:

src/orcapod/core/sources/base.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,24 @@ def resolve_field(self, record_id: str, field_name: str) -> Any:
110110
# PipelineElementProtocol — schema-only identity (base case of Merkle chain)
111111
# -------------------------------------------------------------------------
112112

113+
def _identity_config(self) -> dict[str, Any]:
114+
"""Return identity fields for inclusion in ``to_config()`` output.
115+
116+
These fields allow ``SourceProxy`` to be constructed when the source
117+
cannot be reconstructed from config, preserving identity hashes and
118+
schemas for downstream consumers.
119+
"""
120+
from orcapod.pipeline.serialization import serialize_schema
121+
122+
tag_schema, packet_schema = self.output_schema()
123+
type_converter = self.data_context.type_converter
124+
return {
125+
"content_hash": self.content_hash().to_string(),
126+
"pipeline_hash": self.pipeline_hash().to_string(),
127+
"tag_schema": serialize_schema(tag_schema, type_converter),
128+
"packet_schema": serialize_schema(packet_schema, type_converter),
129+
}
130+
113131
def pipeline_identity_structure(self) -> Any:
114132
"""Return (tag_schema, packet_schema) as the pipeline identity for this
115133
source. Schema-only: no data content is included, so sources with

src/orcapod/core/sources/cached_source.py

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -54,21 +54,26 @@ def __init__(
5454
source: SourceProtocol,
5555
cache_database: ArrowDatabaseProtocol,
5656
cache_path_prefix: tuple[str, ...] = (),
57+
cache_path: tuple[str, ...] | None = None,
58+
source_id: str | None = None,
5759
label: str | None = None,
5860
data_context: str | contexts.DataContext | None = None,
5961
config: Config | None = None,
6062
) -> None:
6163
if data_context is None:
6264
data_context = source.data_context_key
65+
if source_id is None:
66+
source_id = source.source_id
6367
super().__init__(
64-
source_id=source.source_id,
68+
source_id=source_id,
6569
label=label,
6670
data_context=data_context,
6771
config=config,
6872
)
69-
self._source = source
73+
self._source: SourceProtocol = source
7074
self._cache_database = cache_database
7175
self._cache_path_prefix = cache_path_prefix
76+
self._explicit_cache_path = cache_path
7277
self._cached_stream: ArrowTableStream | None = None
7378

7479
# -------------------------------------------------------------------------
@@ -79,20 +84,28 @@ def to_config(self) -> dict[str, Any]:
7984
"""Serialize this CachedSource configuration to a JSON-compatible dict.
8085
8186
Returns:
82-
Dict containing the inner source config, cache database config, and
83-
cache path prefix.
87+
Dict containing the inner source config, cache database config,
88+
cache path prefix, and resolved cache path (for cache-only loading).
8489
"""
8590
return {
8691
"source_type": "cached",
8792
"inner_source": self._source.to_config(),
8893
"cache_database": self._cache_database.to_config(),
8994
"cache_path_prefix": list(self._cache_path_prefix),
95+
"cache_path": list(self.cache_path),
96+
"source_id": self.source_id,
97+
**self._identity_config(),
9098
}
9199

92100
@classmethod
93-
def from_config(cls, config: dict[str, Any]) -> "CachedSource":
101+
def from_config(cls, config: dict[str, Any]) -> CachedSource:
94102
"""Reconstruct a CachedSource from a config dict.
95103
104+
If the inner source cannot be resolved (e.g. it requires live data
105+
that is unavailable), ``resolve_source_from_config`` returns a
106+
``SourceProxy`` preserving the original source's identity. The
107+
CachedSource can still serve data from its cache database.
108+
96109
Args:
97110
config: Dict as produced by :meth:`to_config`.
98111
@@ -104,12 +117,17 @@ def from_config(cls, config: dict[str, Any]) -> "CachedSource":
104117
resolve_source_from_config,
105118
)
106119

107-
inner_source = resolve_source_from_config(config["inner_source"])
108120
cache_db = resolve_database_from_config(config["cache_database"])
121+
inner_source = resolve_source_from_config(
122+
config["inner_source"], fallback_to_proxy=True
123+
)
124+
109125
return cls(
110126
source=inner_source,
111127
cache_database=cache_db,
112128
cache_path_prefix=tuple(config.get("cache_path_prefix", ())),
129+
cache_path=tuple(config["cache_path"]) if "cache_path" in config else None,
130+
source_id=config.get("source_id"),
113131
)
114132

115133
# -------------------------------------------------------------------------
@@ -126,6 +144,8 @@ def identity_structure(self) -> Any:
126144
@property
127145
def cache_path(self) -> tuple[str, ...]:
128146
"""Cache table path, scoped to the source's content hash."""
147+
if self._explicit_cache_path is not None:
148+
return self._explicit_cache_path
129149
return self._cache_path_prefix + (
130150
"source",
131151
f"node:{self._source.content_hash().to_string()}",
@@ -151,12 +171,12 @@ def keys(
151171
) -> tuple[tuple[str, ...], tuple[str, ...]]:
152172
return self._source.keys(columns=columns, all_info=all_info)
153173

154-
def _build_merged_stream(self) -> ArrowTableStream:
155-
"""
156-
Run the live source, store new rows in the cache, load all cached
157-
rows, and return the merged result as an ArrowTableStream.
174+
def _ingest_live_data(self) -> None:
175+
"""Fetch live data from the source and store new rows in the cache.
176+
177+
Raises if the source cannot provide data (e.g. an unbound
178+
``SourceProxy``).
158179
"""
159-
# Get live source table with source info and system tags
160180
live_table = self._source.as_table(
161181
columns={"source": True, "system_tags": True}
162182
)
@@ -184,16 +204,38 @@ def _build_merged_stream(self) -> ArrowTableStream:
184204
)
185205
self._cache_database.flush()
186206

187-
# Load all cached records (union of current + prior runs)
207+
def _build_merged_stream(self) -> ArrowTableStream:
208+
"""Ingest live data (if available), then return all cached records.
209+
210+
If the inner source cannot provide data (e.g. an unbound
211+
``SourceProxy``), the method falls back to returning whatever is
212+
already stored in the cache database. If the cache is empty, an
213+
empty stream is returned.
214+
"""
215+
try:
216+
self._ingest_live_data()
217+
except NotImplementedError:
218+
logger.info(
219+
"Inner source %r cannot provide data; serving from cache only.",
220+
self._source.source_id,
221+
)
222+
188223
all_records = self._cache_database.get_all_records(self.cache_path)
189-
assert all_records is not None, (
190-
"Cache should contain records after storing live data."
191-
)
224+
if all_records is None:
225+
all_records = self._empty_table()
192226

193-
# Build stream from merged table
194227
tag_keys = self._source.keys()[0]
195228
return ArrowTableStream(all_records, tag_columns=tag_keys)
196229

230+
def _empty_table(self) -> pa.Table:
231+
"""Build an empty Arrow table matching the source's output schema."""
232+
tag_schema, packet_schema = self._source.output_schema()
233+
merged = dict(tag_schema)
234+
merged.update(packet_schema)
235+
type_converter = self.data_context.type_converter
236+
arrow_schema = type_converter.python_schema_to_arrow_schema(merged)
237+
return pa.Table.from_pylist([], schema=arrow_schema)
238+
197239
@property
198240
def is_stale(self) -> bool:
199241
"""True if the wrapped source has been modified since the last build.

src/orcapod/core/sources/csv_source.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def to_config(self) -> dict[str, Any]:
6464
"system_tag_columns": list(self._system_tag_columns),
6565
"record_id_column": self._record_id_column,
6666
"source_id": self.source_id,
67+
**self._identity_config(),
6768
}
6869

6970
@classmethod

src/orcapod/core/sources/data_frame_source.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ def to_config(self) -> dict[str, Any]:
7777
"source_type": "data_frame",
7878
"tag_columns": list(self._tag_columns),
7979
"source_id": self.source_id,
80+
**self._identity_config(),
8081
}
8182

8283
@classmethod

src/orcapod/core/sources/delta_table_source.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def to_config(self) -> dict[str, Any]:
7575
"system_tag_columns": list(self._system_tag_columns),
7676
"record_id_column": self._record_id_column,
7777
"source_id": self.source_id,
78+
**self._identity_config(),
7879
}
7980

8081
@classmethod

src/orcapod/core/sources/dict_source.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def to_config(self) -> dict[str, Any]:
5151
"source_type": "dict",
5252
"tag_columns": list(self._tag_columns),
5353
"source_id": self.source_id,
54+
**self._identity_config(),
5455
}
5556

5657
@classmethod

src/orcapod/core/sources/list_source.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def to_config(self) -> dict[str, Any]:
117117
"source_type": "list",
118118
"name": self.name,
119119
"source_id": self.source_id,
120+
**self._identity_config(),
120121
}
121122

122123
@classmethod

0 commit comments

Comments
 (0)