perf(renderer): index project host ownership on repo catalog refresh

mergeFetchedProjectCompatibilityForHost runs synchronously inside a zustand
set() on every repo catalog refresh and was O(projects x (setups + repos)):
getProjectHostIds / getExplicitProjectHostIds rescanned all setups and all
repos once per project, and mergePreviousProjectMetadata rebuilt a full
catalog repo key-set per project.

Precompute the indexes once and hand each helper only its own project's slice.
No ownership logic is reimplemented — the same resolvers run, just on
pre-sliced inputs, memoized per project object.

  200 repos    1.1 ms -> 0.2 ms   (5.2x)
  600 repos   11.2 ms -> 0.7 ms  (15.3x)
  1200 repos  43.0 ms -> 1.6 ms  (26.6x)

Verified against 4,000 randomized differential cases (duplicate repo ids,
dangling sourceRepoIds, empty-repoId and orphan setups, dropped setups,
local/SSH/runtime host mixes) comparing output element provenance and order
against the previous implementation, not just deep structure. Each of the
three index helpers was negative-controlled: breaking any one of them turns
the differential red.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-12 19:46:43 -07:00
co-authored by Orca
parent 150cc6f50c
commit 34f490432f
+107 -11
View File
@@ -792,6 +792,81 @@ function getExplicitProjectHostIds(
return hostIds
}
function indexProjectHostSetupsByProjectId(
setups: readonly ProjectHostSetup[]
): Map<string, ProjectHostSetup[]> {
const setupsByProjectId = new Map<string, ProjectHostSetup[]>()
for (const setup of setups) {
const existing = setupsByProjectId.get(setup.projectId)
if (existing) {
existing.push(setup)
} else {
setupsByProjectId.set(setup.projectId, [setup])
}
}
return setupsByProjectId
}
function getProjectSourceRepos(
project: Project,
reposById: ReadonlyMap<string, readonly Repo[]>
): Repo[] {
const sourceRepos: Repo[] = []
for (const repoId of project.sourceRepoIds) {
for (const repo of reposById.get(repoId) ?? []) {
sourceRepos.push(repo)
}
}
return sourceRepos
}
// Why: the host-id resolvers rescan every setup and repo per project; feeding them the project's own slices keeps a catalog refresh linear.
function createProjectHostIdIndex(
setups: readonly ProjectHostSetup[],
reposById: ReadonlyMap<string, readonly Repo[]>,
resolveHostIds: (
project: Project,
setups: readonly ProjectHostSetup[],
repos: readonly Repo[]
) => Set<string>
): (project: Project) => ReadonlySet<string> {
const noSetups: readonly ProjectHostSetup[] = []
const hostIdsByProject = new Map<Project, ReadonlySet<string>>()
let setupsByProjectId: Map<string, ProjectHostSetup[]> | null = null
return (project) => {
const cached = hostIdsByProject.get(project)
if (cached) {
return cached
}
setupsByProjectId ??= indexProjectHostSetupsByProjectId(setups)
const hostIds = resolveHostIds(
project,
setupsByProjectId.get(project.id) ?? noSetups,
getProjectSourceRepos(project, reposById)
)
hostIdsByProject.set(project, hostIds)
return hostIds
}
}
// Why: mergePreviousProjectMetadata scans the whole catalog's repo key set; a view holding only this pair's repos keeps that scan per-project.
function restrictReposToProjectPair(
previous: Project,
current: Project,
reposById: ReadonlyMap<string, readonly Repo[]>
): Map<string, readonly Repo[]> {
const restricted = new Map<string, readonly Repo[]>()
for (const project of [previous, current]) {
for (const repoId of project.sourceRepoIds) {
const matches = reposById.get(repoId)
if (matches) {
restricted.set(repoId, matches)
}
}
}
return restricted
}
function mergeFetchedProjectCompatibilityForHost({
previous,
fetched,
@@ -819,33 +894,54 @@ function mergeFetchedProjectCompatibilityForHost({
const previousProjectById = new Map(previous.projects.map((project) => [project.id, project]))
const reposById = getReposById(repos)
const currentRepoIds = new Set(repos.map((repo) => repo.id))
const projectHasHost = (project: Project, setups: readonly ProjectHostSetup[]): boolean =>
getProjectHostIds(project, setups, repos).has(hostId)
const projectHasCurrentOwnerOutsideHost = (project: Project): boolean =>
[...getExplicitProjectHostIds(project, projectHostSetups, repos)].some(
(ownerHostId) => ownerHostId !== hostId
)
const fetchedProjectHostIds = createProjectHostIdIndex(
fetched.projectHostSetups,
reposById,
getProjectHostIds
)
const previousProjectHostIds = createProjectHostIdIndex(
previous.projectHostSetups,
reposById,
getProjectHostIds
)
const currentProjectOwnerHostIds = createProjectHostIdIndex(
projectHostSetups,
reposById,
getExplicitProjectHostIds
)
const projectHasCurrentOwnerOutsideHost = (project: Project): boolean => {
for (const ownerHostId of currentProjectOwnerHostIds(project)) {
if (ownerHostId !== hostId) {
return true
}
}
return false
}
const fetchedProjects = fetched.projects
.filter((project) => {
const previousProject = previousProjectById.get(project.id)
// Why: repo-derived compatibility projects include every host; a one-host refresh should only reconcile or prune that host's ownership.
return (
projectHasHost(project, fetched.projectHostSetups) ||
(previousProject ? projectHasHost(previousProject, previous.projectHostSetups) : false)
fetchedProjectHostIds(project).has(hostId) ||
(previousProject ? previousProjectHostIds(previousProject).has(hostId) : false)
)
})
.map((project) => {
const previousProject = previousProjectById.get(project.id)
return previousProject
? mergePreviousProjectMetadata(previousProject, project, reposById, hostId)
? mergePreviousProjectMetadata(
previousProject,
project,
restrictReposToProjectPair(previousProject, project, reposById),
hostId
)
: projectWithCurrentSourceRepoIds(project, currentRepoIds)
})
const fetchedProjectIds = new Set(fetchedProjects.map((project) => project.id))
const preservedProjects = previous.projects.filter(
(project) =>
!fetchedProjectIds.has(project.id) &&
(!getProjectHostIds(project, previous.projectHostSetups, repos).has(hostId) ||
projectHasCurrentOwnerOutsideHost(project))
(!previousProjectHostIds(project).has(hostId) || projectHasCurrentOwnerOutsideHost(project))
)
return {
projects: mergeProjectCompatibilityProjects(