Skip to content

Commit 8b3400d

Browse files
committed
cache, parallelize
1 parent ca4da58 commit 8b3400d

File tree

8 files changed

+223
-32
lines changed

8 files changed

+223
-32
lines changed

ui/server-start.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,11 @@ const server = createServer(async (req, res) => {
7676
const response = await serverHandler.fetch(request);
7777
const ssrTime = Date.now() - ssrStart;
7878

79-
// Log slow SSR requests
79+
// Log SSR timing
8080
if (ssrTime > 1000) {
8181
console.log(`⚠️ SLOW SSR: ${req.method} ${pathname} took ${ssrTime}ms`);
82+
} else if (ssrTime > 200) {
83+
console.log(`⏱️ SSR: ${req.method} ${pathname} took ${ssrTime}ms`);
8284
}
8385

8486
// Convert Web Standard Response to Node.js response

ui/src/api/orchestrator_serverFunctions.ts

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { fetchRepos } from "./orchestrator_repos";
44
import { getOrgSettings, updateOrgSettings } from "./orchestrator_orgs";
55
import { testSlackWebhook } from "./drift_slack";
66
import { fetchProjects, updateProject, fetchProject } from "./orchestrator_projects";
7+
import { timeAsync } from "@/lib/perf.server";
78

89
export const getOrgSettingsFn = createServerFn({method: 'GET'})
910
.inputValidator((data : {userId: string, organisationId: string}) => data)
@@ -28,7 +29,10 @@ export const getProjectsFn = createServerFn({method: 'GET'})
2829
})
2930
.handler(async ({ data }) => {
3031
try {
31-
const projects : any = await fetchProjects(data.organisationId, data.userId)
32+
const projects : any = await timeAsync(
33+
`fetchProjects(org=${data.organisationId})`,
34+
() => fetchProjects(data.organisationId, data.userId)
35+
)
3236
return projects.result || []
3337
} catch (error) {
3438
console.error('Error in getProjectsFn:', error)
@@ -49,7 +53,10 @@ export const getReposFn = createServerFn({method: 'GET'})
4953
.handler(async ({ data }) => {
5054
let repos = []
5155
try {
52-
const reposData :any = await fetchRepos(data.organisationId, data.userId)
56+
const reposData :any = await timeAsync(
57+
`fetchRepos(org=${data.organisationId})`,
58+
() => fetchRepos(data.organisationId, data.userId)
59+
)
5360
repos = reposData.result
5461
} catch (error) {
5562
console.error('Error fetching repos:', error)
@@ -61,7 +68,10 @@ export const getReposFn = createServerFn({method: 'GET'})
6168
export const getProjectFn = createServerFn({method: 'GET'})
6269
.inputValidator((data : {projectId: string, organisationId: string, userId: string}) => data)
6370
.handler(async ({ data }) => {
64-
const project : any = await fetchProject(data.projectId, data.organisationId, data.userId)
71+
const project : any = await timeAsync(
72+
`fetchProject(${data.projectId})`,
73+
() => fetchProject(data.projectId, data.organisationId, data.userId)
74+
)
6575
return project
6676
})
6777

@@ -73,23 +83,29 @@ export const getRepoDetailsFn = createServerFn({method: 'GET'})
7383
let allJobs: Job[] = [];
7484
let repo: Repo
7585
try {
76-
const response = await fetch(`${process.env.ORCHESTRATOR_BACKEND_URL}/api/repos/${repoId}/jobs`, {
77-
method: 'GET',
78-
headers: {
79-
'Authorization': `Bearer ${process.env.ORCHESTRATOR_BACKEND_SECRET}`,
80-
'DIGGER_ORG_ID': organisationId,
81-
'DIGGER_USER_ID': userId,
82-
'DIGGER_ORG_SOURCE': 'workos',
83-
},
84-
});
85-
86-
if (!response.ok) {
87-
throw new Error('Failed to fetch jobs');
88-
}
89-
90-
const data :any = await response.json();
91-
repo = data.repo
92-
allJobs = data.jobs || []
86+
const result = await timeAsync(
87+
`fetchRepoJobs(${repoId})`,
88+
async () => {
89+
const response = await fetch(`${process.env.ORCHESTRATOR_BACKEND_URL}/api/repos/${repoId}/jobs`, {
90+
method: 'GET',
91+
headers: {
92+
'Authorization': `Bearer ${process.env.ORCHESTRATOR_BACKEND_SECRET}`,
93+
'DIGGER_ORG_ID': organisationId,
94+
'DIGGER_USER_ID': userId,
95+
'DIGGER_ORG_SOURCE': 'workos',
96+
},
97+
});
98+
99+
if (!response.ok) {
100+
throw new Error('Failed to fetch jobs');
101+
}
102+
103+
return await response.json();
104+
}
105+
);
106+
107+
repo = result.repo
108+
allJobs = result.jobs || []
93109

94110
} catch (error) {
95111
console.error('Error fetching jobs:', error);

ui/src/api/statesman_serverFunctions.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,34 @@
11
import { createServerFn } from "@tanstack/react-start"
22
import { createUnit, getUnit, listUnits, getUnitVersions, unlockUnit, lockUnit, getUnitStatus, deleteUnit, downloadLatestState, forcePushState, restoreUnitStateVersion } from "./statesman_units"
3+
import { timeAsync } from "@/lib/perf.server"
34

45
export const listUnitsFn = createServerFn({method: 'GET'})
56
.inputValidator((data : {userId: string, organisationId: string, email: string}) => data)
67
.handler(async ({ data }) => {
7-
const units : any = await listUnits(data.organisationId, data.userId, data.email)
8+
const units : any = await timeAsync(
9+
`listUnits(org=${data.organisationId})`,
10+
() => listUnits(data.organisationId, data.userId, data.email)
11+
)
812
return units
913
})
1014

1115
export const getUnitFn = createServerFn({method: 'GET'})
1216
.inputValidator((data : {userId: string, organisationId: string, email: string, unitId: string}) => data)
1317
.handler(async ({ data }) => {
14-
const unit : any = await getUnit(data.organisationId, data.userId, data.email, data.unitId)
18+
const unit : any = await timeAsync(
19+
`getUnit(${data.unitId})`,
20+
() => getUnit(data.organisationId, data.userId, data.email, data.unitId)
21+
)
1522
return unit
1623
})
1724

1825
export const getUnitVersionsFn = createServerFn({method: 'GET'})
1926
.inputValidator((data : {userId: string, organisationId: string, email: string, unitId: string}) => data)
2027
.handler(async ({ data }) => {
21-
const unitVersions : any = await getUnitVersions(data.organisationId, data.userId, data.email, data.unitId)
28+
const unitVersions : any = await timeAsync(
29+
`getUnitVersions(${data.unitId})`,
30+
() => getUnitVersions(data.organisationId, data.userId, data.email, data.unitId)
31+
)
2232
return unitVersions
2333
})
2434

@@ -60,7 +70,10 @@ export const restoreUnitStateVersionFn = createServerFn({method: 'POST'})
6070
export const getUnitStatusFn = createServerFn({method: 'GET'})
6171
.inputValidator((data : {userId: string, organisationId: string, email: string, unitId: string}) => data)
6272
.handler(async ({ data }) => {
63-
const unitStatus : any = await getUnitStatus(data.organisationId, data.userId, data.email, data.unitId)
73+
const unitStatus : any = await timeAsync(
74+
`getUnitStatus(${data.unitId})`,
75+
() => getUnitStatus(data.organisationId, data.userId, data.email, data.unitId)
76+
)
6477
return unitStatus
6578
})
6679

ui/src/authkit/serverFunctions.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Organization } from '@workos-inc/node';
88
import { WidgetScope } from 'node_modules/@workos-inc/node/lib/widgets/interfaces/get-token';
99
import { syncOrgToBackend } from '@/api/orchestrator_orgs';
1010
import { syncOrgToStatesman } from '@/api/statesman_orgs';
11+
import { serverCache } from '@/lib/cache.server';
1112

1213
export const getAuthorizationUrl = createServerFn({ method: 'GET' })
1314
.inputValidator((options?: GetAuthURLOptions) => options)
@@ -26,8 +27,22 @@ export const getAuthorizationUrl = createServerFn({ method: 'GET' })
2627
export const getOrganisationDetails = createServerFn({method: 'GET'})
2728
.inputValidator((data: {organizationId: string}) => data)
2829
.handler(async ({data: {organizationId}}) : Promise<Organization> => {
29-
return getWorkOS().organizations.getOrganization(organizationId).then(organization => organization);
30-
});
30+
// Check cache first
31+
const cached = serverCache.getOrg(organizationId);
32+
if (cached) {
33+
console.log(`✅ Cache hit for org: ${organizationId}`);
34+
return cached;
35+
}
36+
37+
// Cache miss - fetch from WorkOS
38+
console.log(`❌ Cache miss for org: ${organizationId}, fetching from WorkOS...`);
39+
const organization = await getWorkOS().organizations.getOrganization(organizationId);
40+
41+
// Store in cache
42+
serverCache.setOrg(organizationId, organization);
43+
44+
return organization;
45+
});
3146

3247

3348
export const createOrganization = createServerFn({method: 'POST'})
@@ -86,13 +101,33 @@ export const getAuth = createServerFn({ method: 'GET' }).handler(async (): Promi
86101
export const getOrganization = createServerFn({method: 'GET'})
87102
.inputValidator((data: {organizationId: string}) => data)
88103
.handler(async ({data: {organizationId}}) : Promise<Organization> => {
89-
return getWorkOS().organizations.getOrganization(organizationId);
104+
// Check cache first
105+
const cached = serverCache.getOrg(organizationId);
106+
if (cached) {
107+
return cached;
108+
}
109+
110+
// Cache miss - fetch from WorkOS
111+
const organization = await getWorkOS().organizations.getOrganization(organizationId);
112+
serverCache.setOrg(organizationId, organization);
113+
114+
return organization;
90115
});
91116

92117
export const ensureOrgExists = createServerFn({method: 'GET'})
93118
.inputValidator((data: {organizationId: string}) => data)
94119
.handler(async ({data: {organizationId}}) : Promise<Organization> => {
95-
return getWorkOS().organizations.getOrganization(organizationId);
120+
// Check cache first
121+
const cached = serverCache.getOrg(organizationId);
122+
if (cached) {
123+
return cached;
124+
}
125+
126+
// Cache miss - fetch from WorkOS
127+
const organization = await getWorkOS().organizations.getOrganization(organizationId);
128+
serverCache.setOrg(organizationId, organization);
129+
130+
return organization;
96131
});
97132

98133

ui/src/authkit/ssr/workos_api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getWorkOS } from "./workos";
2+
import { serverCache } from "@/lib/cache.server";
23

34
export async function createOrgForUser(userId: string, orgName: string) {
45
try {
@@ -35,7 +36,16 @@ export async function listUserOrganizationMemberships(userId: string) {
3536

3637
export async function getOrganisationDetails(orgId: string) {
3738
try {
39+
// Check cache first
40+
const cached = serverCache.getOrg(orgId);
41+
if (cached) {
42+
return cached;
43+
}
44+
45+
// Cache miss - fetch from WorkOS
3846
const org = await getWorkOS().organizations.getOrganization(orgId);
47+
serverCache.setOrg(orgId, org);
48+
3949
return org;
4050
} catch (error) {
4151
console.error('Error fetching organization details:', error);

ui/src/lib/cache.server.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Server-side in-memory cache for WorkOS organization details ONLY
2+
//
3+
// ⚠️ IMPORTANT: This cache is ONLY for WorkOS organization metadata (name, id)
4+
// DO NOT cache core application data like:
5+
// - Units, unit versions, unit status
6+
// - Projects, project status
7+
// - Repos, jobs
8+
// - Any data that users actively modify
9+
//
10+
// This module is evaluated once per server process and cached by Node's module system.
11+
12+
import { Organization } from '@workos-inc/node';
13+
14+
interface CacheEntry<T> {
15+
data: T;
16+
expiresAt: number;
17+
}
18+
19+
class OrgCache {
20+
private readonly orgCache = new Map<string, CacheEntry<Organization>>();
21+
22+
// Cache organization details for 5 minutes
23+
// Org names/details rarely change, so this is safe
24+
private readonly ORG_TTL = 5 * 60 * 1000;
25+
26+
getOrg(orgId: string): Organization | null {
27+
const entry = this.orgCache.get(orgId);
28+
if (!entry) return null;
29+
30+
if (Date.now() > entry.expiresAt) {
31+
this.orgCache.delete(orgId);
32+
return null;
33+
}
34+
35+
return entry.data;
36+
}
37+
38+
setOrg(orgId: string, org: Organization): void {
39+
this.orgCache.set(orgId, {
40+
data: org,
41+
expiresAt: Date.now() + this.ORG_TTL,
42+
});
43+
}
44+
45+
clearOrg(orgId: string): void {
46+
this.orgCache.delete(orgId);
47+
}
48+
49+
// Clear expired entries periodically
50+
cleanExpired(): void {
51+
const now = Date.now();
52+
for (const [key, entry] of this.orgCache.entries()) {
53+
if (now > entry.expiresAt) {
54+
this.orgCache.delete(key);
55+
}
56+
}
57+
}
58+
59+
// Get cache stats for monitoring
60+
getStats() {
61+
return {
62+
orgCacheSize: this.orgCache.size,
63+
};
64+
}
65+
}
66+
67+
// Single instance per server process
68+
export const serverCache = new OrgCache();
69+
70+
// Clean up expired entries every minute
71+
setInterval(() => serverCache.cleanExpired(), 60 * 1000);
72+

ui/src/lib/perf.server.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Server-side performance logging utilities
2+
3+
export async function timeAsync<T>(
4+
label: string,
5+
fn: () => Promise<T>,
6+
warnThreshold: number = 500
7+
): Promise<T> {
8+
const start = Date.now();
9+
try {
10+
const result = await fn();
11+
const duration = Date.now() - start;
12+
13+
if (duration > warnThreshold) {
14+
console.log(`⚠️ SLOW: ${label} took ${duration}ms`);
15+
} else if (duration > 200) {
16+
console.log(`⏱️ ${label} took ${duration}ms`);
17+
} else {
18+
console.log(`✅ ${label} took ${duration}ms`);
19+
}
20+
21+
return result;
22+
} catch (error) {
23+
const duration = Date.now() - start;
24+
console.error(`❌ ${label} failed after ${duration}ms:`, error);
25+
throw error;
26+
}
27+
}
28+
29+
export function createTimer() {
30+
const start = Date.now();
31+
return {
32+
elapsed: () => Date.now() - start,
33+
log: (label: string) => {
34+
const duration = Date.now() - start;
35+
console.log(`⏱️ ${label}: ${duration}ms`);
36+
}
37+
};
38+
}
39+

ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,13 @@ export const Route = createFileRoute(
7777
component: RouteComponent,
7878
loader: async ({ context, params: {unitId} }) => {
7979
const { user, organisationId, organisationName } = context;
80-
const unitData = await getUnitFn({data: {organisationId: organisationId || '', userId: user?.id || '', email: user?.email || '', unitId: unitId}})
81-
const unitVersionsData = await getUnitVersionsFn({data: {organisationId: organisationId || '', userId: user?.id || '', email: user?.email || '', unitId: unitId}})
82-
const unitStatusData = await getUnitStatusFn({data: {organisationId: organisationId || '', userId: user?.id || '', email: user?.email || '', unitId: unitId}})
80+
81+
// Run all API calls in parallel instead of sequentially! 🚀
82+
const [unitData, unitVersionsData, unitStatusData] = await Promise.all([
83+
getUnitFn({data: {organisationId: organisationId || '', userId: user?.id || '', email: user?.email || '', unitId: unitId}}),
84+
getUnitVersionsFn({data: {organisationId: organisationId || '', userId: user?.id || '', email: user?.email || '', unitId: unitId}}),
85+
getUnitStatusFn({data: {organisationId: organisationId || '', userId: user?.id || '', email: user?.email || '', unitId: unitId}})
86+
]);
8387

8488
const publicServerConfig = context.publicServerConfig
8589
const publicHostname = publicServerConfig.PUBLIC_HOSTNAME || '<hostname>'

0 commit comments

Comments
 (0)