interlaced 2.5.0__tar.gz → 2.7.0__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.
- {interlaced-2.5.0/src/interlaced.egg-info → interlaced-2.7.0}/PKG-INFO +2 -2
- {interlaced-2.5.0 → interlaced-2.7.0}/README.md +1 -1
- {interlaced-2.5.0 → interlaced-2.7.0}/pyproject.toml +2 -1
- interlaced-2.7.0/src/interlace/cdc/__init__.py +6 -0
- interlaced-2.7.0/src/interlace/cdc/decode.py +152 -0
- interlaced-2.7.0/src/interlace/cdc/publish.py +27 -0
- interlaced-2.7.0/src/interlace/cdc/slot.py +97 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/checks/builtin.py +66 -23
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/cli/main.py +345 -78
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/config/config.py +98 -1
- interlaced-2.7.0/src/interlace/connections.py +59 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/dsl/decorators.py +26 -4
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/dsl/discovery.py +29 -4
- interlaced-2.7.0/src/interlace/dsl/dynamic.py +302 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/adbc.py +11 -3
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/base.py +36 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/duckdb.py +19 -3
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/quack.py +4 -1
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/registry.py +4 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/spark.py +14 -3
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/exceptions.py +12 -0
- interlaced-2.7.0/src/interlace/inputs.py +144 -0
- interlaced-2.7.0/src/interlace/inspect.py +320 -0
- interlaced-2.7.0/src/interlace/mcp_server.py +502 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/plan/__init__.py +4 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/plan/apply.py +78 -23
- interlaced-2.7.0/src/interlace/plan/comment.py +62 -0
- interlaced-2.7.0/src/interlace/plan/orchestrate.py +180 -0
- interlaced-2.7.0/src/interlace/plan/table_diff.py +302 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/project.py +41 -3
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/runtime/python_model.py +6 -4
- interlaced-2.7.0/src/interlace/scheduler/engine.py +110 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/scheduler/triggers.py +47 -4
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/scheduler/worker.py +83 -47
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/app.py +571 -514
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/auth.py +14 -9
- interlaced-2.7.0/src/interlace/service/types.py +425 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/app.css +28 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/api.js +7 -3
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/ui.js +82 -1
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/checks.js +37 -2
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/lineage.js +22 -1
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/models.js +190 -133
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/plan.js +176 -29
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/runs.js +16 -1
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/system.js +41 -5
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/sinks.py +30 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/state/store.py +130 -7
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/streaming/log.py +91 -4
- interlaced-2.7.0/src/interlace/testing/__init__.py +5 -0
- interlaced-2.7.0/src/interlace/testing/golden.py +184 -0
- {interlaced-2.5.0 → interlaced-2.7.0/src/interlaced.egg-info}/PKG-INFO +2 -2
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlaced.egg-info/SOURCES.txt +15 -0
- interlaced-2.5.0/src/interlace/scheduler/engine.py +0 -66
- {interlaced-2.5.0 → interlaced-2.7.0}/LICENSE +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/MANIFEST.in +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/setup.cfg +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/__init__.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/checks/__init__.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/checks/runner.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/checks/spec.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/cli/__init__.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/config/__init__.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/contracts.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/dsl/__init__.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/dsl/sql_config.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/__init__.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/bigquery.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/postgres.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/redshift.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/snowflake.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/graph/__init__.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/graph/column_lineage.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/graph/dag.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/graph/project.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/graph/selectors.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/ir/__init__.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/ir/canonicalize.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/ir/fingerprint.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/ir/macros.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/ir/relation.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/physical/__init__.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/physical/annotate.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/physical/ddl.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/physical/drift.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/physical/reconcile.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/physical/spec.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/plan/differ.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/plan/plan.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/plan/resolve.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/plan/run.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/py.typed +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/query.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/runtime/__init__.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/runtime/handles.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/scaffold.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/scheduler/__init__.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/__init__.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/favicon.svg +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/index.html +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/app.js +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/dag.js +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/timeline.js +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/environments.js +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/overview.js +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/query.js +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/streams.js +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/sources/__init__.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/sources/auth.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/sources/rest.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/state/__init__.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/state/interval.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/state/janitor.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/state/locks.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/state/snapshot.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/__init__.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/append.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/base.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/full_merge.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/hash_merge.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/incremental.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/merge.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/replace.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/replace_in_place.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/scd.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/view.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/streaming/__init__.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/streaming/materializer.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/streaming/schema.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/README.md +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/generate.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/interlace.yaml +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/models/events.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/models/events_by_minute.sql +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/models/events_by_type.sql +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/models/top_users.sql +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/models/user_spend.sql +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/template.yaml +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/github/README.md +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/github/interlace.yaml +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/github/models/github_issues.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/github/models/issues_by_state.sql +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/github/template.yaml +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/postgres/README.md +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/postgres/docker-compose.yml +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/postgres/init/seed.sql +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/postgres/interlace.yaml +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/postgres/models/orders.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/postgres/models/orders_by_status.sql +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/postgres/template.yaml +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/README.md +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/interlace.yaml +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/models/enriched_events.py +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/models/event_summary.sql +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/models/raw_events.sql +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/template.yaml +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlaced.egg-info/dependency_links.txt +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlaced.egg-info/entry_points.txt +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlaced.egg-info/requires.txt +0 -0
- {interlaced-2.5.0 → interlaced-2.7.0}/src/interlaced.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: interlaced
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.0
|
|
4
4
|
Summary: Python and SQL models in one DAG. Transformation, orchestration and durable streaming in one process, on DuckDB and Postgres.
|
|
5
5
|
Author-email: Mark <mark@interlace.sh>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -272,7 +272,7 @@ fast lane when possible). Contract: `docs/architecture/MULTI_ENGINE.md`.
|
|
|
272
272
|
with column-level tracing, models, plan/apply with SQL diffs, live runs, query console,
|
|
273
273
|
streams, checks, environments, and system — live over SSE;
|
|
274
274
|
- the **HTTP API** (Litestar + msgspec, OpenAPI at `/schema/scalar`) with the same surface as
|
|
275
|
-
the CLI: plan/apply, runs, checks, streams, engines, schedules, lineage, query, gc;
|
|
275
|
+
the CLI: plan/apply, runs, checks, streams, engines, schedules, lineage, query, gc, reset;
|
|
276
276
|
- the **scheduler**: cron/interval triggers enqueue onto a **durable run queue** (leases,
|
|
277
277
|
retries, cooperative cancellation — `interlace cancel <id>` or `POST /runs/{id}/cancel`);
|
|
278
278
|
- **stream ingestion** and retention.
|
|
@@ -206,7 +206,7 @@ fast lane when possible). Contract: `docs/architecture/MULTI_ENGINE.md`.
|
|
|
206
206
|
with column-level tracing, models, plan/apply with SQL diffs, live runs, query console,
|
|
207
207
|
streams, checks, environments, and system — live over SSE;
|
|
208
208
|
- the **HTTP API** (Litestar + msgspec, OpenAPI at `/schema/scalar`) with the same surface as
|
|
209
|
-
the CLI: plan/apply, runs, checks, streams, engines, schedules, lineage, query, gc;
|
|
209
|
+
the CLI: plan/apply, runs, checks, streams, engines, schedules, lineage, query, gc, reset;
|
|
210
210
|
- the **scheduler**: cron/interval triggers enqueue onto a **durable run queue** (leases,
|
|
211
211
|
retries, cooperative cancellation — `interlace cancel <id>` or `POST /runs/{id}/cancel`);
|
|
212
212
|
- **stream ingestion** and retention.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "interlaced"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.7.0"
|
|
8
8
|
description = "Python and SQL models in one DAG. Transformation, orchestration and durable streaming in one process, on DuckDB and Postgres."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -143,6 +143,7 @@ module = [
|
|
|
143
143
|
"adbc_driver_bigquery.*", # optional 'adbc-bigquery' extra — absent in the base env
|
|
144
144
|
"pyspark.*", # optional 'spark' extra — absent in the base env
|
|
145
145
|
"delta.*", # optional 'spark' extra (delta-spark) — absent in the base env
|
|
146
|
+
"psycopg.*", # optional 'postgres' extra — absent in the base env
|
|
146
147
|
]
|
|
147
148
|
ignore_missing_imports = true
|
|
148
149
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Postgres logical replication into a durable ``@stream``."""
|
|
2
|
+
|
|
3
|
+
from interlace.cdc.decode import Change, decode_message
|
|
4
|
+
from interlace.cdc.publish import confirm_flushed, publish_changes
|
|
5
|
+
|
|
6
|
+
__all__ = ["Change", "confirm_flushed", "decode_message", "publish_changes"]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Decode ``pgoutput`` logical-replication messages into row changes.
|
|
2
|
+
|
|
3
|
+
The slot reader passes each message's WAL start as ``lsn``. Relation messages
|
|
4
|
+
update ``relations`` and return None. Insert, update, and delete return a
|
|
5
|
+
:class:`Change` whose ``row`` is text values keyed by the relation's column names.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import struct
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Relation:
|
|
17
|
+
namespace: str
|
|
18
|
+
name: str
|
|
19
|
+
columns: list[str]
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def qualified(self) -> str:
|
|
23
|
+
return f"{self.namespace}.{self.name}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Change:
|
|
28
|
+
"""One row change. ``kind`` is ``insert``, ``update``, or ``delete``."""
|
|
29
|
+
|
|
30
|
+
lsn: str
|
|
31
|
+
kind: str
|
|
32
|
+
table: str
|
|
33
|
+
row: dict[str, Any]
|
|
34
|
+
before: dict[str, Any] | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class _Cursor:
|
|
39
|
+
data: bytes
|
|
40
|
+
pos: int = 0
|
|
41
|
+
|
|
42
|
+
def byte(self) -> bytes:
|
|
43
|
+
value = self.data[self.pos : self.pos + 1]
|
|
44
|
+
self.pos += 1
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
def i16(self) -> int:
|
|
48
|
+
value = struct.unpack_from("!h", self.data, self.pos)[0]
|
|
49
|
+
self.pos += 2
|
|
50
|
+
return int(value)
|
|
51
|
+
|
|
52
|
+
def i32(self) -> int:
|
|
53
|
+
value = struct.unpack_from("!i", self.data, self.pos)[0]
|
|
54
|
+
self.pos += 4
|
|
55
|
+
return int(value)
|
|
56
|
+
|
|
57
|
+
def i64(self) -> int:
|
|
58
|
+
value = struct.unpack_from("!q", self.data, self.pos)[0]
|
|
59
|
+
self.pos += 8
|
|
60
|
+
return int(value)
|
|
61
|
+
|
|
62
|
+
def text(self) -> str:
|
|
63
|
+
end = self.data.index(b"\x00", self.pos)
|
|
64
|
+
value = self.data[self.pos : end].decode()
|
|
65
|
+
self.pos = end + 1
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
def tuple(self) -> list[str | None]:
|
|
69
|
+
values: list[str | None] = []
|
|
70
|
+
for _ in range(self.i16()):
|
|
71
|
+
kind = self.byte()
|
|
72
|
+
if kind in (b"n", b"u"):
|
|
73
|
+
values.append(None)
|
|
74
|
+
continue
|
|
75
|
+
if kind != b"t":
|
|
76
|
+
raise ValueError(f"unsupported pgoutput tuple marker {kind!r}")
|
|
77
|
+
length = self.i32()
|
|
78
|
+
values.append(self.data[self.pos : self.pos + length].decode())
|
|
79
|
+
self.pos += length
|
|
80
|
+
return values
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def decode_message(payload: bytes, relations: dict[int, Relation], lsn: str) -> Change | None:
|
|
84
|
+
"""Decode one ``pgoutput`` message. Begin, commit, and relation messages return None."""
|
|
85
|
+
if not payload:
|
|
86
|
+
return None
|
|
87
|
+
cur = _Cursor(payload)
|
|
88
|
+
kind = cur.byte()
|
|
89
|
+
if kind == b"R":
|
|
90
|
+
oid = cur.i32()
|
|
91
|
+
namespace = cur.text()
|
|
92
|
+
name = cur.text()
|
|
93
|
+
cur.byte() # replica identity
|
|
94
|
+
columns: list[str] = []
|
|
95
|
+
for _ in range(cur.i16()):
|
|
96
|
+
cur.byte() # flags
|
|
97
|
+
columns.append(cur.text())
|
|
98
|
+
cur.i32() # type oid
|
|
99
|
+
cur.i32() # typemod
|
|
100
|
+
relations[oid] = Relation(namespace, name, columns)
|
|
101
|
+
return None
|
|
102
|
+
if kind == b"I":
|
|
103
|
+
return _row_change(cur, relations, lsn, "insert")
|
|
104
|
+
if kind == b"U":
|
|
105
|
+
return _update(cur, relations, lsn)
|
|
106
|
+
if kind == b"D":
|
|
107
|
+
return _row_change(cur, relations, lsn, "delete")
|
|
108
|
+
return None # begin, commit, truncate, type, origin: no row
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _relation(cur: _Cursor, relations: dict[int, Relation]) -> Relation:
|
|
112
|
+
oid = cur.i32()
|
|
113
|
+
found = relations.get(oid)
|
|
114
|
+
if found is None:
|
|
115
|
+
raise ValueError(f"pgoutput message for unknown relation oid {oid}")
|
|
116
|
+
return found
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _as_row(relation: Relation, values: list[str | None]) -> dict[str, Any]:
|
|
120
|
+
return dict(zip(relation.columns, values, strict=False))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _row_change(cur: _Cursor, relations: dict[int, Relation], lsn: str, kind: str) -> Change:
|
|
124
|
+
relation = _relation(cur, relations)
|
|
125
|
+
cur.byte() # 'N' for insert, 'K' or 'O' for delete
|
|
126
|
+
return Change(lsn=lsn, kind=kind, table=relation.qualified, row=_as_row(relation, cur.tuple()))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _update(cur: _Cursor, relations: dict[int, Relation], lsn: str) -> Change:
|
|
130
|
+
relation = _relation(cur, relations)
|
|
131
|
+
marker = cur.byte()
|
|
132
|
+
before: dict[str, Any] | None = None
|
|
133
|
+
if marker in (b"K", b"O"):
|
|
134
|
+
before = _as_row(relation, cur.tuple())
|
|
135
|
+
marker = cur.byte()
|
|
136
|
+
if marker != b"N":
|
|
137
|
+
raise ValueError(f"pgoutput update expected a new tuple, got {marker!r}")
|
|
138
|
+
return Change(
|
|
139
|
+
lsn=lsn,
|
|
140
|
+
kind="update",
|
|
141
|
+
table=relation.qualified,
|
|
142
|
+
row=_as_row(relation, cur.tuple()),
|
|
143
|
+
before=before,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def payload_of(change: Change) -> dict[str, Any]:
|
|
148
|
+
"""Stream payload: the row, plus ``_change`` so a merge model can drop deletes."""
|
|
149
|
+
body = dict(change.row)
|
|
150
|
+
body["_change"] = change.kind
|
|
151
|
+
body["_table"] = change.table
|
|
152
|
+
return body
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Append decoded changes to a stream, and confirm their LSN only after a flush."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from interlace.cdc.decode import Change, payload_of
|
|
6
|
+
from interlace.state.store import SqliteStateStore
|
|
7
|
+
from interlace.streaming.log import Event, StreamLog
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def publish_changes(log: StreamLog, store: SqliteStateStore, stream: str, changes: list[Change]) -> list[int]:
|
|
11
|
+
"""Append ``changes`` to ``stream``. A repeated LSN is deduped by the log.
|
|
12
|
+
|
|
13
|
+
The LSN is recorded against the log offset and is not confirmed here.
|
|
14
|
+
"""
|
|
15
|
+
if not changes:
|
|
16
|
+
return []
|
|
17
|
+
result = await log.append(
|
|
18
|
+
stream,
|
|
19
|
+
[Event(payload=payload_of(change), idempotency_key=f"cdc:{change.lsn}") for change in changes],
|
|
20
|
+
)
|
|
21
|
+
await store.cdc_note_pending(stream, list(zip(result.offsets, (change.lsn for change in changes), strict=True)))
|
|
22
|
+
return result.offsets
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def confirm_flushed(store: SqliteStateStore, stream: str, watermark: int) -> str | None:
|
|
26
|
+
"""Advance the stored LSN up to the offset ``flush_streams`` has committed."""
|
|
27
|
+
return await store.cdc_advance(stream, watermark)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Read a Postgres logical replication slot with ``psycopg`` and decode ``pgoutput``.
|
|
2
|
+
|
|
3
|
+
The connection is opened only when CDC is configured. Feedback to the slot
|
|
4
|
+
(which lets Postgres drop WAL) is sent by :meth:`SlotReader.feedback` after
|
|
5
|
+
:func:`interlace.cdc.publish.confirm_flushed` has stored the LSN — never when
|
|
6
|
+
the message is merely appended to the stream log.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import cast
|
|
12
|
+
|
|
13
|
+
from interlace.cdc.decode import Change, Relation, decode_message
|
|
14
|
+
from interlace.config.config import CdcConfig
|
|
15
|
+
from interlace.exceptions import ConfigurationError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SlotReader:
|
|
19
|
+
"""One replication connection for one slot. ``poll`` does not advance the slot."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, dsn: str, source: CdcConfig) -> None:
|
|
22
|
+
self._dsn = dsn
|
|
23
|
+
self._source = source
|
|
24
|
+
self._conn: object | None = None
|
|
25
|
+
self._cur: object | None = None
|
|
26
|
+
self._relations: dict[int, Relation] = {}
|
|
27
|
+
self._started = False
|
|
28
|
+
self._tables = set(source.tables)
|
|
29
|
+
|
|
30
|
+
def poll(self, confirmed_lsn: str | None, *, limit: int = 100) -> list[Change]:
|
|
31
|
+
"""Read up to ``limit`` decoded row changes. Blocks briefly when the slot is idle."""
|
|
32
|
+
cursor = self._cursor(confirmed_lsn)
|
|
33
|
+
changes: list[Change] = []
|
|
34
|
+
while len(changes) < limit:
|
|
35
|
+
message = self._read_one(cursor)
|
|
36
|
+
if message is None:
|
|
37
|
+
break
|
|
38
|
+
payload = getattr(message, "payload", b"")
|
|
39
|
+
data = payload if isinstance(payload, bytes) else bytes(payload)
|
|
40
|
+
lsn = str(getattr(message, "data_start", ""))
|
|
41
|
+
change = decode_message(data, self._relations, lsn)
|
|
42
|
+
if change is None or not self._wanted(change.table):
|
|
43
|
+
continue
|
|
44
|
+
changes.append(change)
|
|
45
|
+
return changes
|
|
46
|
+
|
|
47
|
+
def feedback(self, lsn: str) -> None:
|
|
48
|
+
"""Tell the slot that ``lsn`` is flushed. Safe to call only after confirm_flushed."""
|
|
49
|
+
cursor = self._cur
|
|
50
|
+
if cursor is None or not lsn:
|
|
51
|
+
return
|
|
52
|
+
send = getattr(cursor, "send_feedback", None)
|
|
53
|
+
if send is not None:
|
|
54
|
+
send(flush_lsn=lsn, force=True)
|
|
55
|
+
|
|
56
|
+
def close(self) -> None:
|
|
57
|
+
conn = self._conn
|
|
58
|
+
self._cur = None
|
|
59
|
+
self._conn = None
|
|
60
|
+
self._started = False
|
|
61
|
+
close = getattr(conn, "close", None)
|
|
62
|
+
if close is not None:
|
|
63
|
+
close()
|
|
64
|
+
|
|
65
|
+
def _wanted(self, table: str) -> bool:
|
|
66
|
+
if table in self._tables:
|
|
67
|
+
return True
|
|
68
|
+
return any(item == table.rsplit(".", 1)[-1] for item in self._tables)
|
|
69
|
+
|
|
70
|
+
def _cursor(self, confirmed_lsn: str | None) -> object:
|
|
71
|
+
if self._cur is not None:
|
|
72
|
+
return self._cur
|
|
73
|
+
try:
|
|
74
|
+
import psycopg # optional postgres extra
|
|
75
|
+
except ImportError as exc:
|
|
76
|
+
raise ConfigurationError("CDC needs the postgres extra (psycopg). Install interlaced[postgres].") from exc
|
|
77
|
+
conn = psycopg.connect(self._dsn, autocommit=True, replication="database")
|
|
78
|
+
cursor = conn.cursor()
|
|
79
|
+
options = {"proto_version": "1", "publication_names": self._source.publication}
|
|
80
|
+
start = confirmed_lsn if confirmed_lsn else None
|
|
81
|
+
cursor.start_replication(slot_name=self._source.slot, decode=False, start_lsn=start, options=options)
|
|
82
|
+
self._conn = conn
|
|
83
|
+
self._cur = cursor
|
|
84
|
+
self._started = True
|
|
85
|
+
return cursor
|
|
86
|
+
|
|
87
|
+
def _read_one(self, cursor: object) -> object | None:
|
|
88
|
+
read = getattr(cursor, "read_message", None)
|
|
89
|
+
if read is None:
|
|
90
|
+
raise ConfigurationError("this psycopg build has no replication cursor")
|
|
91
|
+
try:
|
|
92
|
+
return cast(object | None, read(timeout=0.2))
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
# psycopg raises a timeout-ish error or returns None when the slot is idle
|
|
95
|
+
if type(exc).__name__ in {"ReplicationTimeout", "QueryCanceled"} or "timeout" in str(exc).lower():
|
|
96
|
+
return None
|
|
97
|
+
raise
|
|
@@ -16,7 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
import re
|
|
18
18
|
from collections.abc import Callable
|
|
19
|
-
from typing import Any
|
|
19
|
+
from typing import Any, cast
|
|
20
20
|
|
|
21
21
|
from sqlglot import exp, parse_one
|
|
22
22
|
|
|
@@ -65,33 +65,23 @@ def _interval(max_age: str) -> exp.Interval:
|
|
|
65
65
|
return exp.Interval(this=exp.Literal.string(match.group(1)), unit=exp.Var(this=_AGE_UNITS[match.group(2)]))
|
|
66
66
|
|
|
67
67
|
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
def _row_predicate(spec: CheckSpec, model: str, dialect: str, resolve: ResolveTable) -> exp.Expression | None:
|
|
69
|
+
"""A WHERE predicate that is true for a violating row.
|
|
70
|
+
|
|
71
|
+
``None`` for checks that are not a per-row filter (``unique`` counts groups,
|
|
72
|
+
``row_count`` / ``freshness`` judge the table, ``sql`` is its own query)."""
|
|
72
73
|
cols = [exp.column(c) for c in spec.columns]
|
|
73
74
|
params: dict[str, Any] = spec.params
|
|
74
75
|
|
|
75
76
|
if spec.type == "not_null":
|
|
76
77
|
_require(spec, model, columns=1)
|
|
77
|
-
|
|
78
|
-
return _count_where(table, condition)
|
|
79
|
-
|
|
80
|
-
if spec.type == "unique":
|
|
81
|
-
_require(spec, model, columns=1)
|
|
82
|
-
grouped = (
|
|
83
|
-
exp.select(*cols)
|
|
84
|
-
.from_(_table(table))
|
|
85
|
-
.group_by(*cols)
|
|
86
|
-
.having(exp.GT(this=exp.Count(this=exp.Star()), expression=exp.Literal.number(1)))
|
|
87
|
-
)
|
|
88
|
-
return exp.select(exp.alias_(exp.Count(this=exp.Star()), _FAILURES)).from_(grouped.subquery("dup"))
|
|
78
|
+
return exp.or_(*[col.is_(exp.null()) for col in cols])
|
|
89
79
|
|
|
90
80
|
if spec.type == "accepted_values":
|
|
91
81
|
_require(spec, model, columns=1, params=("values",))
|
|
92
82
|
values = [exp.Literal.string(v) if isinstance(v, str) else exp.Literal.number(v) for v in params["values"]]
|
|
93
83
|
col = cols[0]
|
|
94
|
-
return
|
|
84
|
+
return exp.and_(col.is_(exp.null()).not_(), exp.In(this=col, expressions=values).not_())
|
|
95
85
|
|
|
96
86
|
if spec.type == "range":
|
|
97
87
|
_require(spec, model, columns=1)
|
|
@@ -103,25 +93,47 @@ def build_check_query(
|
|
|
103
93
|
bounds.append(exp.LT(this=col, expression=exp.Literal.number(params["min"])))
|
|
104
94
|
if "max" in params:
|
|
105
95
|
bounds.append(exp.GT(this=col, expression=exp.Literal.number(params["max"])))
|
|
106
|
-
return
|
|
96
|
+
return exp.or_(*bounds)
|
|
107
97
|
|
|
108
98
|
if spec.type == "pattern":
|
|
109
99
|
_require(spec, model, columns=1, params=("regex",))
|
|
110
100
|
col = cols[0]
|
|
111
101
|
matches = exp.RegexpLike(this=col, expression=exp.Literal.string(str(params["regex"])))
|
|
112
|
-
return
|
|
102
|
+
return exp.and_(col.is_(exp.null()).not_(), matches.not_())
|
|
113
103
|
|
|
114
104
|
if spec.type == "expression":
|
|
115
105
|
_require(spec, model, params=("expression",))
|
|
116
|
-
|
|
117
|
-
return _count_where(table, exp.paren(predicate).not_())
|
|
106
|
+
return cast(exp.Expression, exp.paren(_parse_expr(str(params["expression"]), dialect)).not_())
|
|
118
107
|
|
|
119
108
|
if spec.type == "relationships":
|
|
120
109
|
_require(spec, model, columns=1, params=("to", "field"))
|
|
121
110
|
col = cols[0]
|
|
122
111
|
parent = exp.select(exp.column(str(params["field"]))).from_(_table(resolve(str(params["to"]))))
|
|
123
112
|
orphan = exp.In(this=col, query=exp.Subquery(this=parent)).not_()
|
|
124
|
-
return
|
|
113
|
+
return exp.and_(col.is_(exp.null()).not_(), orphan)
|
|
114
|
+
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def build_check_query(
|
|
119
|
+
spec: CheckSpec, table: TableRef, model: str, dialect: str, resolve: ResolveTable
|
|
120
|
+
) -> exp.Expression:
|
|
121
|
+
"""Compile ``spec`` against ``table`` into a query returning ``failures``."""
|
|
122
|
+
cols = [exp.column(c) for c in spec.columns]
|
|
123
|
+
params: dict[str, Any] = spec.params
|
|
124
|
+
predicate = _row_predicate(spec, model, dialect, resolve)
|
|
125
|
+
if predicate is not None:
|
|
126
|
+
return _count_where(table, predicate)
|
|
127
|
+
|
|
128
|
+
if spec.type == "unique":
|
|
129
|
+
_require(spec, model, columns=1)
|
|
130
|
+
grouped = (
|
|
131
|
+
exp.select(*cols)
|
|
132
|
+
.from_(_table(table))
|
|
133
|
+
.group_by(*cols)
|
|
134
|
+
.having(exp.GT(this=exp.Count(this=exp.Star()), expression=exp.Literal.number(1)))
|
|
135
|
+
)
|
|
136
|
+
return exp.select(exp.alias_(exp.Count(this=exp.Star()), _FAILURES)).from_(grouped.subquery("dup"))
|
|
125
137
|
|
|
126
138
|
if spec.type == "row_count":
|
|
127
139
|
if "min" not in params and "max" not in params:
|
|
@@ -150,3 +162,34 @@ def build_check_query(
|
|
|
150
162
|
)
|
|
151
163
|
|
|
152
164
|
raise DefinitionError(f"unknown check type {spec.type!r} on {model!r}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_failing_rows(
|
|
168
|
+
spec: CheckSpec, table: TableRef, model: str, dialect: str, resolve: ResolveTable
|
|
169
|
+
) -> exp.Expression | None:
|
|
170
|
+
"""The rows a check rejected, or ``None`` when the check has no row set.
|
|
171
|
+
|
|
172
|
+
``row_count`` and ``freshness`` judge the whole table. ``unique`` returns the
|
|
173
|
+
duplicate rows (the failure *count* stays the number of duplicate groups).
|
|
174
|
+
A ``sql`` check's query is already the failing rows."""
|
|
175
|
+
if spec.type in ("row_count", "freshness"):
|
|
176
|
+
return None
|
|
177
|
+
if spec.type == "sql":
|
|
178
|
+
_require(spec, model, params=("query",))
|
|
179
|
+
sql = str(spec.params["query"]).replace("{table}", _table(table).sql(dialect=dialect))
|
|
180
|
+
return _parse_expr(sql, dialect)
|
|
181
|
+
if spec.type == "unique":
|
|
182
|
+
_require(spec, model, columns=1)
|
|
183
|
+
cols = [exp.column(c) for c in spec.columns]
|
|
184
|
+
grouped = (
|
|
185
|
+
exp.select(*cols)
|
|
186
|
+
.from_(_table(table))
|
|
187
|
+
.group_by(*cols)
|
|
188
|
+
.having(exp.GT(this=exp.Count(this=exp.Star()), expression=exp.Literal.number(1)))
|
|
189
|
+
)
|
|
190
|
+
key: exp.Expression = exp.Tuple(expressions=cols) if len(cols) > 1 else cols[0]
|
|
191
|
+
return exp.select(exp.Star()).from_(_table(table)).where(exp.In(this=key, query=exp.Subquery(this=grouped)))
|
|
192
|
+
predicate = _row_predicate(spec, model, dialect, resolve)
|
|
193
|
+
if predicate is None:
|
|
194
|
+
return None
|
|
195
|
+
return exp.select(exp.Star()).from_(_table(table)).where(predicate)
|