mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 18:44:53 +00:00
Compress release backend test selectors
This commit is contained in:
parent
418402bf9e
commit
1327dddad5
6 changed files with 139 additions and 25 deletions
|
|
@ -604,11 +604,15 @@ upgrade, update, release, or artifact-selection behavior.
|
|||
deterministic manifest proving a complete, disjoint partition. Each
|
||||
partition must preserve the exact order emitted by that binary; sorting or
|
||||
hash distribution is not equivalent while legacy tests still exercise
|
||||
package-global state. Each shard must split only at deterministic contiguous
|
||||
batch boundaries whose encoded `-test.run` regex stays below the configured
|
||||
per-argument byte ceiling, so a memory-driven one-shard fallback cannot
|
||||
exceed the operating system's exec limit before tests begin. Two API shards
|
||||
and the remaining Go packages may then
|
||||
package-global state. The planner must prefix-compress the exact test-name
|
||||
regex and keep a shard in one test-binary process whenever that encoded
|
||||
argument fits below the configured byte ceiling. That ceiling must never
|
||||
exceed 120,000 bytes, retaining explicit headroom below Linux's 131,072-byte
|
||||
per-argument limit. A shard may split only at deterministic contiguous
|
||||
batch boundaries when its compressed exact-name regex exceeds the configured
|
||||
ceiling. Tests at those boundaries must initialize their own package-global
|
||||
prerequisites rather than inheriting state from a prior process. Two API
|
||||
shards and the remaining Go packages may then
|
||||
execute concurrently with isolated data directories. A failed shard must
|
||||
fail the complete backend gate and terminate every descendant test process;
|
||||
sharding must never select a coverage subset or leave orphan race binaries
|
||||
|
|
@ -1612,6 +1616,19 @@ deterministic contiguous batch by encoded regex bytes as well as test count.
|
|||
That failed run and its inert private staging child were cancelled; no public
|
||||
tag or GitHub release was created, and the corrected candidate must run from a
|
||||
new exact source SHA.
|
||||
Release run `32497250921` then proved that naïve byte-bounded alternation was
|
||||
not semantically sufficient: its third fresh test-binary process reached
|
||||
`TestEstablishSession` without the package-global session store historically
|
||||
initialized earlier in the ordered suite. Prefix compression reduces the exact
|
||||
3,736-test expression from 182,429 bytes to 110,384 bytes, so the one-shard
|
||||
fallback needs only two bounded processes at the default 65,536-byte ceiling
|
||||
instead of three naïve alternation batches. The two session-establishment tests
|
||||
now initialize their own persistent session store, removing the boundary-order
|
||||
dependency instead of relying on a larger process. The planner still records
|
||||
the complete test-name digest, proves each compressed expression is an exact
|
||||
ordered partition, and rejects any configured ceiling above 120,000 bytes.
|
||||
That failed run and its private child were cancelled before publication; no
|
||||
public tag or release was created.
|
||||
The publish body keeps its `Highlights` section within the governed three-item
|
||||
limit while preserving the packet's operator-facing summary of read-only
|
||||
observer coverage, estate-first platform search and facets, and real delivery-
|
||||
|
|
|
|||
|
|
@ -65,9 +65,9 @@ source commit that passed qualification.
|
|||
- Candidate qualification now runs even when intentionally disabled native
|
||||
signing jobs are skipped, while still failing closed on a required signing
|
||||
failure.
|
||||
- Backend qualification now byte-bounds deterministic API test batches, so a
|
||||
memory-driven one-shard fallback cannot exceed the operating system's exec
|
||||
argument limit before any tests run.
|
||||
- Backend qualification prefix-compresses the exact API test selector, keeping
|
||||
a memory-driven one-shard fallback in one ordered process when safe while
|
||||
still byte-bounding deterministic batches below the operating system limit.
|
||||
|
||||
## Security
|
||||
|
||||
|
|
|
|||
|
|
@ -1604,6 +1604,7 @@ func TestHandleStorageSummaryCharts_AggregatesCapacityAcrossPools(t *testing.T)
|
|||
}
|
||||
|
||||
func TestEstablishSession(t *testing.T) {
|
||||
InitPersistentAuthStores(t.TempDir())
|
||||
router := &Router{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -1619,6 +1620,7 @@ func TestEstablishSession(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestEstablishOIDCSession(t *testing.T) {
|
||||
InitPersistentAuthStores(t.TempDir())
|
||||
router := &Router{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
|
@ -12,7 +13,7 @@ import unittest
|
|||
ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from shard_go_tests import build_plan, write_plan
|
||||
from shard_go_tests import _test_regex, build_plan, write_plan
|
||||
|
||||
|
||||
class ReleasePreflightTest(unittest.TestCase):
|
||||
|
|
@ -149,10 +150,12 @@ class ReleasePreflightTest(unittest.TestCase):
|
|||
build_plan(["TestDuplicate", "TestDuplicate"], 2, 10)
|
||||
with self.assertRaisesRegex(ValueError, "cannot exceed"):
|
||||
build_plan(["TestOnly"], 2, 10)
|
||||
with self.assertRaisesRegex(ValueError, "safe per-argument ceiling"):
|
||||
build_plan(["TestOnly"], 1, 10, max_regex_bytes=120_001)
|
||||
|
||||
def test_api_shard_plan_bounds_one_shard_regex_argument_bytes(self) -> None:
|
||||
test_names = [
|
||||
f"TestReleaseIntegrationCase{index:04d}{'X' * 64}"
|
||||
f"Test{index:04d}{index * 7919:08d}{index * 104729:010d}{'X' * 64}"
|
||||
for index in range(3736)
|
||||
]
|
||||
max_regex_bytes = 64 * 1024
|
||||
|
|
@ -187,6 +190,30 @@ class ReleasePreflightTest(unittest.TestCase):
|
|||
max_regex_bytes=8,
|
||||
)
|
||||
|
||||
def test_api_shard_regex_compresses_shared_names_without_changing_membership(
|
||||
self,
|
||||
) -> None:
|
||||
test_names = [
|
||||
"TestHandleCharts_Success",
|
||||
"TestHandleCharts_MethodNotAllowed",
|
||||
"TestHandleStorageCharts_Success",
|
||||
"TestHandleStorageCharts_MethodNotAllowed",
|
||||
"TestRouterPublicPathsInventory",
|
||||
]
|
||||
regex = _test_regex(test_names)
|
||||
naive = "^(?:" + "|".join(test_names) + ")$"
|
||||
self.assertLess(len(regex.encode()), len(naive.encode()))
|
||||
|
||||
compiled = re.compile(regex)
|
||||
for test_name in test_names:
|
||||
self.assertIsNotNone(compiled.fullmatch(test_name))
|
||||
for excluded in [
|
||||
"TestHandleCharts",
|
||||
"TestHandleCharts_Failure",
|
||||
"TestRouterPublicPathsInventoryExtra",
|
||||
]:
|
||||
self.assertIsNone(compiled.fullmatch(excluded))
|
||||
|
||||
def test_release_workflow_uses_independent_pve_bundle_and_backend_lanes(self) -> None:
|
||||
workflow = (ROOT / ".github/workflows/create-release.yml").read_text()
|
||||
backend = (ROOT / "scripts/run-release-backend-tests.sh").read_text()
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ if [[ ! "$MAX_REGEX_BYTES" =~ ^[1-9][0-9]*$ ]]; then
|
|||
echo "Error: --max-regex-bytes must be a positive integer." >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ "$MAX_REGEX_BYTES" -gt 120000 ]; then
|
||||
echo "Error: --max-regex-bytes cannot exceed the safe 120000-byte per-argument ceiling." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
for command_name in go python3 getconf awk pgrep; do
|
||||
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||
|
|
|
|||
|
|
@ -12,38 +12,88 @@ from typing import Sequence
|
|||
|
||||
|
||||
TEST_NAME_PATTERN = re.compile(r"^Test[A-Za-z0-9_]+$")
|
||||
MAX_SAFE_REGEX_BYTES = 120_000
|
||||
DEFAULT_MAX_REGEX_BYTES = 64 * 1024
|
||||
|
||||
|
||||
class _RegexTrie(dict[str, "_RegexTrie"]):
|
||||
terminal: bool
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.terminal = False
|
||||
|
||||
|
||||
def _digest(names: Sequence[str]) -> str:
|
||||
payload = "".join(f"{name}\n" for name in names).encode()
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _test_regex(names: Sequence[str]) -> str:
|
||||
return "^(?:" + "|".join(re.escape(name) for name in names) + ")$"
|
||||
root = _RegexTrie()
|
||||
for name in names:
|
||||
node = root
|
||||
for character in name:
|
||||
node = node.setdefault(character, _RegexTrie())
|
||||
node.terminal = True
|
||||
|
||||
def render(node: _RegexTrie) -> str:
|
||||
suffix_groups: dict[str, list[str]] = {}
|
||||
for character, child in sorted(node.items()):
|
||||
suffix_groups.setdefault(render(child), []).append(character)
|
||||
|
||||
alternatives: list[str] = []
|
||||
for suffix, characters in sorted(suffix_groups.items()):
|
||||
if len(characters) == 1:
|
||||
prefix = re.escape(characters[0])
|
||||
else:
|
||||
prefix = (
|
||||
"["
|
||||
+ "".join(re.escape(value) for value in characters)
|
||||
+ "]"
|
||||
)
|
||||
alternatives.append(prefix + suffix)
|
||||
|
||||
if node.terminal:
|
||||
if not alternatives:
|
||||
return ""
|
||||
body = (
|
||||
alternatives[0]
|
||||
if len(alternatives) == 1
|
||||
else "(?:" + "|".join(alternatives) + ")"
|
||||
)
|
||||
return "(?:" + body + ")?"
|
||||
if len(alternatives) == 1:
|
||||
return alternatives[0]
|
||||
return "(?:" + "|".join(alternatives) + ")"
|
||||
|
||||
return "^(?:" + render(root) + ")$"
|
||||
|
||||
|
||||
def _batch_names(
|
||||
names: Sequence[str], batch_size: int, max_regex_bytes: int
|
||||
) -> list[list[str]]:
|
||||
batches: list[list[str]] = []
|
||||
current: list[str] = []
|
||||
for name in names:
|
||||
candidate = [*current, name]
|
||||
candidate_bytes = len(_test_regex(candidate).encode())
|
||||
if current and (len(candidate) > batch_size or candidate_bytes > max_regex_bytes):
|
||||
batches.append(current)
|
||||
current = [name]
|
||||
candidate_bytes = len(_test_regex(current).encode())
|
||||
else:
|
||||
current = candidate
|
||||
if candidate_bytes > max_regex_bytes:
|
||||
offset = 0
|
||||
while offset < len(names):
|
||||
if len(_test_regex([names[offset]]).encode()) > max_regex_bytes:
|
||||
raise ValueError(
|
||||
f"top-level Go test name exceeds max regex bytes: {name}"
|
||||
f"top-level Go test name exceeds max regex bytes: {names[offset]}"
|
||||
)
|
||||
if current:
|
||||
batches.append(current)
|
||||
|
||||
low = offset + 1
|
||||
high = min(len(names), offset + batch_size)
|
||||
best = low
|
||||
while low <= high:
|
||||
midpoint = (low + high) // 2
|
||||
encoded_bytes = len(_test_regex(names[offset:midpoint]).encode())
|
||||
if encoded_bytes <= max_regex_bytes:
|
||||
best = midpoint
|
||||
low = midpoint + 1
|
||||
else:
|
||||
high = midpoint - 1
|
||||
batches.append(list(names[offset:best]))
|
||||
offset = best
|
||||
return batches
|
||||
|
||||
|
||||
|
|
@ -59,6 +109,11 @@ def build_plan(
|
|||
raise ValueError("batch_size must be at least 1")
|
||||
if max_regex_bytes < 1:
|
||||
raise ValueError("max_regex_bytes must be at least 1")
|
||||
if max_regex_bytes > MAX_SAFE_REGEX_BYTES:
|
||||
raise ValueError(
|
||||
f"max_regex_bytes cannot exceed the safe per-argument ceiling "
|
||||
f"of {MAX_SAFE_REGEX_BYTES}"
|
||||
)
|
||||
if not test_names:
|
||||
raise ValueError("the Go package did not expose any top-level tests")
|
||||
if shard_count > len(test_names):
|
||||
|
|
@ -92,6 +147,15 @@ def build_plan(
|
|||
ordered_names = list(names)
|
||||
assigned.extend(ordered_names)
|
||||
batches = _batch_names(ordered_names, batch_size, max_regex_bytes)
|
||||
for batch in batches:
|
||||
pattern = re.compile(_test_regex(batch))
|
||||
matched_names = [
|
||||
name for name in canonical if pattern.fullmatch(name) is not None
|
||||
]
|
||||
if matched_names != batch:
|
||||
raise RuntimeError(
|
||||
"generated test regex is not an exact ordered partition"
|
||||
)
|
||||
shard_records.append(
|
||||
{
|
||||
"index": shard_index,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue