diff --git a/README.md b/README.md
index 0e00556..8e523f9 100644
--- a/README.md
+++ b/README.md
@@ -198,8 +198,8 @@ Kustomize resources live in `k8s/` and define:
- ClusterIP port 3000 and Traefik ingress for `guide.sudloh.com`.
- Cloudflare edge TLS with the cluster's standard HTTP Traefik origin route.
- Startup, liveness, and dependency-aware readiness probes.
-- Web requests of 500m CPU/512 MiB and limits of 1 CPU/1 GiB.
-- HPA from 2 to 6 web replicas at 70% CPU and a PDB with one available.
+- Web requests of 500m CPU/1 GiB and limits of 1 CPU/2 GiB.
+- HPA from 2 to 6 web replicas at 70% CPU or 75% memory and a PDB with one available.
- Non-root, read-only containers with dropped capabilities and seccomp.
- ARM64 scheduling constraints matching the production hosts and images.
- A versioned migration Job and namespace-scoped CI deployer permissions.
diff --git a/components/public/structured-directory.tsx b/components/public/structured-directory.tsx
index 9c1b574..777ff4f 100644
--- a/components/public/structured-directory.tsx
+++ b/components/public/structured-directory.tsx
@@ -62,6 +62,7 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
diff --git a/k8s/base/deployment.yaml b/k8s/base/deployment.yaml
index c0821c0..0f7351b 100644
--- a/k8s/base/deployment.yaml
+++ b/k8s/base/deployment.yaml
@@ -57,10 +57,10 @@ spec:
resources:
requests:
cpu: 500m
- memory: 512Mi
+ memory: 1Gi
limits:
cpu: "1"
- memory: 1Gi
+ memory: 2Gi
startupProbe:
httpGet:
path: /api/health
diff --git a/k8s/base/hpa.yaml b/k8s/base/hpa.yaml
index 04e9f67..882e34a 100644
--- a/k8s/base/hpa.yaml
+++ b/k8s/base/hpa.yaml
@@ -23,3 +23,9 @@ spec:
target:
type: Utilization
averageUtilization: 70
+ - type: Resource
+ resource:
+ name: memory
+ target:
+ type: Utilization
+ averageUtilization: 75
diff --git a/lib/guides/queries.ts b/lib/guides/queries.ts
index 843a15a..729733a 100644
--- a/lib/guides/queries.ts
+++ b/lib/guides/queries.ts
@@ -33,6 +33,28 @@ import type { PosterBlendMode } from "@/components/public/material-poster";
const catalogVersionSchema = z.object({ version: z.string() });
const posterBlendModes = new Set(["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"]);
+// Leave most of the per-pod pool available to other visitors during cache misses.
+const PUBLIC_QUERY_CONCURRENCY = 3;
+
+async function runWithConcurrency<
+ const T extends ReadonlyArray<() => Promise>,
+>(
+ tasks: T,
+ concurrency = PUBLIC_QUERY_CONCURRENCY,
+): Promise<{ [K in keyof T]: Awaited> }> {
+ const results = new Array(tasks.length);
+ let next = 0;
+ await Promise.all(
+ Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
+ while (next < tasks.length) {
+ const index = next++;
+ results[index] = await tasks[index]();
+ }
+ }),
+ );
+ return results as { [K in keyof T]: Awaited> };
+}
+
function catalogCharacterWithUrls(
item: typeof catalogCharacters.$inferSelect,
materialByKey = new Map(),
@@ -221,7 +243,11 @@ export async function listPublicGuideCards() {
.from(guides)
.innerJoin(media, eq(media.id, guides.coverMediaId))
.where(and(eq(guides.isPublic, true), isNull(guides.trashedAt)))
- .orderBy(desc(guides.sortOrder), desc(guides.createdAt), asc(guides.name)), getDb().select().from(glossaryAliases)]);
+ .orderBy(desc(guides.sortOrder), desc(guides.createdAt), asc(guides.name)), getDb().select({
+ kind: glossaryAliases.kind,
+ catalogKey: glossaryAliases.catalogKey,
+ alias: glossaryAliases.alias,
+ }).from(glossaryAliases)]);
const grouped = aliasesByItem(aliases);
return rows.map(({ coverObjectKey, ...guide }) => ({
...guide,
@@ -303,22 +329,26 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
const materialKeys = [...new Set(
[...row.character.ascension, ...row.character.leveling].flatMap((group) => group.items.map((item) => item.key)),
)];
- const [sections, weaponConditionsRows, groups, profile, constellationGroups, teamConditionRows, teams, aliases, materials, poster, bleeding] = await Promise.all([
- getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)),
- getDb().select().from(weaponConditions).where(eq(weaponConditions.guideId, row.guide.id)).orderBy(asc(weaponConditions.sortOrder)),
- getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)),
- getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, row.guide.id)).limit(1),
- getDb().select().from(constellationConditions).where(eq(constellationConditions.guideId, row.guide.id)).orderBy(asc(constellationConditions.sortOrder)),
- getDb().select().from(teamConditions).where(eq(teamConditions.guideId, row.guide.id)).orderBy(asc(teamConditions.sortOrder)),
- getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)),
- getDb().select().from(glossaryAliases),
- materialKeys.length
+ const [sections, weaponConditionsRows, groups, profile, constellationGroups, teamConditionRows, teams, aliases, materials, poster, bleeding] = await runWithConcurrency([
+ () => getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)),
+ () => getDb().select().from(weaponConditions).where(eq(weaponConditions.guideId, row.guide.id)).orderBy(asc(weaponConditions.sortOrder)),
+ () => getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)),
+ () => getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, row.guide.id)).limit(1),
+ () => getDb().select().from(constellationConditions).where(eq(constellationConditions.guideId, row.guide.id)).orderBy(asc(constellationConditions.sortOrder)),
+ () => getDb().select().from(teamConditions).where(eq(teamConditions.guideId, row.guide.id)).orderBy(asc(teamConditions.sortOrder)),
+ () => getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)),
+ () => getDb().select({
+ kind: glossaryAliases.kind,
+ catalogKey: glossaryAliases.catalogKey,
+ alias: glossaryAliases.alias,
+ }).from(glossaryAliases),
+ () => materialKeys.length
? getDb().select().from(catalogMaterials).where(inArray(catalogMaterials.key, materialKeys))
- : [],
- row.guide.posterArtMediaId
+ : Promise.resolve([]),
+ () => row.guide.posterArtMediaId
? getDb().select().from(media).where(eq(media.id, row.guide.posterArtMediaId)).limit(1)
: Promise.resolve([]),
- getDb().select({ layer: guidePosterBleeding, asset: media }).from(guidePosterBleeding).leftJoin(media, eq(media.id, guidePosterBleeding.mediaId)).where(eq(guidePosterBleeding.guideId, row.guide.id)).orderBy(asc(guidePosterBleeding.sortOrder)),
+ () => getDb().select({ layer: guidePosterBleeding, asset: media }).from(guidePosterBleeding).leftJoin(media, eq(media.id, guidePosterBleeding.mediaId)).where(eq(guidePosterBleeding.guideId, row.guide.id)).orderBy(asc(guidePosterBleeding.sortOrder)),
]);
const groupedAliases = aliasesByItem(aliases);
const materialByKey = new Map(materials.map((item) => [item.key, item]));
diff --git a/tests/deployment-contract.test.ts b/tests/deployment-contract.test.ts
index 20838cf..75a9610 100644
--- a/tests/deployment-contract.test.ts
+++ b/tests/deployment-contract.test.ts
@@ -17,7 +17,7 @@ describe("production deployment contract", () => {
expect(deployment).toContain("path: /api/health");
expect(deployment).toContain("kubernetes.io/arch: arm64");
expect(deployment).toMatch(
- /requests:\s+cpu: 500m\s+memory: 512Mi\s+limits:\s+cpu: "1"\s+memory: 1Gi/u,
+ /requests:\s+cpu: 500m\s+memory: 1Gi\s+limits:\s+cpu: "1"\s+memory: 2Gi/u,
);
expect(deployment).toContain("runAsNonRoot: true");
expect(deployment).toContain("runAsUser: 1000");
@@ -53,6 +53,8 @@ describe("production deployment contract", () => {
expect(hpa).toContain("minReplicas: 2");
expect(hpa).toContain("maxReplicas: 6");
expect(hpa).toContain("averageUtilization: 70");
+ expect(hpa).toContain("name: memory");
+ expect(hpa).toContain("averageUtilization: 75");
expect(pdb).toContain("minAvailable: 1");
});
diff --git a/tests/public-performance.test.ts b/tests/public-performance.test.ts
index 83ab490..e2dd90d 100644
--- a/tests/public-performance.test.ts
+++ b/tests/public-performance.test.ts
@@ -38,6 +38,7 @@ describe("public rendering performance", () => {
'fetchPriority={index === 0 ? "high" : undefined}',
);
expect(directory).not.toContain("preload={index === 0}");
+ expect(directory).toContain("prefetch={false}");
});
it("publishes a public-only crawler policy", () => {