Skip to content

Commit 53517c1

Browse files
rustyconoverclaude
andcommitted
fix: answer issubclass on function bases from the MRO
Every function base is an ABC, and a failed issubclass(X, Base) makes ABCMeta ask issubclass(X, S) of every subclass S of Base while it looks for a virtual subclass. The framework asks "is this a RowTransformFunction?" of each table-backed scan, so a worker that generates one class per function paid N scans x M row-transform classes: 2.6 s of table setup at 4,000 functions, and about 9 s of a 12 s ATTACH for an 8,241-function catalog generated from the AWS API models. Function.__subclasshook__ now answers from the MRO, which makes every issubclass check against a function base independent of how many function classes exist. Table setup for 4,000 functions drops to 0.02 s and total catalog build is linear (2.4 s at 8,000). ABC.register() can therefore no longer make a virtual subclass of a function base. Nothing in the SDK relied on that. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent ac2cf82 commit 53517c1

2 files changed

Lines changed: 165 additions & 0 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# Copyright 2025, 2026 Query Farm LLC - https://query.farm
2+
3+
"""Regression tests: ``issubclass`` against a function base must not scale with its subclasses.
4+
5+
Found building a worker that generates one function class per AWS API operation
6+
(~8,000 of them). Every function base is an ``ABC``, and a *failed*
7+
``issubclass(X, Base)`` makes ``ABCMeta`` ask ``issubclass(X, S)`` of every
8+
subclass ``S`` of ``Base`` in search of a virtual subclass. The framework asks
9+
"is this a ``RowTransformFunction``?" of each table-backed scan, so declaring
10+
N tables cost N x (number of row-transform classes) checks: 2.6 s for 4,000
11+
functions, 9 s of a 12 s ATTACH for 8,000.
12+
13+
``Function.__subclasshook__`` answers from the MRO instead. These tests pin the
14+
cost (one check, whatever the number of sibling classes) and the answers.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import abc
20+
from dataclasses import dataclass
21+
from typing import Annotated, Any
22+
23+
import pyarrow as pa
24+
import pytest
25+
26+
from vgi import Arg
27+
from vgi.aggregate_function import AggregateFunction
28+
from vgi.function import Function
29+
from vgi.invocation import BindResponse
30+
from vgi.scalar_function import ScalarFunctionGenerator
31+
from vgi.table_function import (
32+
BindParams,
33+
OutputCollector,
34+
ProcessParams,
35+
TableFunctionBase,
36+
TableFunctionGenerator,
37+
init_single_worker,
38+
)
39+
from vgi.table_in_out_function import RowTransformFunction, TableInOutGenerator
40+
41+
_SCHEMA = pa.schema([("value", pa.string())])
42+
43+
44+
@dataclass(slots=True, frozen=True, kw_only=True)
45+
class _NoArgs:
46+
pass
47+
48+
49+
@dataclass(slots=True, frozen=True, kw_only=True)
50+
class _KeyArgs:
51+
key: Annotated[str, Arg(0, doc="Lookup key", arrow_type=pa.string())]
52+
53+
54+
def _make_scan(index: int) -> type[TableFunctionGenerator[Any, Any]]:
55+
@init_single_worker
56+
class Scan(TableFunctionGenerator[_NoArgs, None]):
57+
"""A generated scan."""
58+
59+
class Meta:
60+
name = f"subclass_check_scan_{index}"
61+
62+
@classmethod
63+
def on_bind(cls, params: BindParams[_NoArgs]) -> BindResponse:
64+
return BindResponse(output_schema=_SCHEMA)
65+
66+
@classmethod
67+
def process(cls, params: ProcessParams[_NoArgs], state: None, out: OutputCollector) -> None:
68+
out.finish()
69+
70+
return Scan
71+
72+
73+
def _make_row_transform(index: int) -> type[RowTransformFunction[Any]]:
74+
class Lookup(RowTransformFunction[_KeyArgs]):
75+
"""A generated row transform."""
76+
77+
class Meta:
78+
name = f"subclass_check_lookup_{index}"
79+
80+
@classmethod
81+
def on_bind(cls, params: BindParams[_KeyArgs]) -> BindResponse:
82+
return BindResponse(output_schema=_SCHEMA)
83+
84+
@classmethod
85+
def process(
86+
cls, params: ProcessParams[_KeyArgs], state: None, batch: pa.RecordBatch, out: OutputCollector
87+
) -> None:
88+
out.emit(pa.RecordBatch.from_pylist([], schema=_SCHEMA))
89+
90+
return Lookup
91+
92+
93+
@pytest.fixture
94+
def count_subclass_checks(monkeypatch: pytest.MonkeyPatch) -> list[int]:
95+
"""Count every ``ABCMeta.__subclasscheck__`` call, including ABCMeta's own recursion."""
96+
calls = [0]
97+
original = abc.ABCMeta.__subclasscheck__
98+
99+
def counting(cls: abc.ABCMeta, subclass: type) -> bool:
100+
calls[0] += 1
101+
return original(cls, subclass)
102+
103+
monkeypatch.setattr(abc.ABCMeta, "__subclasscheck__", counting)
104+
return calls
105+
106+
107+
def test_failed_issubclass_does_not_visit_sibling_classes(count_subclass_checks: list[int]) -> None:
108+
"""A scan is not a RowTransformFunction: that takes one check, not one per row-transform class."""
109+
row_transforms = [_make_row_transform(i) for i in range(50)]
110+
scan = _make_scan(0)
111+
112+
count_subclass_checks[0] = 0
113+
assert not issubclass(scan, RowTransformFunction)
114+
assert count_subclass_checks[0] == 1
115+
116+
count_subclass_checks[0] = 0
117+
assert not scan._is_blended()
118+
assert count_subclass_checks[0] == 1
119+
120+
assert all(issubclass(cls, RowTransformFunction) for cls in row_transforms)
121+
122+
123+
def test_issubclass_answers_follow_the_class_hierarchy() -> None:
124+
"""The MRO-based answer agrees with real inheritance in both directions."""
125+
scan = _make_scan(1)
126+
lookup = _make_row_transform(1)
127+
128+
for base in (Function, TableFunctionBase, TableFunctionGenerator):
129+
assert issubclass(scan, base)
130+
for base in (TableInOutGenerator, RowTransformFunction, ScalarFunctionGenerator, AggregateFunction):
131+
assert not issubclass(scan, base)
132+
133+
for base in (Function, TableFunctionBase, TableInOutGenerator, RowTransformFunction):
134+
assert issubclass(lookup, base)
135+
for base in (TableFunctionGenerator, ScalarFunctionGenerator, AggregateFunction):
136+
assert not issubclass(lookup, base)
137+
138+
assert lookup._is_blended()
139+
assert not issubclass(scan, lookup)
140+
assert not issubclass(int, Function)
141+
assert not isinstance(object(), Function)
142+
143+
144+
def test_registering_a_virtual_subclass_has_no_effect() -> None:
145+
"""Function classes are real subclasses; ``ABC.register`` cannot make one."""
146+
147+
class NotAFunction:
148+
pass
149+
150+
RowTransformFunction.register(NotAFunction)
151+
assert not issubclass(NotAFunction, RowTransformFunction)

‎vgi/function.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,20 @@ class Function(ABC, MetadataMixin):
147147
# Cache for resolved metadata
148148
_metadata_cache: ClassVar[ResolvedMetadata | None] = None
149149

150+
@classmethod
151+
def __subclasshook__(cls, subclass: type) -> bool:
152+
"""Answer ``issubclass`` from the MRO alone.
153+
154+
Without this, a *failed* ``issubclass(X, SomeFunctionBase)`` makes
155+
``ABCMeta`` call ``issubclass(X, S)`` for every subclass ``S`` of the
156+
base, looking for a virtual subclass. A worker that generates one class
157+
per function has thousands of them, and the framework asks this about
158+
every class, so building a catalog went quadratic in its function count.
159+
Function classes are real subclasses, never ``ABC.register``-ed ones,
160+
so the MRO is the whole answer.
161+
"""
162+
return cls in getattr(subclass, "__mro__", ())
163+
150164
def __init_subclass__(cls, **kwargs: Any) -> None:
151165
"""Extract [`Arg`][] descriptors from Annotated type hints.
152166

0 commit comments

Comments
 (0)