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.
Files changed (103) hide show
  1. {interlaced-2.0.2/src/interlaced.egg-info → interlaced-2.0.3}/PKG-INFO +1 -1
  2. {interlaced-2.0.2 → interlaced-2.0.3}/pyproject.toml +1 -1
  3. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/cli/main.py +18 -13
  4. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/base.py +7 -3
  5. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/duckdb.py +6 -10
  6. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/app.py +72 -14
  7. {interlaced-2.0.2 → interlaced-2.0.3/src/interlaced.egg-info}/PKG-INFO +1 -1
  8. {interlaced-2.0.2 → interlaced-2.0.3}/LICENSE +0 -0
  9. {interlaced-2.0.2 → interlaced-2.0.3}/MANIFEST.in +0 -0
  10. {interlaced-2.0.2 → interlaced-2.0.3}/README.md +0 -0
  11. {interlaced-2.0.2 → interlaced-2.0.3}/setup.cfg +0 -0
  12. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/__init__.py +0 -0
  13. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/checks/__init__.py +0 -0
  14. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/checks/builtin.py +0 -0
  15. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/checks/runner.py +0 -0
  16. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/checks/spec.py +0 -0
  17. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/cli/__init__.py +0 -0
  18. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/config/__init__.py +0 -0
  19. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/config/config.py +0 -0
  20. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/contracts.py +0 -0
  21. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/dsl/__init__.py +0 -0
  22. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/dsl/decorators.py +0 -0
  23. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/dsl/discovery.py +0 -0
  24. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/dsl/sql_config.py +0 -0
  25. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/__init__.py +0 -0
  26. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/adbc.py +0 -0
  27. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/bigquery.py +0 -0
  28. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/postgres.py +0 -0
  29. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/quack.py +0 -0
  30. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/redshift.py +0 -0
  31. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/registry.py +0 -0
  32. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/snowflake.py +0 -0
  33. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/engines/spark.py +0 -0
  34. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/exceptions.py +0 -0
  35. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/graph/__init__.py +0 -0
  36. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/graph/column_lineage.py +0 -0
  37. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/graph/dag.py +0 -0
  38. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/graph/project.py +0 -0
  39. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/graph/selectors.py +0 -0
  40. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/ir/__init__.py +0 -0
  41. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/ir/canonicalize.py +0 -0
  42. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/ir/fingerprint.py +0 -0
  43. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/ir/relation.py +0 -0
  44. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/plan/__init__.py +0 -0
  45. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/plan/apply.py +0 -0
  46. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/plan/differ.py +0 -0
  47. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/plan/plan.py +0 -0
  48. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/plan/resolve.py +0 -0
  49. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/plan/run.py +0 -0
  50. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/project.py +0 -0
  51. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/py.typed +0 -0
  52. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/runtime/__init__.py +0 -0
  53. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/runtime/handles.py +0 -0
  54. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/runtime/python_model.py +0 -0
  55. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/scaffold.py +0 -0
  56. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/scheduler/__init__.py +0 -0
  57. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/scheduler/engine.py +0 -0
  58. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/scheduler/triggers.py +0 -0
  59. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/scheduler/worker.py +0 -0
  60. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/__init__.py +0 -0
  61. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/auth.py +0 -0
  62. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/app.css +0 -0
  63. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/favicon.svg +0 -0
  64. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/index.html +0 -0
  65. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/api.js +0 -0
  66. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/app.js +0 -0
  67. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/dag.js +0 -0
  68. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/ui.js +0 -0
  69. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/checks.js +0 -0
  70. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/environments.js +0 -0
  71. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/lineage.js +0 -0
  72. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/models.js +0 -0
  73. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/overview.js +0 -0
  74. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/plan.js +0 -0
  75. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/query.js +0 -0
  76. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/runs.js +0 -0
  77. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/streams.js +0 -0
  78. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/service/ui/js/views/system.js +0 -0
  79. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/sinks.py +0 -0
  80. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/state/__init__.py +0 -0
  81. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/state/interval.py +0 -0
  82. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/state/janitor.py +0 -0
  83. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/state/snapshot.py +0 -0
  84. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/state/store.py +0 -0
  85. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/__init__.py +0 -0
  86. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/append.py +0 -0
  87. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/base.py +0 -0
  88. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/full_merge.py +0 -0
  89. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/incremental_by_time.py +0 -0
  90. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/merge.py +0 -0
  91. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/replace.py +0 -0
  92. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/replace_in_place.py +0 -0
  93. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/scd.py +0 -0
  94. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/strategies/view.py +0 -0
  95. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/streaming/__init__.py +0 -0
  96. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/streaming/log.py +0 -0
  97. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/streaming/materializer.py +0 -0
  98. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlace/streaming/schema.py +0 -0
  99. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlaced.egg-info/SOURCES.txt +0 -0
  100. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlaced.egg-info/dependency_links.txt +0 -0
  101. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlaced.egg-info/entry_points.txt +0 -0
  102. {interlaced-2.0.2 → interlaced-2.0.3}/src/interlaced.egg-info/requires.txt +0 -0
  103. {interlaced-2.0.2 → interlaced-2.0.3}/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.0.2
3
+ Version: 2.0.3
4
4
  Summary: Python/SQL-first data platform: transformation, built-in orchestration, and durable streaming ingestion
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.0.2"
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
- uvicorn.run(
659
- create_app(
660
- path,
661
- environment,
662
- quack=quack or None,
663
- quack_token=token or None,
664
- scheduler=scheduler,
665
- scheduler_interval=interval,
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; without a
670
- # bound, uvicorn's graceful shutdown waits on them indefinitely ("Waiting
671
- # for connections to close") and Ctrl+C appears to hang. Lifespan cleanup
672
- # (flush, store/engine close) runs only after this drain completes.
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 default cannot enforce that, so it is the caller's job to
89
- gate this path; adapters over an embedded engine that CAN read the host
90
- filesystem (DuckDB) MUST override to lock the connection down."""
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
- async def fetch_sandboxed(self, ast: exp.Expression) -> pa.RecordBatchReader:
207
- return await asyncio.to_thread(self._fetch_sync, self.transpile(ast), sandboxed=True)
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, *, sandboxed: bool = False) -> pa.RecordBatchReader:
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
- for node in parsed.walk(): # type: ignore[attr-defined]
868
- name = None
869
- if isinstance(node, _exp.Anonymous):
870
- name = str(node.this)
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) # defense in depth; the sandboxed cursor is the real fence
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
- # fetch_sandboxed runs with external access disabled: query()/read_csv()/
907
- # httpfs cannot reach host files or the network however they are spelled
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
- for task in (loop_task, flusher_task, tail_task):
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: interlaced
3
- Version: 2.0.2
3
+ Version: 2.0.3
4
4
  Summary: Python/SQL-first data platform: transformation, built-in orchestration, and durable streaming ingestion
5
5
  Author-email: Mark <mark@interlace.sh>
6
6
  License-Expression: MIT
File without changes
File without changes
File without changes
File without changes