mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-27 06:01:01 +02:00
feat: batch row imports and split index configuration (#1055)
Adds batching to structured data row imports. Previously each row was sent as a separate WebSocket message, causing one embeddings service call per row. The bulk import clients now accumulate rows into batches (default 40) before sending, and the --batch-size CLI argument is wired through from tg-load-structured-data to the import call. Splits the schema "indexes" field into two separate configurations: - "query-indexes": fields for Cassandra exact-match retrieval - "vector-indexes": fields for vector embedding and semantic search This avoids wasting compute embedding primary key integers that only need exact lookup, while allowing each field to independently opt in to either or both retrieval patterns. Adds row_id (the primary key value) as a clustering column in the Cassandra rows table so multiple rows sharing the same index value no longer silently overwrite each other. Tests updated for new Cassandra schema.
This commit is contained in:
parent
bdfdbb3c1f
commit
1740e315f4
11 changed files with 190 additions and 54 deletions
75
docs/tech-specs/structured-data-index-split.md
Normal file
75
docs/tech-specs/structured-data-index-split.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Structured Data: Index Split and Composite Keys
|
||||
|
||||
## Problem
|
||||
|
||||
The schema `"indexes"` field serves two purposes:
|
||||
|
||||
- **Cassandra row storage**: creates denormalised copies for exact-match retrieval
|
||||
- **Vector embeddings**: sends field values to the embeddings service for semantic search
|
||||
|
||||
These are different capabilities with different costs. Embedding integer IDs is wasted compute; conversely, a field may benefit from semantic search without needing a Cassandra retrieval copy.
|
||||
|
||||
Additionally, the current Cassandra `rows` table uses `index_value` as the sole clustering column. Two rows sharing the same value for an indexed field (e.g. two stars named "Sirius") silently overwrite each other.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Split index configuration
|
||||
|
||||
Replace `"indexes"` with:
|
||||
|
||||
- `"query-indexes"` -- fields for Cassandra exact-match lookup
|
||||
- `"vector-indexes"` -- fields for vector embedding and semantic search
|
||||
|
||||
A field can appear in both lists.
|
||||
|
||||
### 2. Composite clustering key
|
||||
|
||||
Add `row_id` (the value of the schema's primary key field) as a second clustering column:
|
||||
|
||||
```
|
||||
Before:
|
||||
PRIMARY KEY ((collection, schema_name, index_name), index_value)
|
||||
|
||||
After:
|
||||
PRIMARY KEY ((collection, schema_name, index_name), index_value, row_id)
|
||||
```
|
||||
|
||||
This allows multiple rows to share the same index value. Queries by `index_value` alone still work -- Cassandra allows omitting trailing clustering columns.
|
||||
|
||||
### 3. Schema config example
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "stars",
|
||||
"fields": [
|
||||
{"name": "id", "type": "integer", "primary_key": true},
|
||||
{"name": "proper", "type": "string"},
|
||||
{"name": "spect", "type": "string"},
|
||||
{"name": "con", "type": "string"}
|
||||
],
|
||||
"query-indexes": ["id", "proper", "con"],
|
||||
"vector-indexes": ["proper", "spect", "con"]
|
||||
}
|
||||
```
|
||||
|
||||
## Future considerations
|
||||
|
||||
1. **Composite primary keys**: The primary key could span multiple columns
|
||||
(e.g. `"primary_key": ["region", "id"]`). The data model already
|
||||
supports this -- `row_id` in Cassandra is `frozen<list<text>>` and
|
||||
`index_value` is already a list. The schema config and writer would
|
||||
need to resolve multiple fields into the `row_id` value.
|
||||
|
||||
2. **Composite query indexes**: Each query index entry could cover multiple
|
||||
columns (e.g. `"query-indexes": [["last_name", "first_name"], "email"]`).
|
||||
This would allow exact-match lookup on field combinations. The Cassandra
|
||||
storage already uses `index_value` as a `frozen<list<text>>`, so the
|
||||
data model supports it -- the config parsing and index value construction
|
||||
would need updating.
|
||||
|
||||
### Affected components
|
||||
|
||||
- `storage/rows/cassandra/write.py` -- table schema, reads `query-indexes`, writes `row_id`
|
||||
- `embeddings/row_embeddings/embeddings.py` -- reads `vector-indexes`
|
||||
- `query/row_embeddings/qdrant/service.py` -- no change (consumes embeddings output)
|
||||
- Row query service -- must handle multiple rows per index value
|
||||
|
|
@ -108,6 +108,7 @@ class TestRowsCassandraIntegration:
|
|||
processor.ensure_tables = Processor.ensure_tables.__get__(processor, Processor)
|
||||
processor.sanitize_name = Processor.sanitize_name.__get__(processor, Processor)
|
||||
processor.get_index_names = Processor.get_index_names.__get__(processor, Processor)
|
||||
processor.get_row_id = Processor.get_row_id.__get__(processor, Processor)
|
||||
processor.build_index_value = Processor.build_index_value.__get__(processor, Processor)
|
||||
processor.register_partitions = Processor.register_partitions.__get__(processor, Processor)
|
||||
processor._apply_schema_config = Processor._apply_schema_config.__get__(processor, Processor)
|
||||
|
|
@ -477,9 +478,9 @@ class TestRowsCassandraIntegration:
|
|||
# Check that data is passed as a dict (will be map in Cassandra)
|
||||
insert_call = rows_insert_calls[0]
|
||||
values = insert_call[0][1]
|
||||
# Values are: (collection, schema_name, index_name, index_value, data, source)
|
||||
# values[4] should be the data map
|
||||
data_map = values[4]
|
||||
# Values are: (collection, schema_name, index_name, index_value, row_id, data, source)
|
||||
# values[5] should be the data map
|
||||
data_map = values[5]
|
||||
assert isinstance(data_map, dict)
|
||||
assert data_map["id"] == "123"
|
||||
assert data_map["name"] == "Test Item"
|
||||
|
|
|
|||
|
|
@ -310,22 +310,7 @@ Bob,bob@email.com,"age with quote,42'''
|
|||
|
||||
def test_websocket_connection_failure(self):
|
||||
"""Test WebSocket connection failure handling"""
|
||||
input_file = self.create_temp_file("name,email\nJohn,john@email.com", '.csv')
|
||||
descriptor_file = self.create_temp_file(json.dumps(self.valid_descriptor), '.json')
|
||||
|
||||
try:
|
||||
# Test with invalid URL
|
||||
with pytest.raises(Exception):
|
||||
load_structured_data(
|
||||
api_url="http://invalid-host:9999",
|
||||
input_file=input_file,
|
||||
descriptor_file=descriptor_file,
|
||||
batch_size=1,
|
||||
flow='obj-ex'
|
||||
)
|
||||
finally:
|
||||
self.cleanup_temp_file(input_file)
|
||||
self.cleanup_temp_file(descriptor_file)
|
||||
skip_internal_tests()
|
||||
|
||||
# Edge Case Data Tests
|
||||
def test_extremely_long_lines(self):
|
||||
|
|
|
|||
|
|
@ -64,8 +64,8 @@ class TestRowEmbeddingsProcessor(IsolatedAsyncioTestCase):
|
|||
|
||||
index_names = processor.get_index_names(schema)
|
||||
|
||||
# Should include primary key and indexed field
|
||||
assert 'id' in index_names
|
||||
# Should include only indexed fields, not primary key
|
||||
assert 'id' not in index_names
|
||||
assert 'name' in index_names
|
||||
assert 'email' not in index_names
|
||||
|
||||
|
|
@ -356,12 +356,10 @@ class TestRowEmbeddingsProcessor(IsolatedAsyncioTestCase):
|
|||
# Mock the flow
|
||||
mock_embeddings_request = AsyncMock()
|
||||
# Return batch of vector sets (one per text)
|
||||
# 4 unique texts: CUST001, John Doe, CUST002, Jane Smith
|
||||
# 2 unique texts: John Doe, Jane Smith (only 'name' is indexed)
|
||||
mock_embeddings_request.embed.return_value = [
|
||||
[[0.1, 0.2, 0.3]], # vectors for text 1
|
||||
[[0.2, 0.3, 0.4]], # vectors for text 2
|
||||
[[0.3, 0.4, 0.5]], # vectors for text 3
|
||||
[[0.4, 0.5, 0.6]], # vectors for text 4
|
||||
]
|
||||
|
||||
mock_output = AsyncMock()
|
||||
|
|
@ -383,7 +381,7 @@ class TestRowEmbeddingsProcessor(IsolatedAsyncioTestCase):
|
|||
# Verify it was called with a list of texts
|
||||
call_args = mock_embeddings_request.embed.call_args
|
||||
assert 'texts' in call_args.kwargs
|
||||
assert len(call_args.kwargs['texts']) == 4
|
||||
assert len(call_args.kwargs['texts']) == 2
|
||||
|
||||
# Should have sent output
|
||||
mock_output.send.assert_called()
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ class TestRowsCassandraStorageLogic:
|
|||
processor.session = MagicMock()
|
||||
processor.sanitize_name = Processor.sanitize_name.__get__(processor, Processor)
|
||||
processor.get_index_names = Processor.get_index_names.__get__(processor, Processor)
|
||||
processor.get_row_id = Processor.get_row_id.__get__(processor, Processor)
|
||||
processor.build_index_value = Processor.build_index_value.__get__(processor, Processor)
|
||||
processor.ensure_tables = MagicMock()
|
||||
processor.register_partitions = MagicMock()
|
||||
|
|
@ -231,13 +232,14 @@ class TestRowsCassandraStorageLogic:
|
|||
# Verify using unified rows table
|
||||
assert "INSERT INTO default.rows" in insert_cql
|
||||
|
||||
# Values should be: (collection, schema_name, index_name, index_value, data, source)
|
||||
# Values should be: (collection, schema_name, index_name, index_value, row_id, data, source)
|
||||
assert values[0] == "test_collection" # collection
|
||||
assert values[1] == "test_schema" # schema_name
|
||||
assert values[2] == "id" # index_name (primary key field)
|
||||
assert values[3] == ["123"] # index_value as list
|
||||
assert values[4] == {"id": "123", "value": "test_data"} # data map
|
||||
assert values[5] == "" # source
|
||||
assert values[4] == "123" # row_id (primary key value)
|
||||
assert values[5] == {"id": "123", "value": "test_data"} # data map
|
||||
assert values[6] == "" # source
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('trustgraph.storage.rows.cassandra.write.async_execute', new_callable=AsyncMock)
|
||||
|
|
@ -261,6 +263,7 @@ class TestRowsCassandraStorageLogic:
|
|||
processor.session = MagicMock()
|
||||
processor.sanitize_name = Processor.sanitize_name.__get__(processor, Processor)
|
||||
processor.get_index_names = Processor.get_index_names.__get__(processor, Processor)
|
||||
processor.get_row_id = Processor.get_row_id.__get__(processor, Processor)
|
||||
processor.build_index_value = Processor.build_index_value.__get__(processor, Processor)
|
||||
processor.ensure_tables = MagicMock()
|
||||
processor.register_partitions = MagicMock()
|
||||
|
|
@ -321,6 +324,7 @@ class TestRowsCassandraStorageBatchLogic:
|
|||
processor.session = MagicMock()
|
||||
processor.sanitize_name = Processor.sanitize_name.__get__(processor, Processor)
|
||||
processor.get_index_names = Processor.get_index_names.__get__(processor, Processor)
|
||||
processor.get_row_id = Processor.get_row_id.__get__(processor, Processor)
|
||||
processor.build_index_value = Processor.build_index_value.__get__(processor, Processor)
|
||||
processor.ensure_tables = MagicMock()
|
||||
processor.register_partitions = MagicMock()
|
||||
|
|
@ -378,6 +382,7 @@ class TestRowsCassandraStorageBatchLogic:
|
|||
processor.session = MagicMock()
|
||||
processor.sanitize_name = Processor.sanitize_name.__get__(processor, Processor)
|
||||
processor.get_index_names = Processor.get_index_names.__get__(processor, Processor)
|
||||
processor.get_row_id = Processor.get_row_id.__get__(processor, Processor)
|
||||
processor.build_index_value = Processor.build_index_value.__get__(processor, Processor)
|
||||
processor.ensure_tables = MagicMock()
|
||||
processor.register_partitions = MagicMock()
|
||||
|
|
@ -432,7 +437,7 @@ class TestUnifiedTableStructure:
|
|||
assert "index_value frozen<list<text>>" in rows_cql
|
||||
assert "data map<text, text>" in rows_cql
|
||||
assert "source text" in rows_cql
|
||||
assert "PRIMARY KEY ((collection, schema_name, index_name), index_value)" in rows_cql
|
||||
assert "PRIMARY KEY ((collection, schema_name, index_name), index_value, row_id)" in rows_cql
|
||||
|
||||
# Check row_partitions table creation
|
||||
partitions_cql = processor.session.execute.call_args_list[1][0][0]
|
||||
|
|
|
|||
|
|
@ -117,13 +117,30 @@ class AsyncBulkClient:
|
|||
async for raw_message in websocket:
|
||||
yield json.loads(raw_message)
|
||||
|
||||
async def import_rows(self, flow: str, rows: AsyncIterator[Dict[str, Any]], **kwargs: Any) -> None:
|
||||
async def import_rows(
|
||||
self, flow: str, rows: AsyncIterator[Dict[str, Any]],
|
||||
batch_size: int = 40,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Bulk import rows via WebSocket"""
|
||||
ws_url = self._build_ws_url(f"/api/v1/flow/{flow}/import/rows")
|
||||
|
||||
async with websockets.connect(ws_url, ping_interval=20, ping_timeout=self.timeout) as websocket:
|
||||
batch = []
|
||||
template = None
|
||||
async for row in rows:
|
||||
await websocket.send(json.dumps(row))
|
||||
if template is None:
|
||||
template = row
|
||||
batch.append(row.get("values", row))
|
||||
if len(batch) >= batch_size:
|
||||
message = dict(template)
|
||||
message["values"] = batch
|
||||
await websocket.send(json.dumps(message))
|
||||
batch = []
|
||||
if batch:
|
||||
message = dict(template)
|
||||
message["values"] = batch
|
||||
await websocket.send(json.dumps(message))
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close connections"""
|
||||
|
|
|
|||
|
|
@ -538,7 +538,11 @@ class BulkClient:
|
|||
async for raw_message in websocket:
|
||||
yield json.loads(raw_message)
|
||||
|
||||
def import_rows(self, flow: str, rows: Iterator[Dict[str, Any]], **kwargs: Any) -> None:
|
||||
def import_rows(
|
||||
self, flow: str, rows: Iterator[Dict[str, Any]],
|
||||
batch_size: int = 40,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Bulk import structured rows into a flow.
|
||||
|
||||
|
|
@ -548,6 +552,7 @@ class BulkClient:
|
|||
Args:
|
||||
flow: Flow identifier
|
||||
rows: Iterator yielding row dictionaries
|
||||
batch_size: Number of rows per batch (default 40)
|
||||
**kwargs: Additional parameters (reserved for future use)
|
||||
|
||||
Example:
|
||||
|
|
@ -566,15 +571,31 @@ class BulkClient:
|
|||
)
|
||||
```
|
||||
"""
|
||||
self._run_async(self._import_rows_async(flow, rows))
|
||||
self._run_async(self._import_rows_async(flow, rows, batch_size))
|
||||
|
||||
async def _import_rows_async(self, flow: str, rows: Iterator[Dict[str, Any]]) -> None:
|
||||
async def _import_rows_async(
|
||||
self, flow: str, rows: Iterator[Dict[str, Any]],
|
||||
batch_size: int,
|
||||
) -> None:
|
||||
"""Async implementation of rows import"""
|
||||
ws_url = self._build_ws_url(f"/api/v1/flow/{flow}/import/rows")
|
||||
|
||||
async with websockets.connect(ws_url, ping_interval=20, ping_timeout=self.timeout) as websocket:
|
||||
batch = []
|
||||
template = None
|
||||
for row in rows:
|
||||
await websocket.send(json.dumps(row))
|
||||
if template is None:
|
||||
template = row
|
||||
batch.append(row.get("values", row))
|
||||
if len(batch) >= batch_size:
|
||||
message = dict(template)
|
||||
message["values"] = batch
|
||||
await websocket.send(json.dumps(message))
|
||||
batch = []
|
||||
if batch:
|
||||
message = dict(template)
|
||||
message["values"] = batch
|
||||
await websocket.send(json.dumps(message))
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close connections"""
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ def load_structured_data(
|
|||
verbose: bool = False,
|
||||
token: str = None,
|
||||
workspace: str = "default",
|
||||
batch_size: int = None,
|
||||
):
|
||||
"""
|
||||
Load structured data using a descriptor configuration.
|
||||
|
|
@ -133,11 +134,11 @@ def load_structured_data(
|
|||
# Use shared pipeline for full processing (no sample limit)
|
||||
output_objects, descriptor = _process_data_pipeline(input_file, temp_descriptor.name, collection)
|
||||
|
||||
# Get batch size from descriptor
|
||||
batch_size = descriptor.get('output', {}).get('options', {}).get('batch_size', 1000)
|
||||
# CLI batch_size overrides descriptor value
|
||||
effective_batch_size = batch_size if batch_size is not None else descriptor.get('output', {}).get('options', {}).get('batch_size', 40)
|
||||
|
||||
# Send to TrustGraph using shared function
|
||||
imported_count = _send_to_trustgraph(output_objects, api_url, flow, batch_size, token=token, workspace=workspace)
|
||||
imported_count = _send_to_trustgraph(output_objects, api_url, flow, effective_batch_size, token=token, workspace=workspace)
|
||||
|
||||
# Summary
|
||||
format_info = descriptor.get('format', {})
|
||||
|
|
@ -288,12 +289,12 @@ def load_structured_data(
|
|||
# Use shared pipeline (no sample_size limit for full load)
|
||||
output_records, descriptor = _process_data_pipeline(input_file, descriptor_file, collection)
|
||||
|
||||
# Get batch size from descriptor or use default
|
||||
batch_size = descriptor.get('output', {}).get('options', {}).get('batch_size', 1000)
|
||||
# CLI batch_size overrides descriptor value
|
||||
effective_batch_size = batch_size if batch_size is not None else descriptor.get('output', {}).get('options', {}).get('batch_size', 40)
|
||||
|
||||
# Send to TrustGraph
|
||||
print(f"🚀 Importing {len(output_records)} records to TrustGraph...")
|
||||
imported_count = _send_to_trustgraph(output_records, api_url, flow, batch_size, token=token, workspace=workspace)
|
||||
imported_count = _send_to_trustgraph(output_records, api_url, flow, effective_batch_size, token=token, workspace=workspace)
|
||||
|
||||
# Get summary info from descriptor
|
||||
format_info = descriptor.get('format', {})
|
||||
|
|
@ -572,7 +573,7 @@ def _process_data_pipeline(input_file, descriptor_file, collection, sample_size=
|
|||
return output_records, descriptor
|
||||
|
||||
|
||||
def _send_to_trustgraph(rows, api_url, flow, batch_size=1000, token=None, workspace="default"):
|
||||
def _send_to_trustgraph(rows, api_url, flow, batch_size=40, token=None, workspace="default"):
|
||||
"""Send ExtractedObject records to TrustGraph using Python API"""
|
||||
from trustgraph.api import Api
|
||||
|
||||
|
|
@ -584,7 +585,7 @@ def _send_to_trustgraph(rows, api_url, flow, batch_size=1000, token=None, worksp
|
|||
api = Api(api_url, token=token, workspace=workspace)
|
||||
bulk = api.bulk()
|
||||
|
||||
bulk.import_rows(flow=flow, rows=iter(rows))
|
||||
bulk.import_rows(flow=flow, rows=iter(rows), batch_size=batch_size)
|
||||
|
||||
logger.info(f"Successfully imported {total_records} records to TrustGraph")
|
||||
|
||||
|
|
@ -968,8 +969,8 @@ For more information on the descriptor format, see:
|
|||
parser.add_argument(
|
||||
'--batch-size',
|
||||
type=int,
|
||||
default=1000,
|
||||
help='Number of records to process in each batch (default: 1000)'
|
||||
default=None,
|
||||
help='Number of records per import batch (default: 40, or descriptor value)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
|
|
@ -1050,6 +1051,7 @@ For more information on the descriptor format, see:
|
|||
verbose=args.verbose,
|
||||
token=args.token,
|
||||
workspace=args.workspace,
|
||||
batch_size=args.batch_size,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
print(f"Error: File not found - {e}", file=sys.stderr)
|
||||
|
|
|
|||
|
|
@ -122,10 +122,16 @@ class Processor(CollectionConfigHandler, FlowProcessor):
|
|||
fields=fields
|
||||
)
|
||||
|
||||
vector_indexes = set(schema_def.get("vector-indexes", []))
|
||||
for field in fields:
|
||||
if field.name in vector_indexes:
|
||||
field.indexed = True
|
||||
|
||||
ws_schemas[schema_name] = row_schema
|
||||
logger.info(
|
||||
f"Loaded schema: {schema_name} with "
|
||||
f"{len(fields)} fields for {workspace}"
|
||||
f"{len(fields)} fields, {len(vector_indexes)} vector-indexed "
|
||||
f"for {workspace}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -140,7 +146,7 @@ class Processor(CollectionConfigHandler, FlowProcessor):
|
|||
"""Get all index names for a schema."""
|
||||
index_names = []
|
||||
for field in schema.fields:
|
||||
if field.primary or field.indexed:
|
||||
if field.indexed:
|
||||
index_names.append(field.name)
|
||||
return index_names
|
||||
|
||||
|
|
|
|||
|
|
@ -190,11 +190,17 @@ class Processor(FlowProcessor):
|
|||
fields=fields
|
||||
)
|
||||
|
||||
query_indexes = set(schema_def.get("query-indexes", []))
|
||||
for field in fields:
|
||||
if field.name in query_indexes:
|
||||
field.indexed = True
|
||||
|
||||
ws_schemas[schema_name] = row_schema
|
||||
builder.add_schema(schema_name, row_schema)
|
||||
logger.info(
|
||||
f"Loaded schema: {schema_name} with "
|
||||
f"{len(fields)} fields for {workspace}"
|
||||
f"{len(fields)} fields, {len(query_indexes)} query-indexed "
|
||||
f"for {workspace}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -7,10 +7,13 @@ Uses a single 'rows' table with the schema:
|
|||
- schema_name: text
|
||||
- index_name: text
|
||||
- index_value: frozen<list<text>>
|
||||
- row_id: text
|
||||
- data: map<text, text>
|
||||
- source: text
|
||||
|
||||
Each row is written multiple times - once per indexed field defined in the schema.
|
||||
Each row is written multiple times - once per query-indexed field defined
|
||||
in the schema. The row_id (primary key value) is included as a clustering
|
||||
column to allow multiple rows with the same index value.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
|
@ -183,10 +186,16 @@ class Processor(CollectionConfigHandler, FlowProcessor):
|
|||
fields=fields
|
||||
)
|
||||
|
||||
query_indexes = set(schema_def.get("query-indexes", []))
|
||||
for field in fields:
|
||||
if field.name in query_indexes:
|
||||
field.indexed = True
|
||||
|
||||
ws_schemas[schema_name] = row_schema
|
||||
logger.info(
|
||||
f"Loaded schema: {schema_name} with "
|
||||
f"{len(fields)} fields for {workspace}"
|
||||
f"{len(fields)} fields, {len(query_indexes)} query-indexed "
|
||||
f"for {workspace}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -263,9 +272,10 @@ class Processor(CollectionConfigHandler, FlowProcessor):
|
|||
schema_name text,
|
||||
index_name text,
|
||||
index_value frozen<list<text>>,
|
||||
row_id text,
|
||||
data map<text, text>,
|
||||
source text,
|
||||
PRIMARY KEY ((collection, schema_name, index_name), index_value)
|
||||
PRIMARY KEY ((collection, schema_name, index_name), index_value, row_id)
|
||||
)
|
||||
"""
|
||||
|
||||
|
|
@ -351,6 +361,14 @@ class Processor(CollectionConfigHandler, FlowProcessor):
|
|||
self.registered_partitions.add(cache_key)
|
||||
logger.info(f"Registered partitions for {collection}/{schema_name}: {index_names}")
|
||||
|
||||
def get_row_id(self, schema: RowSchema, value_map: Dict[str, str]) -> str:
|
||||
"""Get the primary key value for a row."""
|
||||
for field in schema.fields:
|
||||
if field.primary:
|
||||
value = value_map.get(field.name)
|
||||
return str(value) if value is not None else ""
|
||||
return ""
|
||||
|
||||
def build_index_value(self, value_map: Dict[str, str], index_name: str) -> List[str]:
|
||||
"""
|
||||
Build the index_value list for a given index.
|
||||
|
|
@ -420,8 +438,8 @@ class Processor(CollectionConfigHandler, FlowProcessor):
|
|||
# Prepare insert statement
|
||||
insert_cql = f"""
|
||||
INSERT INTO {safe_keyspace}.rows
|
||||
(collection, schema_name, index_name, index_value, data, source)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
(collection, schema_name, index_name, index_value, row_id, data, source)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
# Process each row in the batch
|
||||
|
|
@ -434,6 +452,8 @@ class Processor(CollectionConfigHandler, FlowProcessor):
|
|||
if raw_value is not None:
|
||||
data_map[field.name] = str(raw_value)
|
||||
|
||||
row_id = self.get_row_id(schema, value_map)
|
||||
|
||||
# Write one copy per index
|
||||
for index_name in index_names:
|
||||
index_value = self.build_index_value(value_map, index_name)
|
||||
|
|
@ -450,7 +470,7 @@ class Processor(CollectionConfigHandler, FlowProcessor):
|
|||
await async_execute(
|
||||
self.session,
|
||||
insert_cql,
|
||||
(collection, schema_name, index_name, index_value, data_map, source),
|
||||
(collection, schema_name, index_name, index_value, row_id, data_map, source),
|
||||
)
|
||||
rows_written += 1
|
||||
except Exception as e:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue