Skip to content

Commit e23df30

Browse files
rustyconoverclaude
andcommitted
feat: add DuckDB settings/pragmas support for VGI functions
Functions can now declare required DuckDB settings via Meta.required_settings and access them via self.settings or self.get_setting(). Settings are passed in the Invocation during the bind phase, allowing output schema to depend on setting values. Changes: - Add duckdb_settings field to Invocation class with map<string,string> type - Add required_settings to function Meta class and ResolvedMetadata - Add settings property and get_setting() method to Function base class - Update Worker to validate required settings before function instantiation - Update Client to accept duckdb_settings parameter in function calls - Add SettingsAwareFunction example demonstrating settings-dependent output - Add comprehensive end-to-end tests - Update protocol.md and CLAUDE.md documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent f4f279d commit e23df30

14 files changed

Lines changed: 810 additions & 41 deletions

File tree

.beads/issues.jsonl

Lines changed: 9 additions & 9 deletions
Large diffs are not rendered by default.

CLAUDE.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,53 @@ class SequenceFunction(TableFunctionGenerator):
207207
))
208208
```
209209

210+
## Using DuckDB Settings
211+
212+
Functions can declare required DuckDB settings via `Meta.required_settings` and
213+
access them via `self.settings` or `self.get_setting()`. Settings are available
214+
during the bind phase, allowing output schema to depend on setting values.
215+
216+
```python
217+
class SettingsAwareFunction(TableFunctionGenerator):
218+
"""Function that uses DuckDB settings to determine output."""
219+
220+
class Meta:
221+
required_settings = ["vgi_verbose_mode"] # Declare required settings
222+
max_workers = 1
223+
224+
count = Arg[int](0, doc="Number of rows")
225+
226+
@property
227+
def output_schema(self) -> pa.Schema:
228+
# Settings available during bind - can influence output schema
229+
fields = [pa.field("id", pa.int64())]
230+
231+
if self.get_setting("vgi_verbose_mode") == "true":
232+
fields.append(pa.field("details", pa.string()))
233+
234+
return pa.schema(fields)
235+
236+
def process(self):
237+
verbose = self.get_setting("vgi_verbose_mode") == "true"
238+
for i in range(self.count):
239+
data = {"id": [i]}
240+
if verbose:
241+
data["details"] = [f"row_{i}"]
242+
yield Output(pa.RecordBatch.from_pydict(data, schema=self.output_schema))
243+
```
244+
245+
Client passes settings when invoking:
246+
247+
```python
248+
with Client("vgi-example-worker") as client:
249+
for batch in client.table_function(
250+
function_name="settings_aware",
251+
arguments=Arguments(positional=(pa.scalar(10),)),
252+
duckdb_settings={"vgi_verbose_mode": "true"},
253+
):
254+
process(batch)
255+
```
256+
210257
## Creating a Worker
211258

212259
```python
@@ -285,6 +332,13 @@ output_schema = schema_like(self.input_schema, rename={"old": "new"})
285332
| `setup()` | Acquire resources | No-op |
286333
| `teardown()` | Release resources | No-op |
287334

335+
**All Functions (Common):**
336+
337+
| Property/Method | Description |
338+
|-----------------|-------------|
339+
| `settings` | Dict of DuckDB settings passed to function |
340+
| `get_setting(name, default)` | Get specific setting value |
341+
288342
### Pattern Decision Tree
289343

290344
```

docs/design-duckdb-settings.md

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
# Design: DuckDB Settings/Pragmas Access for VGI Functions
2+
3+
## Overview
4+
5+
VGI functions need access to DuckDB settings/pragmas to:
6+
1. Determine output schema during bind phase (e.g., timezone affects datetime output)
7+
2. Influence processing behavior (e.g., thread limits, memory settings)
8+
3. Maintain consistency with DuckDB's execution environment
9+
10+
This design adds support for functions to declare required settings and access their values.
11+
12+
## Design Decisions
13+
14+
### 1. Settings Declaration in Meta Class
15+
16+
Functions declare required settings in their `Meta` class:
17+
18+
```python
19+
class TimezoneAwareFunction(TableInOutFunction):
20+
class Meta:
21+
required_settings = ["TimeZone", "Calendar"] # List of DuckDB setting names
22+
max_workers = 1
23+
24+
@property
25+
def output_schema(self) -> pa.Schema:
26+
# Settings available during bind - can influence output schema
27+
tz = self.get_setting("TimeZone", "UTC")
28+
return pa.schema([
29+
("timestamp", pa.timestamp("us", tz=tz)),
30+
])
31+
```
32+
33+
**Changes to `vgi/metadata.py`:**
34+
- Add `"required_settings"` to `_VALID_META_ATTRIBUTES`
35+
- Add `required_settings: list[str] = field(default_factory=list)` to `ResolvedMetadata`
36+
- Update `resolve_metadata()` to extract the field
37+
- Update Arrow serialization schema and methods
38+
39+
### 2. Settings in Invocation
40+
41+
Settings are passed as a dict in the `Invocation`:
42+
43+
```python
44+
@dataclass(frozen=True, slots=True)
45+
class Invocation:
46+
# ... existing fields ...
47+
duckdb_settings: dict[str, str] | None = None # New field
48+
```
49+
50+
**Changes to `vgi/invocation.py`:**
51+
- Add `duckdb_settings: dict[str, str] | None = None` field
52+
- Serialize as `pa.map_(pa.utf8(), pa.utf8())` type in Arrow IPC
53+
- Deserialize with backward compatibility (None if field missing)
54+
55+
**Serialization Format:**
56+
```python
57+
# In serialize():
58+
pa.field("duckdb_settings", pa.map_(pa.utf8(), pa.utf8()), nullable=True)
59+
60+
# Value encoding:
61+
"duckdb_settings": (
62+
list(self.duckdb_settings.items()) if self.duckdb_settings else None
63+
)
64+
```
65+
66+
### 3. Settings Accessor API
67+
68+
Functions access settings via the `Function` base class:
69+
70+
```python
71+
class Function:
72+
@property
73+
def settings(self) -> dict[str, str]:
74+
"""All DuckDB settings passed to this function."""
75+
return dict(self.invocation.duckdb_settings or {})
76+
77+
def get_setting(self, name: str, default: str | None = None) -> str | None:
78+
"""Get a specific DuckDB setting value.
79+
80+
Args:
81+
name: DuckDB setting name (e.g., "TimeZone", "threads")
82+
default: Value to return if setting not present
83+
84+
Returns:
85+
Setting value or default
86+
"""
87+
if self.invocation.duckdb_settings is None:
88+
return default
89+
return self.invocation.duckdb_settings.get(name, default)
90+
```
91+
92+
**Changes to `vgi/function.py`:**
93+
- Add `settings` property
94+
- Add `get_setting()` method
95+
96+
### 4. Worker Validation
97+
98+
The worker validates required settings during bind:
99+
100+
```python
101+
# In Worker._validate_required_settings():
102+
def _validate_required_settings(
103+
self,
104+
func_cls: type[Function],
105+
invocation: Invocation
106+
) -> None:
107+
"""Validate that all required settings are present."""
108+
meta = func_cls.get_metadata()
109+
required = set(meta.required_settings)
110+
111+
if not required:
112+
return # No settings required
113+
114+
provided = set(invocation.duckdb_settings.keys()) if invocation.duckdb_settings else set()
115+
missing = required - provided
116+
117+
if missing:
118+
raise ValueError(
119+
f"Function '{meta.name}' requires settings {sorted(missing)} "
120+
f"but they were not provided. Provided: {sorted(provided)}"
121+
)
122+
```
123+
124+
**Changes to `vgi/worker.py`:**
125+
- Add `_validate_required_settings()` method
126+
- Call it after function class resolution, before instantiation
127+
128+
### 5. Client Support
129+
130+
The client passes settings when creating invocations:
131+
132+
```python
133+
# In Client methods:
134+
def invoke(
135+
self,
136+
function_name: str,
137+
...,
138+
duckdb_settings: dict[str, str] | None = None, # New parameter
139+
) -> ...:
140+
```
141+
142+
**Changes to `vgi/client/client.py`:**
143+
- Add `duckdb_settings` parameter to `_initialize_stream_common()` and related methods
144+
- Include in `Invocation` creation
145+
146+
## Protocol Flow
147+
148+
```
149+
Client Worker
150+
│ │
151+
│ Invocation │
152+
│ ├─ function_name: "timezone_func" │
153+
│ ├─ arguments: {...} │
154+
│ ├─ input_schema: {...} │
155+
│ └─ duckdb_settings: { │
156+
│ "TimeZone": "America/New_York", │
157+
│ "threads": "4" │
158+
│ } │
159+
│─────────────────────────────────────────▶│
160+
│ │ 1. Lookup function class
161+
│ │ 2. Validate required_settings
162+
│ │ 3. Instantiate function
163+
│ │ (settings available via self.settings)
164+
│ │ 4. Get output_schema (may use settings)
165+
│◀─────────────────────────────────────────│
166+
│ OutputSpec │
167+
│ └─ output_schema: {...} │
168+
│ │
169+
```
170+
171+
## Example Function
172+
173+
```python
174+
from vgi import TableInOutFunction, Arg
175+
import pyarrow as pa
176+
177+
class DebugOutputFunction(TableInOutFunction):
178+
"""Function that optionally includes debug columns based on setting."""
179+
180+
class Meta:
181+
required_settings = ["vgi_debug_mode"]
182+
max_workers = 1
183+
184+
@property
185+
def output_schema(self) -> pa.Schema:
186+
# Base schema from input
187+
fields = list(self.input_schema)
188+
189+
# Add debug column if debug mode enabled
190+
if self.get_setting("vgi_debug_mode") == "true":
191+
fields.append(pa.field("_debug_worker_pid", pa.int32()))
192+
193+
return pa.schema(fields)
194+
195+
def transform(self, batch: pa.RecordBatch) -> pa.RecordBatch:
196+
if self.get_setting("vgi_debug_mode") == "true":
197+
# Add debug column
198+
import os
199+
debug_col = pa.array([os.getpid()] * batch.num_rows, type=pa.int32())
200+
return pa.RecordBatch.from_arrays(
201+
list(batch.columns) + [debug_col],
202+
schema=self.output_schema
203+
)
204+
return batch
205+
```
206+
207+
## Files to Modify
208+
209+
| File | Changes |
210+
|------|---------|
211+
| `vgi/metadata.py` | Add `required_settings` to Meta, ResolvedMetadata, Arrow schema |
212+
| `vgi/invocation.py` | Add `duckdb_settings` field, serialization |
213+
| `vgi/function.py` | Add `settings` property, `get_setting()` method |
214+
| `vgi/worker.py` | Add settings validation during bind |
215+
| `vgi/client/client.py` | Add `duckdb_settings` parameter |
216+
| `docs/protocol.md` | Document settings in protocol |
217+
| `docs/metadata.md` | Document `required_settings` |
218+
| `CLAUDE.md` | Add settings usage example |
219+
220+
## Test Cases
221+
222+
1. **Serialization roundtrip**: Invocation with settings serializes/deserializes correctly
223+
2. **Empty settings**: Function with no required_settings works without settings
224+
3. **Required settings present**: Function receives and can access settings
225+
4. **Missing required settings**: Worker rejects with clear error
226+
5. **Settings affect output schema**: Verify bind returns different schema based on setting
227+
6. **Settings affect processing**: Verify transform behavior changes based on setting
228+
7. **Backward compatibility**: Old clients without settings field work with new workers
229+
230+
## Implementation Order
231+
232+
1. `vgi/metadata.py` - Add required_settings to Meta
233+
2. `vgi/invocation.py` - Add duckdb_settings field
234+
3. `vgi/function.py` - Add settings accessor
235+
4. `vgi/worker.py` - Add validation
236+
5. `vgi/client/client.py` - Add settings parameter
237+
6. Example function
238+
7. Tests
239+
8. Documentation

docs/protocol.md

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Client Worker
4242
│◀──── OutputSpec (output schema) ──────│
4343
│ │
4444
│──── GlobalStateInitInput ────────────▶│
45-
│◀──── InitResult ────────────────│ perform_init()
45+
│◀──── InitResult ────────────────│ initialize_global_state()
4646
│ │
4747
│◀──── Output Batch 1 ──────────────────│ process() yields
4848
│◀──── Output Batch 2 ──────────────────│
@@ -63,7 +63,7 @@ Client Worker
6363
│◀──── OutputSpec (output schema) ──────│
6464
│ │
6565
│──── GlobalStateInitInput ────────────▶│
66-
│◀──── InitResult ────────────────│ perform_init()
66+
│◀──── InitResult ────────────────│ initialize_global_state()
6767
│ │
6868
│──── Input Batch 1 ───────────────────▶│
6969
│◀──── Output Batch 1 (NEED_MORE_INPUT)─│ transform() / process()
@@ -83,3 +83,40 @@ Client Worker
8383
| `NEED_MORE_INPUT` | Ready for next input batch |
8484
| `HAVE_MORE_OUTPUT` | Call send() again for more output |
8585
| `FINISHED` | Processing complete |
86+
87+
## Invocation Fields
88+
89+
The Invocation message contains the following fields:
90+
91+
| Field | Type | Description |
92+
|-------|------|-------------|
93+
| `function_name` | string | Name of the function to invoke |
94+
| `arguments` | struct | Positional and named arguments |
95+
| `input_schema` | binary | Arrow IPC serialized schema (nullable) |
96+
| `function_type` | string | `SCALAR` or `TABLE` |
97+
| `invocation_id` | binary | Unique ID for this binding (nullable) |
98+
| `correlation_id` | string | For request tracing/logging |
99+
| `global_execution_identifier` | binary | Shared state ID for parallel workers |
100+
| `client_features` | list\<string\> | Feature flags from client |
101+
| `attach_id` | binary | DuckDB attachment identifier (nullable) |
102+
| `duckdb_settings` | map\<string, string\> | DuckDB settings/pragmas (nullable) |
103+
104+
## DuckDB Settings
105+
106+
Functions can declare required DuckDB settings via `Meta.required_settings`. These
107+
settings are passed from client to worker in the Invocation during the bind phase.
108+
109+
```python
110+
class MyFunction(TableFunctionGenerator):
111+
class Meta:
112+
required_settings = ["TimeZone", "threads"]
113+
114+
@property
115+
def output_schema(self) -> pa.Schema:
116+
# Settings available during bind
117+
tz = self.get_setting("TimeZone", "UTC")
118+
return pa.schema([("timestamp", pa.timestamp("us", tz=tz))])
119+
```
120+
121+
The worker validates that all required settings are present before instantiating
122+
the function. Missing settings result in an error.

tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def make_invocation(
2828
function_type: InvocationType = InvocationType.TABLE,
2929
arguments: Arguments | None = None,
3030
function_name: str = "test",
31+
duckdb_settings: dict[str, str] | None = None,
3132
) -> Invocation:
3233
"""Create a test invocation with flexible parameters."""
3334
return Invocation(
@@ -37,6 +38,7 @@ def make_invocation(
3738
correlation_id="test",
3839
invocation_id=b"test",
3940
arguments=arguments or Arguments(),
41+
duckdb_settings=duckdb_settings,
4042
)
4143

4244

0 commit comments

Comments
 (0)