@anthusai/papyrus 1.0.0-next.53 → 1.0.0-next.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/functions/content-actions/handler.py +15 -2
- package/amplify/publication/reproject-on-deploy.js +63 -21
- package/amplify/publication/reproject-on-deploy.ts +66 -24
- package/lib/graphql-content-repository.ts +14 -11
- package/lib/rate-limit-retry.ts +27 -0
- package/package.json +2 -1
- package/routes.manifest.json +1 -1
|
@@ -7,6 +7,7 @@ from typing import Any
|
|
|
7
7
|
from papyrus_content.edge_invalidation import distribution_ids_from_environment, invalidate_revalidated_paths
|
|
8
8
|
from papyrus_content.graphql_authoring import create_authoring_client
|
|
9
9
|
from papyrus_content.markus_renderer.derive import derive_body
|
|
10
|
+
from papyrus_content.projection.deploy_render import deploy_render, drift_metric_line
|
|
10
11
|
from papyrus_content.projection.engine import project, project_all, project_item_pages
|
|
11
12
|
from papyrus_content.projection.runtime import (
|
|
12
13
|
DEAD_LETTER_QUEUE_ENV,
|
|
@@ -146,6 +147,18 @@ def schedule_unpurged_invalidation(keys: list[str]) -> None:
|
|
|
146
147
|
schedule_projection_task({"task": "invalidate", "keys": keys}, "edge invalidation failed")
|
|
147
148
|
|
|
148
149
|
|
|
150
|
+
def run_deploy_render_task(client) -> dict:
|
|
151
|
+
"""Render every published page after a backend deploy, then report drift (a dry run: nothing is deleted)."""
|
|
152
|
+
context = projection_context(client)
|
|
153
|
+
report = deploy_render(context)
|
|
154
|
+
print(drift_metric_line(report))
|
|
155
|
+
if report.render.unpurged:
|
|
156
|
+
schedule_unpurged_invalidation(report.render.unpurged)
|
|
157
|
+
if not report.ok:
|
|
158
|
+
raise ProjectionTaskFailed("; ".join([*report.render.errors, *report.drift.errors]))
|
|
159
|
+
return report.to_dict()
|
|
160
|
+
|
|
161
|
+
|
|
149
162
|
def run_projection_task(task: dict) -> dict:
|
|
150
163
|
"""An asynchronous projection task. Raising makes Lambda retry it, then send the event to the dead letter queue."""
|
|
151
164
|
client, _claims = create_authoring_client()
|
|
@@ -157,8 +170,8 @@ def run_projection_task(task: dict) -> dict:
|
|
|
157
170
|
schedule_projection_task({"task": "item-pages", "exceptItemId": task["itemId"]}, "follow-up")
|
|
158
171
|
elif task.get("task") == "all":
|
|
159
172
|
report = project_all(projection_context(client))
|
|
160
|
-
elif task.get("task") == "
|
|
161
|
-
|
|
173
|
+
elif task.get("task") == "deploy-render":
|
|
174
|
+
return run_deploy_render_task(client)
|
|
162
175
|
elif task.get("task") == "invalidate":
|
|
163
176
|
invalidate = projection_context(client).invalidate
|
|
164
177
|
if invalidate is None:
|
|
@@ -1,31 +1,73 @@
|
|
|
1
|
+
import { CustomResource, Duration } from "aws-cdk-lib";
|
|
2
|
+
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
|
|
1
3
|
import { PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
5
|
+
import { Provider } from "aws-cdk-lib/custom-resources";
|
|
6
|
+
export const REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS = 900;
|
|
7
|
+
export const DEPLOY_RENDER_METRIC_NAMESPACE = "Papyrus/Projection";
|
|
8
|
+
export const DEPLOY_RENDER_DRIFT_METRIC_NAME = "DeployRenderDrift";
|
|
9
|
+
export const DEPLOY_RENDER_TASK = "deploy-render";
|
|
10
|
+
const FAILURE_MESSAGE_LIMIT = 600;
|
|
11
|
+
const RESULT_SUMMARY_LIMIT = 900;
|
|
4
12
|
export function reprojectDeployToken(environment, now = Date.now) {
|
|
5
13
|
const buildJobId = (environment.AWS_JOB_ID ?? "").trim();
|
|
6
14
|
return buildJobId !== "" ? `job-${buildJobId}` : `time-${now()}`;
|
|
7
15
|
}
|
|
8
16
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
17
|
+
* Runs inside the deploy: invokes the content-actions Lambda synchronously, so a render that fails fails the
|
|
18
|
+
* stack update (and with it the Amplify build) with the failure in the build log. The Lambda's result, which
|
|
19
|
+
* includes the drift check, is written to its log and returned as the resource's data.
|
|
20
|
+
*/
|
|
21
|
+
export const DEPLOY_RENDER_HANDLER_SOURCE = `
|
|
22
|
+
const { LambdaClient, InvokeCommand } = require("@aws-sdk/client-lambda");
|
|
23
|
+
|
|
24
|
+
exports.handler = async (event) => {
|
|
25
|
+
if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
|
|
26
|
+
const properties = event.ResourceProperties;
|
|
27
|
+
const client = new LambdaClient({ maxAttempts: 1 });
|
|
28
|
+
const response = await client.send(new InvokeCommand({
|
|
29
|
+
FunctionName: properties.FunctionName,
|
|
30
|
+
InvocationType: "RequestResponse",
|
|
31
|
+
Payload: JSON.stringify({ projectionTask: { task: properties.Task, deployToken: properties.DeployToken } }),
|
|
32
|
+
}));
|
|
33
|
+
const text = Buffer.from(response.Payload || []).toString("utf8");
|
|
34
|
+
console.log("deploy render result: " + text);
|
|
35
|
+
if (response.FunctionError) {
|
|
36
|
+
throw new Error("The post-deploy render failed (" + response.FunctionError + "): " + text.slice(0, ${FAILURE_MESSAGE_LIMIT}));
|
|
37
|
+
}
|
|
38
|
+
return { PhysicalResourceId: "reproject-" + properties.DeployToken, Data: { Result: text.slice(0, ${RESULT_SUMMARY_LIMIT}) } };
|
|
39
|
+
};
|
|
40
|
+
`;
|
|
41
|
+
/**
|
|
42
|
+
* Renders every published page after each backend deploy and checks the site store for drift (a dry run:
|
|
43
|
+
* nothing is deleted). The token is a property, so CloudFormation runs the resource again on every deploy.
|
|
11
44
|
*/
|
|
12
45
|
export function addReprojectOnDeploy(stack, contentActions, deployToken) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
46
|
+
const handler = new lambda.Function(stack, "DeployRenderHandler", {
|
|
47
|
+
runtime: lambda.Runtime.NODEJS_20_X,
|
|
48
|
+
handler: "index.handler",
|
|
49
|
+
code: lambda.Code.fromInline(DEPLOY_RENDER_HANDLER_SOURCE),
|
|
50
|
+
timeout: Duration.seconds(REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS),
|
|
51
|
+
description: "Starts the post-deploy render of the site and fails the deploy if it cannot finish.",
|
|
52
|
+
});
|
|
53
|
+
handler.addToRolePolicy(new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [contentActions.functionArn] }));
|
|
54
|
+
const provider = new Provider(stack, "DeployRenderProvider", { onEventHandler: handler });
|
|
55
|
+
new cloudwatch.Alarm(stack, "DeployRenderDriftAlarm", {
|
|
56
|
+
metric: new cloudwatch.Metric({
|
|
57
|
+
namespace: DEPLOY_RENDER_METRIC_NAMESPACE,
|
|
58
|
+
metricName: DEPLOY_RENDER_DRIFT_METRIC_NAME,
|
|
59
|
+
dimensionsMap: { FunctionName: contentActions.functionName },
|
|
60
|
+
statistic: "Maximum",
|
|
61
|
+
period: Duration.days(1),
|
|
62
|
+
}),
|
|
63
|
+
evaluationPeriods: 1,
|
|
64
|
+
threshold: 1,
|
|
65
|
+
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
|
|
66
|
+
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
|
67
|
+
alarmDescription: "The last post-deploy render found drift between the published content and the site store (stale pages, orphan pages or redirects). Nothing was deleted; run papyrus ops content project --reconcile to repair.",
|
|
68
|
+
});
|
|
69
|
+
return new CustomResource(stack, "ReprojectOnDeploy", {
|
|
70
|
+
serviceToken: provider.serviceToken,
|
|
71
|
+
properties: { FunctionName: contentActions.functionName, Task: DEPLOY_RENDER_TASK, DeployToken: deployToken },
|
|
30
72
|
});
|
|
31
73
|
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { Stack } from "aws-cdk-lib";
|
|
1
|
+
import { CustomResource, Duration, Stack } from "aws-cdk-lib";
|
|
2
|
+
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
|
|
2
3
|
import { PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
4
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
5
|
+
import { Provider } from "aws-cdk-lib/custom-resources";
|
|
5
6
|
|
|
6
|
-
export const REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS =
|
|
7
|
+
export const REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS = 900;
|
|
8
|
+
export const DEPLOY_RENDER_METRIC_NAMESPACE = "Papyrus/Projection";
|
|
9
|
+
export const DEPLOY_RENDER_DRIFT_METRIC_NAME = "DeployRenderDrift";
|
|
10
|
+
export const DEPLOY_RENDER_TASK = "deploy-render";
|
|
11
|
+
const FAILURE_MESSAGE_LIMIT = 600;
|
|
12
|
+
const RESULT_SUMMARY_LIMIT = 900;
|
|
7
13
|
|
|
8
14
|
export function reprojectDeployToken(environment: Record<string, string | undefined>, now: () => number = Date.now): string {
|
|
9
15
|
const buildJobId = (environment.AWS_JOB_ID ?? "").trim();
|
|
@@ -11,26 +17,62 @@ export function reprojectDeployToken(environment: Record<string, string | undefi
|
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
20
|
+
* Runs inside the deploy: invokes the content-actions Lambda synchronously, so a render that fails fails the
|
|
21
|
+
* stack update (and with it the Amplify build) with the failure in the build log. The Lambda's result, which
|
|
22
|
+
* includes the drift check, is written to its log and returned as the resource's data.
|
|
16
23
|
*/
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
export const DEPLOY_RENDER_HANDLER_SOURCE = `
|
|
25
|
+
const { LambdaClient, InvokeCommand } = require("@aws-sdk/client-lambda");
|
|
26
|
+
|
|
27
|
+
exports.handler = async (event) => {
|
|
28
|
+
if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
|
|
29
|
+
const properties = event.ResourceProperties;
|
|
30
|
+
const client = new LambdaClient({ maxAttempts: 1 });
|
|
31
|
+
const response = await client.send(new InvokeCommand({
|
|
32
|
+
FunctionName: properties.FunctionName,
|
|
33
|
+
InvocationType: "RequestResponse",
|
|
34
|
+
Payload: JSON.stringify({ projectionTask: { task: properties.Task, deployToken: properties.DeployToken } }),
|
|
35
|
+
}));
|
|
36
|
+
const text = Buffer.from(response.Payload || []).toString("utf8");
|
|
37
|
+
console.log("deploy render result: " + text);
|
|
38
|
+
if (response.FunctionError) {
|
|
39
|
+
throw new Error("The post-deploy render failed (" + response.FunctionError + "): " + text.slice(0, ${FAILURE_MESSAGE_LIMIT}));
|
|
40
|
+
}
|
|
41
|
+
return { PhysicalResourceId: "reproject-" + properties.DeployToken, Data: { Result: text.slice(0, ${RESULT_SUMMARY_LIMIT}) } };
|
|
42
|
+
};
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Renders every published page after each backend deploy and checks the site store for drift (a dry run:
|
|
47
|
+
* nothing is deleted). The token is a property, so CloudFormation runs the resource again on every deploy.
|
|
48
|
+
*/
|
|
49
|
+
export function addReprojectOnDeploy(stack: Stack, contentActions: lambda.IFunction, deployToken: string): CustomResource {
|
|
50
|
+
const handler = new lambda.Function(stack, "DeployRenderHandler", {
|
|
51
|
+
runtime: lambda.Runtime.NODEJS_20_X,
|
|
52
|
+
handler: "index.handler",
|
|
53
|
+
code: lambda.Code.fromInline(DEPLOY_RENDER_HANDLER_SOURCE),
|
|
54
|
+
timeout: Duration.seconds(REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS),
|
|
55
|
+
description: "Starts the post-deploy render of the site and fails the deploy if it cannot finish.",
|
|
56
|
+
});
|
|
57
|
+
handler.addToRolePolicy(new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [contentActions.functionArn] }));
|
|
58
|
+
const provider = new Provider(stack, "DeployRenderProvider", { onEventHandler: handler });
|
|
59
|
+
new cloudwatch.Alarm(stack, "DeployRenderDriftAlarm", {
|
|
60
|
+
metric: new cloudwatch.Metric({
|
|
61
|
+
namespace: DEPLOY_RENDER_METRIC_NAMESPACE,
|
|
62
|
+
metricName: DEPLOY_RENDER_DRIFT_METRIC_NAME,
|
|
63
|
+
dimensionsMap: { FunctionName: contentActions.functionName },
|
|
64
|
+
statistic: "Maximum",
|
|
65
|
+
period: Duration.days(1),
|
|
66
|
+
}),
|
|
67
|
+
evaluationPeriods: 1,
|
|
68
|
+
threshold: 1,
|
|
69
|
+
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
|
|
70
|
+
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
|
71
|
+
alarmDescription:
|
|
72
|
+
"The last post-deploy render found drift between the published content and the site store (stale pages, orphan pages or redirects). Nothing was deleted; run papyrus ops content project --reconcile to repair.",
|
|
73
|
+
});
|
|
74
|
+
return new CustomResource(stack, "ReprojectOnDeploy", {
|
|
75
|
+
serviceToken: provider.serviceToken,
|
|
76
|
+
properties: { FunctionName: contentActions.functionName, Task: DEPLOY_RENDER_TASK, DeployToken: deployToken },
|
|
35
77
|
});
|
|
36
78
|
}
|
|
@@ -21,6 +21,7 @@ import { SITE_BRAND } from "./site-brand";
|
|
|
21
21
|
import { shouldFetchVideoScripts } from "./video-mode";
|
|
22
22
|
import { collectVideoScriptTargets, parseVideoScriptRef, videomlItemSlug, type VideoScriptRef } from "./video-script";
|
|
23
23
|
import { currentContentSource, currentRequestClient, runWithContentSource } from "./content-source-context";
|
|
24
|
+
import { withRateLimitRetry } from "./rate-limit-retry";
|
|
24
25
|
import { getContentSource } from "./site-env";
|
|
25
26
|
|
|
26
27
|
const DEFAULT_EDITION_SLUG = "current";
|
|
@@ -157,17 +158,19 @@ async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise
|
|
|
157
158
|
return runWithContentSource(source, operation, client);
|
|
158
159
|
}
|
|
159
160
|
return runWithContentSource(source, () =>
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
161
|
+
withRateLimitRetry(() =>
|
|
162
|
+
runWithAmplifyServerContext({
|
|
163
|
+
nextServerContext: null,
|
|
164
|
+
operation: async () => {
|
|
165
|
+
readerContextClient = generateClient<Schema>({ authMode: authMode() });
|
|
166
|
+
try {
|
|
167
|
+
return await operation();
|
|
168
|
+
} finally {
|
|
169
|
+
readerContextClient = null;
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
),
|
|
171
174
|
);
|
|
172
175
|
}
|
|
173
176
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const RATE_LIMIT_RETRY_ATTEMPTS = 6;
|
|
2
|
+
export const RATE_LIMIT_RETRY_BASE_MILLISECONDS = 300;
|
|
3
|
+
|
|
4
|
+
export function isRateLimitError(error: unknown): boolean {
|
|
5
|
+
if (!error || typeof error !== "object") return false;
|
|
6
|
+
const { name, message } = error as { name?: unknown; message?: unknown };
|
|
7
|
+
const text = `${typeof name === "string" ? name : ""} ${typeof message === "string" ? message : ""}`;
|
|
8
|
+
return /TooManyRequests|ThrottlingException|Rate exceeded|Unauthorized[\s\S]*set the \\?"authMode\\?"/i.test(text);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function withRateLimitRetry<T>(
|
|
12
|
+
operation: () => Promise<T>,
|
|
13
|
+
options: { attempts?: number; baseMilliseconds?: number; sleep?: (milliseconds: number) => Promise<void>; random?: () => number } = {},
|
|
14
|
+
): Promise<T> {
|
|
15
|
+
const attempts = options.attempts ?? RATE_LIMIT_RETRY_ATTEMPTS;
|
|
16
|
+
const baseMilliseconds = options.baseMilliseconds ?? RATE_LIMIT_RETRY_BASE_MILLISECONDS;
|
|
17
|
+
const sleep = options.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
|
18
|
+
const random = options.random ?? Math.random;
|
|
19
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
20
|
+
try {
|
|
21
|
+
return await operation();
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (attempt >= attempts || !isRateLimitError(error)) throw error;
|
|
24
|
+
await sleep(baseMilliseconds * 2 ** (attempt - 1) * (0.5 + random()));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -197,6 +197,7 @@
|
|
|
197
197
|
"./lib/pretext-layout": "./lib/pretext-layout.ts",
|
|
198
198
|
"./lib/public-placeholder-config": "./lib/public-placeholder-config.ts",
|
|
199
199
|
"./lib/publication-items": "./lib/publication-items.ts",
|
|
200
|
+
"./lib/rate-limit-retry": "./lib/rate-limit-retry.ts",
|
|
200
201
|
"./lib/reader-base-path": "./lib/reader-base-path.ts",
|
|
201
202
|
"./lib/reader-cache-lifetime": "./lib/reader-cache-lifetime.ts",
|
|
202
203
|
"./lib/reader-route-config": "./lib/reader-route-config.ts",
|
|
@@ -309,7 +310,7 @@
|
|
|
309
310
|
"optional": true
|
|
310
311
|
}
|
|
311
312
|
},
|
|
312
|
-
"version": "1.0.0-next.
|
|
313
|
+
"version": "1.0.0-next.54",
|
|
313
314
|
"dependencies": {
|
|
314
315
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
315
316
|
"@aws-sdk/client-cognito-identity-provider": "3.1045.0",
|
package/routes.manifest.json
CHANGED