Skip to content

Commit c9494f3

Browse files
committed
Merge branch 'main' into v3
2 parents 914ef02 + dea4c2b commit c9494f3

29 files changed

Lines changed: 980 additions & 230 deletions

.copier-answers.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Changes here will be overwritten by Copier
2-
_commit: b8951e0
2+
_commit: dbcb02b
33
_src_path: https://github.com/python-project-templates/base.git
44
add_docs: false
55
add_extension: js

.github/workflows/build.yaml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,11 @@ jobs:
3232
strategy:
3333
matrix:
3434
os: [ubuntu-latest]
35-
python-version: ["3.11"]
35+
python-version: ["3.11", "3.14"]
3636
node-version: [22.x]
3737

3838
steps:
39-
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
39+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
4040

4141
- name: Set up Python
4242
uses: actions-ext/python/setup@main
@@ -71,7 +71,7 @@ jobs:
7171
with:
7272
module: csp_gateway
7373

74-
- uses: actions/upload-artifact@v7
74+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
7575
with:
7676
name: dist
7777
path: dist
@@ -89,7 +89,7 @@ jobs:
8989
os: macos-latest
9090

9191
steps:
92-
- uses: actions/checkout@v6
92+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
9393

9494
- name: Set up Python
9595
uses: actions-ext/python/setup@main
@@ -103,7 +103,7 @@ jobs:
103103
run: make develop-js
104104

105105
- name: Download wheels
106-
uses: actions/download-artifact@v8
106+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
107107
with:
108108
name: dist
109109

@@ -121,13 +121,13 @@ jobs:
121121
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'
122122

123123
- name: Publish Unit Test Results
124-
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
124+
uses: EnricoMi/publish-unit-test-result-action@d0a4676d0e0b938bc201470d88276b7c74c712b3 # v2.24.0
125125
with:
126126
files: junit.xml
127127
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'
128128

129129
- name: Upload coverage
130-
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
130+
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
131131
with:
132132
token: ${{ secrets.CODECOV_TOKEN }}
133133
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'
@@ -140,7 +140,7 @@ jobs:
140140
wheel: ["", "client"]
141141

142142
steps:
143-
- uses: actions/checkout@v6
143+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
144144

145145
- name: Set up Python
146146
uses: actions-ext/python/setup@main

.github/workflows/wiki.yaml

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,11 @@ jobs:
2020
deploy:
2121
runs-on: ubuntu-latest
2222
steps:
23-
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
23+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
2424
- run: cp README.md docs/wiki/Home.md
25-
- uses: Andrew-Chen-Wang/github-wiki-action@64efa0a9436db17670a2259e0ac249d6f08bb352 # v5
26-
with:
27-
path: docs/wiki
28-
2925
- uses: actions-ext/python/setup@main
3026
- run: pip install nbconvert
3127
- run: jupyter nbconvert examples/Client.ipynb --to markdown --output ../docs/wiki/Client.md
32-
33-
- uses: Andrew-Chen-Wang/github-wiki-action@v5
28+
- uses: Andrew-Chen-Wang/github-wiki-action@4e78882ff52f8751488e506e49b221145506ee34 # v5.0.5
3429
with:
3530
path: docs/wiki

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,8 @@ emsdk
146146
.DS_Store
147147

148148
# Hydra
149-
outputs/
150-
multirun/
149+
/outputs/
150+
/multirun/
151151

152152
# AI
153153
ROADMAP.md

csp_gateway/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "2.7.1"
1+
__version__ = "2.8.0"
22

33
try:
44
from .client import *

csp_gateway/server/gateway/csp/state.py

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import abc
22
import datetime
33
import logging
4+
import os
45
import threading
56
import typing
67
from collections import deque
@@ -29,6 +30,20 @@
2930
_DUCKDB_BUFFER_THRESHOLD_CURRENT = _DUCKDB_BUFFER_THRESHOLD_ORIGINAL
3031
_USE_DUCKDB_STATE = True
3132

33+
# DuckDB sizes its worker-thread pool to the host core count by default, spawning (threads - 1)
34+
# background workers per database instance. To keep the worker count from scaling with the number of
35+
# Struct channels (and with the host core count), every DuckDBState shares a single process-wide DuckDB
36+
# instance -- each state operates on its own cursor -- so the pool is created once and its size is a
37+
# single global knob. The default caps the pool at 4 threads: bounded on large hosts, while still
38+
# allowing some intra-query parallelism. Tune it with modify_duckdb_threads.
39+
# See https://github.com/Point72/csp-gateway/issues/292
40+
_DUCKDB_THREADS_ORIGINAL = min(os.cpu_count() or 1, 4)
41+
_DUCKDB_THREADS_CURRENT = _DUCKDB_THREADS_ORIGINAL
42+
# The shared DuckDB database instance, lazily created on first use. All DuckDBState objects operate on
43+
# cursors of this instance so that they share one TaskScheduler / worker-thread pool.
44+
_DUCKDB_SHARED_CONNECTION = None
45+
_DUCKDB_SHARED_CONNECTION_LOCK = threading.Lock()
46+
3247
log = logging.getLogger(__name__)
3348

3449
__all__ = [
@@ -39,6 +54,8 @@
3954
"build_track_state_node",
4055
"modify_buffer_threshold",
4156
"restore_buffer_threshold",
57+
"modify_duckdb_threads",
58+
"restore_duckdb_threads",
4259
"enable_duckdb_state",
4360
"disable_duckdb_state",
4461
]
@@ -61,6 +78,49 @@ def restore_buffer_threshold() -> None:
6178
_DUCKDB_BUFFER_THRESHOLD_CURRENT = _DUCKDB_BUFFER_THRESHOLD_ORIGINAL
6279

6380

81+
def _get_duckdb_cursor() -> "duckdb.DuckDBPyConnection":
82+
"""Return a cursor on the process-wide shared DuckDB instance.
83+
84+
A single database instance is shared across all DuckDBState objects so that DuckDB's TaskScheduler
85+
(and therefore its worker-thread pool) is created once for the whole process rather than once per
86+
Struct channel. Each state uses its own cursor, which has an independent result context and so can
87+
be used safely from that state's own threads -- sharing a single connection object instead raises
88+
"Attempting to execute an unsuccessful or closed pending query result" under concurrent access.
89+
"""
90+
91+
global _DUCKDB_SHARED_CONNECTION
92+
with _DUCKDB_SHARED_CONNECTION_LOCK:
93+
if _DUCKDB_SHARED_CONNECTION is None:
94+
_DUCKDB_SHARED_CONNECTION = duckdb.connect(config={"threads": _DUCKDB_THREADS_CURRENT})
95+
# The memory filesystem (used for bulk loading) is registered on the shared instance and is
96+
# visible to every cursor, so it must be registered exactly once here rather than per state.
97+
_DUCKDB_SHARED_CONNECTION.register_filesystem(fsspec.filesystem("memory"))
98+
return _DUCKDB_SHARED_CONNECTION.cursor()
99+
100+
101+
def modify_duckdb_threads(new_threads: int) -> None:
102+
"""Set the size of the shared DuckDB worker-thread pool.
103+
104+
DuckDB spawns ``new_threads - 1`` background worker threads for the whole process (all state
105+
tables share one pool), so this is a single global knob independent of the host core count and the
106+
number of state channels. Takes effect immediately, including on an already-created shared instance.
107+
"""
108+
109+
global _DUCKDB_THREADS_CURRENT
110+
if new_threads < 1:
111+
raise ValueError("Number of threads should be a positive integer")
112+
with _DUCKDB_SHARED_CONNECTION_LOCK:
113+
_DUCKDB_THREADS_CURRENT = new_threads
114+
if _DUCKDB_SHARED_CONNECTION is not None:
115+
_DUCKDB_SHARED_CONNECTION.execute(f"SET threads={new_threads}")
116+
117+
118+
def restore_duckdb_threads() -> None:
119+
"""Reset the shared DuckDB worker-thread pool size to the original value"""
120+
121+
modify_duckdb_threads(_DUCKDB_THREADS_ORIGINAL)
122+
123+
64124
def enable_duckdb_state() -> None:
65125
"""Enable using DuckDBState as State class specialization"""
66126

@@ -205,10 +265,13 @@ def __init__(self, typ: Struct, keyby: Union[Tuple[str, ...], str], schema: Dict
205265
# Store the schema
206266
self._schema = schema
207267
self._schema_str = orjson.dumps(self._schema).decode()
208-
# NOTE: We make a new connection object for each state object. Using the same state connection across
209-
# multiple state objects leads to issues in DuckDB's python API related to pending query results not
210-
# being fully executed. This should be okay since we are doing this once during the initialization
211-
self._con = duckdb.connect()
268+
# NOTE: Each state operates on its own cursor of a single process-wide DuckDB instance (see
269+
# _get_duckdb_cursor). We use a per-state cursor -- rather than one shared connection object --
270+
# because DuckDB's Python API raises on pending/unfinished query results when a single
271+
# connection is used concurrently from multiple states. Sharing the underlying instance keeps
272+
# the worker-thread pool bounded globally instead of one pool per Struct channel. The table
273+
# name (self._table_name) is unique per instance, so states never collide in the shared catalog.
274+
self._con = _get_duckdb_cursor()
212275
self._keyby = keyby
213276
# ID generator for new records. We cannot rely on the id in the record as that might or might not exist
214277
self._obj_id_generator = Counter(1)
@@ -260,9 +323,6 @@ def __init__(self, typ: Struct, keyby: Union[Tuple[str, ...], str], schema: Dict
260323
# Open the memory file for buffering data before bulk loading
261324
self._mem_file = fsspec.filesystem("memory").open(self._mem_file_name, "wb")
262325

263-
# Register memory filesystem so that duckdb can read from memory
264-
self._con.register_filesystem(fsspec.filesystem("memory"))
265-
266326
# TODO: Improve this to support querying dicts and lists as well, currently it only
267327
# works with structs and primitive types
268328
@classmethod

csp_gateway/server/middleware/api_key.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ def rest(self, app: GatewayWebApp) -> None:
6666
check = self.get_check_dependency()
6767

6868
@auth_router.get("/login")
69-
async def route_login_and_add_cookie(api_key: str = Depends(check)):
70-
response = RedirectResponse(url="/")
69+
async def route_login_and_add_cookie(request: Request, api_key: str = Depends(check)):
70+
response = RedirectResponse(url=app.root_path_url(request, "/"))
7171
response.set_cookie(
7272
self.api_key_name,
7373
value=api_key,
@@ -79,8 +79,8 @@ async def route_login_and_add_cookie(api_key: str = Depends(check)):
7979
return response
8080

8181
@auth_router.get("/logout")
82-
async def route_logout_and_remove_cookie():
83-
response = RedirectResponse(url="/login")
82+
async def route_logout_and_remove_cookie(request: Request):
83+
response = RedirectResponse(url=app.root_path_url(request, "/login"))
8484
response.delete_cookie(self.api_key_name, domain=self.domain)
8585
return response
8686

@@ -94,7 +94,7 @@ def _setup_public_routes(self, app: GatewayWebApp) -> None:
9494
async def get_login_page(token: str = "", request: Request = None):
9595
if token:
9696
if token != "":
97-
return RedirectResponse(url=f"{app.settings.API_STR}/auth/login?token={token}")
97+
return RedirectResponse(url=app.root_path_url(request, f"{app.settings.API_STR}/auth/login?token={token}"))
9898
return app.templates.TemplateResponse(
9999
request,
100100
"login.html.j2",

csp_gateway/server/middleware/oauth.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ async def oauth_callback(request: Request, code: str = None, error: str = None):
311311
"id_token": tokens.get("id_token"),
312312
}
313313

314-
response = RedirectResponse(url="/")
314+
response = RedirectResponse(url=app.root_path_url(request, "/"))
315315
response.set_cookie(
316316
self.cookie_name,
317317
value=session_uuid,
@@ -332,7 +332,7 @@ async def logout(request: Request):
332332
if session_uuid and session_uuid in self._identity_store:
333333
self._identity_store.pop(session_uuid, None)
334334

335-
response = RedirectResponse(url="/login")
335+
response = RedirectResponse(url=app.root_path_url(request, "/login"))
336336
response.delete_cookie(self.cookie_name, domain=self.domain)
337337
return response
338338

@@ -355,4 +355,4 @@ async def auth_error_handler(request: Request, exc):
355355
{"detail": self.unauthorized_status_message, "status_code": exc.status_code},
356356
status_code=exc.status_code,
357357
)
358-
return RedirectResponse(url="/login")
358+
return RedirectResponse(url=app.root_path_url(request, "/login"))

csp_gateway/server/middleware/simple.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -383,13 +383,13 @@ async def post_login(request: Request):
383383
password = form.get("password", "")
384384

385385
if not username or not password:
386-
return RedirectResponse(url="/login?error=missing_credentials", status_code=303)
386+
return RedirectResponse(url=app.root_path_url(request, "/login?error=missing_credentials"), status_code=303)
387387

388388
identity = self._validate_credentials(str(username), str(password))
389389

390390
if identity and isinstance(identity, dict):
391391
session_uuid = self._create_session(identity)
392-
response = RedirectResponse(url="/", status_code=303)
392+
response = RedirectResponse(url=app.root_path_url(request, "/"), status_code=303)
393393
response.set_cookie(
394394
self.cookie_name,
395395
value=session_uuid,
@@ -400,12 +400,12 @@ async def post_login(request: Request):
400400
)
401401
return response
402402

403-
return RedirectResponse(url="/login?error=invalid_credentials", status_code=303)
403+
return RedirectResponse(url=app.root_path_url(request, "/login?error=invalid_credentials"), status_code=303)
404404

405405
@auth_router.get("/login")
406-
async def api_login(session_uuid: str = Depends(check)):
406+
async def api_login(request: Request, session_uuid: str = Depends(check)):
407407
"""API login endpoint - validates Basic Auth and returns session cookie."""
408-
response = RedirectResponse(url="/")
408+
response = RedirectResponse(url=app.root_path_url(request, "/"))
409409
response.set_cookie(
410410
self.cookie_name,
411411
value=session_uuid,
@@ -423,7 +423,7 @@ async def logout(request: Request):
423423
if session_uuid and session_uuid in self._identity_store:
424424
self._identity_store.pop(session_uuid, None)
425425

426-
response = RedirectResponse(url="/login")
426+
response = RedirectResponse(url=app.root_path_url(request, "/login"))
427427
response.delete_cookie(self.cookie_name, domain=self.domain)
428428
return response
429429

@@ -450,7 +450,7 @@ async def auth_error_handler(request: Request, exc):
450450
status_code=exc.status_code,
451451
)
452452
if self.enable_form_login:
453-
return RedirectResponse(url="/login")
453+
return RedirectResponse(url=app.root_path_url(request, "/login"))
454454
# Return 401 with Basic Auth challenge
455455
return JSONResponse(
456456
{"detail": self.unauthorized_status_message},

csp_gateway/server/settings.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,47 @@ class Settings(BaseSettings):
3333
BIND: str = "0.0.0.0"
3434
PORT: int = 8000
3535

36+
ROOT_PATH: str = Field(
37+
default="",
38+
description="URL path prefix the app is served under when behind a reverse proxy that "
39+
"strips the prefix (e.g. '/watchtower'). Passed to the ASGI server as root_path and used "
40+
"to prefix all server-rendered asset/API URLs so the UI works under a sub-path. Leave "
41+
"empty when served at a domain root.",
42+
)
43+
3644
UI: bool = Field(False, description="Enables ui in the web application")
3745

38-
# --- DEPRECATED auth settings ---
46+
# UI customization fields that let downstream applications white-label the
47+
# default UI from server-side config alone, without a custom Javascript bundle.
48+
HEADER_LOGO: Optional[str] = Field(
49+
default=None,
50+
description="Header logo image, given as an http(s) URL, a data URI, an absolute "
51+
"URL path, or a local file path (local files are served automatically).",
52+
)
53+
FOOTER_LOGO: Optional[str] = Field(
54+
default=None,
55+
description="Footer logo image, given as an http(s) URL, a data URI, an absolute "
56+
"URL path, or a local file path (local files are served automatically).",
57+
)
58+
CUSTOM_JS: List[str] = Field(
59+
default_factory=list,
60+
description="Custom Javascript files to inject into the UI, given as URLs or local "
61+
"file paths (local files are served automatically). Loaded after the main bundle.",
62+
)
63+
CUSTOM_CSS: List[str] = Field(
64+
default_factory=list,
65+
description="Custom CSS files to inject into the UI, given as URLs or local file "
66+
"paths (local files are served automatically). Loaded after the main stylesheet.",
67+
)
68+
CUSTOM_STATIC_DIR: Optional[str] = Field(
69+
default=None,
70+
description="Local directory served at <root_path>/custom. The entire directory is exposed "
71+
"as public static content (useful for assets like logos, e.g. /custom/logo.svg); its top-level "
72+
"*.js and *.css files are additionally auto-injected into the UI in sorted filename order. Do "
73+
"not point this at a directory containing private files.",
74+
)
75+
76+
# DEPRECATED auth settings
3977
# Historically (csp-gateway <2.5), auth was configured via these two fields
4078
# on Settings. In 2.5+, auth moved onto `MountAPIKeyMiddleware` as module
4179
# fields. Keeping these here (default-None sentinels) lets existing YAML

0 commit comments

Comments
 (0)