interlaced 2.8.3__tar.gz → 2.8.4__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.8.3/src/interlaced.egg-info → interlaced-2.8.4}/PKG-INFO +1 -1
- {interlaced-2.8.3 → interlaced-2.8.4}/pyproject.toml +3 -2
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/checks/builtin.py +19 -11
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/cli/operate.py +7 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/dsl/decorators.py +28 -3
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/graph/project.py +2 -2
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/scheduler/daemon.py +35 -3
- interlaced-2.8.4/src/interlace/scheduler/engine.py +254 -0
- interlaced-2.8.4/src/interlace/scheduler/triggers.py +237 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/app.py +5 -1
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/lifespan.py +5 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/routes/catalog.py +10 -3
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/types.py +1 -1
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/js/views/system.js +1 -1
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/interval.py +21 -4
- {interlaced-2.8.3 → interlaced-2.8.4/src/interlaced.egg-info}/PKG-INFO +1 -1
- interlaced-2.8.3/src/interlace/scheduler/engine.py +0 -126
- interlaced-2.8.3/src/interlace/scheduler/triggers.py +0 -127
- {interlaced-2.8.3 → interlaced-2.8.4}/LICENSE +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/MANIFEST.in +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/README.md +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/setup.cfg +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/cdc/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/cdc/decode.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/cdc/publish.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/cdc/slot.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/checks/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/checks/runner.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/checks/spec.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/cli/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/cli/build.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/cli/inspect.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/cli/main.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/cli/support.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/config/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/config/config.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/connections.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/contracts.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/dsl/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/dsl/discovery.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/dsl/dynamic.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/dsl/sql_config.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/engines/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/engines/adbc.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/engines/base.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/engines/bigquery.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/engines/duckdb.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/engines/postgres.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/engines/quack.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/engines/redshift.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/engines/registry.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/engines/snowflake.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/engines/spark.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/exceptions.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/graph/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/graph/column_lineage.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/graph/dag.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/graph/selectors.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/inputs.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/inspect.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/ir/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/ir/canonicalize.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/ir/dialect.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/ir/fingerprint.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/ir/layout.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/ir/macros.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/ir/relation.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/ir/vars.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/mcp_server.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/physical/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/physical/changes.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/physical/ddl.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/physical/drift.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/physical/reconcile.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/physical/spec.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/annotate.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/apply.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/backfill.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/comment.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/delivery.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/differ.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/files.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/fit.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/orchestrate.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/payload.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/plan.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/resolve.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/result.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/run.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/schedule.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/table_diff.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/plan/transfer.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/project.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/py.typed +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/query.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/runtime/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/runtime/handles.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/runtime/python_model.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/scaffold.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/scheduler/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/scheduler/worker.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/auth.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/present.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/routes/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/routes/admin.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/routes/environments.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/routes/models.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/routes/plan.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/routes/runs.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/routes/streams.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/app.css +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/favicon.svg +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/index.html +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/js/api.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/js/app.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/js/dag.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/js/timeline.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/js/ui.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/js/views/checks.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/js/views/environments.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/js/views/lineage.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/js/views/models.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/js/views/overview.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/js/views/plan.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/js/views/query.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/js/views/runs.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/service/ui/js/views/streams.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/sinks.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/sources/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/sources/auth.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/sources/rest.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/advisory.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/beat.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/cdc.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/checks.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/codec.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/conn.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/events.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/janitor.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/keys.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/locks.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/queue.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/schema.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/snapshot.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/snapshots.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/store.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/state/triggers.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/strategies/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/strategies/append.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/strategies/base.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/strategies/full_merge.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/strategies/hash_merge.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/strategies/incremental.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/strategies/merge.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/strategies/replace.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/strategies/replace_in_place.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/strategies/scd.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/strategies/view.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/streaming/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/streaming/log.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/streaming/materializer.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/streaming/schema.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/cdc/README.md +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/cdc/docker-compose.yml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/cdc/init/seed.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/cdc/interlace.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/cdc/models/orders.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/cdc/models/orders_by_status.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/cdc/models/orders_stream.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/cdc/push.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/cdc/template.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/events/README.md +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/events/generate.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/events/interlace.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/events/models/events.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/events/models/events_by_minute.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/events/models/events_by_type.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/events/models/top_users.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/events/models/user_spend.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/events/template.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/github/README.md +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/github/interlace.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/github/models/github_issues.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/github/models/issues_by_state.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/github/template.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/postgres/README.md +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/postgres/docker-compose.yml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/postgres/init/seed.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/postgres/interlace.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/postgres/models/orders.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/postgres/models/orders_by_status.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/postgres/template.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/quickstart/README.md +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/quickstart/interlace.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/quickstart/models/enriched_events.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/quickstart/models/event_summary.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/quickstart/models/raw_events.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/templates/quickstart/template.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/testing/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlace/testing/golden.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlaced.egg-info/SOURCES.txt +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlaced.egg-info/dependency_links.txt +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlaced.egg-info/entry_points.txt +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/src/interlaced.egg-info/requires.txt +0 -0
- {interlaced-2.8.3 → interlaced-2.8.4}/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.8.
|
|
3
|
+
Version: 2.8.4
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "interlaced"
|
|
7
|
-
version = "2.8.
|
|
7
|
+
version = "2.8.4"
|
|
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"
|
|
@@ -61,7 +61,8 @@ service = [
|
|
|
61
61
|
"msgspec>=0.18,<1.0", # the wire types (litestar serialises msgspec structs natively)
|
|
62
62
|
]
|
|
63
63
|
# Remote engines via Arrow-native ADBC transfer. postgres (+ redshift, same wire)
|
|
64
|
-
# are the tested transport
|
|
64
|
+
# are the tested transport. snowflake and bigquery are ALPHA: snowflake has been
|
|
65
|
+
# exercised against one account, outside CI; bigquery has not been run live.
|
|
65
66
|
adbc = [
|
|
66
67
|
"adbc-driver-manager>=1.2,<2.0",
|
|
67
68
|
"adbc-driver-postgresql>=1.2,<2.0",
|
|
@@ -14,7 +14,6 @@ NULLs as pattern/range failures, which conflated the two).
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
-
import re
|
|
18
17
|
from collections.abc import Callable
|
|
19
18
|
from typing import Any, cast
|
|
20
19
|
|
|
@@ -24,10 +23,9 @@ from interlace.checks.spec import CheckSpec
|
|
|
24
23
|
from interlace.exceptions import DefinitionError
|
|
25
24
|
from interlace.ir.canonicalize import as_query
|
|
26
25
|
from interlace.ir.relation import TableRef
|
|
26
|
+
from interlace.state.interval import as_grain
|
|
27
27
|
|
|
28
28
|
_FAILURES = "failures"
|
|
29
|
-
_AGE_RE = re.compile(r"^\s*(\d+)\s*([smhdw])\s*$")
|
|
30
|
-
_AGE_UNITS = {"s": "SECOND", "m": "MINUTE", "h": "HOUR", "d": "DAY", "w": "WEEK"}
|
|
31
29
|
|
|
32
30
|
ResolveTable = Callable[[str], TableRef]
|
|
33
31
|
"""Maps an upstream model name to its physical table (for ``relationships``)."""
|
|
@@ -59,11 +57,24 @@ def _parse_expr(sql: str, dialect: str) -> exp.Expr:
|
|
|
59
57
|
return parse_one(sql, read=dialect)
|
|
60
58
|
|
|
61
59
|
|
|
60
|
+
def sql_interval(amount: int, unit: str) -> exp.Interval:
|
|
61
|
+
"""``amount`` of a SQL unit (``HOUR``, ``DAY``, …) as an interval expression."""
|
|
62
|
+
return exp.Interval(this=exp.Literal.string(str(amount)), unit=exp.Var(this=unit))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def max_is_stale(column: exp.Expr, interval: exp.Expr) -> exp.Expr:
|
|
66
|
+
"""``max(column)`` is null or older than ``interval``. An empty table counts."""
|
|
67
|
+
newest = exp.Max(this=column)
|
|
68
|
+
threshold = exp.Sub(this=exp.CurrentTimestamp(), expression=interval)
|
|
69
|
+
return exp.or_(exp.LT(this=newest, expression=threshold), newest.copy().is_(exp.null()))
|
|
70
|
+
|
|
71
|
+
|
|
62
72
|
def _interval(max_age: str) -> exp.Interval:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
try:
|
|
74
|
+
parsed = as_grain(str(max_age))
|
|
75
|
+
except ValueError as exc:
|
|
76
|
+
raise DefinitionError(f"invalid max_age {max_age!r}; expected like '2h', '30m', '1d'") from exc
|
|
77
|
+
return sql_interval(parsed.amount, parsed.sql_unit)
|
|
67
78
|
|
|
68
79
|
|
|
69
80
|
def _row_predicate(spec: CheckSpec, model: str, dialect: str, resolve: ResolveTable) -> exp.Expr | None:
|
|
@@ -147,10 +158,7 @@ def build_check_query(spec: CheckSpec, table: TableRef, model: str, dialect: str
|
|
|
147
158
|
|
|
148
159
|
if spec.type == "freshness":
|
|
149
160
|
_require(spec, model, columns=1, params=("max_age",))
|
|
150
|
-
|
|
151
|
-
newest = exp.Max(this=cols[0])
|
|
152
|
-
stale = exp.or_(exp.LT(this=newest, expression=threshold), newest.copy().is_(exp.null())) # no rows = stale
|
|
153
|
-
return _flag(table, stale)
|
|
161
|
+
return _flag(table, max_is_stale(cols[0], _interval(str(params["max_age"]))))
|
|
154
162
|
|
|
155
163
|
if spec.type == "sql":
|
|
156
164
|
_require(spec, model, params=("query",))
|
|
@@ -285,6 +285,11 @@ def serve(
|
|
|
285
285
|
True, "--scheduler/--no-scheduler", help="Run the scheduler loop in this process (combined daemon)."
|
|
286
286
|
),
|
|
287
287
|
interval: float = typer.Option(60.0, "--interval", help="Seconds between scheduler ticks."),
|
|
288
|
+
apply_on_start: bool = typer.Option(
|
|
289
|
+
True,
|
|
290
|
+
"--apply/--no-apply",
|
|
291
|
+
help="Apply the project once at startup. --no-apply serves the warehouse as it is.",
|
|
292
|
+
),
|
|
288
293
|
allow_open: bool = typer.Option(
|
|
289
294
|
False,
|
|
290
295
|
"--allow-open",
|
|
@@ -294,6 +299,7 @@ def serve(
|
|
|
294
299
|
"""Run the interlace daemon: HTTP API + scheduler in one process (requires the `service` extra).
|
|
295
300
|
|
|
296
301
|
Use --no-scheduler for an API-only process (run `interlace scheduler` separately).
|
|
302
|
+
The project is applied once before the loops start; pass --no-apply to skip that.
|
|
297
303
|
A `cdc:` block in the project config is read here: each Postgres slot appends into its stream.
|
|
298
304
|
"""
|
|
299
305
|
try:
|
|
@@ -339,6 +345,7 @@ def serve(
|
|
|
339
345
|
quack_token=token or None,
|
|
340
346
|
scheduler=scheduler,
|
|
341
347
|
scheduler_interval=interval,
|
|
348
|
+
apply_on_start=apply_on_start,
|
|
342
349
|
)
|
|
343
350
|
config = uvicorn.Config(
|
|
344
351
|
app,
|
|
@@ -9,7 +9,7 @@ Compilation, planning, and execution happen later over the registry. UK spelling
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import re
|
|
12
|
-
from collections.abc import Callable, Sequence
|
|
12
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
13
13
|
from contextvars import ContextVar
|
|
14
14
|
from dataclasses import dataclass, field
|
|
15
15
|
from typing import Any
|
|
@@ -30,6 +30,28 @@ from interlace.strategies import named_strategy
|
|
|
30
30
|
|
|
31
31
|
ModelFn = Callable[..., Any]
|
|
32
32
|
|
|
33
|
+
|
|
34
|
+
def normalize_schedule(schedule: Mapping[str, Any] | None, *, model: str) -> dict[str, str] | None:
|
|
35
|
+
"""Schedules are string values. ``fresh: {column, within}`` becomes ``"column within"``."""
|
|
36
|
+
if not schedule:
|
|
37
|
+
return None
|
|
38
|
+
normalised: dict[str, str] = {}
|
|
39
|
+
for key, value in schedule.items():
|
|
40
|
+
if key == "fresh" and isinstance(value, dict):
|
|
41
|
+
column = value.get("column")
|
|
42
|
+
window = value.get("within")
|
|
43
|
+
if not isinstance(column, str) or not column.strip():
|
|
44
|
+
raise DefinitionError(f"model {model!r}: fresh.column must name a timestamp column")
|
|
45
|
+
if not isinstance(window, str) or not window.strip():
|
|
46
|
+
raise DefinitionError(f"model {model!r}: fresh.within must be a window like '2h'")
|
|
47
|
+
normalised[key] = f"{column.strip()} {window.strip()}"
|
|
48
|
+
continue
|
|
49
|
+
if not isinstance(value, str):
|
|
50
|
+
raise DefinitionError(f"model {model!r}: schedule {key!r} must be a string")
|
|
51
|
+
normalised[key] = value
|
|
52
|
+
return normalised
|
|
53
|
+
|
|
54
|
+
|
|
33
55
|
# Set while a Python model function is running. Registrations then replace a
|
|
34
56
|
# model this run already created, and are recorded for the apply that builds them.
|
|
35
57
|
dynamic_owner: ContextVar[str | None] = ContextVar("interlace_dynamic_owner", default=None)
|
|
@@ -130,7 +152,9 @@ class ModelDef:
|
|
|
130
152
|
path: str | None = None # output path for materialise: file
|
|
131
153
|
format: str | None = None # csv | parquet | json for materialise: file
|
|
132
154
|
environments: tuple[str, ...] = ("prod",) # which environments actually deliver a terminal model
|
|
133
|
-
|
|
155
|
+
# cron, every, watch, on_change, fresh, or webhook. ``fresh`` may be the
|
|
156
|
+
# mapping ``{column, within}`` at construction; __post_init__ stores "column within".
|
|
157
|
+
schedule: dict[str, Any] | None = None
|
|
134
158
|
checks: tuple[CheckSpec, ...] = () # data-quality checks; error severity gates promotion
|
|
135
159
|
indexes: tuple[IndexSpec, ...] = () # physical indexes; not part of the data fingerprint
|
|
136
160
|
constraints: tuple[ConstraintSpec, ...] = () # physical constraints; engine-enforced, not checks
|
|
@@ -151,6 +175,7 @@ class ModelDef:
|
|
|
151
175
|
self.constraints = parse_constraints(self.constraints, self.name)
|
|
152
176
|
self.schema_policy = parse_schema_policy(self.schema_policy, self.name)
|
|
153
177
|
validate_physical_allowed(self.name, self.materialise, self.indexes, self.constraints, self.schema_policy)
|
|
178
|
+
self.schedule = normalize_schedule(self.schedule, model=self.name)
|
|
154
179
|
|
|
155
180
|
@property
|
|
156
181
|
def is_terminal(self) -> bool:
|
|
@@ -242,7 +267,7 @@ def model(
|
|
|
242
267
|
owner: str | None = None,
|
|
243
268
|
description: str | None = None,
|
|
244
269
|
columns: dict[str, str | None] | Sequence[str] | None = None,
|
|
245
|
-
schedule: dict[str,
|
|
270
|
+
schedule: dict[str, Any] | None = None,
|
|
246
271
|
checks: Sequence[dict[str, Any] | CheckSpec] | None = None,
|
|
247
272
|
indexes: Sequence[dict[str, Any] | IndexSpec] | None = None,
|
|
248
273
|
constraints: Sequence[dict[str, Any] | ConstraintSpec] | None = None,
|
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
import inspect
|
|
14
14
|
from collections.abc import Iterable, Mapping
|
|
15
15
|
from dataclasses import dataclass, field
|
|
16
|
-
from typing import Any
|
|
16
|
+
from typing import Any, cast
|
|
17
17
|
|
|
18
18
|
from sqlglot import exp
|
|
19
19
|
|
|
@@ -308,7 +308,7 @@ def compile_models( # noqa: C901
|
|
|
308
308
|
cursor=definition.cursor,
|
|
309
309
|
interval=definition.interval,
|
|
310
310
|
tags=definition.tags,
|
|
311
|
-
schedule=definition.schedule,
|
|
311
|
+
schedule=cast(dict[str, str] | None, definition.schedule), # __post_init__ stores strings
|
|
312
312
|
columns=definition.columns,
|
|
313
313
|
target=definition.target,
|
|
314
314
|
path=definition.path,
|
|
@@ -205,6 +205,35 @@ async def flusher_loop(state: Any, *, flush_interval: float) -> None:
|
|
|
205
205
|
await asyncio.sleep(1.0)
|
|
206
206
|
|
|
207
207
|
|
|
208
|
+
async def startup_apply(state: Any) -> None:
|
|
209
|
+
"""Apply the project once, before the scheduler starts.
|
|
210
|
+
|
|
211
|
+
A breaking or blocked plan is logged and the process still serves. Any other
|
|
212
|
+
failure propagates: a warehouse that cannot be opened should not pretend to
|
|
213
|
+
be up.
|
|
214
|
+
"""
|
|
215
|
+
from interlace.exceptions import PlanError
|
|
216
|
+
from interlace.plan.orchestrate import plan_and_apply
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
_plan, result = await plan_and_apply(
|
|
220
|
+
state.compiled,
|
|
221
|
+
environment=state.environment,
|
|
222
|
+
project=state.project,
|
|
223
|
+
engines=state.engines,
|
|
224
|
+
state=state.store,
|
|
225
|
+
lock_owner=state.lock_owner,
|
|
226
|
+
connections=state.connections,
|
|
227
|
+
on_compiled=lambda fresh: publish_compiled(state, fresh),
|
|
228
|
+
)
|
|
229
|
+
except PlanError as exc:
|
|
230
|
+
logger.error("startup apply refused: %s", exc.message)
|
|
231
|
+
return
|
|
232
|
+
if result is None:
|
|
233
|
+
return
|
|
234
|
+
logger.info("startup apply built %s", ", ".join(result.built) or "nothing")
|
|
235
|
+
|
|
236
|
+
|
|
208
237
|
async def scheduler_loop(
|
|
209
238
|
state: Any,
|
|
210
239
|
*,
|
|
@@ -220,9 +249,12 @@ async def scheduler_loop(
|
|
|
220
249
|
try:
|
|
221
250
|
await reload_if_stale(state)
|
|
222
251
|
compiled = state.compiled
|
|
223
|
-
await TriggerEngine(
|
|
224
|
-
|
|
225
|
-
|
|
252
|
+
await TriggerEngine(
|
|
253
|
+
build_triggers(compiled, root=state.project.root, environment=state.environment),
|
|
254
|
+
state.store,
|
|
255
|
+
compiled,
|
|
256
|
+
engines=state.engines,
|
|
257
|
+
).tick(datetime.now())
|
|
226
258
|
if asyncio.get_running_loop().time() >= next_trim:
|
|
227
259
|
await state.store.trim_logs()
|
|
228
260
|
next_trim = asyncio.get_running_loop().time() + 6 * 3600
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""The trigger engine — we own the scheduling loop.
|
|
2
|
+
|
|
3
|
+
Each tick, every trigger is asked what's due given when it last fired; due runs
|
|
4
|
+
are enqueued onto the durable work queue and the trigger's last-fired time is
|
|
5
|
+
persisted. State lives in our state DB (not an external scheduler's jobstore), so
|
|
6
|
+
it survives restarts and is unified with runs and snapshots.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import cast
|
|
15
|
+
|
|
16
|
+
from sqlglot import exp
|
|
17
|
+
|
|
18
|
+
from interlace.engines.base import EngineAdapter, relation_is_absent
|
|
19
|
+
from interlace.engines.registry import EngineRegistry
|
|
20
|
+
from interlace.exceptions import DefinitionError
|
|
21
|
+
from interlace.graph.project import CompiledModel, CompiledProject
|
|
22
|
+
from interlace.ir.canonicalize import table_references
|
|
23
|
+
from interlace.ir.layout import env_view
|
|
24
|
+
from interlace.ir.relation import TableRef
|
|
25
|
+
from interlace.scheduler.triggers import (
|
|
26
|
+
ABSENT,
|
|
27
|
+
CronTrigger,
|
|
28
|
+
FreshTrigger,
|
|
29
|
+
IntervalTrigger,
|
|
30
|
+
OnChangeTrigger,
|
|
31
|
+
Trigger,
|
|
32
|
+
WatchTrigger,
|
|
33
|
+
)
|
|
34
|
+
from interlace.state.interval import Grain, as_grain, parse_grain
|
|
35
|
+
from interlace.state.store import SqliteStateStore
|
|
36
|
+
|
|
37
|
+
_SCHEDULE_KINDS = ("cron", "every", "watch", "on_change", "fresh", "webhook")
|
|
38
|
+
_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def schedule_kind(model: str, schedule: dict[str, str]) -> tuple[str, str]:
|
|
42
|
+
"""The one schedule key and its value. Cron, interval, file watch, table change, freshness, or webhook."""
|
|
43
|
+
present = [key for key in _SCHEDULE_KINDS if key in schedule]
|
|
44
|
+
if len(present) != 1:
|
|
45
|
+
raise DefinitionError(
|
|
46
|
+
f"model {model!r}: schedule needs exactly one of {', '.join(_SCHEDULE_KINDS)}",
|
|
47
|
+
details={"schedule": schedule},
|
|
48
|
+
)
|
|
49
|
+
return present[0], schedule[present[0]]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def webhook_targets(project: CompiledProject) -> dict[str, str]:
|
|
53
|
+
"""Map a webhook name to the model ``POST /hooks/{name}`` enqueues."""
|
|
54
|
+
found: dict[str, str] = {}
|
|
55
|
+
for model in project.models.values():
|
|
56
|
+
if not model.schedule or "webhook" not in model.schedule:
|
|
57
|
+
continue
|
|
58
|
+
kind, name = schedule_kind(model.name, model.schedule)
|
|
59
|
+
if kind != "webhook":
|
|
60
|
+
continue
|
|
61
|
+
other = found.get(name)
|
|
62
|
+
if other is not None:
|
|
63
|
+
raise DefinitionError(f"webhook {name!r} is declared by both {other!r} and {model.name!r}")
|
|
64
|
+
found[name] = model.name
|
|
65
|
+
return found
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_triggers(project: CompiledProject, *, root: Path | None = None, environment: str = "prod") -> list[Trigger]:
|
|
69
|
+
"""Construct triggers from each model's ``schedule`` config.
|
|
70
|
+
|
|
71
|
+
``root`` is the project directory a ``watch:`` glob is relative to.
|
|
72
|
+
``environment`` names the view an ``on_change`` or ``fresh`` schedule reads when its
|
|
73
|
+
source is another model. Webhook schedules are validated here and enqueued
|
|
74
|
+
by ``POST /hooks/{name}``, so they do not become ticking triggers.
|
|
75
|
+
"""
|
|
76
|
+
triggers: list[Trigger] = []
|
|
77
|
+
for model in project.models.values():
|
|
78
|
+
trigger = _trigger_for(model, project, root, environment)
|
|
79
|
+
if trigger is not None:
|
|
80
|
+
triggers.append(trigger)
|
|
81
|
+
webhook_targets(project) # reject two models sharing one hook name
|
|
82
|
+
return triggers
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _trigger_for(model: CompiledModel, project: CompiledProject, root: Path | None, environment: str) -> Trigger | None:
|
|
86
|
+
schedule = model.schedule
|
|
87
|
+
if not schedule:
|
|
88
|
+
return None
|
|
89
|
+
kind, value = schedule_kind(model.name, schedule)
|
|
90
|
+
if kind == "cron":
|
|
91
|
+
return CronTrigger(model.name, value)
|
|
92
|
+
if kind == "every":
|
|
93
|
+
return IntervalTrigger(model.name, parse_grain(value))
|
|
94
|
+
if kind == "watch":
|
|
95
|
+
if root is None:
|
|
96
|
+
raise DefinitionError(f"model {model.name!r}: a watch schedule needs the project root")
|
|
97
|
+
return WatchTrigger(model.name, value, root)
|
|
98
|
+
if kind == "on_change":
|
|
99
|
+
table, column, engine = resolve_column(model, project, environment, value)
|
|
100
|
+
return OnChangeTrigger(model.name, column, table, engine)
|
|
101
|
+
if kind == "fresh":
|
|
102
|
+
column_spec, window = _fresh_parts(model.name, value)
|
|
103
|
+
table, column, engine = resolve_column(model, project, environment, column_spec, label="fresh")
|
|
104
|
+
return FreshTrigger(model.name, column, table, engine, _fresh_grain(model.name, window))
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _fresh_parts(model: str, value: str) -> tuple[str, str]:
|
|
109
|
+
column_spec, _, window = value.strip().rpartition(" ")
|
|
110
|
+
if not column_spec.strip() or not window.strip():
|
|
111
|
+
raise DefinitionError(f"model {model!r}: fresh {value!r} must look like 'updated_at 2h'")
|
|
112
|
+
return column_spec.strip(), window.strip()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _fresh_grain(model: str, window: str) -> Grain:
|
|
116
|
+
try:
|
|
117
|
+
return as_grain(window)
|
|
118
|
+
except ValueError as exc:
|
|
119
|
+
raise DefinitionError(f"model {model!r}: {exc}") from exc
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def resolve_column(
|
|
123
|
+
model: CompiledModel, project: CompiledProject, environment: str, value: str, *, label: str = "on_change"
|
|
124
|
+
) -> tuple[TableRef, str, str]:
|
|
125
|
+
"""The table, column, and engine a column schedule probes.
|
|
126
|
+
|
|
127
|
+
A bare column is read from the one table the model reads. ``table.column``
|
|
128
|
+
and ``schema.table.column`` name it. A name that is a model is that model's
|
|
129
|
+
environment view; anything else is a table on the model's own engine.
|
|
130
|
+
"""
|
|
131
|
+
parts = value.split(".")
|
|
132
|
+
if not parts or any(not _IDENT.match(part) for part in parts):
|
|
133
|
+
raise DefinitionError(
|
|
134
|
+
f"model {model.name!r}: {label} {value!r} must be a column, table.column, or schema.table.column"
|
|
135
|
+
)
|
|
136
|
+
column = parts[-1]
|
|
137
|
+
if len(parts) == 1:
|
|
138
|
+
table, engine = _infer_change_table(model, project, environment, label=label)
|
|
139
|
+
return table, column, engine
|
|
140
|
+
if len(parts) == 2:
|
|
141
|
+
schema = None
|
|
142
|
+
relation = parts[0]
|
|
143
|
+
elif len(parts) == 3:
|
|
144
|
+
schema, relation = parts[0], parts[1]
|
|
145
|
+
else:
|
|
146
|
+
raise DefinitionError(
|
|
147
|
+
f"model {model.name!r}: {label} {value!r} must be a column, table.column, or schema.table.column"
|
|
148
|
+
)
|
|
149
|
+
qualified = f"{schema}.{relation}" if schema else relation
|
|
150
|
+
if qualified in project.models:
|
|
151
|
+
upstream = project.models[qualified]
|
|
152
|
+
return env_view(environment, qualified), column, upstream.engine
|
|
153
|
+
return TableRef(schema=schema or "main", name=relation), column, model.engine
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _infer_change_table(
|
|
157
|
+
model: CompiledModel, project: CompiledProject, environment: str, *, label: str = "on_change"
|
|
158
|
+
) -> tuple[TableRef, str]:
|
|
159
|
+
refs = table_references(model.ast) if model.ast is not None else []
|
|
160
|
+
external = [ref for ref in refs if ref not in project.models]
|
|
161
|
+
models = [ref for ref in refs if ref in project.models]
|
|
162
|
+
if model.ast is None:
|
|
163
|
+
external = []
|
|
164
|
+
models = list(model.dependencies)
|
|
165
|
+
if len(external) == 1 and not models:
|
|
166
|
+
ref = external[0]
|
|
167
|
+
schema, _, name = ref.rpartition(".")
|
|
168
|
+
return TableRef(schema=schema or "main", name=name), model.engine
|
|
169
|
+
if not external and len(models) == 1:
|
|
170
|
+
upstream = project.models[models[0]]
|
|
171
|
+
return env_view(environment, models[0]), upstream.engine
|
|
172
|
+
raise DefinitionError(
|
|
173
|
+
f"model {model.name!r}: {label} needs one source table; write table.column or schema.table.column",
|
|
174
|
+
details={"reads": refs or list(model.dependencies)},
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def scheduled_closure(project: CompiledProject, models: list[str]) -> list[str]:
|
|
179
|
+
"""The triggered models plus every model downstream of them.
|
|
180
|
+
|
|
181
|
+
A cron, interval, file watch, table change, freshness, or webhook means that model's inputs changed.
|
|
182
|
+
Downstream snapshots would otherwise keep the previous build. An explicit
|
|
183
|
+
``run --select`` is not expanded here: ``model``, ``model+``, and ``+model``
|
|
184
|
+
stay exactly what was asked for.
|
|
185
|
+
"""
|
|
186
|
+
chosen: set[str] = set()
|
|
187
|
+
for name in models:
|
|
188
|
+
chosen.add(name)
|
|
189
|
+
if name in project.models:
|
|
190
|
+
chosen |= project.graph.descendants(name)
|
|
191
|
+
return sorted(chosen)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class TriggerEngine:
|
|
195
|
+
"""Evaluates triggers on each tick and enqueues due runs."""
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
triggers: list[Trigger],
|
|
200
|
+
store: SqliteStateStore,
|
|
201
|
+
project: CompiledProject,
|
|
202
|
+
*,
|
|
203
|
+
engines: EngineRegistry | EngineAdapter | None = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
self.triggers = triggers
|
|
206
|
+
self.store = store
|
|
207
|
+
self.project = project
|
|
208
|
+
self.engines = engines
|
|
209
|
+
|
|
210
|
+
async def tick(self, now: datetime) -> int:
|
|
211
|
+
"""Enqueue all runs due at ``now``; returns how many were newly enqueued."""
|
|
212
|
+
enqueued = 0
|
|
213
|
+
for trigger in self.triggers:
|
|
214
|
+
last_fired = await self.store.get_trigger_last_fired(trigger.id)
|
|
215
|
+
requests = await trigger.due(now, last_fired, self)
|
|
216
|
+
for request in requests:
|
|
217
|
+
selector = scheduled_closure(self.project, request.flow_selector)
|
|
218
|
+
partition = (
|
|
219
|
+
(request.partition.start.isoformat(), request.partition.end.isoformat())
|
|
220
|
+
if request.partition is not None
|
|
221
|
+
else None
|
|
222
|
+
)
|
|
223
|
+
if await self.store.enqueue_run(request.idempotency_key, selector, partition, request.priority):
|
|
224
|
+
enqueued += 1
|
|
225
|
+
await self.store.append_event(
|
|
226
|
+
"run.enqueued", entity=request.idempotency_key, payload={"models": selector}
|
|
227
|
+
)
|
|
228
|
+
if requests:
|
|
229
|
+
await self.store.set_trigger_last_fired(trigger.id, now)
|
|
230
|
+
return enqueued
|
|
231
|
+
|
|
232
|
+
async def scalar(self, engine: str, table: TableRef, query: exp.Expression) -> object | None:
|
|
233
|
+
"""First cell of ``query``. ``ABSENT`` when the table is not there yet; ``None`` when the cell is NULL."""
|
|
234
|
+
adapter = self._engine(engine)
|
|
235
|
+
if not await adapter.table_exists(table):
|
|
236
|
+
return ABSENT
|
|
237
|
+
try:
|
|
238
|
+
reader = await adapter.fetch(query)
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
if relation_is_absent(exc):
|
|
241
|
+
return ABSENT
|
|
242
|
+
raise
|
|
243
|
+
rows = reader.read_all()
|
|
244
|
+
if rows.num_rows == 0:
|
|
245
|
+
return ABSENT
|
|
246
|
+
return cast(object | None, rows.column(0)[0].as_py())
|
|
247
|
+
|
|
248
|
+
def _engine(self, name: str) -> EngineAdapter:
|
|
249
|
+
engines = self.engines
|
|
250
|
+
if isinstance(engines, EngineRegistry):
|
|
251
|
+
return engines.get(name)
|
|
252
|
+
if isinstance(engines, EngineAdapter):
|
|
253
|
+
return engines
|
|
254
|
+
raise DefinitionError("a column schedule needs the project's engines")
|