confluent-sql 0.5.0__tar.gz → 0.5.2__tar.gz
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.
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/CHANGELOG.md +15 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/PKG-INFO +2 -2
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/STREAMING.md +1 -1
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/pyproject.toml +1 -1
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/changelog_compressor.py +58 -128
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/statement.py +54 -4
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/integration/test_cursor.py +362 -5
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/integration/test_fetch.py +128 -1
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_changelog_compressor_unit.py +335 -330
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_statement_unit.py +52 -3
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/uv.lock +1 -1
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/.github/CODEOWNERS +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/.gitignore +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/.semaphore/publish_to_pypi.yml +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/.semaphore/semaphore.yml +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/ARCHITECTURE.md +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/CLAUDE.md +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/DBAPI_EXTENSIONS.md +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/LICENSE.txt +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/Makefile +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/README.md +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/TYPES.md +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/examples/byoidc_bearer_token_example.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/examples/errors.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/examples/simple_append_only_streaming_query_example.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/examples/snapshot_mode_tuple_cursor_simple_example.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/examples/statement_properties_example.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/examples/tableflow_lifecycle_example.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/service.yml +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/__init__.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/__version__.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/auth.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/connection.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/connectors.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/cursor.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/exceptions.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/execution_mode.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/polling.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/result_readers.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/retry.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/statement_properties.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/tableflow.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/types.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/__init__.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/conftest.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/integration/conftest.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/integration/test_connection.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/integration/test_connector.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/integration/test_tableflow.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/conftest.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_auth_unit.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_changelog_unit.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_connection_byoidc_unit.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_connection_connector_unit.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_connection_unit.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_connection_unit_properties.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_connectors_connection_unit.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_connectors_unit.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_cursor_unit.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_execution_mode_unit.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_polling_unit.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_result_readers_unit.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_retry_unit.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_statement_properties_unit.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_tableflow_connection_unit.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_tableflow_unit.py +0 -0
- {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_types_unit.py +0 -0
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this dbapi driver will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.5.2, 2026-08-26
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `Statement.is_pure_ddl` / `_PURE_DDL_KINDS` now include `CREATE_MATERIALIZED_TABLE`, `CREATE_OR_ALTER_MATERIALIZED_TABLE`, `ALTER_MATERIALIZED_TABLE` (the bare, query-evolving form), and `DROP_MATERIALIZED_TABLE`.
|
|
10
|
+
- Previously these kinds weren't classified as pure or impure DDL, so in **streaming** execution mode, `Statement.can_fetch_results` fell through to the "ready when RUNNING" branch used for CTAS -- meaning a streaming-mode `Cursor.execute()` (and therefore `Connection.execute_streaming_ddl()`) on one of these statements could in principle return control as soon as the statement was RUNNING rather than waiting for it to reach a terminal phase, the same way `execute_streaming_ddl()` correctly waits out a genuinely perpetual CTAS job's RUNNING phase. Snapshot execution mode (the default, and what `execute_snapshot_ddl()` always uses) was unaffected -- it already waits for a terminal phase regardless of `is_pure_ddl`.
|
|
11
|
+
|
|
12
|
+
## 0.5.1, 2026-08-25
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Streaming changelog compressor fixes for nontrivial changelog streams written to Kafka topics with multiple partitions:
|
|
17
|
+
- The changelog compressor for streaming non-append-only queries **without** upsert columns (`cursor.changelog_compressor()` on a keyless result, e.g. a global aggregation) no longer raises `InterfaceError` on legitimately out-of-order changelog events. When such a changelog is sinked to a multi-partition keyless topic, partitions are assigned by whole-row hash, so an updated row's `+U`/`-D` spelling can land on a different partition than its original `+I`/`-U` spelling; since Kafka only guarantees ordering within a partition, the events can be observed in a surprising order across spellings (a `+U` before its logical `-U`, or a `-D` before a later `+I`). The compressor previously assumed each `UPDATE_BEFORE` was immediately followed by its `UPDATE_AFTER` and failed on those sequences. It now treats the two additive ops (`+I`, `+U`) as inserts and the two retracting ops (`-U`, `-D`) as deletions, makes no ordering assumptions across different rows' spellings, and converges to the correct result set (intermediate snapshots may transiently show an extra row). Adds `Op.treat_as_insert` / `Op.treat_as_delete` helper properties. (#184)
|
|
18
|
+
- The changelog compressor for streaming non-append-only queries **with** upsert columns (`cursor.changelog_compressor()` on a keyed result, e.g. a `GROUP BY`) no longer raises `InterfaceError` when an unrelated key's changelog event arrives between one key's `UPDATE_BEFORE` and its own `UPDATE_AFTER`. A consumer reading a keyed upsert topic drains multiple partitions per poll; same-key events stay ordered (a key always hashes to the same partition), but a single fetch batch can still interleave *different* keys' events. The compressor previously modeled `UPDATE_BEFORE`/`UPDATE_AFTER` as a single-slot pending pair that had to be adjacent, and failed on that interleaving even though nothing was actually wrong. `UPDATE_BEFORE` is now treated as an unconditional no-op (it carries no information the matching insert/update doesn't already supply under key-based upsert semantics), and `UPDATE_AFTER` is handled exactly like `INSERT` (last write for the key wins); `DELETE` is unaffected and still validates that the key exists. As part of the same cleanup, the now-unused pending-update tracking hooks are removed from the shared compressor base class. (#185)
|
|
19
|
+
|
|
5
20
|
## 0.5.0, 2026-08-07
|
|
6
21
|
|
|
7
22
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: confluent-sql
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: DB-API v2 compliant driver for Confluent Cloud Flink SQL
|
|
5
5
|
Project-URL: Repository, https://github.com/confluentinc/confluent-sql
|
|
6
6
|
Project-URL: Documentation, https://github.com/confluentinc/confluent-sql?tab=readme-ov-file#confluent-sql
|
|
@@ -376,7 +376,7 @@ A **snapshot of results** is a self-consistent, complete result set showing the
|
|
|
376
376
|
|
|
377
377
|
- **Current State of All Rows**: Built from all INSERT/UPDATE/DELETE operations processed so far
|
|
378
378
|
- **Point-in-Time View**: Represents state after consuming all available changelog events at that moment
|
|
379
|
-
- **Self-Consistent**:
|
|
379
|
+
- **Self-Consistent**: Reflects every changelog event fetched so far -- there's nothing still in flight waiting on a match
|
|
380
380
|
- **Complete Result Set, Not Incremental**: Each snapshot is the full accumulated result set at this moment in time, not just the changes since the last snapshot
|
|
381
381
|
|
|
382
382
|
Don't think of snapshots of results as collections of individual changelog events. Instead, think of them as complete result sets:
|
|
@@ -144,39 +144,11 @@ class ChangelogCompressor(abc.ABC):
|
|
|
144
144
|
"""
|
|
145
145
|
...
|
|
146
146
|
|
|
147
|
-
@abc.abstractmethod
|
|
148
|
-
def _has_pending_update(self) -> bool:
|
|
149
|
-
"""Check if there's a pending UPDATE_BEFORE awaiting UPDATE_AFTER.
|
|
150
|
-
|
|
151
|
-
Returns:
|
|
152
|
-
True if a pending update is in progress, False otherwise.
|
|
153
|
-
"""
|
|
154
|
-
...
|
|
155
|
-
|
|
156
147
|
@abc.abstractmethod
|
|
157
148
|
def _clear_storage(self) -> None:
|
|
158
149
|
"""Clear internal row storage."""
|
|
159
150
|
...
|
|
160
151
|
|
|
161
|
-
@abc.abstractmethod
|
|
162
|
-
def _clear_pending_update(self) -> None:
|
|
163
|
-
"""Clear pending update tracking state."""
|
|
164
|
-
...
|
|
165
|
-
|
|
166
|
-
def _validate_no_pending_update(self, op: Op, row: ResultTupleOrDict) -> None:
|
|
167
|
-
"""Raise if we have a pending update and aren't processing UPDATE_AFTER.
|
|
168
|
-
|
|
169
|
-
Args:
|
|
170
|
-
op: The changelog operation being processed.
|
|
171
|
-
row: The row data.
|
|
172
|
-
|
|
173
|
-
Raises:
|
|
174
|
-
InterfaceError: If a pending UPDATE_BEFORE exists while processing a
|
|
175
|
-
non-UPDATE_AFTER operation.
|
|
176
|
-
"""
|
|
177
|
-
if op != Op.UPDATE_AFTER and self._has_pending_update():
|
|
178
|
-
raise InterfaceError(f"Received {op.name} while an UPDATE_BEFORE is pending: {row}")
|
|
179
|
-
|
|
180
152
|
def _resolve_batchsize(self, fetch_batchsize: int | None) -> int:
|
|
181
153
|
"""Resolve and validate the batch size to use for fetching.
|
|
182
154
|
|
|
@@ -219,8 +191,7 @@ class ChangelogCompressor(abc.ABC):
|
|
|
219
191
|
|
|
220
192
|
**Self-Consistency**: A snapshot is considered self-consistent when all currently
|
|
221
193
|
available changelog events have been consumed and applied. This means the snapshot
|
|
222
|
-
reflects
|
|
223
|
-
matching UPDATE_AFTER.
|
|
194
|
+
reflects every event fetched so far; nothing is retained awaiting a match.
|
|
224
195
|
|
|
225
196
|
**No Guarantee of Logical Changes**: There is NO guarantee that consecutive snapshots
|
|
226
197
|
will differ. If no new changelog events arrived since the prior yield, the snapshot
|
|
@@ -298,8 +269,8 @@ class ChangelogCompressor(abc.ABC):
|
|
|
298
269
|
- Is non-blocking - returns immediately after consuming available events
|
|
299
270
|
|
|
300
271
|
**Self-Consistency**: The returned snapshot is self-consistent, meaning all currently
|
|
301
|
-
available changelog events have been consumed and applied.
|
|
302
|
-
|
|
272
|
+
available changelog events have been consumed and applied. It reflects every event
|
|
273
|
+
fetched so far; nothing is retained awaiting a match.
|
|
303
274
|
|
|
304
275
|
**Deep Copy**: The returned snapshot is a deep copy. Mutations will not affect the
|
|
305
276
|
compressor's internal state.
|
|
@@ -348,7 +319,6 @@ class ChangelogCompressor(abc.ABC):
|
|
|
348
319
|
After calling close(), the compressor should not be used anymore.
|
|
349
320
|
"""
|
|
350
321
|
self._clear_storage()
|
|
351
|
-
self._clear_pending_update()
|
|
352
322
|
self._cursor.close()
|
|
353
323
|
|
|
354
324
|
|
|
@@ -357,8 +327,19 @@ class UpsertColumnsCompressor(ChangelogCompressor):
|
|
|
357
327
|
|
|
358
328
|
Uses dict-based storage for fast O(1) key-based lookups.
|
|
359
329
|
|
|
360
|
-
|
|
361
|
-
|
|
330
|
+
The Confluent-cloud-side Kafka consumer reading a keyed upsert topic may be draining multiple
|
|
331
|
+
partitions per poll. Same-key events stay ordered (a key always hashes to the same partition),
|
|
332
|
+
but a single fetchmany() batch can still interleave *different* keys' events, so a key's
|
|
333
|
+
UPDATE_BEFORE need not be immediately followed by that same key's UPDATE_AFTER -- an unrelated
|
|
334
|
+
key's event can land in between. See issue #185.
|
|
335
|
+
|
|
336
|
+
To stay correct under that interleaving, this compressor tracks no pending-update state:
|
|
337
|
+
UPDATE_BEFORE (-U) is treated as a pure no-op, since under key-based upsert semantics it
|
|
338
|
+
carries no information the matching INSERT/UPDATE_AFTER doesn't already supply. UPDATE_AFTER
|
|
339
|
+
(+U) is handled exactly like INSERT -- both simply upsert the row for its key, last write
|
|
340
|
+
wins. DELETE is unaffected by this and still validates that the key exists: same-key ops stay
|
|
341
|
+
ordered relative to each other, so a DELETE for an untracked key remains a genuine protocol
|
|
342
|
+
violation worth surfacing.
|
|
362
343
|
|
|
363
344
|
Rows can be either tuples or dicts (as determined by cursor.as_dict). The row format
|
|
364
345
|
decision is made by the result reader layer, and this compressor works transparently
|
|
@@ -374,9 +355,6 @@ class UpsertColumnsCompressor(ChangelogCompressor):
|
|
|
374
355
|
_rows_by_key: dict[tuple, ResultTupleOrDict]
|
|
375
356
|
"""Dictionary mapping key tuples to row data. Dict maintains insertion order in Python 3.7+."""
|
|
376
357
|
|
|
377
|
-
_expecting_update_after: bool
|
|
378
|
-
"""True when UPDATE_BEFORE has been received and UPDATE_AFTER is expected next."""
|
|
379
|
-
|
|
380
358
|
def __init__(self, cursor: Cursor, statement: Statement):
|
|
381
359
|
"""Initialize the compressor with upsert column indices.
|
|
382
360
|
|
|
@@ -400,7 +378,6 @@ class UpsertColumnsCompressor(ChangelogCompressor):
|
|
|
400
378
|
]
|
|
401
379
|
|
|
402
380
|
self._rows_by_key = {}
|
|
403
|
-
self._expecting_update_after = False
|
|
404
381
|
|
|
405
382
|
def _extract_key(self, row: ResultTupleOrDict) -> tuple:
|
|
406
383
|
"""Extract the key tuple from a row based on upsert columns.
|
|
@@ -421,22 +398,10 @@ class UpsertColumnsCompressor(ChangelogCompressor):
|
|
|
421
398
|
# Tuple case: use direct index access
|
|
422
399
|
return tuple(row[i] for i in self._upsert_column_indices)
|
|
423
400
|
|
|
424
|
-
def _has_pending_update(self) -> bool:
|
|
425
|
-
"""Check if there's a pending UPDATE_BEFORE awaiting UPDATE_AFTER.
|
|
426
|
-
|
|
427
|
-
Returns:
|
|
428
|
-
True if a pending update is in progress, False otherwise.
|
|
429
|
-
"""
|
|
430
|
-
return self._expecting_update_after
|
|
431
|
-
|
|
432
401
|
def _clear_storage(self) -> None:
|
|
433
402
|
"""Clear internal row storage."""
|
|
434
403
|
self._rows_by_key.clear()
|
|
435
404
|
|
|
436
|
-
def _clear_pending_update(self) -> None:
|
|
437
|
-
"""Clear pending update tracking state."""
|
|
438
|
-
self._expecting_update_after = False
|
|
439
|
-
|
|
440
405
|
def _apply_operation(self, op: Op, row: ResultTupleOrDict) -> None:
|
|
441
406
|
"""Apply a changelog operation to the internal state.
|
|
442
407
|
|
|
@@ -444,42 +409,23 @@ class UpsertColumnsCompressor(ChangelogCompressor):
|
|
|
444
409
|
op: The changelog operation.
|
|
445
410
|
row: The row data.
|
|
446
411
|
"""
|
|
447
|
-
|
|
412
|
+
if op == Op.UPDATE_BEFORE:
|
|
413
|
+
# No-op: under key-based upsert semantics a retraction carries no information the
|
|
414
|
+
# matching INSERT/UPDATE_AFTER doesn't already supply. See issue #185.
|
|
415
|
+
return
|
|
448
416
|
|
|
449
417
|
key = self._extract_key(row)
|
|
450
418
|
|
|
451
|
-
if op
|
|
452
|
-
# When iterating
|
|
453
|
-
# at the end of the dict, so insertion order
|
|
454
|
-
self._rows_by_key[key] = row
|
|
455
|
-
|
|
456
|
-
elif op == Op.UPDATE_BEFORE:
|
|
457
|
-
# Raise if we receive an UPDATE_BEFORE for a key that doesn't exist in current state
|
|
458
|
-
if key not in self._rows_by_key:
|
|
459
|
-
raise InterfaceError(
|
|
460
|
-
f"Received UPDATE_BEFORE for a key that does not exist in current state: {key}"
|
|
461
|
-
)
|
|
462
|
-
# Mark that we're expecting UPDATE_AFTER next
|
|
463
|
-
self._expecting_update_after = True
|
|
464
|
-
|
|
465
|
-
elif op == Op.UPDATE_AFTER:
|
|
466
|
-
# May or may not have gotten a preceding UPDATE_BEFORE.
|
|
467
|
-
# In either case, verify the key exists in current state
|
|
468
|
-
if key not in self._rows_by_key:
|
|
469
|
-
raise InterfaceError(
|
|
470
|
-
f"Received UPDATE_AFTER for a key that does not exist in current state: {key}"
|
|
471
|
-
)
|
|
472
|
-
|
|
473
|
-
# Update the row
|
|
419
|
+
if op.treat_as_insert: # INSERT or UPDATE_AFTER: last write for this key wins
|
|
420
|
+
# When iterating _rows_by_key in get_snapshot(), an upsert for a brand-new key will
|
|
421
|
+
# be at the end of the dict, so insertion order is maintained.
|
|
474
422
|
self._rows_by_key[key] = row
|
|
475
|
-
self._expecting_update_after = False
|
|
476
423
|
|
|
477
|
-
elif
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
del self._rows_by_key[key]
|
|
424
|
+
elif self._rows_by_key.pop(key, None) is None: # DELETE
|
|
425
|
+
# Wacky, the delete is for a key that doesn't exist in current state!
|
|
426
|
+
raise InterfaceError(
|
|
427
|
+
f"Received DELETE for a key that does not exist in current state: {key}"
|
|
428
|
+
)
|
|
483
429
|
|
|
484
430
|
def _copy_accumulated_rows(self) -> list[ResultTupleOrDict]:
|
|
485
431
|
"""Return deep copy of rows from dict storage in insertion order.
|
|
@@ -495,19 +441,34 @@ class NoUpsertColumnsCompressor(ChangelogCompressor):
|
|
|
495
441
|
|
|
496
442
|
Uses list-based storage with linear scan for row matching.
|
|
497
443
|
|
|
498
|
-
|
|
499
|
-
|
|
444
|
+
Without an upsert key, a row can only be identified by its full-row spelling, and the
|
|
445
|
+
changelog reaches this client via a multi-partition keyless sink that partitions by
|
|
446
|
+
whole-row hash. An updated row's +U/-D spelling therefore hashes to a potentially
|
|
447
|
+
different partition than its original +I/-U spelling, and since Kafka only guarantees
|
|
448
|
+
order within a partition, events can be observed in a surprising order *across* spellings
|
|
449
|
+
(e.g. a +U before its logical -U, or a -D before a later +I). See issue #184.
|
|
450
|
+
|
|
451
|
+
To stay correct under that skew this compressor makes no ordering assumptions *across
|
|
452
|
+
different spellings* and holds no pending-update state (see below for the one ordering
|
|
453
|
+
guarantee it does rely on). It collapses the four ops to two: the additive ops (+I, +U;
|
|
454
|
+
Op.treat_as_insert) append a row spelling, and the retracting ops (-U, -D;
|
|
455
|
+
Op.treat_as_delete) remove one occurrence of a row spelling. The result set is eventually
|
|
456
|
+
consistent -- intermediate points in time may transiently show extra rows, but once every
|
|
457
|
+
event has arrived the set converges to the correct contents.
|
|
458
|
+
|
|
459
|
+
A retraction for a spelling never arrives before the matching insert for that same
|
|
460
|
+
spelling: identical whole-row values always hash to the same partition, which preserves
|
|
461
|
+
their relative order. `_find_row_position` therefore always finds its match on a
|
|
462
|
+
well-formed stream; its raise is retained only to surface a genuine protocol violation.
|
|
500
463
|
|
|
501
464
|
Rows can be either tuples or dicts (as determined by cursor.as_dict). Row matching
|
|
502
465
|
is performed by equality comparison, which works identically for both tuple and dict.
|
|
503
466
|
"""
|
|
504
467
|
|
|
505
468
|
_rows: list[ResultTupleOrDict]
|
|
506
|
-
"""List of
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
"""Position of the row marked by the most recent UPDATE_BEFORE, awaiting UPDATE_AFTER.
|
|
510
|
-
None when no UPDATE_BEFORE is pending."""
|
|
469
|
+
"""List of row spellings, ordered by when each op was applied. Scanned linearly for
|
|
470
|
+
matching. Not a stable per-row order: an update's new spelling is appended anew, not
|
|
471
|
+
repositioned at its old spelling's spot."""
|
|
511
472
|
|
|
512
473
|
def __init__(self, cursor: Cursor, statement: Statement):
|
|
513
474
|
"""Initialize the compressor.
|
|
@@ -518,24 +479,11 @@ class NoUpsertColumnsCompressor(ChangelogCompressor):
|
|
|
518
479
|
"""
|
|
519
480
|
super().__init__(cursor, statement)
|
|
520
481
|
self._rows = []
|
|
521
|
-
self._pending_update_position = None
|
|
522
|
-
|
|
523
|
-
def _has_pending_update(self) -> bool:
|
|
524
|
-
"""Check if there's a pending UPDATE_BEFORE awaiting UPDATE_AFTER.
|
|
525
|
-
|
|
526
|
-
Returns:
|
|
527
|
-
True if a pending update is in progress, False otherwise.
|
|
528
|
-
"""
|
|
529
|
-
return self._pending_update_position is not None
|
|
530
482
|
|
|
531
483
|
def _clear_storage(self) -> None:
|
|
532
484
|
"""Clear internal row storage."""
|
|
533
485
|
self._rows.clear()
|
|
534
486
|
|
|
535
|
-
def _clear_pending_update(self) -> None:
|
|
536
|
-
"""Clear pending update tracking state."""
|
|
537
|
-
self._pending_update_position = None
|
|
538
|
-
|
|
539
487
|
def _find_row_position(self, row: ResultTupleOrDict, operation: Op) -> int:
|
|
540
488
|
"""Find the position of a matching row by scanning backwards.
|
|
541
489
|
|
|
@@ -562,37 +510,19 @@ class NoUpsertColumnsCompressor(ChangelogCompressor):
|
|
|
562
510
|
def _apply_operation(self, op: Op, row: ResultTupleOrDict) -> None:
|
|
563
511
|
"""Apply a changelog operation to the internal state.
|
|
564
512
|
|
|
513
|
+
Additive ops (+I, +U) append the row; retracting ops (-U, -D) remove the most recent
|
|
514
|
+
occurrence of a matching row. No ordering is assumed across *different* rows' ops --
|
|
515
|
+
but a row's own retracting op is assumed to arrive after its own matching additive op
|
|
516
|
+
(see class docstring); `_find_row_position` raises otherwise.
|
|
517
|
+
|
|
565
518
|
Args:
|
|
566
519
|
op: The changelog operation.
|
|
567
520
|
row: The row data.
|
|
568
521
|
"""
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if op == Op.INSERT:
|
|
522
|
+
if op.treat_as_insert:
|
|
572
523
|
self._rows.append(row)
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
# Find row position (raises InterfaceError if not found)
|
|
576
|
-
pos = self._find_row_position(row, Op.UPDATE_BEFORE)
|
|
577
|
-
# Record position for pending update (expecting matching UPDATE_AFTER next)
|
|
578
|
-
self._pending_update_position = pos
|
|
579
|
-
|
|
580
|
-
elif op == Op.UPDATE_AFTER:
|
|
581
|
-
# MUST have gotten a preceding UPDATE_BEFORE.
|
|
582
|
-
# (Without upsert columns, we can't identify the row to update from the
|
|
583
|
-
# UPDATE_AFTER row alone, since the row content has changed.)
|
|
584
|
-
if self._pending_update_position is None:
|
|
585
|
-
raise InterfaceError(
|
|
586
|
-
f"Received UPDATE_AFTER without a preceding UPDATE_BEFORE: {row}"
|
|
587
|
-
)
|
|
588
|
-
|
|
589
|
-
self._rows[self._pending_update_position] = row
|
|
590
|
-
self._pending_update_position = None
|
|
591
|
-
|
|
592
|
-
elif op == Op.DELETE:
|
|
593
|
-
# Find row position (raises InterfaceError if not found)
|
|
594
|
-
pos = self._find_row_position(row, Op.DELETE)
|
|
595
|
-
del self._rows[pos]
|
|
524
|
+
else: # op.treat_as_delete
|
|
525
|
+
del self._rows[self._find_row_position(row, op)]
|
|
596
526
|
|
|
597
527
|
def _copy_accumulated_rows(self) -> list[ResultTupleOrDict]:
|
|
598
528
|
"""Return deep copy of rows from list storage.
|
|
@@ -75,6 +75,25 @@ class Op(Enum):
|
|
|
75
75
|
String representation: -D
|
|
76
76
|
"""
|
|
77
77
|
|
|
78
|
+
@property
|
|
79
|
+
def treat_as_insert(self) -> bool:
|
|
80
|
+
"""True for the two additive changelog ops: INSERT (+I) and UPDATE_AFTER (+U).
|
|
81
|
+
|
|
82
|
+
Both add a row spelling to the result set. Consumers that only care whether an event
|
|
83
|
+
grows or shrinks the result set (e.g. a keyless compressor that cannot pair
|
|
84
|
+
UPDATE_BEFORE with UPDATE_AFTER) can collapse the four ops to this insert/delete
|
|
85
|
+
dichotomy. Every Op is exactly one of treat_as_insert or treat_as_delete.
|
|
86
|
+
"""
|
|
87
|
+
return self in (Op.INSERT, Op.UPDATE_AFTER)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def treat_as_delete(self) -> bool:
|
|
91
|
+
"""True for the two retracting changelog ops: UPDATE_BEFORE (-U) and DELETE (-D).
|
|
92
|
+
|
|
93
|
+
Both remove a row spelling from the result set. See treat_as_insert for the rationale.
|
|
94
|
+
"""
|
|
95
|
+
return self in (Op.UPDATE_BEFORE, Op.DELETE)
|
|
96
|
+
|
|
78
97
|
def __str__(self):
|
|
79
98
|
if self is self.INSERT:
|
|
80
99
|
return "+I"
|
|
@@ -151,12 +170,30 @@ class Statement:
|
|
|
151
170
|
"""Represents a Confluent SQL statement, including its metadata, spec, status,
|
|
152
171
|
and parsed traits such as schema, sql kind, etc."""
|
|
153
172
|
|
|
154
|
-
# SQL kinds that represent pure DDL statements (create/modify schema objects)
|
|
173
|
+
# SQL kinds that represent pure DDL statements (create/modify schema objects) which should
|
|
174
|
+
# be waiting for a terminal state before the created/modified objects are usable.
|
|
155
175
|
_PURE_DDL_KINDS = frozenset(
|
|
156
|
-
{
|
|
176
|
+
{
|
|
177
|
+
"CREATE_TABLE",
|
|
178
|
+
"DROP_TABLE",
|
|
179
|
+
"CREATE_VIEW",
|
|
180
|
+
"DROP_VIEW",
|
|
181
|
+
"ALTER_TABLE",
|
|
182
|
+
# The four MATERIALIZED_TABLE kinds stretch "pure" a little: unlike the other
|
|
183
|
+
# members, CREATE_MATERIALIZED_TABLE, CREATE_OR_ALTER_MATERIALIZED_TABLE, and
|
|
184
|
+
# ALTER_MATERIALIZED_TABLE (a query-evolving ALTER, not just a metadata-only one)
|
|
185
|
+
# also kick off (or redeploy) a persistent background refresh job that keeps running
|
|
186
|
+
# long after this statement settles. But they still will reliably reach terminal
|
|
187
|
+
# COMPLETED (or FAILED) phase independent of the background job, which is the one
|
|
188
|
+
# thing this property actually gates.
|
|
189
|
+
"CREATE_MATERIALIZED_TABLE",
|
|
190
|
+
"CREATE_OR_ALTER_MATERIALIZED_TABLE",
|
|
191
|
+
"ALTER_MATERIALIZED_TABLE",
|
|
192
|
+
"DROP_MATERIALIZED_TABLE",
|
|
193
|
+
}
|
|
157
194
|
)
|
|
158
195
|
|
|
159
|
-
# SQL kinds that represent impure DDL (produce no result set but
|
|
196
|
+
# SQL kinds that represent impure DDL (produce no result set but will remain in RUNNING phase)
|
|
160
197
|
_IMPURE_DDL_KINDS = frozenset({"CREATE_TABLE_AS"})
|
|
161
198
|
|
|
162
199
|
# From the cursor that created this statement ...
|
|
@@ -317,9 +354,22 @@ class Statement:
|
|
|
317
354
|
Pure DDL statements need to complete fully before the created/modified objects
|
|
318
355
|
are ready for use, unlike streaming queries or CTAS which are ready when RUNNING.
|
|
319
356
|
|
|
357
|
+
"Pure" is stretched a little to also cover CREATE_MATERIALIZED_TABLE,
|
|
358
|
+
CREATE_OR_ALTER_MATERIALIZED_TABLE, and ALTER_MATERIALIZED_TABLE (the query-evolving
|
|
359
|
+
form -- see CREATE_OR_ALTER_MATERIALIZED_TABLE): their completion additionally kicks off
|
|
360
|
+
(or redeploys) a persistent background refresh job that keeps running long after the
|
|
361
|
+
statement settles, which isn't true of the other members. They earn the label anyway
|
|
362
|
+
because they satisfy the one thing this property actually gates: the statement's own
|
|
363
|
+
phase reliably reaches a terminal phase before the created/altered object is usable,
|
|
364
|
+
rather than lingering in RUNNING the way CTAS does (see _PURE_DDL_KINDS for the
|
|
365
|
+
confirming detail). DROP_MATERIALIZED_TABLE needs no such stretch -- like the other
|
|
366
|
+
DROP_* kinds, it's a one-shot action with no background job of its own.
|
|
367
|
+
|
|
320
368
|
Returns:
|
|
321
369
|
True if the statement is one of: CREATE_TABLE, DROP_TABLE, CREATE_VIEW,
|
|
322
|
-
DROP_VIEW, ALTER_TABLE
|
|
370
|
+
DROP_VIEW, ALTER_TABLE, CREATE_MATERIALIZED_TABLE,
|
|
371
|
+
CREATE_OR_ALTER_MATERIALIZED_TABLE, ALTER_MATERIALIZED_TABLE,
|
|
372
|
+
DROP_MATERIALIZED_TABLE. False otherwise.
|
|
323
373
|
"""
|
|
324
374
|
return self.sql_kind in self._PURE_DDL_KINDS
|
|
325
375
|
|