mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Merge protected Pulse main with reviewed publication range
Preserve upstream browser verification for the newer Relay retirement surface; earlier action recovery proof remains in reviewed ancestry. Include current CI sharding and qualification canary without rewriting accepted commits. Change-source: pulse-maintainer
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
# Run time in seconds of the slowest internal/api top-level tests under -race
|
||||
# on a GitHub ubuntu-24.04 runner. Used only by select-internal-api-shard.sh
|
||||
# to choose where the Backend tests (api-N) shards cut go test's list. Tests
|
||||
# not named here weigh DEFAULT_WEIGHT, the mean of the tests under 0.5s. A
|
||||
# missing, stale or renamed entry can make the shards uneven but can never
|
||||
# drop or repeat a test.
|
||||
#
|
||||
# Every api shard uploads an internal-api-test-seconds-N artifact with the
|
||||
# seconds of each test it ran on the runner, so refresh this file from recent
|
||||
# passing Build and Test runs with
|
||||
# python3 .github/scripts/refresh-internal-api-test-seconds.py --run <id> [--run <id>]
|
||||
# when one api shard runs well over the others.
|
||||
#
|
||||
# Seeded 2026-09-29, before any such artifact existed, from a local
|
||||
# GOMAXPROCS=4 go test -race -json run with each region of go test's order
|
||||
# scaled so its sum matches the seconds the runner reported for it.
|
||||
# Equal-count quarters (runs 36557693864, 36560070496, 36567708327,
|
||||
# 36577491881) averaged 107s, 437s, 100s and 787s. The weighted cut (runs
|
||||
# 36566872567, 36578019732) averaged 131s for the first 1187 tests, 350s for
|
||||
# the next 23 and 608s for the last 21. Its 2556-test shard (887s, 875s) is
|
||||
# left out, because it ran through -run . -skip, which recompiles the skip
|
||||
# pattern for every test and subtest. The last quarter and the last 21 tests
|
||||
# disagree on how the tail splits, so the two tail regions take the midpoint
|
||||
# of an exact fit and one pooled factor.
|
||||
DEFAULT_WEIGHT 0.05
|
||||
TestActionRunnerRotationProductionRouterTLSPersistenceRestart 0.82
|
||||
TestMockActionInboxAndDetailUseCanonicalResponses 2.85
|
||||
TestMockActionInboxHonorsLimitAndLegacyPendingProjection 2.12
|
||||
TestMockActionMutationsFailReadOnly 1.16
|
||||
TestAgentReportHandlersClassifyBodyFailures 1.31
|
||||
TestStart_MockModeStartsChatServiceWhenPersistedConfigDisabled 1.13
|
||||
TestRestart_MockModeStartsChatServiceWhenPersistedConfigDisabled 1.13
|
||||
TestHandleChat_EmitsIdleProgressWhileServiceIsSilent 2.85
|
||||
TestAISettingsHandler_ProviderSettersConcurrentServiceCreation 9.86
|
||||
TestHandleSaveGuestNote_VerifySavedFields 0.62
|
||||
TestHandleClearGuestKnowledge_OversizedBody 0.68
|
||||
TestHandlePatrolStream_LastEventIDHeaderTakesPrecedenceOverQueryFallback 2.37
|
||||
TestHandleExecuteStream_LicenseRequired 1.25
|
||||
TestHandleExecuteStream_PromptRequired 1.26
|
||||
TestHandleExecuteStream_Success 2.13
|
||||
TestHandleExecuteStream_EmitsIdleProgressWhileServiceIsSilent 2.06
|
||||
TestHandleInvestigateAlert_EmitsIdleProgressWhileServiceIsSilent 2.01
|
||||
TestAISettingsHandler_PatrolReadinessBranches 1.01
|
||||
TestAISettingsHandler_KeylessOpenAICompatibleSetupIsConfiguredAndValidated 0.51
|
||||
TestAISettingsHandler_TestProvider_ConnectionFailure 0.52
|
||||
TestHandleGetAICostSummary_MethodNotAllowed 0.54
|
||||
TestHandleInvestigateAlert_UsesVisibleIdleProgressStream 2.05
|
||||
TestHandleGetPatterns_UnlockedWithData 1.23
|
||||
TestHandleGetPredictions_WithData 1.23
|
||||
TestHandleGetCorrelations_WithData 1.25
|
||||
TestHandleGetCorrelations_UsesCanonicalIntelligenceFacade 1.24
|
||||
TestExtractAndStoreAuthContext_UsesTenantConfigForToken 3.54
|
||||
TestAuthDenialPruneKeepsTrackedSetBounded 2.28
|
||||
TestAvailabilityHandlersListReturnsMockTargetsInMockMode 1.22
|
||||
TestAvailabilityHandlersTestSavedMockTargetUsesSyntheticStatus 1.22
|
||||
TestChangePasswordRefusesNonAdminSession 0.90
|
||||
TestChangePasswordAllowsConfiguredAdminSession 1.66
|
||||
TestChangePasswordBasicAuthPathUnaffected 4.57
|
||||
TestChangePasswordFixtureOwnsRouterLifecycle 0.91
|
||||
TestConfigTransferAnonymousAuthenticatedModesDenyBeforeBodyRead 0.55
|
||||
TestConfigTransferAuthorizedInstanceModesReachHandler 0.77
|
||||
TestConfigTransferTenantSessionsRequireManagement 0.54
|
||||
TestConnectionsHandleListIncludesMockAvailabilityTargets 1.23
|
||||
TestConnectionsHandleListDropsRealSourcesInMockMode 1.30
|
||||
TestConnectionsLedgerKeepsRealSourcesWhenRealPollingRetained 1.26
|
||||
TestContract_MockAvailabilityTargetsUseSavedTargetPayloads 1.23
|
||||
TestContract_MockDiscoveryEndpointsUseCanonicalPayloads 1.15
|
||||
TestContract_MockChartRoutesUseCanonicalMockUnifiedReadStateForVMwareHosts 324.29
|
||||
TestContract_PlatformMockToggleRebindsRuntimeConnectionsAndResources 24.19
|
||||
TestContract_PlatformMockConnectionListsUseSharedFixtureMetadata 1.31
|
||||
TestContract_RecoveryPointsMockPathReturnsCanonicalProviderBackedFixtures 2.15
|
||||
TestContract_SecurityStatusPresentationPolicyProEditionCommercialContext 0.67
|
||||
TestContract_StateAndResourceListShareCanonicalMockResourceContract 6.16
|
||||
TestContract_AgentCommandConfigGateMirrorsChannelAdmission 1.50
|
||||
TestContract_HostedDiagnosticsDockerPrepareTokenValidatesOriginBeforeMutation 1.62
|
||||
TestContract_HostedInstallerOriginsFailClosedAtRouter 5.36
|
||||
TestContract_RuntimeDisplayServesPresentationValuesWithoutAdmin 1.11
|
||||
TestSecurityStatusCurrentUserForScopedLocalSession 1.11
|
||||
TestSecurityStatusOrgBoundSettingsFollowOrgManagementRule 1.11
|
||||
TestSecurityStatusOrgBoundReportingRespectsAuthorizer 1.10
|
||||
TestDiscoveryHandlers_MockModeServesFixturesWithoutService 1.50
|
||||
TestDockerAgentReportSizeContractAcceptsOrdinaryAndLargeFleets 0.73
|
||||
TestDockerAgentReportSizeContractBoundaries 3.42
|
||||
TestHostAgentRemovalLifecycleThroughAuthenticatedRouterAndRestart 0.59
|
||||
TestCollectorUninstallTransactionFailsClosedAndRetriesAfterRestart 2.85
|
||||
TestHostedTenantAgentInstallCommand_GeneratesOrgBoundTokenAndCommand 4.15
|
||||
TestGenerateHostedTenantAgentInstallCommandEnforcesHostedOrgBoundary 1.48
|
||||
TestHostedTenantAgentInstallCommandCarriesCommandPolicyIntent 1.98
|
||||
TestHostedTenantAgentInstallTokenCannotReportToOtherTenant 0.67
|
||||
TestIssue1753SameNameProxmoxAgentsAuthenticateAndStayDistinctEndToEnd 1.73
|
||||
TestSyncReleaseDemoFixtureRuntime_DoesNotPoliceMockFixturesInDevBuilds 0.96
|
||||
TestMetricsHistoryFallbackSynthesizesUnseededMockWorkloadHistory 1.29
|
||||
TestMetricsHistoryFallbackSynthesizesMockHostAndNodeDrawerSeries 1.57
|
||||
TestMetricsHistoryFallbackSupplementsPartialMockStoreSeries 1.32
|
||||
TestMetricsHistoryFallbackMockDiskSynthesizesSeries 1.30
|
||||
TestMetricsHistoryFallbackMockUnifiedStorageSynthesizesSeries 1.79
|
||||
TestMetricsHistoryFallbackMockStorageBypassesSparseStoreSeries 1.94
|
||||
TestSSOOnlyExplicitAdminReachesEveryAdminGuard 1.11
|
||||
TestUnrelatedSSOUserStillRefusedWhenLocalAdminConfigured 1.65
|
||||
TestPlatformAdminRouteRefusesUnrelatedSSOUser 0.66
|
||||
TestJourneyGeneralAPIBudgetIsolation 1.38
|
||||
TestTenantRBACProvider_NoConnectionLeak 1.13
|
||||
TestHandleListPointsAcceptsCanonicalPlatformQuery 1.43
|
||||
TestHandleListRollupsExposeCanonicalPlatformsPayload 1.46
|
||||
TestHandleListRollupsMetaReportsNormalizedPagination 2.13
|
||||
TestHandleListPointsMetaReportsNormalizedPagination 2.15
|
||||
TestStartPatrolForContext_DoesNotOverwriteOtherTenantPatrolComponents 1.81
|
||||
TestStartPatrolForContext_RejectsMismatchedAIServiceOrg 0.98
|
||||
TestRouterMockMode_SeedsTrueNASAndVMwareSupplementalResources 1.24
|
||||
TestRouterMockMode_SeedsVMwareSupplementalActivity 0.95
|
||||
TestRouteExecuteStream_Success 0.74
|
||||
TestRouteExecuteStream_MethodNotAllowed 0.60
|
||||
TestRouteExecuteStream_WrongScope 0.51
|
||||
TestRouteExecuteStream_EmptyPrompt 0.65
|
||||
TestRouteExecuteStream_WhitespaceOnlyPrompt 0.58
|
||||
TestRouteExecuteStream_InvalidJSON 0.63
|
||||
TestRouteExecuteStream_AIDisabled 0.61
|
||||
TestRouteExecuteStream_AutofixLicenseRequired 0.59
|
||||
TestRouteExecuteStream_RemediationLicenseRequired 0.52
|
||||
TestRouteExecuteStream_OversizedBody 0.64
|
||||
TestRouteExecuteStream_UseCaseCaseInsensitive 0.68
|
||||
TestRouteExecuteStream_SSEHeaders 0.52
|
||||
TestRouteExecuteStream_CompleteEventBeforeDone 0.60
|
||||
TestRouteExecuteStream_EmptyBody 0.54
|
||||
TestRouteExecuteStream_NullPrompt 0.58
|
||||
TestRouteExecuteStream_ServiceError 0.55
|
||||
TestRouteExecuteStream_InvalidHistoryRole 0.55
|
||||
TestRouteExecuteStream_ValidTargetTypes 1.64
|
||||
TestRouteExecuteStream_EmptyTargetTypeAllowed 1.18
|
||||
TestRouteExecuteStream_WithTargetContext 0.59
|
||||
TestRouteExecuteStream_DefaultUseCaseIsChat 0.58
|
||||
TestRouteExecuteStream_FindingIDPassedThrough 0.58
|
||||
TestRouteExecute_Success 0.58
|
||||
TestRouteExecute_MethodNotAllowed 0.59
|
||||
TestRouteExecute_NoAuth 0.62
|
||||
TestRouteExecute_WrongScope 0.55
|
||||
TestRouteExecute_EmptyPrompt 0.58
|
||||
TestRouteExecute_InvalidJSON 0.59
|
||||
TestRouteExecute_WithConversationHistory 0.54
|
||||
TestRouteExecute_WithTargetContext 0.69
|
||||
TestRouteExecute_DefaultUseCaseIsChat 0.58
|
||||
TestRouteExecute_AutofixLicenseRequired 0.59
|
||||
TestRouteExecute_OversizedBody 0.73
|
||||
TestRouteExecute_EmptyBody 0.52
|
||||
TestRouteExecute_EmptyJSONObject 0.74
|
||||
TestRouteExecute_NullPrompt 0.59
|
||||
TestRouteExecute_ServiceError 0.58
|
||||
TestRouteExecute_InvalidTargetType 0.59
|
||||
TestRouteExecute_ValidTargetTypes 1.70
|
||||
TestRouteExecute_EmptyTargetTypeAllowed 0.59
|
||||
TestRouteExecute_InvalidHistoryRole 0.58
|
||||
TestRouteExecute_FindingIDPassedThrough 0.60
|
||||
TestRouteExecute_ModelFieldPassedThrough 0.58
|
||||
TestRouteListModels_Success 0.54
|
||||
TestRouteListModels_MethodNotAllowed 0.52
|
||||
TestRouteListModels_WrongScope 0.57
|
||||
TestRouteListModels_ProviderError 0.59
|
||||
TestRouteListModels_EmptyModelList 0.60
|
||||
TestRouteListModels_CachedField 0.60
|
||||
TestRouteListModels_MalformedProviderResponse 0.61
|
||||
TestRouteListModels_HEADMethod 0.69
|
||||
TestRouteTestConnection_Success 0.60
|
||||
TestRouteTestConnection_MethodNotAllowed 0.57
|
||||
TestRouteTestConnection_NoAuth 0.58
|
||||
TestRouteTestConnection_WrongScope 0.58
|
||||
TestRouteTestProvider_Success 0.57
|
||||
TestRouteTestProvider_UnconfiguredProvider 0.52
|
||||
TestRouteTestProvider_MethodNotAllowed 0.60
|
||||
TestRouteTestProvider_NoAuth 0.59
|
||||
TestRouteTestProvider_MultipleProviders 0.58
|
||||
TestRouteTestProvider_WrongScope 0.59
|
||||
TestRouteTestProvider_InvalidProviderNames 0.58
|
||||
TestRouteTestProvider_PathTraversal 0.67
|
||||
TestRouter_HandleState_MockIsolation 3.26
|
||||
TestAITestConnectionRouteWithValidScope 0.72
|
||||
TestAITestConnectionRouteRejectsWrongScope 0.79
|
||||
TestAITestProviderRouteRejectsWrongScope 0.71
|
||||
TestPublicEndpointsBypassAuthInAPIMode 0.59
|
||||
TestSettingsCapabilitiesMatchRouteEnforcementWithoutRBAC 0.83
|
||||
TestSettingsCapabilitiesGrantConfiguredAdminWithoutRBAC 0.90
|
||||
TestTrueNASHandlers_HandleList_ReturnsMockConnectionsInMockMode 1.18
|
||||
TestFreeBSDOfflineAgentPreflight 1.77
|
||||
TestBranchcov0723AmExtractFromZip 8.53
|
||||
TestHandleApplyUpdate_BlocksWhenReadinessBlocked 1.42
|
||||
TestHandleGetUpdatePlan 1.59
|
||||
TestHandleGetUpdatePlan_IncludesUpgradeReadiness 1.29
|
||||
TestHandleGetUpdatePlan_InvalidChannel 1.05
|
||||
TestHandleGetUpdatePlan_PrepareError 1.38
|
||||
TestVMwareHandlers_HandleList_ReturnsMockConnectionsInMockMode 1.28
|
||||
TestAlertsEndpoints 22.93
|
||||
TestRecoveryRollupsEndpointToleratesMalformedPersistedMetadata 0.53
|
||||
TestHealthEndpoint 21.24
|
||||
TestVersionEndpointUsesRepoVersion 21.71
|
||||
TestAlertAcknowledge_AllowsPrintableAlertIDs 20.98
|
||||
TestStateEndpointReturnsMockData 23.11
|
||||
TestRecoveryRollupsEndpointReturnsMockData 23.03
|
||||
TestProtectedEndpointsRequireAuthentication 20.77
|
||||
TestAPIOnlyModeRequiresToken 31.38
|
||||
TestServerInfoEndpointReportsDevelopment 27.15
|
||||
TestRecoveryPointsEndpointReturnsMockData 31.33
|
||||
TestServerInfoEndpointMethodNotAllowed 26.85
|
||||
TestHealthEndpointMethodNotAllowed 27.02
|
||||
TestHealthEndpointHeadAllowed 26.90
|
||||
TestConfigNodesUsesMockTopology 25.70
|
||||
TestMockModeToggleEndpoint 36.62
|
||||
TestMockModeToggleFromRealDataRebindsPlatformSupplementalProviders 27.70
|
||||
TestAuthenticatedEndpointsRequireToken 27.94
|
||||
TestAPITokenQueryAndHeaderAuth 33.28
|
||||
TestRecoveryEndpointRequiresDirectLoopback 29.26
|
||||
TestWebSocketSendsInitialState 27.92
|
||||
TestWebsocketPayloadContractShape 25.38
|
||||
TestWebsocketPayloadUsesCanonicalStateContract 28.16
|
||||
TestSessionCookieAllowsAuthenticatedAccess 29.04
|
||||
TestRevokedAPITokenImmediatelyLosesAccess 35.04
|
||||
TestLimitedAPITokenCannotCreateBroaderToken 29.25
|
||||
TestPublicURLDetectionUsesForwardedHeaders 24.93
|
||||
TestPublicURLDetectionRespectsEnvOverride 27.56
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Print `go test -json` like plain `go test` and record per-test seconds.
|
||||
|
||||
Usage: go test -json ... | record-internal-api-test-seconds.py <seconds-file>
|
||||
|
||||
`go test -json` reports every test the way `-v` does, which for internal/api
|
||||
means thousands of lines per shard. This filter keeps the log as readable as
|
||||
plain `go test`: package lines (build errors, `ok`, `FAIL`, panics outside a
|
||||
test) print straight away, and a test's own output is held until the test
|
||||
ends, printed if it failed and dropped if it passed or was skipped. Output of
|
||||
a test that never finished (a panic or timeout killed the binary) is printed
|
||||
at the end, so a crash is never hidden.
|
||||
|
||||
Every finished top-level test is written to <seconds-file> as
|
||||
`<TestName> <elapsed seconds>`, after a `# package-seconds <elapsed>` comment
|
||||
holding the whole test binary's run time, the same shape as
|
||||
.github/scripts/internal-api-test-seconds.txt, so
|
||||
refresh-internal-api-test-seconds.py can rebuild the shard weights from CI.
|
||||
|
||||
The exit status is non-zero when any test or package failed, but the caller
|
||||
must still run with `set -o pipefail` so a `go test` failure that produced no
|
||||
JSON (for example a vet or build error) fails the step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
print(f"usage: {sys.argv[0]} <seconds-file>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
held: dict[str, list[str]] = defaultdict(list)
|
||||
running: set[str] = set()
|
||||
seconds: dict[str, float] = {}
|
||||
package_seconds = None
|
||||
failed = False
|
||||
out = sys.stdout
|
||||
|
||||
for raw in sys.stdin:
|
||||
try:
|
||||
event = json.loads(raw)
|
||||
except ValueError:
|
||||
event = None
|
||||
if not isinstance(event, dict):
|
||||
out.write(raw)
|
||||
out.flush()
|
||||
continue
|
||||
|
||||
action = event.get("Action")
|
||||
test = event.get("Test") or ""
|
||||
top = test.split("/", 1)[0]
|
||||
text = event.get("Output") or ""
|
||||
|
||||
if not top:
|
||||
# Package level: build output, `ok`/`FAIL` summary, panics and
|
||||
# timeouts reported outside a test.
|
||||
if text:
|
||||
out.write(text)
|
||||
out.flush()
|
||||
if action == "fail":
|
||||
failed = True
|
||||
if action in ("pass", "fail") and isinstance(event.get("Elapsed"), (int, float)):
|
||||
package_seconds = float(event["Elapsed"])
|
||||
continue
|
||||
|
||||
if action == "run" and test == top:
|
||||
running.add(top)
|
||||
if text:
|
||||
held[top].append(text)
|
||||
if test != top or action not in ("pass", "fail", "skip"):
|
||||
continue
|
||||
|
||||
running.discard(top)
|
||||
lines = held.pop(top, [])
|
||||
if action == "fail":
|
||||
failed = True
|
||||
out.write("".join(lines))
|
||||
out.flush()
|
||||
elapsed = event.get("Elapsed")
|
||||
if isinstance(elapsed, (int, float)):
|
||||
seconds[top] = float(elapsed)
|
||||
|
||||
for top in sorted(running):
|
||||
failed = True
|
||||
out.write(f"--- {top} did not finish; its output follows\n")
|
||||
out.write("".join(held.get(top, [])))
|
||||
out.flush()
|
||||
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||
if package_seconds is not None:
|
||||
handle.write(f"# package-seconds {package_seconds:.2f}\n")
|
||||
for name, value in seconds.items():
|
||||
handle.write(f"{name} {value:.2f}\n")
|
||||
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rebuild .github/scripts/internal-api-test-seconds.txt from CI timings.
|
||||
|
||||
Every `Backend tests (api-N)` job of Build and Test uploads an
|
||||
`internal-api-test-seconds-N` artifact written by
|
||||
record-internal-api-test-seconds.py, holding the elapsed seconds of each
|
||||
top-level internal/api test it ran under -race on the GitHub runner. This
|
||||
script downloads those artifacts for one or more runs (or reads files already
|
||||
on disk), takes the median seconds of every test across the runs, and writes
|
||||
the weights file that select-internal-api-shard.sh uses to cut the shards.
|
||||
|
||||
Tests at or above --min-seconds are listed by name. Every other test, and any
|
||||
test added later, weighs the file's DEFAULT_WEIGHT, which is set to the mean
|
||||
of the unlisted tests so the per-shard totals stay close to what CI measured.
|
||||
|
||||
Usage, from the repository root:
|
||||
python3 .github/scripts/refresh-internal-api-test-seconds.py --run <run-id> [--run <run-id> ...]
|
||||
python3 .github/scripts/refresh-internal-api-test-seconds.py --dir <downloaded-artifacts>
|
||||
|
||||
Use runs whose api shards all passed, ideally two or three recent ones, since
|
||||
a single runner can be 30% faster or slower than the next. Pass --tests with
|
||||
the output of `go test -list . ./internal/api` to also print the shard cut
|
||||
the new weights produce.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
WEIGHTS = REPO / ".github" / "scripts" / "internal-api-test-seconds.txt"
|
||||
SELECTOR = REPO / ".github" / "scripts" / "select-internal-api-shard.sh"
|
||||
ARTIFACT_PATTERN = "internal-api-test-seconds-*"
|
||||
|
||||
|
||||
def download(run_id: str, into: Path, repo: str) -> None:
|
||||
subprocess.run(
|
||||
["gh", "run", "download", run_id, "--repo", repo, "--pattern", ARTIFACT_PATTERN, "--dir", str(into)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def read_timings(files: list[Path]) -> tuple[dict[str, list[float]], list[tuple[Path, float | None, float, int]]]:
|
||||
samples: dict[str, list[float]] = defaultdict(list)
|
||||
shards = []
|
||||
for path in files:
|
||||
package_seconds = None
|
||||
total = 0.0
|
||||
count = 0
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
fields = line.split()
|
||||
if len(fields) == 3 and fields[:2] == ["#", "package-seconds"]:
|
||||
package_seconds = float(fields[2])
|
||||
continue
|
||||
if len(fields) != 2 or fields[0].startswith("#"):
|
||||
continue
|
||||
seconds = float(fields[1])
|
||||
samples[fields[0]].append(seconds)
|
||||
total += seconds
|
||||
count += 1
|
||||
shards.append((path, package_seconds, total, count))
|
||||
return samples, shards
|
||||
|
||||
|
||||
def render(weights: dict[str, float], default: float, sources: list[str], min_seconds: float) -> str:
|
||||
today = datetime.date.today().isoformat()
|
||||
header = f"""\
|
||||
# Run time in seconds of the slowest internal/api top-level tests under -race
|
||||
# on a GitHub ubuntu-24.04 runner, the median of the Build and Test runs named
|
||||
# below. Used only by select-internal-api-shard.sh to choose where the
|
||||
# Backend tests (api-N) shards cut go test's list. Tests not named here weigh
|
||||
# DEFAULT_WEIGHT, the mean of the tests under {min_seconds:g}s in the same runs. A
|
||||
# missing, stale or renamed entry can make the shards uneven but can never
|
||||
# drop or repeat a test.
|
||||
#
|
||||
# Generated {today} by .github/scripts/refresh-internal-api-test-seconds.py
|
||||
# from {', '.join(sources)}. Every api shard uploads an
|
||||
# internal-api-test-seconds-N artifact, so rerun that script with recent
|
||||
# passing runs when one api shard runs well over the others.
|
||||
DEFAULT_WEIGHT {default:.2f}
|
||||
"""
|
||||
body = "".join(f"{name} {seconds:.2f}\n" for name, seconds in weights.items())
|
||||
return header + body
|
||||
|
||||
|
||||
def report_cut(weights_path: Path, tests_path: Path, shards: int, samples: dict[str, float], default: float) -> None:
|
||||
tests = [line.strip() for line in tests_path.read_text(encoding="utf-8").splitlines()]
|
||||
tests = [t for t in tests if t.startswith(("Test", "Fuzz", "Example"))]
|
||||
stdin = "\n".join(tests) + "\n"
|
||||
print(f"Predicted cut with {shards} shards (measured seconds, default {default:.2f}s when unmeasured):")
|
||||
for index in range(shards):
|
||||
selected = subprocess.run(
|
||||
["bash", str(SELECTOR), str(weights_path), str(shards), str(index)],
|
||||
input=stdin, capture_output=True, text=True, check=True,
|
||||
).stdout.split()
|
||||
seconds = sum(samples.get(name, default) for name in selected)
|
||||
print(f" api-{index}: {len(selected):5d} tests {seconds:7.1f}s {selected[0]} .. {selected[-1]}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--run", action="append", default=[], help="Build and Test run id to download artifacts from")
|
||||
parser.add_argument("--dir", action="append", default=[], type=Path, help="directory of downloaded artifacts")
|
||||
parser.add_argument("--repo", default="rcourtman/Pulse")
|
||||
parser.add_argument("--min-seconds", type=float, default=0.5)
|
||||
parser.add_argument("--output", type=Path, default=WEIGHTS)
|
||||
parser.add_argument("--tests", type=Path, help="go test -list output, to print the resulting shard cut")
|
||||
parser.add_argument("--shards", type=int, help="shard count for --tests (default: API_SHARD_COUNT from the workflow)")
|
||||
args = parser.parse_args()
|
||||
if not args.run and not args.dir:
|
||||
parser.error("pass at least one --run or --dir")
|
||||
|
||||
with tempfile.TemporaryDirectory() as scratch:
|
||||
dirs = list(args.dir)
|
||||
for run_id in args.run:
|
||||
target = Path(scratch) / run_id
|
||||
download(run_id, target, args.repo)
|
||||
dirs.append(target)
|
||||
files = sorted(path for d in dirs for path in d.rglob("*.txt"))
|
||||
if not files:
|
||||
print("no timing files found", file=sys.stderr)
|
||||
return 1
|
||||
by_test, shards = read_timings(files)
|
||||
|
||||
for path, package_seconds, total, count in shards:
|
||||
wall = f"{package_seconds:7.1f}s" if package_seconds is not None else " ?"
|
||||
print(f"{path.parent.name}/{path.name}: {count} tests, {total:.1f}s in tests, package {wall}")
|
||||
|
||||
median = {name: statistics.median(values) for name, values in by_test.items()}
|
||||
listed = {name: seconds for name, seconds in median.items() if seconds >= args.min_seconds}
|
||||
rest = [seconds for name, seconds in median.items() if name not in listed]
|
||||
default = max(statistics.fmean(rest) if rest else 0.05, 0.01)
|
||||
|
||||
sources = [f"run {run_id}" for run_id in args.run] + [str(d) for d in args.dir]
|
||||
args.output.write_text(render(listed, default, sources, args.min_seconds), encoding="utf-8")
|
||||
print(f"Wrote {len(listed)} tests at {args.min_seconds:g}s or more and DEFAULT_WEIGHT {default:.2f} to {args.output}")
|
||||
|
||||
if args.tests:
|
||||
shards_count = args.shards
|
||||
if shards_count is None:
|
||||
workflow = (REPO / ".github" / "workflows" / "build-and-test.yml").read_text(encoding="utf-8")
|
||||
shards_count = int(workflow.split("API_SHARD_COUNT: ", 1)[1].split()[0])
|
||||
report_cut(args.output, args.tests, shards_count, median, default)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# Print the internal/api top-level tests that belong to one CI shard.
|
||||
#
|
||||
# Usage: select-internal-api-shard.sh <weights-file> <shard-count> <shard-index>
|
||||
# The full test list, in go test's own order, is read from stdin.
|
||||
#
|
||||
# Shards are contiguous runs of go test's order, because some internal/api
|
||||
# tests still depend on package state left by the tests just before them.
|
||||
# Cut points are chosen by estimated run time instead of test count. Every
|
||||
# test weighs its seconds from the weights file, or the default weight when
|
||||
# the file does not name it (new tests, renamed tests). The default weight is
|
||||
# the file's `DEFAULT_WEIGHT <seconds>` line, 0.05 when it has none, and a
|
||||
# DEFAULT_WEIGHT environment variable overrides both. The smallest per-shard
|
||||
# capacity that lets the list be cut into at most <shard-count> contiguous
|
||||
# runs is found by binary search, then the list is filled greedily up to that
|
||||
# capacity. If fewer runs than shards would result, the tail is cut early so
|
||||
# every shard still gets at least one test.
|
||||
#
|
||||
# The weights only move the cut points. Each test is assigned exactly one
|
||||
# shard index in 0..count-1 by a single pass over the list, so a stale,
|
||||
# missing or wrong weight can make shards uneven but can never drop or repeat
|
||||
# a test.
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 3 ]; then
|
||||
echo "usage: $0 <weights-file> <shard-count> <shard-index> < tests" >&2
|
||||
exit 2
|
||||
fi
|
||||
weights=$1
|
||||
count=$2
|
||||
index=$3
|
||||
|
||||
case "$count" in '' | *[!0-9]*) echo "shard count must be a positive integer" >&2; exit 2 ;; esac
|
||||
case "$index" in '' | *[!0-9]*) echo "shard index must be a non-negative integer" >&2; exit 2 ;; esac
|
||||
if [ "$count" -lt 1 ] || [ "$index" -ge "$count" ]; then
|
||||
echo "shard index ${index} is outside 0..$((count - 1))" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ ! -f "$weights" ]; then
|
||||
echo "weights file ${weights} not found" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
awk -v n="$count" -v want="$index" -v env_default="${DEFAULT_WEIGHT:-}" '
|
||||
function centis(seconds, c) {
|
||||
c = int(seconds * 100 + 0.5)
|
||||
return c < 1 ? 1 : c
|
||||
}
|
||||
# Number of contiguous runs a greedy fill up to capacity cap produces.
|
||||
function runs(cap, k, used, groups) {
|
||||
groups = 1
|
||||
used = 0
|
||||
for (k = 1; k <= total; k++) {
|
||||
if (used > 0 && used + cost[k] > cap) {
|
||||
groups++
|
||||
used = 0
|
||||
}
|
||||
used += cost[k]
|
||||
}
|
||||
return groups
|
||||
}
|
||||
FILENAME == ARGV[1] {
|
||||
if ($0 ~ /^[[:space:]]*(#|$)/) next
|
||||
if (NF != 2 || $2 !~ /^[0-9]+(\.[0-9]+)?$/ || $2 + 0 <= 0) {
|
||||
printf "invalid weights line %d: %s\n", FNR, $0 > "/dev/stderr"
|
||||
bad = 1
|
||||
exit 1
|
||||
}
|
||||
if ($1 == "DEFAULT_WEIGHT") file_default = $2 + 0
|
||||
else weight[$1] = $2 + 0
|
||||
next
|
||||
}
|
||||
NF {
|
||||
if (!default_weight) {
|
||||
default_weight = env_default != "" ? env_default + 0 : (file_default ? file_default : 0.05)
|
||||
}
|
||||
total++
|
||||
name[total] = $1
|
||||
cost[total] = centis(($1 in weight) ? weight[$1] : default_weight)
|
||||
sum += cost[total]
|
||||
if (cost[total] > heaviest) heaviest = cost[total]
|
||||
}
|
||||
END {
|
||||
if (bad) exit 1
|
||||
if (total < n) {
|
||||
printf "%d tests cannot fill %d shards\n", total, n > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
lo = heaviest
|
||||
hi = sum
|
||||
while (lo < hi) {
|
||||
mid = int((lo + hi) / 2)
|
||||
if (runs(mid) <= n) hi = mid
|
||||
else lo = mid + 1
|
||||
}
|
||||
shard = 0
|
||||
used = 0
|
||||
for (k = 1; k <= total; k++) {
|
||||
if (used > 0 && used + cost[k] > lo) {
|
||||
shard++
|
||||
used = 0
|
||||
}
|
||||
used += cost[k]
|
||||
if (shard == want) print name[k]
|
||||
# Once the tests left equal the shards left, give each its own shard
|
||||
# so no shard is empty.
|
||||
if (total - k > 0 && total - k == n - 1 - shard) {
|
||||
shard++
|
||||
used = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
' "$weights" -
|
||||
Reference in New Issue
Block a user