Skip to content

Commit e697cd9

Browse files
authored
perf(runtime): chunk captured non-ASCII output on UTF-8 byte boundaries (#949)
Command-output capture split any line over the record limit that contained non-ASCII text one character at a time in Python. Encode the line once, slice greedily at the last code point boundary within each limit, and decode each slice. Chunk boundaries and text are unchanged; lines with lone surrogates keep the original character-by-character path because surrogateescape bytes are not valid UTF-8. Closes CHI-504.
1 parent 6039e07 commit e697cd9

7 files changed

Lines changed: 306 additions & 0 deletions

File tree

‎src/sqlbuild/runtime/output_capture/_helpers/text.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
import re
66

7+
from sqlbuild.runtime.output_capture.constants import (
8+
UTF8_CONTINUATION_BYTE_MASK,
9+
UTF8_CONTINUATION_BYTE_TAG,
10+
)
11+
712
_ANSI_ESCAPE: re.Pattern[str] = re.compile(
813
r"(?:\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~])"
914
)
@@ -22,6 +27,48 @@ def chunk_text(*, text: str, max_bytes: int) -> tuple[str, ...]:
2227
if len(text) <= max_bytes:
2328
return (text,)
2429
return tuple(text[index : index + max_bytes] for index in range(0, len(text), max_bytes))
30+
try:
31+
encoded: bytes = text.encode("utf-8")
32+
except UnicodeEncodeError:
33+
return _chunk_text_by_character(text=text, max_bytes=max_bytes)
34+
if len(encoded) <= max_bytes:
35+
return (text,)
36+
return _chunk_utf8(encoded=encoded, max_bytes=max_bytes)
37+
38+
39+
def _chunk_utf8(*, encoded: bytes, max_bytes: int) -> tuple[str, ...]:
40+
"""Split valid UTF-8 greedily at the last code point boundary within each limit."""
41+
42+
chunks: list[str] = []
43+
total: int = len(encoded)
44+
start: int = 0
45+
while start < total:
46+
end: int = start + max_bytes
47+
if end >= total:
48+
end = total
49+
else:
50+
while (
51+
end > start
52+
and encoded[end] & UTF8_CONTINUATION_BYTE_MASK == UTF8_CONTINUATION_BYTE_TAG
53+
):
54+
end -= 1
55+
if end <= start:
56+
end = start + 1
57+
while (
58+
end < total
59+
and encoded[end] & UTF8_CONTINUATION_BYTE_MASK == UTF8_CONTINUATION_BYTE_TAG
60+
):
61+
end += 1
62+
chunks.append(encoded[start:end].decode("utf-8"))
63+
start = end
64+
if not chunks:
65+
chunks.append("")
66+
return tuple(chunks)
67+
68+
69+
def _chunk_text_by_character(*, text: str, max_bytes: int) -> tuple[str, ...]:
70+
"""Split text containing lone surrogates, which surrogateescape maps to single bytes."""
71+
2572
if len(text) <= max_bytes and len(text.encode("utf-8", "surrogateescape")) <= max_bytes:
2673
return (text,)
2774

‎src/sqlbuild/runtime/output_capture/constants.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
DEFAULT_OUTPUT_QUEUE_CAPACITY: int = 1024
99
DEFAULT_OUTPUT_SHUTDOWN_TIMEOUT_SECONDS: float = 2.0
1010
MIN_OUTPUT_RECORD_BYTES: int = 4
11+
UTF8_CONTINUATION_BYTE_MASK: int = 0xC0
12+
UTF8_CONTINUATION_BYTE_TAG: int = 0x80
1113
INVOCATION_CONTEXT_ENV: str = "SQLBUILD_INVOCATION_CONTEXT_JSON"
1214
MAX_INVOCATION_CONTEXT_BYTES: int = 16 * 1024
1315
MAX_INVOCATION_CONTEXT_DEPTH: int = 8

‎tests/unit/src/sqlbuild/runtime/output_capture/_helpers/__init__.py‎

Whitespace-only changes.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Parameterized text chunking test cases."""
2+
3+
from collections.abc import Callable
4+
from dataclasses import dataclass
5+
6+
type ChunkTextFunction = Callable[..., tuple[str, ...]]
7+
8+
9+
@dataclass(frozen=True)
10+
class ChunkTextTestCase:
11+
"""One exact chunk_text expectation."""
12+
13+
description: str
14+
text: str
15+
max_bytes: int
16+
expected_chunks: tuple[str, ...]
17+
18+
19+
@dataclass(frozen=True)
20+
class ChunkTextErrorTestCase:
21+
"""One input the stored chunk contract rejects."""
22+
23+
description: str
24+
text: str
25+
max_bytes: int
26+
expected_error: type[Exception]
27+
28+
29+
@dataclass(frozen=True)
30+
class ChunkTextOracleTestCase:
31+
"""One seeded random comparison against the reference implementation."""
32+
33+
description: str
34+
seed: int
35+
max_bytes: int
36+
count: int
37+
expected_mismatch_count: int
38+
39+
40+
@dataclass(frozen=True)
41+
class ChunkTextPerformanceTestCase:
42+
"""One large payload chunking time budget."""
43+
44+
description: str
45+
unit: str
46+
payload_bytes: int
47+
max_bytes: int
48+
expected_max_seconds: float
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Shared fixtures for output capture text chunking tests."""
2+
3+
import pytest
4+
5+
from tests.unit.src.sqlbuild.runtime.output_capture._helpers._test_types import ChunkTextFunction
6+
7+
8+
def _reference_chunk_text(*, text: str, max_bytes: int) -> tuple[str, ...]:
9+
"""Copy of the original character-at-a-time chunker that defines the stored contract."""
10+
11+
if text.isascii() and max_bytes > 0:
12+
if len(text) <= max_bytes:
13+
return (text,)
14+
return tuple(text[index : index + max_bytes] for index in range(0, len(text), max_bytes))
15+
if len(text) <= max_bytes and len(text.encode("utf-8", "surrogateescape")) <= max_bytes:
16+
return (text,)
17+
18+
chunks: list[str] = []
19+
current: list[str] = []
20+
current_bytes: int = 0
21+
for character in text:
22+
character_bytes: int = len(character.encode("utf-8", "surrogateescape"))
23+
if current and current_bytes + character_bytes > max_bytes:
24+
chunks.append("".join(current))
25+
current = []
26+
current_bytes = 0
27+
current.append(character)
28+
current_bytes += character_bytes
29+
if current or not chunks:
30+
chunks.append("".join(current))
31+
return tuple(chunks)
32+
33+
34+
@pytest.fixture
35+
def reference_chunk_text() -> ChunkTextFunction:
36+
"""Return the reference oracle for chunk boundaries and text."""
37+
38+
return _reference_chunk_text
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Random inputs for output capture text chunking tests."""
2+
3+
import random
4+
5+
_ALPHABET: tuple[str, ...] = (
6+
"a",
7+
"Z",
8+
" ",
9+
"\n",
10+
"é",
11+
"ß",
12+
"Ω",
13+
"订",
14+
"€",
15+
"✓",
16+
"📦",
17+
"🙂",
18+
"𝔸",
19+
"e\u0301",
20+
"\u0308",
21+
)
22+
23+
24+
def random_mixed_width_texts(*, seed: int, max_bytes: int, count: int) -> tuple[str, ...]:
25+
"""Build texts whose encoded lengths land around multiples of the chunk limit."""
26+
27+
generator: random.Random = random.Random(seed)
28+
return tuple(
29+
_random_mixed_width_text(generator=generator, max_bytes=max_bytes) for _ in range(count)
30+
)
31+
32+
33+
def _random_mixed_width_text(*, generator: random.Random, max_bytes: int) -> str:
34+
target_bytes: int = max(
35+
0, max_bytes * generator.choice((0, 1, 1, 2, 3)) + generator.randint(-5, 5)
36+
)
37+
parts: list[str] = []
38+
size: int = 0
39+
while size < target_bytes:
40+
piece: str = generator.choice(_ALPHABET)
41+
parts.append(piece)
42+
size += len(piece.encode("utf-8"))
43+
return "".join(parts)
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Tests for deterministic output capture text chunking."""
2+
3+
import time
4+
5+
import pytest
6+
7+
from sqlbuild.runtime.output_capture._helpers.text import chunk_text
8+
from tests.unit.src.sqlbuild.runtime.output_capture._helpers._test_types import (
9+
ChunkTextErrorTestCase,
10+
ChunkTextFunction,
11+
ChunkTextOracleTestCase,
12+
ChunkTextPerformanceTestCase,
13+
ChunkTextTestCase,
14+
)
15+
from tests.unit.src.sqlbuild.runtime.output_capture._helpers.helpers import (
16+
random_mixed_width_texts,
17+
)
18+
19+
20+
@pytest.mark.parametrize(
21+
"test_case",
22+
(
23+
ChunkTextTestCase("two_byte_straddles_limit", "aé", 2, ("a", "é")),
24+
ChunkTextTestCase("three_byte_straddles_limit", "ab订c", 4, ("ab", "订c")),
25+
ChunkTextTestCase("four_byte_straddles_limit", "abc📦", 4, ("abc", "📦")),
26+
ChunkTextTestCase("four_byte_exact_limit", "📦📦", 4, ("📦", "📦")),
27+
ChunkTextTestCase("combining_mark_split_from_base", "ée\u0301", 4, ("ée", "\u0301")),
28+
ChunkTextTestCase("character_wider_than_limit", "a📦b", 2, ("a", "📦", "b")),
29+
ChunkTextTestCase("each_character_when_limit_zero", "a订", 0, ("a", "订")),
30+
ChunkTextTestCase("empty_text_negative_limit", "", -1, ("",)),
31+
ChunkTextTestCase("escaped_surrogate_counts_one_byte", "é\udc80a", 3, ("é\udc80", "a")),
32+
),
33+
ids=lambda case: case.description,
34+
)
35+
def test_given_multibyte_boundaries_when_chunking_then_code_points_stay_whole(
36+
test_case: ChunkTextTestCase,
37+
reference_chunk_text: ChunkTextFunction,
38+
) -> None:
39+
result: tuple[str, ...] = chunk_text(text=test_case.text, max_bytes=test_case.max_bytes)
40+
41+
assert result == test_case.expected_chunks
42+
assert result == reference_chunk_text(text=test_case.text, max_bytes=test_case.max_bytes)
43+
44+
45+
@pytest.mark.parametrize(
46+
"test_case",
47+
(ChunkTextErrorTestCase("unescapable_surrogate", "é\ud800", 2, UnicodeEncodeError),),
48+
ids=lambda case: case.description,
49+
)
50+
def test_given_unescapable_surrogate_when_chunking_then_raises_like_reference(
51+
test_case: ChunkTextErrorTestCase,
52+
reference_chunk_text: ChunkTextFunction,
53+
) -> None:
54+
with pytest.raises(test_case.expected_error):
55+
_ = reference_chunk_text(text=test_case.text, max_bytes=test_case.max_bytes)
56+
with pytest.raises(test_case.expected_error):
57+
_ = chunk_text(text=test_case.text, max_bytes=test_case.max_bytes)
58+
59+
60+
@pytest.mark.parametrize(
61+
"test_case",
62+
[
63+
ChunkTextOracleTestCase(
64+
f"limit_{max_bytes}",
65+
seed=max_bytes,
66+
max_bytes=max_bytes,
67+
count=400,
68+
expected_mismatch_count=0,
69+
)
70+
for max_bytes in (1, 2, 3, 4, 5, 6, 7, 8, 13, 64, 257)
71+
],
72+
ids=lambda case: case.description,
73+
)
74+
def test_given_random_mixed_width_text_when_chunking_then_matches_reference(
75+
test_case: ChunkTextOracleTestCase,
76+
reference_chunk_text: ChunkTextFunction,
77+
) -> None:
78+
texts: tuple[str, ...] = random_mixed_width_texts(
79+
seed=test_case.seed, max_bytes=test_case.max_bytes, count=test_case.count
80+
)
81+
82+
actual: tuple[tuple[str, ...], ...] = tuple(
83+
chunk_text(text=text, max_bytes=test_case.max_bytes) for text in texts
84+
)
85+
86+
expected: tuple[tuple[str, ...], ...] = tuple(
87+
reference_chunk_text(text=text, max_bytes=test_case.max_bytes) for text in texts
88+
)
89+
mismatch_count: int = sum(
90+
result != reference for result, reference in zip(actual, expected, strict=True)
91+
)
92+
assert mismatch_count == test_case.expected_mismatch_count
93+
assert tuple("".join(chunks) for chunks in actual) == texts
94+
95+
96+
@pytest.mark.parametrize(
97+
"test_case",
98+
(
99+
ChunkTextPerformanceTestCase(
100+
"non_ascii_ten_megabytes",
101+
unit="order ünïcode 订单 📦 e\u0301 ",
102+
payload_bytes=10_000_000,
103+
max_bytes=64 * 1024,
104+
expected_max_seconds=0.1,
105+
),
106+
),
107+
ids=lambda case: case.description,
108+
)
109+
def test_given_large_non_ascii_payload_when_chunking_then_completes_within_budget(
110+
test_case: ChunkTextPerformanceTestCase,
111+
) -> None:
112+
unit_bytes: int = len(test_case.unit.encode("utf-8"))
113+
text: str = test_case.unit * (test_case.payload_bytes // unit_bytes + 1)
114+
timings: list[float] = []
115+
result: tuple[str, ...] = ()
116+
117+
for _ in range(3):
118+
started: float = time.perf_counter()
119+
result = chunk_text(text=text, max_bytes=test_case.max_bytes)
120+
timings.append(time.perf_counter() - started)
121+
122+
assert "".join(result) == text
123+
assert all(len(chunk.encode("utf-8")) <= test_case.max_bytes for chunk in result)
124+
assert min(timings) < test_case.expected_max_seconds
125+
126+
127+
if __name__ == "__main__":
128+
pytest.main([__file__, "-vv"])

0 commit comments

Comments
 (0)