Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: await cache set #446

Merged
merged 7 commits into from
Jun 19, 2024
Merged
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
11 changes: 11 additions & 0 deletions .changeset/eight-parrots-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"open-next-docs": patch
"open-next-benchmark": patch
"app-pages-router": patch
"app-router": patch
"pages-router": patch
"open-next": patch
"tests-e2e": patch
---

Fix: dangling promises
19 changes: 19 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,24 @@ module.exports = {
"sonarjs/elseif-without-else": "warn",
"sonarjs/no-duplicate-string": "warn",
"sonarjs/cognitive-complexity": "warn",

// We add some typescript rules - The recommended rules breaks too much stuff
// TODO: We should add more rules, especially around typescript types

// Promises related rules
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: false },
],

"@typescript-eslint/unbound-method": "error",

"@typescript-eslint/no-non-null-assertion": "warn",
},
parserOptions: {
project: ["./tsconfig.eslint.json", "./**/tsconfig.json"],
},
ignorePatterns: ["**/node_modules/**", "**/dist/**", "**/out/**"],
};
3 changes: 3 additions & 0 deletions docs/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ const withNextra = require("nextra")({

module.exports = withNextra({
swcMinify: true,
eslint: {
ignoreDuringBuilds: true,
},
images: {
unoptimized: true,
},
Expand Down
3 changes: 3 additions & 0 deletions example/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ const nextConfig = {
reactStrictMode: true,
cleanDistDir: true,
swcMinify: true,
eslint: {
ignoreDuringBuilds: true,
},
images: {
remotePatterns: [
{
Expand Down
3 changes: 3 additions & 0 deletions examples/app-pages-router/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ const nextConfig = {
experimental: {
serverActions: true,
},
eslint: {
ignoreDuringBuilds: true,
},
trailingSlash: true,
skipTrailingSlashRedirect: true,
};
Expand Down
6 changes: 3 additions & 3 deletions examples/app-router/app/api/sse/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

export const dynamic = "force-dynamic";

export async function GET(request: NextRequest) {

Check warning on line 6 in examples/app-router/app/api/sse/route.ts

View workflow job for this annotation

GitHub Actions / validate

'request' is defined but never used. Allowed unused args must match /^_/u
const resStream = new TransformStream();
const writer = resStream.writable.getWriter();

Expand All @@ -16,15 +16,15 @@
});

setTimeout(async () => {
writer.write(
await writer.write(
`data: ${JSON.stringify({
message: "open",
time: new Date().toISOString(),
})}\n\n`,
);
for (let i = 1; i <= 4; i++) {
await wait(2000);
writer.write(
await writer.write(
`data: ${JSON.stringify({
message: "hello:" + i,
time: new Date().toISOString(),
Expand All @@ -33,7 +33,7 @@
}

await wait(2000); // Wait for 4 seconds
writer.write(
await writer.write(
`data: ${JSON.stringify({
message: "close",
time: new Date().toISOString(),
Expand Down
3 changes: 3 additions & 0 deletions examples/app-router/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ const nextConfig = {
experimental: {
serverActions: true,
},
eslint: {
ignoreDuringBuilds: true,
},
images: {
remotePatterns: [
{
Expand Down
3 changes: 3 additions & 0 deletions examples/pages-router/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ const nextConfig = {
reactStrictMode: true,
output: "standalone",
outputFileTracing: "../sst",
eslint: {
ignoreDuringBuilds: true,
},
rewrites: () => [
{ source: "/rewrite", destination: "/" },
{
Expand Down
15 changes: 8 additions & 7 deletions packages/open-next/src/adapters/cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { DetachedPromise } from "utils/promise.js";

import { IncrementalCache } from "../cache/incremental/types.js";
import { TagCache } from "../cache/tag/types.js";
import { isBinaryContentType } from "./binary.js";
Expand Down Expand Up @@ -225,8 +223,11 @@
if (globalThis.disableIncrementalCache) {
return;
}
const detachedPromise = new DetachedPromise<void>();
globalThis.__als.getStore()?.pendingPromises.push(detachedPromise);
// This one might not even be necessary anymore
// Better be safe than sorry
const detachedPromise = globalThis.__als
.getStore()
?.pendingPromiseRunner.withResolvers<void>();
try {
if (data?.kind === "ROUTE") {
const { body, status, headers } = data;
Expand All @@ -250,7 +251,7 @@
const { html, pageData } = data;
const isAppPath = typeof pageData === "string";
if (isAppPath) {
globalThis.incrementalCache.set(
await globalThis.incrementalCache.set(
key,
{
type: "app",
Expand All @@ -260,7 +261,7 @@
false,
);
} else {
globalThis.incrementalCache.set(
await globalThis.incrementalCache.set(
key,
{
type: "page",
Expand All @@ -281,7 +282,7 @@
},
false,
);
} else if (data === null || data === undefined) {

Check warning on line 285 in packages/open-next/src/adapters/cache.ts

View workflow job for this annotation

GitHub Actions / validate

Add the missing "else" clause
await globalThis.incrementalCache.delete(key);
}
// Write derivedTags to dynamodb
Expand Down Expand Up @@ -312,7 +313,7 @@
error("Failed to set cache", e);
} finally {
// We need to resolve the promise even if there was an error
detachedPromise.resolve();
detachedPromise?.resolve();
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/open-next/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export async function build(
// Build Next.js app
printHeader("Building Next.js app");
setStandaloneBuildMode(monorepoRoot);
await buildNextjsApp(packager);
buildNextjsApp(packager);

// Generate deployable bundle
printHeader("Generating bundle");
Expand Down Expand Up @@ -280,7 +280,7 @@ async function createRevalidationBundle(config: OpenNextConfig) {
copyOpenNextConfig(options.tempDir, outputPath);

// Build Lambda code
esbuildAsync(
await esbuildAsync(
{
external: ["next", "styled-jsx", "react"],
entryPoints: [path.join(__dirname, "adapters", "revalidate.js")],
Expand Down
4 changes: 2 additions & 2 deletions packages/open-next/src/build/createServerBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ async function generateBundle(
// Bundle next server if necessary
const isBundled = fnOptions.experimentalBundledNextServer ?? false;
if (isBundled) {
bundleNextServer(path.join(outputPath, packagePath), appPath);
await bundleNextServer(path.join(outputPath, packagePath), appPath);
}

// // Copy middleware
Expand All @@ -181,7 +181,7 @@ async function generateBundle(
copyEnvFile(appBuildOutputPath, packagePath, outputPath);

// Copy all necessary traced files
copyTracedFiles(
await copyTracedFiles(
appBuildOutputPath,
packagePath,
outputPath,
Expand Down
4 changes: 2 additions & 2 deletions packages/open-next/src/core/createMainHandler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AsyncLocalStorage } from "node:async_hooks";

import type { OpenNextConfig } from "types/open-next";
import { DetachedPromise } from "utils/promise";
import { DetachedPromiseRunner } from "utils/promise";

import { debug } from "../adapters/logger";
import { generateUniqueId } from "../adapters/util";
Expand All @@ -23,7 +23,7 @@ declare global {
var serverId: string;
var __als: AsyncLocalStorage<{
requestId: string;
pendingPromises: DetachedPromise<void>[];
pendingPromiseRunner: DetachedPromiseRunner;
}>;
}

Expand Down
18 changes: 8 additions & 10 deletions packages/open-next/src/core/requestHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
StreamCreator,
} from "http/index.js";
import { InternalEvent, InternalResult } from "types/open-next";
import { DetachedPromise } from "utils/promise";
import { DetachedPromiseRunner } from "utils/promise";

import { debug, error, warn } from "../adapters/logger";
import { convertRes, createServerResponse, proxyRequest } from "./routing/util";
Expand All @@ -16,7 +16,7 @@ import { requestHandler, setNextjsPrebundledReact } from "./util";
// This is used to identify requests in the cache
globalThis.__als = new AsyncLocalStorage<{
requestId: string;
pendingPromises: DetachedPromise<any>[];
pendingPromiseRunner: DetachedPromiseRunner;
}>();

export async function openNextHandler(
Expand Down Expand Up @@ -85,9 +85,10 @@ export async function openNextHandler(
remoteAddress: preprocessedEvent.remoteAddress,
};
const requestId = Math.random().toString(36);
const pendingPromises: DetachedPromise<void>[] = [];
const pendingPromiseRunner: DetachedPromiseRunner =
new DetachedPromiseRunner();
const internalResult = await globalThis.__als.run(
{ requestId, pendingPromises },
{ requestId, pendingPromiseRunner },
async () => {
const preprocessedResult = preprocessResult as MiddlewareOutputEvent;
const req = new IncomingMessage(reqProps);
Expand Down Expand Up @@ -117,10 +118,7 @@ export async function openNextHandler(
// reset lastModified. We need to do this to avoid memory leaks
delete globalThis.lastModified[requestId];

// Wait for all promises to resolve
// We are not catching errors here, because they are catched before
// This may need to change in the future
await Promise.all(pendingPromises.map((p) => p.promise));
await pendingPromiseRunner.await();

return internalResult;
},
Expand Down Expand Up @@ -161,10 +159,10 @@ async function processRequest(
if (e.constructor.name === "NoFallbackError") {
// Do we need to handle _not-found
// Ideally this should never get triggered and be intercepted by the routing handler
tryRenderError("404", res, internalEvent);
await tryRenderError("404", res, internalEvent);
} else {
error("NextJS request failed.", e);
tryRenderError("500", res, internalEvent);
await tryRenderError("500", res, internalEvent);
}
}
}
Expand Down
9 changes: 0 additions & 9 deletions packages/open-next/src/core/routing/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { OpenNextNodeResponse } from "http/openNextResponse.js";
import { parseHeaders } from "http/util.js";
import type { MiddlewareManifest } from "types/next-types";
import { InternalEvent } from "types/open-next.js";
import { DetachedPromise } from "utils/promise.js";

import { isBinaryContentType } from "../../adapters/binary.js";
import { debug, error } from "../../adapters/logger.js";
Expand Down Expand Up @@ -356,11 +355,6 @@ export async function revalidateIfRequired(
: internalMeta?._nextRewroteUrl
: rawPath;

// We want to ensure that the revalidation is done in the background
// But we should still wait for the queue send to be successful
const detachedPromise = new DetachedPromise<void>();
globalThis.__als.getStore()?.pendingPromises.push(detachedPromise);

// We need to pass etag to the revalidation queue to try to bypass the default 5 min deduplication window.
// https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/using-messagededuplicationid-property.html
// If you need to have a revalidation happen more frequently than 5 minutes,
Expand All @@ -387,9 +381,6 @@ export async function revalidateIfRequired(
});
} catch (e) {
error(`Failed to revalidate stale page ${rawPath}`, e);
} finally {
// We don't care if it fails or not, we don't want to block the request
detachedPromise.resolve();
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion packages/open-next/src/http/openNextResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ export class OpenNextNodeResponse extends Transform implements ServerResponse {
if (!this.headersSent) {
this.flushHeaders();
}
onEnd(this.headers);
globalThis.__als
.getStore()
?.pendingPromiseRunner.add(onEnd(this.headers));
const bodyLength = this.body.length;
this.streamCreator?.onFinish(bodyLength);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/open-next/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ if (command !== "build") printHelp();
const args = parseArgs();
if (Object.keys(args).includes("--help")) printHelp();

build(args["--config-path"], args["--node-externals"]);
await build(args["--config-path"], args["--node-externals"]);

function parseArgs() {
return process.argv.slice(2).reduce(
Expand Down
33 changes: 33 additions & 0 deletions packages/open-next/src/utils/promise.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { debug, error } from "../adapters/logger";

/**
* A `Promise.withResolvers` implementation that exposes the `resolve` and
* `reject` functions on a `Promise`.
Expand All @@ -21,7 +23,38 @@ export class DetachedPromise<T = any> {

// We know that resolvers is defined because the Promise constructor runs
// synchronously.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.resolve = resolve!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.reject = reject!;
}
}

export class DetachedPromiseRunner {
private promises: DetachedPromise<any>[] = [];

public withResolvers<T>(): DetachedPromise<T> {
const detachedPromise = new DetachedPromise<T>();
this.promises.push(detachedPromise);
return detachedPromise;
}

public add<T>(promise: Promise<T>): void {
const detachedPromise = new DetachedPromise<T>();
this.promises.push(detachedPromise);
promise.then(detachedPromise.resolve, detachedPromise.reject);
}

public async await(): Promise<void> {
debug(`Awaiting ${this.promises.length} detached promises`);
const results = await Promise.allSettled(
this.promises.map((p) => p.promise),
);
const rejectedPromises = results.filter(
(r) => r.status === "rejected",
) as PromiseRejectedResult[];
rejectedPromises.forEach((r) => {
error(r.reason);
});
}
}
2 changes: 1 addition & 1 deletion packages/tests-e2e/tests/appPagesRouter/pages_ssr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ test("Server Side Render", async ({ page }) => {
el = page.getByText("Time:");
newTime = await el.textContent();
await expect(el).toBeVisible();
await expect(time).not.toEqual(newTime);
expect(time).not.toEqual(newTime);
time = newTime;
await wait(250);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/tests-e2e/tests/appPagesRouter/ssr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ test("Server Side Render", async ({ page }) => {
el = page.getByText("Time:");
newTime = await el.textContent();
await expect(el).toBeVisible();
await expect(time).not.toEqual(newTime);
expect(time).not.toEqual(newTime);
time = newTime;
await wait(250);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/tests-e2e/tests/appRouter/headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ test("Headers", async ({ page }) => {
const response = await responsePromise;
// Response header should be set
const headers = response.headers();
await expect(headers["response-header"]).toEqual("response-header");
expect(headers["response-header"]).toEqual("response-header");

// The next.config.js headers should be also set in response
await expect(headers["e2e-headers"]).toEqual("next.config.js");
expect(headers["e2e-headers"]).toEqual("next.config.js");

// Request header should be available in RSC
let el = page.getByText(`request-header`);
Expand Down
Loading
Loading