chalk-remote-call-python 1.10.5__cp315-cp315-win_amd64.whl
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.
- chalk_remote_call/__init__.py +4 -0
- chalk_remote_call/__main__.py +3 -0
- chalk_remote_call/_gen/__init__.py +0 -0
- chalk_remote_call/_gen/chalk/__init__.py +0 -0
- chalk_remote_call/_gen/chalk/auth/__init__.py +0 -0
- chalk_remote_call/_gen/chalk/auth/v1/__init__.py +0 -0
- chalk_remote_call/_gen/chalk/auth/v1/permissions_pb2.py +115 -0
- chalk_remote_call/_gen/chalk/auth/v1/permissions_pb2_grpc.py +4 -0
- chalk_remote_call/_gen/chalk/common/__init__.py +0 -0
- chalk_remote_call/_gen/chalk/common/v1/__init__.py +0 -0
- chalk_remote_call/_gen/chalk/common/v1/chalk_error_pb2.py +33 -0
- chalk_remote_call/_gen/chalk/common/v1/chalk_error_pb2_grpc.py +4 -0
- chalk_remote_call/_gen/chalk/runtime/__init__.py +0 -0
- chalk_remote_call/_gen/chalk/runtime/v1/__init__.py +0 -0
- chalk_remote_call/_gen/chalk/runtime/v1/fnq_consumer_pb2.py +53 -0
- chalk_remote_call/_gen/chalk/runtime/v1/fnq_consumer_pb2_grpc.py +138 -0
- chalk_remote_call/_gen/chalk/runtime/v1/remote_python_call_pb2.py +104 -0
- chalk_remote_call/_gen/chalk/runtime/v1/remote_python_call_pb2_grpc.py +363 -0
- chalk_remote_call/_gen/chalk/utils/__init__.py +0 -0
- chalk_remote_call/_gen/chalk/utils/v1/__init__.py +0 -0
- chalk_remote_call/_gen/chalk/utils/v1/encoding_pb2.py +32 -0
- chalk_remote_call/_gen/chalk/utils/v1/encoding_pb2_grpc.py +4 -0
- chalk_remote_call/_gen/chalk/utils/v1/field_change_pb2.py +29 -0
- chalk_remote_call/_gen/chalk/utils/v1/field_change_pb2_grpc.py +4 -0
- chalk_remote_call/_gen/chalk/utils/v1/large_pb2.py +26 -0
- chalk_remote_call/_gen/chalk/utils/v1/large_pb2_grpc.py +4 -0
- chalk_remote_call/_gen/chalk/utils/v1/sensitive_pb2.py +26 -0
- chalk_remote_call/_gen/chalk/utils/v1/sensitive_pb2_grpc.py +4 -0
- chalk_remote_call/_native.cp315-win_amd64.pyd +0 -0
- chalk_remote_call/_native.pyi +13 -0
- chalk_remote_call/_version.py +1 -0
- chalk_remote_call/arrow_utils.py +21 -0
- chalk_remote_call/cli.py +160 -0
- chalk_remote_call/errors.py +118 -0
- chalk_remote_call/handler_loader.py +57 -0
- chalk_remote_call/input_transform.py +50 -0
- chalk_remote_call/logging.py +29 -0
- chalk_remote_call/server.py +117 -0
- chalk_remote_call/servicer.py +400 -0
- chalk_remote_call/tracing.py +445 -0
- chalk_remote_call_python-1.10.5.dist-info/METADATA +288 -0
- chalk_remote_call_python-1.10.5.dist-info/RECORD +45 -0
- chalk_remote_call_python-1.10.5.dist-info/WHEEL +5 -0
- chalk_remote_call_python-1.10.5.dist-info/entry_points.txt +2 -0
- chalk_remote_call_python-1.10.5.dist-info/top_level.txt +1 -0
chalk_remote_call/cli.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from chalk_remote_call.handler_loader import load_function, load_handler
|
|
9
|
+
from chalk_remote_call.logging import JsonFormatter
|
|
10
|
+
from chalk_remote_call.server import serve
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _env_int(name: str) -> int | None:
|
|
14
|
+
"""Read an optional int from the environment; return None if unset/empty."""
|
|
15
|
+
raw = os.environ.get(name)
|
|
16
|
+
if raw is None or raw.strip() == "":
|
|
17
|
+
return None
|
|
18
|
+
try:
|
|
19
|
+
return int(raw)
|
|
20
|
+
except ValueError as e:
|
|
21
|
+
raise ValueError(f"{name} must be an integer, got: {raw!r}") from e
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main() -> None:
|
|
25
|
+
log_handler = logging.StreamHandler(sys.stderr)
|
|
26
|
+
log_handler.setFormatter(JsonFormatter())
|
|
27
|
+
logging.basicConfig(level=logging.INFO, handlers=[log_handler], force=True)
|
|
28
|
+
try:
|
|
29
|
+
_run()
|
|
30
|
+
except Exception:
|
|
31
|
+
logging.getLogger(__name__).exception("Remote-call server failed")
|
|
32
|
+
sys.exit(1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run() -> None:
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
prog="chalk-remote-call",
|
|
38
|
+
description="Start a gRPC server implementing the Chalk RemoteCallService.",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--handler",
|
|
42
|
+
required=True,
|
|
43
|
+
help="Dotted path to the handler function (e.g. my_module.handler)",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--port",
|
|
47
|
+
type=int,
|
|
48
|
+
default=int(os.environ.get("CHALK_REMOTE_CALL_PORT", "6666")),
|
|
49
|
+
help="Port to listen on (default: 6666, env: CHALK_REMOTE_CALL_PORT)",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--host",
|
|
53
|
+
default=os.environ.get("CHALK_REMOTE_CALL_HOST", "[::]"),
|
|
54
|
+
help="Host to bind to (default: [::], env: CHALK_REMOTE_CALL_HOST)",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--workers",
|
|
58
|
+
type=int,
|
|
59
|
+
default=int(os.environ.get("CHALK_REMOTE_CALL_WORKERS", "10")),
|
|
60
|
+
help="Number of worker threads (default: 10, env: CHALK_REMOTE_CALL_WORKERS)",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--on-startup",
|
|
64
|
+
default=None,
|
|
65
|
+
help="Dotted path to a startup function (e.g. my_module.setup)",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--on-shutdown",
|
|
69
|
+
default=None,
|
|
70
|
+
help="Dotted path to a shutdown function (e.g. my_module.cleanup)",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--max-batching-size",
|
|
74
|
+
type=int,
|
|
75
|
+
default=_env_int("CHALK_REMOTE_CALL_MAX_BATCHING_SIZE"),
|
|
76
|
+
help=(
|
|
77
|
+
"Max concurrent requests to coalesce into a single handler "
|
|
78
|
+
"invocation. Unset disables batching. "
|
|
79
|
+
"(env: CHALK_REMOTE_CALL_MAX_BATCHING_SIZE)"
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--max-buffer-duration-ms",
|
|
84
|
+
type=int,
|
|
85
|
+
default=_env_int("CHALK_REMOTE_CALL_MAX_BUFFER_DURATION_MS"),
|
|
86
|
+
help=(
|
|
87
|
+
"Max milliseconds to buffer concurrent requests before flushing, "
|
|
88
|
+
"even if max-batching-size hasn't been reached. Only effective "
|
|
89
|
+
"when --max-batching-size is also set. Defaults to 1000ms when "
|
|
90
|
+
"batching is enabled. "
|
|
91
|
+
"(env: CHALK_REMOTE_CALL_MAX_BUFFER_DURATION_MS)"
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--batching-mode",
|
|
96
|
+
choices=["per_caller", "combined"],
|
|
97
|
+
default=os.environ.get("CHALK_REMOTE_CALL_BATCHING_MODE") or None,
|
|
98
|
+
help=(
|
|
99
|
+
"Coalescing variant when --max-batching-size is set. "
|
|
100
|
+
"'per_caller' (default) passes events as a list of dicts; "
|
|
101
|
+
"'combined' passes a single concatenated RecordBatch with "
|
|
102
|
+
"offsets. (env: CHALK_REMOTE_CALL_BATCHING_MODE)"
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
parser.add_argument(
|
|
106
|
+
"--log-level",
|
|
107
|
+
default="INFO",
|
|
108
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
|
109
|
+
help="Log level (default: INFO)",
|
|
110
|
+
)
|
|
111
|
+
args = parser.parse_args()
|
|
112
|
+
|
|
113
|
+
logging.getLogger().setLevel(args.log_level)
|
|
114
|
+
|
|
115
|
+
from chalk_remote_call.input_transform import parse_input_args
|
|
116
|
+
|
|
117
|
+
# Load handler
|
|
118
|
+
try:
|
|
119
|
+
handler, auto_startup, auto_shutdown = load_handler(args.handler)
|
|
120
|
+
except Exception:
|
|
121
|
+
logging.getLogger(__name__).exception("Error loading handler")
|
|
122
|
+
sys.exit(1)
|
|
123
|
+
|
|
124
|
+
# Determine startup function
|
|
125
|
+
on_startup = None
|
|
126
|
+
if args.on_startup:
|
|
127
|
+
try:
|
|
128
|
+
on_startup = load_function(args.on_startup, label="Startup")
|
|
129
|
+
except Exception:
|
|
130
|
+
logging.getLogger(__name__).exception("Error loading startup function")
|
|
131
|
+
sys.exit(1)
|
|
132
|
+
elif auto_startup is not None:
|
|
133
|
+
on_startup = auto_startup
|
|
134
|
+
|
|
135
|
+
# Determine shutdown function
|
|
136
|
+
on_shutdown = None
|
|
137
|
+
if args.on_shutdown:
|
|
138
|
+
try:
|
|
139
|
+
on_shutdown = load_function(args.on_shutdown, label="Shutdown")
|
|
140
|
+
except Exception:
|
|
141
|
+
logging.getLogger(__name__).exception("Error loading shutdown function")
|
|
142
|
+
sys.exit(1)
|
|
143
|
+
elif auto_shutdown is not None:
|
|
144
|
+
on_shutdown = auto_shutdown
|
|
145
|
+
|
|
146
|
+
# Parse input args
|
|
147
|
+
arg_names = parse_input_args()
|
|
148
|
+
|
|
149
|
+
serve(
|
|
150
|
+
handler=handler,
|
|
151
|
+
host=args.host,
|
|
152
|
+
port=args.port,
|
|
153
|
+
workers=args.workers,
|
|
154
|
+
on_startup=on_startup,
|
|
155
|
+
on_shutdown=on_shutdown,
|
|
156
|
+
arg_names=arg_names,
|
|
157
|
+
max_batching_size=args.max_batching_size,
|
|
158
|
+
max_buffer_duration_ms=args.max_buffer_duration_ms,
|
|
159
|
+
batching_mode=args.batching_mode,
|
|
160
|
+
)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Versioned, data-only remote exception transport shared by all call modes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import json
|
|
7
|
+
import traceback
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _user_stack(tb: TracebackType | None) -> traceback.StackSummary:
|
|
12
|
+
# Start at the outermost bridge's callee, so Python callers above the
|
|
13
|
+
# dispatch boundary cannot leak into user diagnostics or make async runner
|
|
14
|
+
# frames look like user code. Stop at the first bridge to preserve user
|
|
15
|
+
# frames if a handler itself invokes another bridge.
|
|
16
|
+
dispatch_tb = tb
|
|
17
|
+
while dispatch_tb is not None:
|
|
18
|
+
frame = dispatch_tb.tb_frame
|
|
19
|
+
if frame.f_globals.get("__name__") == "chalk_remote_call.servicer" and frame.f_code.co_name in {
|
|
20
|
+
"process_batches",
|
|
21
|
+
"process_batches_coalesced",
|
|
22
|
+
"process_batches_coalesced_combined",
|
|
23
|
+
}:
|
|
24
|
+
tb = dispatch_tb.tb_next
|
|
25
|
+
break
|
|
26
|
+
dispatch_tb = dispatch_tb.tb_next
|
|
27
|
+
|
|
28
|
+
frames = []
|
|
29
|
+
entered_user_code = False
|
|
30
|
+
while tb is not None:
|
|
31
|
+
frame = tb.tb_frame
|
|
32
|
+
module = str(frame.f_globals.get("__name__", ""))
|
|
33
|
+
runner_frame = not entered_user_code and module in {
|
|
34
|
+
"asyncio.base_events",
|
|
35
|
+
"asyncio.runners",
|
|
36
|
+
"concurrent.futures._base",
|
|
37
|
+
"concurrent.futures.thread",
|
|
38
|
+
}
|
|
39
|
+
if not (
|
|
40
|
+
runner_frame
|
|
41
|
+
or module == "chalk_remote_call.servicer"
|
|
42
|
+
or module == "chalk_remote_call.errors"
|
|
43
|
+
or module.startswith("chalkcompute.")
|
|
44
|
+
or frame.f_globals.get("__chalk_generated_handler__") is True
|
|
45
|
+
):
|
|
46
|
+
entered_user_code = True
|
|
47
|
+
filename, lineno = frame.f_code.co_filename, tb.tb_lineno
|
|
48
|
+
source_map = frame.f_globals.get("__chalk_source_map__", {})
|
|
49
|
+
mapped = source_map.get(lineno) if isinstance(source_map, dict) else None
|
|
50
|
+
if isinstance(mapped, tuple) and len(mapped) == 3:
|
|
51
|
+
filename, lineno, source = mapped
|
|
52
|
+
else:
|
|
53
|
+
source = None
|
|
54
|
+
frames.append(traceback.FrameSummary(filename, lineno, frame.f_code.co_name, line=source))
|
|
55
|
+
tb = tb.tb_next
|
|
56
|
+
return traceback.StackSummary.from_list(frames)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _filter_traceback(summary: traceback.TracebackException, error: BaseException) -> None:
|
|
60
|
+
summary.stack = _user_stack(error.__traceback__)
|
|
61
|
+
if summary.__cause__ is not None and error.__cause__ is not None:
|
|
62
|
+
_filter_traceback(summary.__cause__, error.__cause__)
|
|
63
|
+
if summary.__context__ is not None and error.__context__ is not None:
|
|
64
|
+
_filter_traceback(summary.__context__, error.__context__)
|
|
65
|
+
children = getattr(error, "exceptions", ())
|
|
66
|
+
for child_summary, child in zip(getattr(summary, "exceptions", None) or (), children, strict=False):
|
|
67
|
+
_filter_traceback(child_summary, child)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def serialize_exception(error: BaseException) -> str:
|
|
71
|
+
"""Keep chains and groups, without pickling exceptions or their locals."""
|
|
72
|
+
summary = traceback.TracebackException.from_exception(error, capture_locals=False)
|
|
73
|
+
internal = "".join(summary.format())
|
|
74
|
+
_filter_traceback(summary, error)
|
|
75
|
+
payload: dict[str, object] = {
|
|
76
|
+
"chalk_remote_error": 1,
|
|
77
|
+
"exception": {
|
|
78
|
+
"kind": type(error).__name__[:256],
|
|
79
|
+
"message": _safe_message(error),
|
|
80
|
+
"stacktrace": "".join(summary.format()),
|
|
81
|
+
"internal_stacktrace": internal,
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
# Include a trace ID when serialization runs with an active trace context.
|
|
85
|
+
try:
|
|
86
|
+
trace = importlib.import_module("opentelemetry.trace")
|
|
87
|
+
context = trace.get_current_span().get_span_context()
|
|
88
|
+
if context.is_valid:
|
|
89
|
+
payload["trace_id"] = format(context.trace_id, "032x")
|
|
90
|
+
except ImportError:
|
|
91
|
+
pass
|
|
92
|
+
# gRPC status messages live in headers. Keep the complete diagnostic in
|
|
93
|
+
# server logs, and bound the wire payload below common 8 KiB header limits.
|
|
94
|
+
encoded = json.dumps(payload, ensure_ascii=True)
|
|
95
|
+
exception = payload["exception"]
|
|
96
|
+
assert isinstance(exception, dict)
|
|
97
|
+
while _status_header_size(encoded) > 6000:
|
|
98
|
+
field = max(exception, key=lambda key: _status_header_size(json.dumps(exception[key], ensure_ascii=True)))
|
|
99
|
+
text = exception[field]
|
|
100
|
+
keep = len(text) // 4
|
|
101
|
+
if keep < 20:
|
|
102
|
+
break
|
|
103
|
+
exception[field] = text[:keep] + "\n... remote traceback truncated ...\n" + text[-keep:]
|
|
104
|
+
encoded = json.dumps(payload, ensure_ascii=True)
|
|
105
|
+
return encoded
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _status_header_size(message: str) -> int:
|
|
109
|
+
# Tonic percent-encodes these ASCII characters in grpc-message. The JSON
|
|
110
|
+
# serializer already escapes controls and non-ASCII code points.
|
|
111
|
+
return len(message) + 2 * sum(char in ' "#%<>`?{}' for char in message)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _safe_message(error: BaseException) -> str:
|
|
115
|
+
try:
|
|
116
|
+
return str(error)
|
|
117
|
+
except Exception:
|
|
118
|
+
return "<exception message unavailable>"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_handler(
|
|
9
|
+
dotted_path: str,
|
|
10
|
+
) -> tuple[Callable[..., Any], Callable[[], None] | None, Callable[[], None] | None]:
|
|
11
|
+
"""Load a handler function from a dotted module path.
|
|
12
|
+
|
|
13
|
+
Returns (handler_fn, on_startup_fn_or_None, on_shutdown_fn_or_None).
|
|
14
|
+
The on_startup/on_shutdown functions are auto-discovered if the handler's
|
|
15
|
+
module defines top-level callables with those names.
|
|
16
|
+
"""
|
|
17
|
+
if "." not in dotted_path:
|
|
18
|
+
raise ValueError(f"Handler path must be a dotted path like 'my_module.handler', got: {dotted_path!r}")
|
|
19
|
+
|
|
20
|
+
module_path, func_name = dotted_path.rsplit(".", 1)
|
|
21
|
+
module = importlib.import_module(module_path)
|
|
22
|
+
handler = getattr(module, func_name, None)
|
|
23
|
+
if handler is None:
|
|
24
|
+
raise AttributeError(f"Module {module_path!r} has no attribute {func_name!r}")
|
|
25
|
+
if not callable(handler):
|
|
26
|
+
raise TypeError(f"{dotted_path!r} is not callable")
|
|
27
|
+
|
|
28
|
+
on_startup = getattr(module, "on_startup", None)
|
|
29
|
+
if on_startup is not None and not callable(on_startup):
|
|
30
|
+
on_startup = None
|
|
31
|
+
|
|
32
|
+
on_shutdown = getattr(module, "on_shutdown", None)
|
|
33
|
+
if on_shutdown is not None and not callable(on_shutdown):
|
|
34
|
+
on_shutdown = None
|
|
35
|
+
|
|
36
|
+
return handler, on_startup, on_shutdown
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_function(dotted_path: str, label: str = "Function") -> Callable[[], None]:
|
|
40
|
+
"""Load a callable from a dotted module path.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
dotted_path: e.g. 'my_module.setup'
|
|
44
|
+
label: Human-readable label for error messages (e.g. "Startup", "Shutdown").
|
|
45
|
+
"""
|
|
46
|
+
if "." not in dotted_path:
|
|
47
|
+
raise ValueError(f"{label} path must be a dotted path like 'my_module.func', got: {dotted_path!r}")
|
|
48
|
+
|
|
49
|
+
module_path, func_name = dotted_path.rsplit(".", 1)
|
|
50
|
+
module = importlib.import_module(module_path)
|
|
51
|
+
func = getattr(module, func_name, None)
|
|
52
|
+
if func is None:
|
|
53
|
+
raise AttributeError(f"Module {module_path!r} has no attribute {func_name!r}")
|
|
54
|
+
if not callable(func):
|
|
55
|
+
raise TypeError(f"{dotted_path!r} is not callable")
|
|
56
|
+
|
|
57
|
+
return func
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
|
|
6
|
+
import pyarrow as pa
|
|
7
|
+
|
|
8
|
+
# Columns the caller attaches as framework side channels rather than handler arguments.
|
|
9
|
+
# They are identified by name and are optional per request, so they must be held out of the
|
|
10
|
+
# positional CHALK_INPUT_ARGS mapping, which describes only the handler's declared arguments.
|
|
11
|
+
RESERVED_COLUMNS = frozenset({"__chalk_row_metadata__"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resolve_column_names(field_names: Sequence[str], arg_names: Sequence[str]) -> list[str]:
|
|
15
|
+
"""Map `arg_names` positionally onto the non-reserved columns of `field_names`.
|
|
16
|
+
|
|
17
|
+
Reserved columns keep their own name and consume no entry from `arg_names`, so a request
|
|
18
|
+
that carries one maps its arguments exactly as a request without one does.
|
|
19
|
+
"""
|
|
20
|
+
positional = [name for name in field_names if name not in RESERVED_COLUMNS]
|
|
21
|
+
if len(arg_names) != len(positional):
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"CHALK_INPUT_ARGS specifies {len(arg_names)} names but RecordBatch has {len(positional)} columns"
|
|
24
|
+
)
|
|
25
|
+
remaining = iter(arg_names)
|
|
26
|
+
return [name if name in RESERVED_COLUMNS else next(remaining) for name in field_names]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_input_args() -> list[str] | None:
|
|
30
|
+
"""Parse CHALK_INPUT_ARGS env var into a list of column names.
|
|
31
|
+
|
|
32
|
+
Returns None if the env var is unset or empty.
|
|
33
|
+
"""
|
|
34
|
+
raw = os.environ.get("CHALK_INPUT_ARGS", "").strip()
|
|
35
|
+
if not raw:
|
|
36
|
+
return None
|
|
37
|
+
return [name.strip() for name in raw.split(",") if name.strip()]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def transform(batch: pa.RecordBatch, arg_names: list[str] | None) -> dict[str, pa.Array]:
|
|
41
|
+
"""Transform a RecordBatch into a dict of named arrays.
|
|
42
|
+
|
|
43
|
+
If arg_names is provided, columns are renamed by index to the given names
|
|
44
|
+
(ignoring the original column names), except for reserved columns, which keep
|
|
45
|
+
their own name. If arg_names is None, the original column names are used.
|
|
46
|
+
"""
|
|
47
|
+
if arg_names is not None:
|
|
48
|
+
names = resolve_column_names(batch.schema.names, arg_names)
|
|
49
|
+
return {name: batch.column(i) for i, name in enumerate(names)}
|
|
50
|
+
return {batch.schema.field(i).name: batch.column(i) for i in range(batch.num_columns)}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_RECORD_FIELDS = frozenset(logging.makeLogRecord({}).__dict__) | {"message", "asctime"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JsonFormatter(logging.Formatter):
|
|
12
|
+
"""Emit one JSON object per line using fields recognized by Chalk's log collector."""
|
|
13
|
+
|
|
14
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
15
|
+
fields: dict[str, Any] = {key: value for key, value in vars(record).items() if key not in _RECORD_FIELDS}
|
|
16
|
+
fields.update(
|
|
17
|
+
timestamp=datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
|
18
|
+
severity=record.levelname,
|
|
19
|
+
logger=record.name,
|
|
20
|
+
message=record.getMessage(),
|
|
21
|
+
)
|
|
22
|
+
# Keep tracebacks out of the message and escape their newlines so the collector sees one event.
|
|
23
|
+
if record.exc_info:
|
|
24
|
+
fields["exception.stacktrace"] = self.formatException(record.exc_info)
|
|
25
|
+
elif record.exc_text:
|
|
26
|
+
fields["exception.stacktrace"] = record.exc_text
|
|
27
|
+
if record.stack_info:
|
|
28
|
+
fields["stack_info"] = self.formatStack(record.stack_info)
|
|
29
|
+
return json.dumps(fields, default=str)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
_BATCHING_MODE_ENV = "CHALK_REMOTE_CALL_BATCHING_MODE"
|
|
11
|
+
_VALID_BATCHING_MODES = ("per_caller", "combined")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _resolve_batching_mode(kwarg: str | None) -> str:
|
|
15
|
+
"""Resolve batching mode from kwarg or env var.
|
|
16
|
+
|
|
17
|
+
Precedence: explicit kwarg > env var > default "per_caller". The env var
|
|
18
|
+
is the canonical opt-in for combined mode; the kwarg exists so embedders
|
|
19
|
+
can override programmatically.
|
|
20
|
+
"""
|
|
21
|
+
if kwarg is not None:
|
|
22
|
+
mode = kwarg
|
|
23
|
+
else:
|
|
24
|
+
mode = (os.environ.get(_BATCHING_MODE_ENV, "") or "per_caller").strip() or "per_caller"
|
|
25
|
+
if mode not in _VALID_BATCHING_MODES:
|
|
26
|
+
raise ValueError(f"batching_mode must be one of {_VALID_BATCHING_MODES}, got {mode!r}")
|
|
27
|
+
return mode
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def serve(
|
|
31
|
+
handler: Callable[..., Any],
|
|
32
|
+
host: str = "[::]",
|
|
33
|
+
port: int = 6666,
|
|
34
|
+
workers: int = 10,
|
|
35
|
+
on_startup: Callable[[], None] | None = None,
|
|
36
|
+
on_shutdown: Callable[[], None] | None = None,
|
|
37
|
+
arg_names: list[str] | None = None,
|
|
38
|
+
max_batching_size: int | None = None,
|
|
39
|
+
max_buffer_duration_ms: int | None = None,
|
|
40
|
+
batching_mode: Literal["per_caller", "combined"] | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Start the gRPC server implementing RemoteCallService.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
handler: The user's handler function.
|
|
46
|
+
Single-request mode (default): (event, context) -> result.
|
|
47
|
+
Coalesced per-caller mode (when max_batching_size is set and
|
|
48
|
+
batching_mode == "per_caller"):
|
|
49
|
+
(events: list[dict], contexts: list[dict]) -> list[result],
|
|
50
|
+
one result per input.
|
|
51
|
+
Coalesced combined mode (when max_batching_size is set and
|
|
52
|
+
batching_mode == "combined"):
|
|
53
|
+
(combined: pa.RecordBatch, offsets: list[int], contexts: list[dict])
|
|
54
|
+
-> pa.RecordBatch with combined.num_rows rows.
|
|
55
|
+
host: The host to bind to.
|
|
56
|
+
port: The port to bind to.
|
|
57
|
+
workers: Number of worker threads for the tokio runtime.
|
|
58
|
+
on_startup: Optional startup hook called before the server starts accepting requests.
|
|
59
|
+
on_shutdown: Optional shutdown hook called after the server stops accepting requests.
|
|
60
|
+
arg_names: Parsed CHALK_INPUT_ARGS column names, or None.
|
|
61
|
+
max_batching_size: Maximum number of concurrent requests to coalesce
|
|
62
|
+
before invoking the handler. None/0 disables coalescing.
|
|
63
|
+
max_buffer_duration_ms: Max time in milliseconds to buffer incoming
|
|
64
|
+
requests before flushing even if max_batching_size hasn't been
|
|
65
|
+
reached. Defaults to 1000ms when batching is enabled.
|
|
66
|
+
batching_mode: "per_caller" (default) or "combined". Only effective
|
|
67
|
+
when max_batching_size > 0. Overrides the
|
|
68
|
+
CHALK_REMOTE_CALL_BATCHING_MODE env var when set; otherwise the
|
|
69
|
+
env var (or "per_caller") wins.
|
|
70
|
+
"""
|
|
71
|
+
# Keep package imports lightweight so the CLI can configure logging before dependency failures.
|
|
72
|
+
from chalk_remote_call._native import start_server
|
|
73
|
+
from chalk_remote_call.servicer import (
|
|
74
|
+
process_batches,
|
|
75
|
+
process_batches_coalesced,
|
|
76
|
+
process_batches_coalesced_combined,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if on_startup is not None:
|
|
80
|
+
logger.info("Running startup hook...")
|
|
81
|
+
on_startup()
|
|
82
|
+
|
|
83
|
+
mode = _resolve_batching_mode(batching_mode)
|
|
84
|
+
|
|
85
|
+
if max_batching_size is not None and max_batching_size > 0:
|
|
86
|
+
process_fn = process_batches_coalesced_combined if mode == "combined" else process_batches_coalesced
|
|
87
|
+
batch_size = max_batching_size
|
|
88
|
+
duration_ms = max_buffer_duration_ms if max_buffer_duration_ms is not None else 1000
|
|
89
|
+
logger.info(
|
|
90
|
+
"Batching enabled: mode=%s max_batching_size=%d max_buffer_duration_ms=%d",
|
|
91
|
+
mode,
|
|
92
|
+
batch_size,
|
|
93
|
+
duration_ms,
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
if mode == "combined":
|
|
97
|
+
raise ValueError("batching_mode='combined' requires max_batching_size > 0")
|
|
98
|
+
process_fn = process_batches
|
|
99
|
+
batch_size = 0
|
|
100
|
+
duration_ms = 0
|
|
101
|
+
|
|
102
|
+
logger.info("Starting server on %s:%d", host, port)
|
|
103
|
+
try:
|
|
104
|
+
start_server(
|
|
105
|
+
handler=handler,
|
|
106
|
+
process_fn=process_fn,
|
|
107
|
+
host=host,
|
|
108
|
+
port=port,
|
|
109
|
+
workers=workers,
|
|
110
|
+
arg_names=arg_names,
|
|
111
|
+
max_batching_size=batch_size,
|
|
112
|
+
max_buffer_duration_ms=duration_ms,
|
|
113
|
+
)
|
|
114
|
+
finally:
|
|
115
|
+
if on_shutdown is not None:
|
|
116
|
+
logger.info("Running shutdown hook...")
|
|
117
|
+
on_shutdown()
|