Skip to content

Commit 0205fbc

Browse files
msullivanfantix
andauthored
Add TOML config file support (#8121) (#8215)
This adds a hidden server command-line argument --config-file, as well as the "config-file" key in multi- tenant config file. Replaces #8059, Fixes #1325, Fixes #7990 Co-authored-by: Fantix King <fantix.king@gmail.com>
1 parent 6377c58 commit 0205fbc

File tree

17 files changed

+1011
-100
lines changed

17 files changed

+1011
-100
lines changed

edb/common/asyncutil.py

Lines changed: 197 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,20 @@
1818

1919

2020
from __future__ import annotations
21-
from typing import Callable, TypeVar, Awaitable
21+
from typing import (
22+
Any,
23+
Awaitable,
24+
Callable,
25+
cast,
26+
overload,
27+
Self,
28+
TypeVar,
29+
Type,
30+
)
2231

2332
import asyncio
33+
import inspect
34+
import warnings
2435

2536

2637
_T = TypeVar('_T')
@@ -140,3 +151,188 @@ async def debounce(
140151
batch = []
141152
last_signal = t
142153
target_time = None
154+
155+
156+
_Owner = TypeVar("_Owner")
157+
HandlerFunction = Callable[[], Awaitable[None]]
158+
HandlerMethod = Callable[[Any], Awaitable[None]]
159+
160+
161+
class ExclusiveTask:
162+
"""Manages to run a repeatable task once at a time."""
163+
164+
_handler: HandlerFunction
165+
_task: asyncio.Task | None
166+
_scheduled: bool
167+
_stop_requested: bool
168+
169+
def __init__(self, handler: HandlerFunction) -> None:
170+
self._handler = handler
171+
self._task = None
172+
self._scheduled = False
173+
self._stop_requested = False
174+
175+
@property
176+
def scheduled(self) -> bool:
177+
return self._scheduled
178+
179+
async def _run(self) -> None:
180+
if self._scheduled and not self._stop_requested:
181+
self._scheduled = False
182+
else:
183+
return
184+
try:
185+
await self._handler()
186+
finally:
187+
if self._scheduled and not self._stop_requested:
188+
self._task = asyncio.create_task(self._run())
189+
else:
190+
self._task = None
191+
192+
def schedule(self) -> None:
193+
"""Schedule to run the task as soon as possible.
194+
195+
If already scheduled, nothing happens; it won't queue up.
196+
197+
If the task is already running, it will be scheduled to run again as
198+
soon as the running task is done.
199+
"""
200+
if not self._stop_requested:
201+
self._scheduled = True
202+
if self._task is None:
203+
self._task = asyncio.create_task(self._run())
204+
205+
async def stop(self) -> None:
206+
"""Cancel scheduled task and wait for the running one to finish.
207+
208+
After an ExclusiveTask is stopped, no more new schedules are allowed.
209+
Note: "cancel scheduled task" only means setting self._scheduled to
210+
False; if an asyncio task is scheduled, stop() will still wait for it.
211+
"""
212+
self._scheduled = False
213+
self._stop_requested = True
214+
if self._task is not None:
215+
await self._task
216+
217+
218+
class ExclusiveTaskProperty:
219+
_method: HandlerMethod
220+
_name: str | None
221+
222+
def __init__(
223+
self, method: HandlerMethod, *, slot: str | None = None
224+
) -> None:
225+
self._method = method
226+
self._name = slot
227+
228+
def __set_name__(self, owner: Type[_Owner], name: str) -> None:
229+
if (slots := getattr(owner, "__slots__", None)) is not None:
230+
if self._name is None:
231+
raise TypeError("missing slot in @exclusive_task()")
232+
if self._name not in slots:
233+
raise TypeError(
234+
f"slot {self._name!r} must be defined in __slots__"
235+
)
236+
237+
if self._name is None:
238+
self._name = name
239+
240+
@overload
241+
def __get__(self, instance: None, owner: Type[_Owner]) -> Self: ...
242+
243+
@overload
244+
def __get__(
245+
self, instance: _Owner, owner: Type[_Owner]
246+
) -> ExclusiveTask: ...
247+
248+
def __get__(
249+
self, instance: _Owner | None, owner: Type[_Owner]
250+
) -> ExclusiveTask | Self:
251+
# getattr on the class
252+
if instance is None:
253+
return self
254+
255+
assert self._name is not None
256+
257+
# getattr on an object with __dict__
258+
if (d := getattr(instance, "__dict__", None)) is not None:
259+
if rv := d.get(self._name, None):
260+
return rv
261+
rv = ExclusiveTask(self._method.__get__(instance, owner))
262+
d[self._name] = rv
263+
return rv
264+
265+
# getattr on an object with __slots__
266+
else:
267+
if rv := getattr(instance, self._name, None):
268+
return rv
269+
rv = ExclusiveTask(self._method.__get__(instance, owner))
270+
setattr(instance, self._name, rv)
271+
return rv
272+
273+
274+
ExclusiveTaskDecorator = Callable[
275+
[HandlerFunction | HandlerMethod], ExclusiveTask | ExclusiveTaskProperty
276+
]
277+
278+
279+
def _exclusive_task(
280+
handler: HandlerFunction | HandlerMethod, *, slot: str | None
281+
) -> ExclusiveTask | ExclusiveTaskProperty:
282+
sig = inspect.signature(handler)
283+
params = list(sig.parameters.values())
284+
if len(params) == 0:
285+
handler = cast(HandlerFunction, handler)
286+
if slot is not None:
287+
warnings.warn(
288+
"slot is specified but unused in @exclusive_task()",
289+
stacklevel=2,
290+
)
291+
return ExclusiveTask(handler)
292+
elif len(params) == 1 and params[0].kind in (
293+
inspect.Parameter.POSITIONAL_ONLY,
294+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
295+
):
296+
handler = cast(HandlerMethod, handler)
297+
return ExclusiveTaskProperty(handler, slot=slot)
298+
else:
299+
raise TypeError("bad signature")
300+
301+
302+
@overload
303+
def exclusive_task(handler: HandlerFunction) -> ExclusiveTask: ...
304+
305+
306+
@overload
307+
def exclusive_task(
308+
handler: HandlerMethod, *, slot: str | None = None
309+
) -> ExclusiveTaskProperty: ...
310+
311+
312+
@overload
313+
def exclusive_task(*, slot: str | None = None) -> ExclusiveTaskDecorator: ...
314+
315+
316+
def exclusive_task(
317+
handler: HandlerFunction | HandlerMethod | None = None,
318+
*,
319+
slot: str | None = None,
320+
) -> ExclusiveTask | ExclusiveTaskProperty | ExclusiveTaskDecorator:
321+
"""Convert an async function into an ExclusiveTask.
322+
323+
This decorator can be applied to either top-level functions or methods
324+
in a class. In the latter case, the exclusiveness is bound to each object
325+
of the owning class. If the owning class defines __slots__, you must also
326+
define an extra slot to store the exclusive state and tell exclusive_task()
327+
by providing the `slot` argument.
328+
"""
329+
if handler is None:
330+
331+
def decorator(
332+
handler: HandlerFunction | HandlerMethod,
333+
) -> ExclusiveTask | ExclusiveTaskProperty:
334+
return _exclusive_task(handler, slot=slot)
335+
336+
return decorator
337+
338+
return _exclusive_task(handler, slot=slot)

edb/pgsql/metaschema.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3580,6 +3580,32 @@ class SysConfigFullFunction(trampoline.VersionedFunction):
35803580
SELECT * FROM config_defaults WHERE name like '%::%'
35813581
),
35823582
3583+
config_static AS (
3584+
SELECT
3585+
s.name AS name,
3586+
s.value AS value,
3587+
(CASE
3588+
WHEN s.type = 'A' THEN 'command line'
3589+
-- Due to inplace upgrade limits, without adding a new
3590+
-- layer, configuration file values are manually squashed
3591+
-- into the `environment variables` layer, see below.
3592+
ELSE 'environment variable'
3593+
END) AS source,
3594+
config_spec.backend_setting IS NOT NULL AS is_backend
3595+
FROM
3596+
_edgecon_state s
3597+
INNER JOIN config_spec ON (config_spec.name = s.name)
3598+
WHERE
3599+
-- Give precedence to configuration file values over
3600+
-- environment variables manually.
3601+
s.type = 'A' OR s.type = 'F' OR (
3602+
s.type = 'E' AND NOT EXISTS (
3603+
SELECT 1 FROM _edgecon_state ss
3604+
WHERE ss.name = s.name AND ss.type = 'F'
3605+
)
3606+
)
3607+
),
3608+
35833609
config_sys AS (
35843610
SELECT
35853611
s.key AS name,
@@ -3610,16 +3636,12 @@ class SysConfigFullFunction(trampoline.VersionedFunction):
36103636
SELECT
36113637
s.name AS name,
36123638
s.value AS value,
3613-
(CASE
3614-
WHEN s.type = 'A' THEN 'command line'
3615-
WHEN s.type = 'E' THEN 'environment variable'
3616-
ELSE 'session'
3617-
END) AS source,
3618-
FALSE AS from_backend -- only 'B' is for backend settings
3639+
'session' AS source,
3640+
FALSE AS is_backend -- only 'B' is for backend settings
36193641
FROM
36203642
_edgecon_state s
36213643
WHERE
3622-
s.type != 'B'
3644+
s.type = 'C'
36233645
),
36243646
36253647
pg_db_setting AS (
@@ -3789,6 +3811,7 @@ class SysConfigFullFunction(trampoline.VersionedFunction):
37893811
FROM
37903812
(
37913813
SELECT * FROM config_defaults UNION ALL
3814+
SELECT * FROM config_static UNION ALL
37923815
SELECT * FROM config_sys UNION ALL
37933816
SELECT * FROM config_db UNION ALL
37943817
SELECT * FROM config_sess

edb/pgsql/patches.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,12 @@ def _setup_patches(patches: list[tuple[str, str]]) -> list[tuple[str, str]]:
7777
* sql-introspection - refresh all sql introspection views
7878
"""
7979
PATCHES: list[tuple[str, str]] = _setup_patches([
80-
# 6.0b2?
80+
# 6.0b2
8181
# One of the sql-introspection's adds a param with a default to
8282
# uuid_to_oid, so we need to drop the original to avoid ambiguity.
8383
('sql', '''
8484
drop function if exists edgedbsql_v6_2f20b3fed0.uuid_to_oid(uuid) cascade
8585
'''),
8686
('sql-introspection', ''),
87+
('metaschema-sql', 'SysConfigFullFunction'),
8788
])

edb/server/args.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ class ReloadTrigger(enum.StrEnum):
150150
3. Multi-tenant config file (server config)
151151
4. Readiness state (server or tenant config)
152152
5. JWT sub allowlist and revocation list (server or tenant config)
153+
6. The TOML config file (server or tenant config)
153154
"""
154155

155156
Default = "default"
@@ -265,6 +266,7 @@ class ServerConfig(NamedTuple):
265266
disable_dynamic_system_config: bool
266267
reload_config_files: ReloadTrigger
267268
net_worker_mode: NetWorkerMode
269+
config_file: Optional[pathlib.Path]
268270

269271
startup_script: Optional[StartupScript]
270272
status_sinks: List[Callable[[str], None]]
@@ -1106,6 +1108,13 @@ def resolve_envvar_value(self, ctx: click.Context):
11061108
default='default',
11071109
help='Controls how the std::net workers work.',
11081110
),
1111+
click.option(
1112+
"--config-file", type=PathPath(), metavar="PATH",
1113+
envvar="GEL_SERVER_CONFIG_FILE",
1114+
cls=EnvvarResolver,
1115+
help='Path to a TOML file to configure the server.',
1116+
hidden=True,
1117+
),
11091118
])
11101119

11111120

@@ -1534,6 +1543,7 @@ def parse_args(**kwargs: Any):
15341543
"readiness_state_file",
15351544
"jwt_sub_allowlist_file",
15361545
"jwt_revocation_list_file",
1546+
"config_file",
15371547
):
15381548
if kwargs.get(name):
15391549
opt = "--" + name.replace("_", "-")

edb/server/config/__init__.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818

1919

2020
from __future__ import annotations
21-
from typing import Any, Mapping
21+
from typing import Any, Mapping, TypedDict
22+
23+
import enum
2224

2325
import immutables
2426

@@ -50,9 +52,25 @@
5052
'load_ext_settings_from_schema',
5153
'get_compilation_config',
5254
'QueryCacheMode',
55+
'ConState', 'ConStateType',
5356
)
5457

5558

59+
# See edb/server/pgcon/connect.py for documentation of the types
60+
class ConStateType(enum.StrEnum):
61+
session_config = "C"
62+
backend_session_config = "B"
63+
command_line_argument = "A"
64+
environment_variable = "E"
65+
config_file = "F"
66+
67+
68+
class ConState(TypedDict):
69+
name: str
70+
value: Any
71+
type: ConStateType
72+
73+
5674
def lookup(
5775
name: str,
5876
*configs: Mapping[str, SettingValue],

0 commit comments

Comments
 (0)