feat(spiraldb): preserve Arrow table- and column-level metadata via native KV store (ITL-471)#217
Conversation
…etadata helpers (ITL-471) Implement pure serialization helpers that encode Arrow table- and field-level metadata (including nested struct/list fields) into a JSON blob stored under __arrow_metadata__ in the SpiralDB KV store. Add stub placeholders for _load_arrow_metadata and _restore_field (to be implemented in Task 3) so test imports resolve cleanly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…_tree (ITL-471) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nnotations (ITL-471)
…pers (ITL-471) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…servation test (ITL-471) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ectorArrowDatabase guard (ITL-471) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… (ITL-471) Calls _serialize_arrow_metadata(records) and tbl.set_metadata(meta_kv) after each tbl.write() in upsert_records; skips set_metadata when meta_kv is None or when no novel rows were written (skip_existing path). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…_restore_field (ITL-471) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-471) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…le_source (ITL-471)
… and extension-detection duplication (ITL-471)
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
This PR adds Arrow schema- and field-metadata round-tripping support to the SpiralDB backend by serializing metadata into SpiralDB’s native table KV store on write and restoring it on read, enabling Arrow “metadata-only” extension types to survive a full write→read cycle. It also replaces ConnectorArrowDatabase’s hard-coded extension-type rejection with a per-connector validate_records hook so connectors can opt into permissive behavior when they preserve metadata.
Changes:
- Persist Arrow
schema.metadataandfield.metadata(recursively for struct/list) in SpiralDB table KV metadata and restore it initer_batches. - Introduce
validate_recordson the connector protocol and delegate validation fromConnectorArrowDatabaseto the connector implementation. - Add unit + integration tests covering helper serialization/deserialization, connector read/write behavior, and end-to-end round trips (gated).
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/test_databases/test_spiraldb_metadata_helpers.py | New pure-function unit tests for metadata helper functions. |
| tests/test_databases/test_spiraldb_connector.py | Adds tests for validate_records no-op and metadata write/read behavior. |
| tests/test_databases/test_spiraldb_connector_integration.py | Adds gated integration tests validating metadata round trips. |
| tests/test_databases/test_connector_arrow_database.py | Updates mock connector + tests for delegated validate_records. |
| tests/test_core/sources/test_db_table_source.py | Updates mock connector to include validate_records for protocol conformance. |
| superpowers/specs/2026-07-08-itl-471-spiraldb-arrow-metadata-preservation-design.md | Design spec describing encoding, restoration, and validation-hook approach. |
| superpowers/plans/2026-07-08-itl-471-spiraldb-arrow-metadata-preservation.md | Implementation plan/checklist for the feature and tests. |
| src/orcapod/protocols/db_connector_protocol.py | Adds validate_records to the connector protocol. |
| src/orcapod/databases/sqlite_connector.py | Adds rejecting validate_records implementation for SQLite. |
| src/orcapod/databases/spiraldb_connector.py | Implements metadata KV serialization/restoration + validate_records no-op. |
| src/orcapod/databases/postgresql_connector.py | Adds rejecting validate_records implementation for PostgreSQL. |
| src/orcapod/databases/connector_arrow_database.py | Replaces inline extension guard with self._connector.validate_records(records). |
| DESIGN_ISSUES.md | Updates design notes and documents duplicated extension-detection logic. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…a_helpers (ITL-471)
Review round summaryOne review comment was raised by Copilot and has been addressed:
|
…arrow-table-and-column-level
| conn.rollback() | ||
| raise | ||
|
|
||
| def validate_records(self, records: pa.Table) -> None: |
There was a problem hiding this comment.
let's generally issue an error for ANY metadata found as those won't be preserved
There was a problem hiding this comment.
Fixed in commit 29748ba. Both SQLiteConnector and PostgreSQLConnector now reject any Arrow field or schema metadata, not just extension types:
- Per-field: raises if
isinstance(field.type, pa.ExtensionType)orfield.metadatais non-empty (any key-value pair) - Schema-level: raises if
records.schema.metadatais non-empty
The error message names which fields have metadata and whether schema-level metadata is present.
The mirroring MockDBConnector in the test file was updated to match. The TestExtensionTypeWriteGuard class was renamed TestMetadataWriteGuard and extended with two new tests: one for arbitrary field metadata rejection and one for schema-level metadata rejection.
…cords (ITL-471) Broaden SQLiteConnector and PostgreSQLConnector validate_records from rejecting only Arrow extension-typed columns to rejecting any field or schema metadata, since neither backend preserves Arrow metadata at all. Update MockDBConnector and TestMetadataWriteGuard tests to match, adding coverage for arbitrary field metadata and schema-level metadata rejection. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review round summaryOne review comment addressed:
|
| import pyarrow as _pa # noqa: PLC0415 | ||
|
|
||
| problem_fields: list[str] = [] | ||
| for field in records.schema: | ||
| if isinstance(field.type, _pa.ExtensionType) or field.metadata: | ||
| problem_fields.append(field.name) | ||
| has_schema_meta = bool(records.schema.metadata) |
There was a problem hiding this comment.
Fixed in commit 0b43d99. Added a module-level _field_has_metadata(field) helper to sqlite_connector.py that recursively walks struct and list type trees (struct, list, large_list, fixed_size_list) in addition to checking the top-level field.metadata. validate_records now delegates to this helper for each top-level column, so nested child metadata is caught too. Two new tests cover the nested cases: one for a struct with an inner field carrying metadata, one for a list whose value field carries metadata.
| import pyarrow as _pa # noqa: PLC0415 | ||
|
|
||
| problem_fields: list[str] = [] | ||
| for field in records.schema: | ||
| if isinstance(field.type, _pa.ExtensionType) or field.metadata: | ||
| problem_fields.append(field.name) | ||
| has_schema_meta = bool(records.schema.metadata) |
There was a problem hiding this comment.
Fixed in commit 0b43d99. Same change as for sqlite_connector.py: added _field_has_metadata(field) to postgresql_connector.py that recursively walks struct and list type trees. validate_records now uses it so nested child metadata (struct inner fields, list value fields) is detected and rejected rather than silently dropped on write.
| if _ARROW_METADATA_KEY not in kv: | ||
| return (None, {}) | ||
|
|
||
| blob = json.loads(kv[_ARROW_METADATA_KEY].decode("utf-8")) | ||
|
|
There was a problem hiding this comment.
Fixed in commit 0b43d99. _load_arrow_metadata now wraps the .decode("utf-8") and json.loads() calls in try/except (json.JSONDecodeError, UnicodeDecodeError). On failure it logs a warning at WARNING level (naming the exception type and message) and returns (None, {}) — the same value as the "key absent" path — so iter_batches reads the table normally without metadata restoration rather than raising. Two new unit tests cover the invalid-JSON and invalid-UTF-8 cases.
…eful KV decode (ITL-471)
validate_records in SQLiteConnector and PostgreSQLConnector now recursively
walks struct and list type trees to detect field metadata on nested children
(struct members, list value fields, large_list, fixed_size_list). Previously
only top-level field.metadata was checked, allowing writes with nested metadata
that would be silently dropped on read.
_load_arrow_metadata now catches JSONDecodeError and UnicodeDecodeError from
a corrupt or unexpected KV value, logs a warning, and returns (None, {}) so
iter_batches can continue without the metadata restoration — preventing a hard
failure that would make the whole table unreadable.
Tests: added two nested-metadata rejection tests (struct child, list value
field) and two corrupt-KV resilience tests (invalid JSON, invalid UTF-8).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review round summary (commit
|
| Comment | Action |
|---|---|
validate_records misses nested struct/list child metadata (SQLite) |
Added _field_has_metadata(field) module-level helper that recursively walks struct and list type trees (struct, list, large_list, fixed_size_list). validate_records now delegates to it. |
validate_records misses nested struct/list child metadata (PostgreSQL) |
Same _field_has_metadata helper added to postgresql_connector.py. |
_load_arrow_metadata raises hard on corrupt KV data |
Wrapped the .decode("utf-8") + json.loads() in try/except (json.JSONDecodeError, UnicodeDecodeError). On failure, logs a WARNING and returns (None, {}) so iter_batches reads the table without metadata restoration instead of failing entirely. |
Tests added: nested struct child metadata rejection, nested list value field metadata rejection, corrupt-JSON KV resilience, invalid-UTF-8 KV resilience. All 4252 tests pass.
Cover previously untested paths across four test files: - spiraldb_connector.py 100% (was 96%): _restore_field fixed_size_list branch, delete_table method - sqlite_connector.py 100% (was 92%): validate_records (field/schema/nested metadata), _field_has_metadata recursive branches, _arrow_type_to_sqlite_sql unsupported-type fallback, iter_batches with NULL cursor.description and unquoted table name - connector_arrow_database.py 99% (was 96%): invalid record_path components, get_all_records returns None when iter_batches is empty, add_records raises for missing/wrong-type record_id_column, skip_duplicates=True with pending conflicts (partial and full filter paths) - test_spiraldb_metadata_helpers.py: _restore_field fixed_size_list child metadata restoration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review round summaryeywalker approved the PR (no new inline comments). All previous review comments have been addressed:
Additional coverage work: |
Summary
SpiralDBConnectornow serializes schema-level (schema.metadata) and per-field (field.metadata) Arrow metadata into SpiralDB's native KV store (Table.set_metadata/Table.get_metadata) on write, and restores it on read. Nested struct/list field metadata is handled recursively.validate_recordshook: Replaces the blanket extension-type guard inConnectorArrowDatabase.add_recordswith avalidate_recordsdelegation call.SpiralDBConnectoroverrides it as a no-op (metadata preserved);SQLiteConnectorandPostgreSQLConnectorretain the rejecting behavior.ARROW:extension:name/ARROW:extension:metadatain field metadata) now survive a full write→read cycle throughSpiralDBConnector.Implementation
Four private helpers in
spiraldb_connector.py:_serialize_field_meta_tree/_serialize_arrow_metadata— recursive serialization to a single"__arrow_metadata__"JSON blob in the KV store_load_arrow_metadata/_restore_field— recursive deserialization, reattaching metadata bottom-up through struct/list type treesSingle blob encoding (
base64k/v pairs under a reserved key) avoids KV namespace collisions and is backward compatible — tables written before this change return(None, {})from_load_arrow_metadataand behave identically to before.Files changed
src/orcapod/databases/spiraldb_connector.pyupsert_records(write metadata after write) anditer_batches(load + restore metadata); no-opvalidate_recordssrc/orcapod/protocols/db_connector_protocol.pyvalidate_recordsmethodsrc/orcapod/databases/sqlite_connector.pyvalidate_recordssrc/orcapod/databases/postgresql_connector.pyvalidate_recordssrc/orcapod/databases/connector_arrow_database.pyself._connector.validate_records(records)tests/test_databases/test_spiraldb_metadata_helpers.pytests/test_databases/test_spiraldb_connector.pyTestValidateRecords,TestUpsertRecordsMetadata,TestIterBatchesMetadatatests/test_databases/test_connector_arrow_database.pyMockDBConnector.validate_records;test_permissive_connector_allows_extension_typestests/test_databases/test_spiraldb_connector_integration.pyTestArrowMetadataRoundTrip(6 tests, gated onSPIRAL_INTEGRATION_TESTS=1)tests/test_core/sources/test_db_table_source.pyMockDBConnector.validate_records(protocol conformance fix)DESIGN_ISSUES.mdTest plan
uv run pytest tests/ --ignore=tests/test_databases/test_spiraldb_connector_integration.py)SPIRAL_INTEGRATION_TESTS=1SPIRAL_INTEGRATION_TESTS=1against dev project to validate full round-tripsFixes ITL-471
🤖 Generated with Claude Code