interlaced 2.8.3__tar.gz → 2.8.6__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.6}/PKG-INFO +1 -1
- {interlaced-2.8.3 → interlaced-2.8.6}/pyproject.toml +3 -2
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/checks/builtin.py +19 -11
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cli/operate.py +7 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/config/config.py +8 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/dsl/decorators.py +37 -3
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/graph/project.py +2 -2
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/differ.py +12 -1
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/schedule.py +5 -1
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/project.py +8 -3
- interlaced-2.8.6/src/interlace/scheduler/__init__.py +39 -0
- interlaced-2.8.6/src/interlace/scheduler/completion.py +132 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/scheduler/daemon.py +35 -3
- interlaced-2.8.6/src/interlace/scheduler/engine.py +229 -0
- interlaced-2.8.6/src/interlace/scheduler/triggers.py +237 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/app.py +5 -1
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/lifespan.py +5 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/routes/catalog.py +11 -3
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/types.py +1 -1
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/plan.js +4 -1
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/system.js +1 -1
- interlaced-2.8.6/src/interlace/state/advisory.py +101 -0
- interlaced-2.8.6/src/interlace/state/conn.py +83 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/events.py +2 -2
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/interval.py +21 -4
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/locks.py +4 -4
- interlaced-2.8.6/src/interlace/state/pg.py +191 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/queue.py +8 -4
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/snapshots.py +3 -1
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/store.py +16 -9
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/streaming/log.py +33 -11
- {interlaced-2.8.3 → interlaced-2.8.6/src/interlaced.egg-info}/PKG-INFO +1 -1
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlaced.egg-info/SOURCES.txt +2 -0
- interlaced-2.8.3/src/interlace/scheduler/__init__.py +0 -17
- 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/src/interlace/state/advisory.py +0 -87
- interlaced-2.8.3/src/interlace/state/conn.py +0 -40
- {interlaced-2.8.3 → interlaced-2.8.6}/LICENSE +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/MANIFEST.in +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/README.md +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/setup.cfg +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cdc/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cdc/decode.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cdc/publish.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cdc/slot.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/checks/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/checks/runner.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/checks/spec.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cli/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cli/build.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cli/inspect.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cli/main.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cli/support.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/config/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/connections.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/contracts.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/dsl/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/dsl/discovery.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/dsl/dynamic.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/dsl/sql_config.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/adbc.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/base.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/bigquery.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/duckdb.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/postgres.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/quack.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/redshift.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/registry.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/snowflake.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/spark.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/exceptions.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/graph/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/graph/column_lineage.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/graph/dag.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/graph/selectors.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/inputs.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/inspect.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/ir/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/ir/canonicalize.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/ir/dialect.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/ir/fingerprint.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/ir/layout.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/ir/macros.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/ir/relation.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/ir/vars.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/mcp_server.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/physical/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/physical/changes.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/physical/ddl.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/physical/drift.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/physical/reconcile.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/physical/spec.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/annotate.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/apply.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/backfill.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/comment.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/delivery.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/files.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/fit.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/orchestrate.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/payload.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/plan.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/resolve.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/result.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/run.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/table_diff.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/transfer.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/py.typed +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/query.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/runtime/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/runtime/handles.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/runtime/python_model.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/scaffold.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/scheduler/worker.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/auth.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/present.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/routes/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/routes/admin.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/routes/environments.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/routes/models.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/routes/plan.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/routes/runs.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/routes/streams.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/app.css +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/favicon.svg +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/index.html +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/api.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/app.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/dag.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/timeline.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/ui.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/checks.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/environments.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/lineage.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/models.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/overview.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/query.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/runs.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/streams.js +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/sinks.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/sources/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/sources/auth.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/sources/rest.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/beat.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/cdc.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/checks.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/codec.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/janitor.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/keys.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/schema.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/snapshot.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/triggers.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/append.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/base.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/full_merge.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/hash_merge.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/incremental.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/merge.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/replace.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/replace_in_place.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/scd.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/view.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/streaming/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/streaming/materializer.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/streaming/schema.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/README.md +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/docker-compose.yml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/init/seed.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/interlace.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/models/orders.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/models/orders_by_status.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/models/orders_stream.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/push.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/template.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/README.md +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/generate.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/interlace.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/models/events.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/models/events_by_minute.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/models/events_by_type.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/models/top_users.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/models/user_spend.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/template.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/github/README.md +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/github/interlace.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/github/models/github_issues.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/github/models/issues_by_state.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/github/template.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/postgres/README.md +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/postgres/docker-compose.yml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/postgres/init/seed.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/postgres/interlace.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/postgres/models/orders.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/postgres/models/orders_by_status.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/postgres/template.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/quickstart/README.md +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/quickstart/interlace.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/quickstart/models/enriched_events.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/quickstart/models/event_summary.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/quickstart/models/raw_events.sql +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/quickstart/template.yaml +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/testing/__init__.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/testing/golden.py +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlaced.egg-info/dependency_links.txt +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlaced.egg-info/entry_points.txt +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/src/interlaced.egg-info/requires.txt +0 -0
- {interlaced-2.8.3 → interlaced-2.8.6}/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.6
|
|
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.6"
|
|
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,
|
|
@@ -250,6 +250,9 @@ class ProjectConfig(BaseModel):
|
|
|
250
250
|
default_engine: str = "default"
|
|
251
251
|
engines: dict[str, EngineConfig] = Field(default_factory=dict)
|
|
252
252
|
state_path: str = ".interlace/state.db" # SQLite control-plane database
|
|
253
|
+
# When set, snapshots, the queue, and the event log live in this Postgres
|
|
254
|
+
# database (schema ``interlace``) instead of ``state_path``. Not the warehouse.
|
|
255
|
+
state_url: str | None = None
|
|
253
256
|
# Optional NDJSON mirror of the operator event log. Unset writes nowhere.
|
|
254
257
|
# One JSON line is appended after each SQLite commit (CLI and daemon share it).
|
|
255
258
|
event_log_path: str | None = None
|
|
@@ -275,6 +278,11 @@ class ProjectConfig(BaseModel):
|
|
|
275
278
|
secrets: dict[str, SecretConfig] = Field(default_factory=dict)
|
|
276
279
|
quack_token: str | None = None # token for quack: databases (or INTERLACE_QUACK_TOKEN)
|
|
277
280
|
stream_path: str = ".interlace/streams.db" # durable stream log (SQLite WAL)
|
|
281
|
+
# When set, the stream log lives in this Postgres database (schema
|
|
282
|
+
# ``interlace_streams``) instead of ``stream_path``. A commit is durable
|
|
283
|
+
# under Postgres ``synchronous_commit`` (the default), the same promise as
|
|
284
|
+
# the SQLite WAL fsync.
|
|
285
|
+
stream_url: str | None = None
|
|
278
286
|
# Databases to ATTACH to the warehouse engine at open: alias -> DuckDB attach
|
|
279
287
|
# URI/path (a .duckdb file, "postgres:...", "sqlite:...", ...). Models can read
|
|
280
288
|
# them and table exports can write to them as <alias>.<schema>.<table>.
|
|
@@ -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,37 @@ 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
|
+
``after: [a, b]`` becomes ``"a,b"``."""
|
|
37
|
+
if not schedule:
|
|
38
|
+
return None
|
|
39
|
+
normalised: dict[str, str] = {}
|
|
40
|
+
for key, value in schedule.items():
|
|
41
|
+
if key == "fresh" and isinstance(value, dict):
|
|
42
|
+
column = value.get("column")
|
|
43
|
+
window = value.get("within")
|
|
44
|
+
if not isinstance(column, str) or not column.strip():
|
|
45
|
+
raise DefinitionError(f"model {model!r}: fresh.column must name a timestamp column")
|
|
46
|
+
if not isinstance(window, str) or not window.strip():
|
|
47
|
+
raise DefinitionError(f"model {model!r}: fresh.within must be a window like '2h'")
|
|
48
|
+
normalised[key] = f"{column.strip()} {window.strip()}"
|
|
49
|
+
continue
|
|
50
|
+
if key == "after" and isinstance(value, (list, tuple)):
|
|
51
|
+
names = [str(item).strip() for item in value]
|
|
52
|
+
if not names or any(not name for name in names):
|
|
53
|
+
raise DefinitionError(f"model {model!r}: after must name a model, or a list of models")
|
|
54
|
+
normalised[key] = ",".join(names)
|
|
55
|
+
continue
|
|
56
|
+
if key == "after" and not isinstance(value, str):
|
|
57
|
+
raise DefinitionError(f"model {model!r}: after must name a model, or a list of models")
|
|
58
|
+
if not isinstance(value, str):
|
|
59
|
+
raise DefinitionError(f"model {model!r}: schedule {key!r} must be a string")
|
|
60
|
+
normalised[key] = value
|
|
61
|
+
return normalised
|
|
62
|
+
|
|
63
|
+
|
|
33
64
|
# Set while a Python model function is running. Registrations then replace a
|
|
34
65
|
# model this run already created, and are recorded for the apply that builds them.
|
|
35
66
|
dynamic_owner: ContextVar[str | None] = ContextVar("interlace_dynamic_owner", default=None)
|
|
@@ -130,7 +161,9 @@ class ModelDef:
|
|
|
130
161
|
path: str | None = None # output path for materialise: file
|
|
131
162
|
format: str | None = None # csv | parquet | json for materialise: file
|
|
132
163
|
environments: tuple[str, ...] = ("prod",) # which environments actually deliver a terminal model
|
|
133
|
-
|
|
164
|
+
# cron, every, watch, on_change, fresh, or webhook. ``fresh`` may be the
|
|
165
|
+
# mapping ``{column, within}`` at construction; __post_init__ stores "column within".
|
|
166
|
+
schedule: dict[str, Any] | None = None
|
|
134
167
|
checks: tuple[CheckSpec, ...] = () # data-quality checks; error severity gates promotion
|
|
135
168
|
indexes: tuple[IndexSpec, ...] = () # physical indexes; not part of the data fingerprint
|
|
136
169
|
constraints: tuple[ConstraintSpec, ...] = () # physical constraints; engine-enforced, not checks
|
|
@@ -151,6 +184,7 @@ class ModelDef:
|
|
|
151
184
|
self.constraints = parse_constraints(self.constraints, self.name)
|
|
152
185
|
self.schema_policy = parse_schema_policy(self.schema_policy, self.name)
|
|
153
186
|
validate_physical_allowed(self.name, self.materialise, self.indexes, self.constraints, self.schema_policy)
|
|
187
|
+
self.schedule = normalize_schedule(self.schedule, model=self.name)
|
|
154
188
|
|
|
155
189
|
@property
|
|
156
190
|
def is_terminal(self) -> bool:
|
|
@@ -242,7 +276,7 @@ def model(
|
|
|
242
276
|
owner: str | None = None,
|
|
243
277
|
description: str | None = None,
|
|
244
278
|
columns: dict[str, str | None] | Sequence[str] | None = None,
|
|
245
|
-
schedule: dict[str,
|
|
279
|
+
schedule: dict[str, Any] | None = None,
|
|
246
280
|
checks: Sequence[dict[str, Any] | CheckSpec] | None = None,
|
|
247
281
|
indexes: Sequence[dict[str, Any] | IndexSpec] | None = None,
|
|
248
282
|
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,
|
|
@@ -338,6 +338,8 @@ async def diff( # noqa: C901
|
|
|
338
338
|
plan = Plan(environment=environment)
|
|
339
339
|
impact: dict[str, str] = {} # changed models only: "semantic" | "additive" | "clean"
|
|
340
340
|
touched: dict[str, frozenset[str] | None] = {} # semantic models: provably-changed columns (None = all)
|
|
341
|
+
# downstream models: the upstream columns that forced the rebuild
|
|
342
|
+
indirect: dict[str, frozenset[str]] = {}
|
|
341
343
|
|
|
342
344
|
previous_snapshots = await state.get_snapshots(
|
|
343
345
|
(name, fingerprint)
|
|
@@ -410,6 +412,8 @@ async def diff( # noqa: C901
|
|
|
410
412
|
consumed = _consumed_columns(model.ast, dep)
|
|
411
413
|
if dep_touched is not None and consumed is not None and not (consumed & dep_touched):
|
|
412
414
|
continue # column-pruned: reads only provably-unchanged columns
|
|
415
|
+
if dep_touched is not None and consumed is not None:
|
|
416
|
+
indirect[model.name] = consumed & dep_touched
|
|
413
417
|
verdict = "semantic"
|
|
414
418
|
break
|
|
415
419
|
if dep_impact == "additive" and _selects_star(model.ast):
|
|
@@ -436,8 +440,15 @@ async def diff( # noqa: C901
|
|
|
436
440
|
)
|
|
437
441
|
if inherit:
|
|
438
442
|
category = ChangeCategory.FORWARD_ONLY
|
|
443
|
+
shown = added
|
|
444
|
+
if impact.get(model.name) == "semantic":
|
|
445
|
+
own = touched.get(model.name)
|
|
446
|
+
if own:
|
|
447
|
+
shown = tuple(sorted(own))
|
|
448
|
+
elif model.name in indirect:
|
|
449
|
+
shown = tuple(sorted(indirect[model.name]))
|
|
439
450
|
plan.changes.append(
|
|
440
|
-
ModelChange(model.name, ChangeType.MODIFIED, category, previous_fingerprint, model.fingerprint,
|
|
451
|
+
ModelChange(model.name, ChangeType.MODIFIED, category, previous_fingerprint, model.fingerprint, shown)
|
|
441
452
|
)
|
|
442
453
|
if inherit: # copy-on-write: history seeds the NEW table; checks gate before views move
|
|
443
454
|
snapshot = replace(
|
|
@@ -25,7 +25,8 @@ from interlace.ir.relation import TableRef
|
|
|
25
25
|
from interlace.plan.backfill import run_backfill
|
|
26
26
|
from interlace.plan.plan import BackfillTask, Plan
|
|
27
27
|
from interlace.plan.result import ApplyResult, ProgressCallback, build_detail, failure_detail
|
|
28
|
-
from interlace.
|
|
28
|
+
from interlace.scheduler.completion import after_waiters, wake_after
|
|
29
|
+
from interlace.state.store import SqliteStateStore, StateStore
|
|
29
30
|
|
|
30
31
|
logger = logging.getLogger("interlace.apply")
|
|
31
32
|
|
|
@@ -123,6 +124,7 @@ async def schedule_builds( # noqa: C901
|
|
|
123
124
|
deps.add(ref)
|
|
124
125
|
blocking[name] = deps
|
|
125
126
|
|
|
127
|
+
waiting = after_waiters(compiled)
|
|
126
128
|
finished = {name: asyncio.Event() for name in per_model}
|
|
127
129
|
build_slots = asyncio.Semaphore(max(1, parallelism))
|
|
128
130
|
|
|
@@ -163,6 +165,8 @@ async def schedule_builds( # noqa: C901
|
|
|
163
165
|
finished[name].set()
|
|
164
166
|
if on_progress is not None:
|
|
165
167
|
on_progress(name, "done", build_detail(result, name))
|
|
168
|
+
if isinstance(state, SqliteStateStore):
|
|
169
|
+
await wake_after(state, compiled, name, building=set(per_model), waiters=waiting)
|
|
166
170
|
|
|
167
171
|
# Copied into each build task at creation, so a Python model that registers
|
|
168
172
|
# models (including from a worker thread) appends to this same list.
|
|
@@ -375,15 +375,20 @@ class Project:
|
|
|
375
375
|
)
|
|
376
376
|
|
|
377
377
|
async def open_state(self) -> SqliteStateStore:
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
378
|
+
if self.config.state_url:
|
|
379
|
+
store = await SqliteStateStore.open_postgres(self.config.state_url)
|
|
380
|
+
else:
|
|
381
|
+
path = self.root / self.config.state_path
|
|
382
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
383
|
+
store = await SqliteStateStore.open(path)
|
|
381
384
|
if self.config.event_log_path:
|
|
382
385
|
target = Path(self.config.event_log_path)
|
|
383
386
|
store.event_log_path = str(target if target.is_absolute() else self.root / target)
|
|
384
387
|
return store
|
|
385
388
|
|
|
386
389
|
async def open_stream_log(self) -> SqliteStreamLog:
|
|
390
|
+
if self.config.stream_url:
|
|
391
|
+
return await SqliteStreamLog.open_postgres(self.config.stream_url)
|
|
387
392
|
path = self.root / self.config.stream_path
|
|
388
393
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
389
394
|
return await SqliteStreamLog.open(path)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Orchestration: triggers, the trigger engine, and the worker (queue = state store).
|
|
2
|
+
|
|
3
|
+
Submodules import each other. This package does not load them up front: importing
|
|
4
|
+
``interlace.scheduler.completion`` must not pull in the worker, which imports the
|
|
5
|
+
apply path that imports completion.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CronTrigger",
|
|
15
|
+
"IntervalTrigger",
|
|
16
|
+
"RunRequest",
|
|
17
|
+
"Trigger",
|
|
18
|
+
"TriggerEngine",
|
|
19
|
+
"build_triggers",
|
|
20
|
+
"drain",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
_EXPORTS: dict[str, tuple[str, str]] = {
|
|
24
|
+
"CronTrigger": ("interlace.scheduler.triggers", "CronTrigger"),
|
|
25
|
+
"IntervalTrigger": ("interlace.scheduler.triggers", "IntervalTrigger"),
|
|
26
|
+
"RunRequest": ("interlace.scheduler.triggers", "RunRequest"),
|
|
27
|
+
"Trigger": ("interlace.scheduler.triggers", "Trigger"),
|
|
28
|
+
"TriggerEngine": ("interlace.scheduler.engine", "TriggerEngine"),
|
|
29
|
+
"build_triggers": ("interlace.scheduler.engine", "build_triggers"),
|
|
30
|
+
"drain": ("interlace.scheduler.worker", "drain"),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def __getattr__(name: str) -> Any:
|
|
35
|
+
found = _EXPORTS.get(name)
|
|
36
|
+
if found is None:
|
|
37
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
38
|
+
module_name, attr = found
|
|
39
|
+
return getattr(importlib.import_module(module_name), attr)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Run a model when another model finishes.
|
|
2
|
+
|
|
3
|
+
``schedule: {after: …}`` is not a clock tick. Apply calls :func:`wake_after` once
|
|
4
|
+
a build reaches ``model.done``. The trigger engine only validates the graph.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from interlace.exceptions import DefinitionError
|
|
12
|
+
from interlace.graph.project import CompiledProject
|
|
13
|
+
from interlace.state.store import SqliteStateStore
|
|
14
|
+
|
|
15
|
+
_SCHEDULE_KINDS = ("cron", "every", "watch", "on_change", "fresh", "after", "webhook")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def schedule_kind(model: str, schedule: dict[str, str]) -> tuple[str, str]:
|
|
19
|
+
"""The one schedule key and its value.
|
|
20
|
+
|
|
21
|
+
Cron, interval, file watch, table change, freshness, upstream completion, or webhook.
|
|
22
|
+
"""
|
|
23
|
+
present = [key for key in _SCHEDULE_KINDS if key in schedule]
|
|
24
|
+
if len(present) != 1:
|
|
25
|
+
raise DefinitionError(
|
|
26
|
+
f"model {model!r}: schedule needs exactly one of {', '.join(_SCHEDULE_KINDS)}",
|
|
27
|
+
details={"schedule": schedule},
|
|
28
|
+
)
|
|
29
|
+
return present[0], schedule[present[0]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def scheduled_closure(project: CompiledProject, models: list[str]) -> list[str]:
|
|
33
|
+
"""The triggered models plus every model downstream of them.
|
|
34
|
+
|
|
35
|
+
A cron, interval, file watch, table change, freshness, upstream completion, or webhook
|
|
36
|
+
means that model's inputs changed. Downstream snapshots would otherwise keep the
|
|
37
|
+
previous build. An explicit ``run --select`` is not expanded here: ``model``,
|
|
38
|
+
``model+``, and ``+model`` stay exactly what was asked for.
|
|
39
|
+
"""
|
|
40
|
+
chosen: set[str] = set()
|
|
41
|
+
for name in models:
|
|
42
|
+
chosen.add(name)
|
|
43
|
+
if name in project.models:
|
|
44
|
+
chosen |= project.graph.descendants(name)
|
|
45
|
+
return sorted(chosen)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def after_waiters(project: CompiledProject) -> dict[str, list[str]]:
|
|
49
|
+
"""Map an upstream model to the models that run when it finishes.
|
|
50
|
+
|
|
51
|
+
``schedule: {after: raw}`` or ``{after: [raw, staging]}`` (stored as
|
|
52
|
+
``"raw,staging"``). A model cannot name itself, and the ``after`` edges
|
|
53
|
+
cannot cycle — otherwise two models would enqueue each other forever.
|
|
54
|
+
"""
|
|
55
|
+
found: dict[str, list[str]] = {}
|
|
56
|
+
for model in project.models.values():
|
|
57
|
+
if not model.schedule or "after" not in model.schedule:
|
|
58
|
+
continue
|
|
59
|
+
kind, value = schedule_kind(model.name, model.schedule)
|
|
60
|
+
if kind != "after":
|
|
61
|
+
continue
|
|
62
|
+
names = list(dict.fromkeys(part.strip() for part in value.split(",") if part.strip()))
|
|
63
|
+
if not names:
|
|
64
|
+
raise DefinitionError(f"model {model.name!r}: after must name a model")
|
|
65
|
+
if model.name in names:
|
|
66
|
+
raise DefinitionError(f"model {model.name!r}: after cannot name itself")
|
|
67
|
+
missing = [name for name in names if name not in project.models]
|
|
68
|
+
if missing:
|
|
69
|
+
raise DefinitionError(
|
|
70
|
+
f"model {model.name!r}: after names unknown models: {', '.join(missing)}",
|
|
71
|
+
details={"missing": missing},
|
|
72
|
+
)
|
|
73
|
+
for name in names:
|
|
74
|
+
found.setdefault(name, []).append(model.name)
|
|
75
|
+
_reject_after_cycles(found)
|
|
76
|
+
return found
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _reject_after_cycles(waiters: dict[str, list[str]]) -> None:
|
|
80
|
+
visiting: set[str] = set()
|
|
81
|
+
visited: set[str] = set()
|
|
82
|
+
|
|
83
|
+
def walk(node: str) -> None:
|
|
84
|
+
if node in visited:
|
|
85
|
+
return
|
|
86
|
+
if node in visiting:
|
|
87
|
+
raise DefinitionError(f"schedule after has a cycle through {node!r}")
|
|
88
|
+
visiting.add(node)
|
|
89
|
+
for nxt in waiters.get(node, ()):
|
|
90
|
+
walk(nxt)
|
|
91
|
+
visiting.remove(node)
|
|
92
|
+
visited.add(node)
|
|
93
|
+
|
|
94
|
+
for node in list(waiters):
|
|
95
|
+
walk(node)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def wake_after(
|
|
99
|
+
store: SqliteStateStore,
|
|
100
|
+
project: CompiledProject,
|
|
101
|
+
upstream: str,
|
|
102
|
+
*,
|
|
103
|
+
building: set[str],
|
|
104
|
+
waiters: dict[str, list[str]] | None = None,
|
|
105
|
+
stamp: str | None = None,
|
|
106
|
+
) -> int:
|
|
107
|
+
"""Enqueue models waiting on ``upstream`` after it reaches ``model.done``.
|
|
108
|
+
|
|
109
|
+
``building`` is the set already in this apply, so a model the plan is
|
|
110
|
+
constructing is not queued again. One stamp shared by the waiters of a
|
|
111
|
+
single finish: a retry of the same finish dedupes, a later finish does not.
|
|
112
|
+
Returns how many runs were newly enqueued.
|
|
113
|
+
"""
|
|
114
|
+
table = after_waiters(project) if waiters is None else waiters
|
|
115
|
+
pending = [name for name in table.get(upstream, ()) if name not in building]
|
|
116
|
+
if not pending:
|
|
117
|
+
return 0
|
|
118
|
+
order = {model.name: index for index, model in enumerate(project.ordered())}
|
|
119
|
+
pending.sort(key=lambda name: order.get(name, 0))
|
|
120
|
+
token = stamp if stamp is not None else str(time.time_ns())
|
|
121
|
+
covered: set[str] = set(building)
|
|
122
|
+
enqueued = 0
|
|
123
|
+
for name in pending:
|
|
124
|
+
if name in covered:
|
|
125
|
+
continue
|
|
126
|
+
selector = scheduled_closure(project, [name])
|
|
127
|
+
key = f"after:{name}:{upstream}:{token}"
|
|
128
|
+
if await store.enqueue_run(key, selector, None, 0):
|
|
129
|
+
enqueued += 1
|
|
130
|
+
await store.append_event("run.enqueued", entity=key, payload={"models": selector, "after": upstream})
|
|
131
|
+
covered.update(selector)
|
|
132
|
+
return enqueued
|
|
@@ -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
|