@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.
@@ -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") == "all":
161
- report = project_all(projection_context(client))
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 { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
3
- export const REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS = 300;
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
- * Starts the content-actions `all` projection task (asynchronously) every time the stack deploys.
10
- * The token is in the request, so CloudFormation sees a changed property on each deploy.
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 invocation = {
14
- service: "Lambda",
15
- action: "invoke",
16
- parameters: {
17
- FunctionName: contentActions.functionName,
18
- InvocationType: "Event",
19
- Payload: JSON.stringify({ projectionTask: { task: "all", deployToken } }),
20
- },
21
- physicalResourceId: PhysicalResourceId.of(`reproject-${deployToken}`),
22
- };
23
- return new AwsCustomResource(stack, "ReprojectOnDeploy", {
24
- onCreate: invocation,
25
- onUpdate: invocation,
26
- installLatestAwsSdk: false,
27
- policy: AwsCustomResourcePolicy.fromStatements([
28
- new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [contentActions.functionArn] }),
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 type { IFunction } from "aws-cdk-lib/aws-lambda";
4
- import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
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 = 300;
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
- * Starts the content-actions `all` projection task (asynchronously) every time the stack deploys.
15
- * The token is in the request, so CloudFormation sees a changed property on each deploy.
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 function addReprojectOnDeploy(stack: Stack, contentActions: IFunction, deployToken: string): AwsCustomResource {
18
- const invocation = {
19
- service: "Lambda",
20
- action: "invoke",
21
- parameters: {
22
- FunctionName: contentActions.functionName,
23
- InvocationType: "Event",
24
- Payload: JSON.stringify({ projectionTask: { task: "all", deployToken } }),
25
- },
26
- physicalResourceId: PhysicalResourceId.of(`reproject-${deployToken}`),
27
- };
28
- return new AwsCustomResource(stack, "ReprojectOnDeploy", {
29
- onCreate: invocation,
30
- onUpdate: invocation,
31
- installLatestAwsSdk: false,
32
- policy: AwsCustomResourcePolicy.fromStatements([
33
- new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [contentActions.functionArn] }),
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
- runWithAmplifyServerContext({
161
- nextServerContext: null,
162
- operation: async () => {
163
- readerContextClient = generateClient<Schema>({ authMode: authMode() });
164
- try {
165
- return await operation();
166
- } finally {
167
- readerContextClient = null;
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.53",
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",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.53",
2
+ "version": "1.0.0-next.54",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",