interlaced 2.0.2__tar.gz → 2.0.3__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.0.2/src/interlaced.egg-info → interlaced-2.0.3}/PKG-INFO +1 -1
- {interlaced-2.0.2 → interlaced-2.0.3}/pyproject.toml +1 -1
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/cli/main.py +18 -13
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/base.py +7 -3
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/duckdb.py +6 -10
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/app.py +72 -14
- {interlaced-2.0.2 → interlaced-2.0.3/src/interlaced.egg-info}/PKG-INFO +1 -1
- {interlaced-2.0.2 → interlaced-2.0.3}/LICENSE +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/MANIFEST.in +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/README.md +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/setup.cfg +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/__init__.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/checks/__init__.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/checks/builtin.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/checks/runner.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/checks/spec.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/cli/__init__.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/config/__init__.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/config/config.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/contracts.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/dsl/__init__.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/dsl/decorators.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/dsl/discovery.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/dsl/sql_config.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/__init__.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/adbc.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/bigquery.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/postgres.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/quack.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/redshift.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/registry.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/snowflake.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/spark.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/exceptions.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/graph/__init__.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/graph/column_lineage.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/graph/dag.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/graph/project.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/graph/selectors.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/ir/__init__.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/ir/canonicalize.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/ir/fingerprint.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/ir/relation.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/plan/__init__.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/plan/apply.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/plan/differ.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/plan/plan.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/plan/resolve.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/plan/run.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/project.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/py.typed +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/runtime/__init__.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/runtime/handles.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/runtime/python_model.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/scaffold.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/scheduler/__init__.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/scheduler/engine.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/scheduler/triggers.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/scheduler/worker.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/__init__.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/auth.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/app.css +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/favicon.svg +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/index.html +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/api.js +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/app.js +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/dag.js +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/ui.js +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/checks.js +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/environments.js +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/lineage.js +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/models.js +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/overview.js +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/plan.js +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/query.js +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/runs.js +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/streams.js +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/system.js +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/sinks.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/state/__init__.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/state/interval.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/state/janitor.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/state/snapshot.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/state/store.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/__init__.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/append.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/base.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/full_merge.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/incremental_by_time.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/merge.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/replace.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/replace_in_place.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/scd.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/view.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/streaming/__init__.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/streaming/log.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/streaming/materializer.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/streaming/schema.py +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlaced.egg-info/SOURCES.txt +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlaced.egg-info/dependency_links.txt +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlaced.egg-info/entry_points.txt +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlaced.egg-info/requires.txt +0 -0
- {interlaced-2.0.2 → interlaced-2.0.3}/src/interlaced.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "interlaced"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.3"
|
|
8
8
|
description = "Python/SQL-first data platform: transformation, built-in orchestration, and durable streaming ingestion"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -655,23 +655,28 @@ def serve(
|
|
|
655
655
|
token = secrets.token_hex(8)
|
|
656
656
|
console.print(f"[bold]quack[/bold] warehouse at [cyan]{quack}[/cyan] · token [yellow]{token}[/yellow]")
|
|
657
657
|
console.print("Clients: set [bold]database: quack:...[/bold] and INTERLACE_QUACK_TOKEN in the environment.")
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
658
|
+
app = create_app(
|
|
659
|
+
path,
|
|
660
|
+
environment,
|
|
661
|
+
quack=quack or None,
|
|
662
|
+
quack_token=token or None,
|
|
663
|
+
scheduler=scheduler,
|
|
664
|
+
scheduler_interval=interval,
|
|
665
|
+
)
|
|
666
|
+
config = uvicorn.Config(
|
|
667
|
+
app,
|
|
667
668
|
host=host,
|
|
668
669
|
port=port,
|
|
669
|
-
# SSE clients (/events/stream) hold their response open forever
|
|
670
|
-
#
|
|
671
|
-
#
|
|
672
|
-
# (
|
|
670
|
+
# SSE clients (/events/stream) hold their response open forever. The app's
|
|
671
|
+
# shutdown watcher ends them the instant this server flips `should_exit`, so
|
|
672
|
+
# the graceful-shutdown drain finds the connections already closed instead of
|
|
673
|
+
# force-cancelling them (which used to dump a CancelledError traceback on
|
|
674
|
+
# Ctrl+C). The bound is a backstop; lifespan cleanup runs after the drain.
|
|
673
675
|
timeout_graceful_shutdown=3,
|
|
674
676
|
)
|
|
677
|
+
server = uvicorn.Server(config)
|
|
678
|
+
app.state.uvicorn_server = server # let the app release SSE streams as shutdown begins
|
|
679
|
+
server.run()
|
|
675
680
|
|
|
676
681
|
|
|
677
682
|
@app.command("models")
|
|
@@ -85,9 +85,13 @@ class EngineAdapter(ABC):
|
|
|
85
85
|
async def fetch_sandboxed(self, ast: exp.Expression) -> pa.RecordBatchReader:
|
|
86
86
|
"""Like :meth:`fetch`, but for untrusted queries (the HTTP query console):
|
|
87
87
|
the engine must not touch anything outside the warehouse — no local files,
|
|
88
|
-
no network. The
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
no network. The engine cannot always enforce that itself (DuckDB's
|
|
89
|
+
``enable_external_access`` is instance-wide and one-way, so disabling it on
|
|
90
|
+
the shared warehouse connection would permanently break the writer's own
|
|
91
|
+
file writes), so **the caller MUST gate this path** — the service validates
|
|
92
|
+
the SELECT's AST (real tables only, no table functions, no file paths) before
|
|
93
|
+
calling here. An engine with a genuinely isolated read-only mode may override
|
|
94
|
+
to add defence in depth."""
|
|
91
95
|
return await self.fetch(ast)
|
|
92
96
|
|
|
93
97
|
def transpile(self, ast: exp.Expression) -> str:
|
|
@@ -203,8 +203,11 @@ class DuckDBAdapter(EngineAdapter):
|
|
|
203
203
|
async def fetch_sql(self, sql: str) -> pa.RecordBatchReader:
|
|
204
204
|
return await asyncio.to_thread(self._fetch_sync, sql)
|
|
205
205
|
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
# No fetch_sandboxed override: DuckDB cannot sandbox one query on the shared
|
|
207
|
+
# warehouse connection without poisoning the writer (enable_external_access is
|
|
208
|
+
# instance-wide and one-way, and a DuckLake catalog can be held by only one
|
|
209
|
+
# connection per process). The untrusted /query path is fenced at parse time by
|
|
210
|
+
# the service's _guard_console_query instead — as the base contract expects.
|
|
208
211
|
|
|
209
212
|
async def create_schema(self, name: str) -> None:
|
|
210
213
|
await self.execute_sql(f"CREATE SCHEMA IF NOT EXISTS {exp.to_identifier(name).sql(dialect=self.dialect)}")
|
|
@@ -244,19 +247,12 @@ class DuckDBAdapter(EngineAdapter):
|
|
|
244
247
|
cur.close()
|
|
245
248
|
return counts
|
|
246
249
|
|
|
247
|
-
def _fetch_sync(self, sql: str
|
|
250
|
+
def _fetch_sync(self, sql: str) -> pa.RecordBatchReader:
|
|
248
251
|
# Read-only: deliberately not locked, so scans run concurrently (DuckDB MVCC).
|
|
249
252
|
# The cursor must outlive the stream that reads from it, so it is closed when
|
|
250
253
|
# that stream ends rather than left for the garbage collector to reclaim on
|
|
251
254
|
# whatever thread happens to drop the last reference.
|
|
252
255
|
cur = self._cursor()
|
|
253
|
-
if sandboxed:
|
|
254
|
-
# Untrusted query (the HTTP console). enable_external_access is
|
|
255
|
-
# connection-local, so this cursor can neither read host files nor
|
|
256
|
-
# open network connections — closing the query()/read_csv()/httpfs
|
|
257
|
-
# escape hatches at the engine level, not by name-matching. The build
|
|
258
|
-
# path (other cursors) keeps external access for exports and ATTACH.
|
|
259
|
-
cur.execute("SET enable_external_access = false")
|
|
260
256
|
cur.execute(sql)
|
|
261
257
|
reader = cur.to_arrow_reader()
|
|
262
258
|
|
|
@@ -858,24 +858,58 @@ _EXTERNAL_FN = re.compile(
|
|
|
858
858
|
r"|(_scan|_query)$",
|
|
859
859
|
re.IGNORECASE,
|
|
860
860
|
)
|
|
861
|
+
# Table functions the console may use: pure in-memory row generators, no I/O.
|
|
862
|
+
# `range(...)` normalises to generate_series in the sqlglot AST. Anything else in
|
|
863
|
+
# table position (read_csv / read_parquet / query / query_table / glob / …) is
|
|
864
|
+
# rejected, named or not — the allowlist is the robust part.
|
|
865
|
+
_SAFE_TABLE_FUNCTIONS = frozenset({"generate_series"})
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _console_fn_name(node: object) -> str | None:
|
|
869
|
+
from sqlglot import exp as _exp
|
|
870
|
+
|
|
871
|
+
if isinstance(node, _exp.Anonymous):
|
|
872
|
+
return str(node.this).lower()
|
|
873
|
+
if isinstance(node, _exp.Func):
|
|
874
|
+
return str(node.sql_name()).lower()
|
|
875
|
+
return None
|
|
876
|
+
|
|
877
|
+
|
|
861
878
|
_QUERY_MAX_CELL_BYTES = 8_000_000 # ~8 MB of rendered cells; the console inspects, never extracts
|
|
862
879
|
|
|
863
880
|
|
|
864
881
|
def _guard_console_query(parsed: object) -> None:
|
|
882
|
+
"""Reject anything the read-only console must never run. This is the *fence*, not
|
|
883
|
+
merely defence in depth: an engine-level lockdown is not available to us. On a
|
|
884
|
+
DuckLake warehouse the console shares the writer's DuckDB connection (a process
|
|
885
|
+
can hold the catalog file only once), and ``enable_external_access`` is
|
|
886
|
+
instance-wide and one-way — flipping it off to sandbox a query would permanently
|
|
887
|
+
disable the writer's own file writes (the flusher, apply, exports).
|
|
888
|
+
|
|
889
|
+
So the SELECT is validated structurally. The key rule: every table source must be
|
|
890
|
+
a real table/view identifier, never a table function. ``read_csv`` / ``read_parquet``
|
|
891
|
+
/ ``query`` / ``query_table`` / ``glob`` / … all parse as a *function* in table
|
|
892
|
+
position (``Table.this`` is a function node, not an ``Identifier``), so this
|
|
893
|
+
rejects every one of them — including any not yet named. A function-name denylist
|
|
894
|
+
and a file-path check backstop it for the rare scalar-position reader."""
|
|
865
895
|
from sqlglot import exp as _exp
|
|
866
896
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
if isinstance(
|
|
870
|
-
|
|
871
|
-
elif isinstance(node, _exp.Func):
|
|
872
|
-
name = node.sql_name()
|
|
873
|
-
if name and _EXTERNAL_FN.search(name):
|
|
874
|
-
raise ClientException(detail=f"function {name!r} reads outside the warehouse — not in the console")
|
|
875
|
-
if isinstance(node, _exp.Table):
|
|
876
|
-
raw = node.name or ""
|
|
897
|
+
parsed_expr: _exp.Expression = parsed # type: ignore[assignment]
|
|
898
|
+
for table in parsed_expr.find_all(_exp.Table):
|
|
899
|
+
if isinstance(table.this, _exp.Identifier): # a real table/view reference
|
|
900
|
+
raw = table.name or ""
|
|
877
901
|
if "/" in raw or "\\" in raw or "://" in raw:
|
|
878
902
|
raise ClientException(detail="file paths are not queryable from the console")
|
|
903
|
+
continue
|
|
904
|
+
name = _console_fn_name(table.this) # a table function in FROM/JOIN position
|
|
905
|
+
if name not in _SAFE_TABLE_FUNCTIONS:
|
|
906
|
+
raise ClientException(
|
|
907
|
+
detail=f"table function {name or 'call'!r} is not queryable from the console — read tables only"
|
|
908
|
+
)
|
|
909
|
+
for node in parsed_expr.walk(): # backstop: file/network readers in scalar position
|
|
910
|
+
name = _console_fn_name(node)
|
|
911
|
+
if name and _EXTERNAL_FN.search(name):
|
|
912
|
+
raise ClientException(detail=f"function {name!r} reads outside the warehouse — not in the console")
|
|
879
913
|
|
|
880
914
|
|
|
881
915
|
@post("/query", opt={"scope": "read"})
|
|
@@ -897,14 +931,14 @@ async def post_query(data: QueryRequest, state: State) -> QueryResponse:
|
|
|
897
931
|
parsed = statements[0]
|
|
898
932
|
if not isinstance(parsed, (_exp.Select, _exp.Union)):
|
|
899
933
|
raise ClientException(detail="SELECT only — the console never writes")
|
|
900
|
-
_guard_console_query(parsed) #
|
|
934
|
+
_guard_console_query(parsed) # the fence: SELECT-only, tables (not table functions), no file paths
|
|
901
935
|
limit = max(1, min(data.limit, 10_000))
|
|
902
936
|
bounded = _exp.select("*").from_(parsed.subquery("q")).limit(limit + 1) # +1: exact truncation
|
|
903
937
|
started = _time.perf_counter()
|
|
904
938
|
|
|
905
939
|
async def _run() -> pa.Table:
|
|
906
|
-
#
|
|
907
|
-
#
|
|
940
|
+
# The query was already fenced by _guard_console_query (SELECT-only, no table
|
|
941
|
+
# functions, no file paths), so no host file or network read can be expressed.
|
|
908
942
|
reader = await state.engine.fetch_sandboxed(bounded)
|
|
909
943
|
return await asyncio.to_thread(reader.read_all)
|
|
910
944
|
|
|
@@ -1469,9 +1503,28 @@ def create_app(
|
|
|
1469
1503
|
logger.exception("scheduler tick failed; retrying next interval")
|
|
1470
1504
|
await asyncio.sleep(scheduler_interval)
|
|
1471
1505
|
|
|
1506
|
+
async def shutdown_watch() -> None:
|
|
1507
|
+
"""End every open SSE stream the moment uvicorn begins shutting down.
|
|
1508
|
+
|
|
1509
|
+
SSE responses (/events/stream) block forever on their queue, so at Ctrl+C
|
|
1510
|
+
uvicorn's graceful-shutdown drain waits the full timeout and then force-
|
|
1511
|
+
cancels them — which surfaces as a CancelledError traceback from the held-
|
|
1512
|
+
open stream. Poisoning the subscribers (their tail() returns on ``None``)
|
|
1513
|
+
lets the drain find the connections already closed, so shutdown is clean
|
|
1514
|
+
and immediate. ``uvicorn_server`` is injected by the serve command."""
|
|
1515
|
+
server = getattr(app.state, "uvicorn_server", None)
|
|
1516
|
+
if server is None:
|
|
1517
|
+
return
|
|
1518
|
+
while not getattr(server, "should_exit", False):
|
|
1519
|
+
await asyncio.sleep(0.2)
|
|
1520
|
+
for subscriber in list(app.state.sse_subscribers):
|
|
1521
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
1522
|
+
subscriber.put_nowait(None)
|
|
1523
|
+
|
|
1472
1524
|
tail_task = asyncio.create_task(event_tail())
|
|
1473
1525
|
flusher_task = asyncio.create_task(flusher_loop()) if streams else None
|
|
1474
1526
|
loop_task = asyncio.create_task(scheduler_loop()) if scheduler else None
|
|
1527
|
+
watch_task = asyncio.create_task(shutdown_watch())
|
|
1475
1528
|
try:
|
|
1476
1529
|
yield
|
|
1477
1530
|
finally:
|
|
@@ -1481,7 +1534,12 @@ def create_app(
|
|
|
1481
1534
|
# and the store/log/engine handles would leak — shield the whole thing.
|
|
1482
1535
|
import anyio
|
|
1483
1536
|
|
|
1484
|
-
|
|
1537
|
+
# Belt-and-braces: release any SSE stream still open (the watcher above
|
|
1538
|
+
# normally does this the instant should_exit flips).
|
|
1539
|
+
for subscriber in list(app.state.sse_subscribers):
|
|
1540
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
1541
|
+
subscriber.put_nowait(None)
|
|
1542
|
+
for task in (loop_task, flusher_task, tail_task, watch_task):
|
|
1485
1543
|
if task is not None:
|
|
1486
1544
|
task.cancel()
|
|
1487
1545
|
# suppress Exception too: a task that already died must not
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|