Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/archive-month-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Reduces the CPU work needed to render the Archives widget on sites with many posts. Archive links, ordering, and post counts stay the same.
5 changes: 5 additions & 0 deletions .changeset/quiet-widgets-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Reduces duplicate database reads when widget areas render while layout prefetch is still running on remote database adapters.
4 changes: 2 additions & 2 deletions infra/perf-monitor/probe/src/measure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ async function measureTtfb(url: string): Promise<{
const response = await fetch(url, {
method: "GET",
headers: {
Accept: "text/html",
"User-Agent": "emdash-perf-probe/1.0",
// Bust any edge cache
"Cache-Control": "no-cache",
},
redirect: "follow",
Expand Down Expand Up @@ -149,7 +149,7 @@ export async function measureRoutes(req: MeasureRequest): Promise<RouteResult[]>
for (const route of req.routes) {
const url = `${req.targetUrl}${route.path}`;

// Cold request -- add a unique query param to avoid any isolate reuse
// A distinct URL does not guarantee a fresh Worker isolate.
const coldUrl = url + (url.includes("?") ? "&" : "?") + `_perf_cold=${Date.now()}`;
const cold = await measureTtfb(coldUrl);

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/astro/prefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@

import { getDb } from "../loader.js";
import { getMenu } from "../menus/index.js";
import { setRequestCacheEntry } from "../request-cache.js";
import { requestCached, setRequestCacheEntry } from "../request-cache.js";
import { getSiteSettings } from "../settings/index.js";
import { getTaxonomyDefs, getTaxonomyTerms } from "../taxonomies/index.js";
import { getWidgetAreas } from "../widgets/index.js";

/** Warm widget areas: one bulk load, primed under each per-area cache key. */
async function prefetchWidgetAreas(): Promise<void> {
const areas = await getWidgetAreas();
const areas = await requestCached("widget-areas", getWidgetAreas);
// getWidgetArea(name) caches under `widget-area:${name}` and returns the same
// WidgetArea shape getWidgetAreas yields, so priming here makes those calls hit.
for (const area of areas) {
Expand Down
11 changes: 6 additions & 5 deletions packages/core/src/components/widgets/Archives.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
import { getEmDashCollection } from "../../query.js";
import { getPublishedDates } from "../../query.js";
import { groupEntriesByPublishedAt } from "../../widgets/archives.js";

interface Props {
Expand All @@ -9,11 +9,12 @@ interface Props {

const { type = "monthly", limit = 12 } = Astro.props;

const { entries: posts } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
});
const { dates } = await getPublishedDates("posts");

const archiveList = groupEntriesByPublishedAt(posts, { type, limit });
const archiveList = groupEntriesByPublishedAt(
dates.map((publishedAt) => ({ data: { publishedAt } })),
{ type, limit },
);
---

<ul class="widget-archives">
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1168,6 +1168,20 @@ export async function getDb(): Promise<Kysely<Database>> {
return dbInstance;
}

/** @internal Date projection for published archive entries. */
export async function loadPublishedDates(type: string, locale?: string) {
const tableName = getTableName(type);
const db = await getDb();
const result = await sql<{ published_at: string | null; updated_at: string | null }>`
SELECT published_at, updated_at FROM ${sql.ref(tableName)}
WHERE deleted_at IS NULL
AND ${buildStatusCondition(db, "published")}
${locale ? sql`AND locale = ${locale}` : sql``}
ORDER BY published_at DESC, id DESC
`.execute(db);
return result.rows;
}

/**
* Create an EmDash Live Collections loader
*
Expand Down
56 changes: 55 additions & 1 deletion packages/core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@ import {
FOLDED_BYLINES,
FOLDED_BYLINES_EXIST,
FOLDED_TERMS,
loadPublishedDates,
type WhereRange,
type WhereValue,
} from "./loader.js";
import {
cachedQuery,
contentCacheNamespaces,
contentNamespaces,
invalidateSchemaObjectCache,
} from "./object-cache/index.js";
Expand All @@ -46,7 +48,7 @@ import { getRequestContext } from "./request-context.js";
import { resetRegisteredCollectionsCache } from "./schema/collection-slugs-cache.js";
import { compileUrlPattern } from "./schema/url-pattern.js";
import type { TaxonomyTerm } from "./taxonomies/types.js";
import { isMissingTableError } from "./utils/db-errors.js";
import { isMissingColumnError, isMissingTableError } from "./utils/db-errors.js";
import {
createEditable,
createNoop,
Expand Down Expand Up @@ -210,6 +212,58 @@ export interface CacheHint {
lastModified?: Date;
}

interface PublishedDatesResult {
dates: Date[];
cacheHint: CacheHint;
error?: Error;
}

/** @internal Publication dates for the Archives widget. */
export async function getPublishedDates(
type: string,
options?: { locale?: string },
): Promise<PublishedDatesResult> {
const locale = effectiveLocaleKey(options) || undefined;
const key = `publishedDates:${JSON.stringify([type, locale])}`;
try {
return await requestCached(key, () =>
cachedQuery<PublishedDatesResult>({
namespace: contentCacheNamespaces(type),
key,
load: async () => {
const rows = await loadPublishedDates(type, locale);
const dates: Date[] = [];
let lastModified: Date | undefined;
for (const row of rows) {
if (row.published_at) {
const date = new Date(row.published_at);
if (!Number.isNaN(date.getTime())) dates.push(date);
}
if (row.updated_at) {
const modified = new Date(row.updated_at);
if (!Number.isNaN(modified.getTime()) && (!lastModified || modified > lastModified)) {
lastModified = modified;
}
}
}
return { dates, cacheHint: { tags: [type], lastModified } };
},
}),
);
} catch (error) {
return {
dates: [],
cacheHint: {},
error:
isMissingTableError(error) || isMissingColumnError(error)
? undefined
: error instanceof Error
? error
: new Error("Failed to load publication dates"),
};
}
}

/**
* Result from getEmDashCollection
*/
Expand Down
19 changes: 10 additions & 9 deletions packages/core/src/widgets/archives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,39 +29,40 @@ export function groupEntriesByPublishedAt(
): ArchiveGroup[] {
const type = options.type ?? "monthly";
const limit = options.limit ?? 12;
const archives = new Map<string, ArchiveGroup>();
const archives = new Map<string, { date: Date; count: number; url: string }>();

for (const entry of entries) {
const date = toPublishedDate(entry.data.publishedAt);
if (!date) continue;

let key: string;
let label: string;
let url: string;

if (type === "yearly") {
const year = date.getFullYear();
key = `${year}`;
label = `${year}`;
url = `/archives/${year}`;
} else {
const year = date.getFullYear();
const month = date.getMonth() + 1;
key = `${year}-${month.toString().padStart(2, "0")}`;
label = date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
});
url = `/archives/${year}/${month.toString().padStart(2, "0")}`;
}

const existing = archives.get(key);
if (existing) {
existing.count++;
} else {
archives.set(key, { label, count: 1, url });
archives.set(key, { date, count: 1, url });
}
}

return [...archives.values()].slice(0, limit);
return [...archives.values()].slice(0, limit).map(({ date, count, url }) => ({
label:
type === "yearly"
? `${date.getFullYear()}`
: date.toLocaleDateString("en-US", { year: "numeric", month: "long" }),
count,
url,
}));
}
12 changes: 11 additions & 1 deletion packages/core/src/widgets/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { widgetAreaTag } from "../cache/chrome-tags.js";
import { getDb } from "../loader.js";
import type { CacheHint } from "../query.js";
import { requestCached } from "../request-cache.js";
import { peekRequestCache, requestCached } from "../request-cache.js";
import { getWidgetComponents as getComponentRegistry } from "./components.js";
import type { Widget, WidgetArea, WidgetRow, WidgetComponentDef } from "./types.js";

Expand All @@ -26,6 +26,16 @@ export type {
*/
export async function getWidgetArea(name: string): Promise<WidgetArea | null> {
return requestCached(`widget-area:${name}`, async () => {
const prefetched = peekRequestCache<WidgetArea[]>("widget-areas");
if (prefetched) {
try {
const areas = await prefetched;
return areas.find((area) => area.name === name) ?? null;
} catch {
// Optional prefetch failure must still allow the scoped read.
}
}

const db = await getDb();
const rows = await db
.selectFrom("_emdash_widget_areas as a")
Expand Down
35 changes: 35 additions & 0 deletions packages/core/tests/unit/perf-monitor-measure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { createServer } from "node:http";
import { promisify } from "node:util";

import { expect, it } from "vitest";

import { measureRoutes } from "../../../../infra/perf-monitor/probe/src/measure.js";

it("measures the HTML response on both cold and warm page requests", async () => {
let htmlRequests = 0;
const server = createServer((request, response) => {
const acceptsHtml = request.headers.accept?.split(",", 1)[0]?.trim().startsWith("text/html");
if (acceptsHtml) htmlRequests++;
response.writeHead(200, {
"Content-Type": acceptsHtml ? "text/html" : "application/json",
"Server-Timing": `render;dur=${acceptsHtml ? 12 : 1}`,
});
response.end(acceptsHtml ? "<p>Rendered page</p>" : "{}");
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing test server address");
const [result] = await measureRoutes({
targetUrl: `http://127.0.0.1:${address.port}`,
routes: [{ path: "/", label: "Home" }],
warmRequests: 2,
});
expect(htmlRequests).toBe(3);
expect(result?.coldServerTimings?.render?.dur).toBe(12);
expect(result?.warmServerTimings?.render?.dur).toBe(12);
} finally {
server.closeAllConnections();
await promisify(server.close.bind(server))();
}
});
Loading
Loading