@anthusai/papyrus 1.0.0-next.45 → 1.0.0-next.47

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.
@@ -6,7 +6,7 @@ from typing import Any
6
6
 
7
7
  from papyrus_content.graphql_authoring import create_authoring_client
8
8
  from papyrus_content.markus_renderer.derive import derive_body
9
- from papyrus_content.projection.engine import project
9
+ from papyrus_content.projection.engine import project, project_item_pages
10
10
  from papyrus_content.projection.runtime import (
11
11
  DEAD_LETTER_QUEUE_ENV,
12
12
  context_from_environment,
@@ -63,7 +63,14 @@ def derive_markus_action(arguments: dict) -> dict:
63
63
  return response
64
64
 
65
65
 
66
- def send_to_projection_dead_letter_queue(item_id: str, unpublished: bool, error: str) -> None:
66
+ PROJECTION_TASK_KEY = "projectionTask"
67
+
68
+
69
+ class ProjectionTaskFailed(Exception):
70
+ """Raised by an asynchronous projection task so Lambda retries it and, finally, sends it to the dead letter queue."""
71
+
72
+
73
+ def send_to_projection_dead_letter_queue(task: dict, error: str) -> None:
67
74
  queue_url = (os.environ.get(DEAD_LETTER_QUEUE_ENV) or "").strip()
68
75
  if not queue_url:
69
76
  return
@@ -71,23 +78,50 @@ def send_to_projection_dead_letter_queue(item_id: str, unpublished: bool, error:
71
78
  import boto3
72
79
 
73
80
  boto3.client("sqs").send_message(
74
- QueueUrl=queue_url,
75
- MessageBody=json.dumps({"itemId": item_id, "unpublished": unpublished, "error": error}),
81
+ QueueUrl=queue_url, MessageBody=json.dumps({PROJECTION_TASK_KEY: task, "error": error})
76
82
  )
77
83
  except Exception: # noqa: BLE001 - the dead letter is best effort; the response already reports the failure
78
84
  pass
79
85
 
80
86
 
87
+ def schedule_projection_task(task: dict, error: str) -> bool:
88
+ """Run a projection task later, in this same function, as an asynchronous invocation.
89
+
90
+ The function has an on-failure destination (the dead letter queue), so a task
91
+ that keeps failing ends up there for ``papyrus ops content project --reconcile``.
92
+ """
93
+ function_name = (os.environ.get("AWS_LAMBDA_FUNCTION_NAME") or "").strip()
94
+ if not function_name:
95
+ send_to_projection_dead_letter_queue(task, error)
96
+ return False
97
+ try:
98
+ import boto3
99
+
100
+ boto3.client("lambda").invoke(
101
+ FunctionName=function_name, InvocationType="Event", Payload=json.dumps({PROJECTION_TASK_KEY: task}).encode("utf-8")
102
+ )
103
+ return True
104
+ except Exception as scheduling_error: # noqa: BLE001 - fall back to the dead letter queue
105
+ send_to_projection_dead_letter_queue(task, f"{error}; scheduling failed: {scheduling_error}")
106
+ return False
107
+
108
+
109
+ def projection_context(client):
110
+ context = context_from_environment(client)
111
+ context.defer_follow_up = True
112
+ return context
113
+
114
+
81
115
  def project_item_action(client, item_id: str, *, unpublished: bool) -> dict:
82
116
  if not projection_configured():
83
117
  return {}
84
118
  liveness = {} if unpublished else {"live": False}
119
+ task = {"task": "item", "itemId": item_id, "unpublished": unpublished}
85
120
  try:
86
- context = context_from_environment(client)
87
- report = project(context, item_id, unpublished=unpublished)
121
+ report = project(projection_context(client), item_id, unpublished=unpublished)
88
122
  except Exception as error: # noqa: BLE001 - a projection failure never fails the save
89
123
  message = f"{type(error).__name__}: {error}"
90
- send_to_projection_dead_letter_queue(item_id, unpublished, message)
124
+ schedule_projection_task(task, message)
91
125
  return {**liveness, "projected": False, "paths": [], "error": message}
92
126
  response = {"projected": report.ok, "paths": [*report.written, *report.deleted]}
93
127
  if not unpublished:
@@ -97,11 +131,29 @@ def project_item_action(client, item_id: str, *, unpublished: bool) -> dict:
97
131
  response["url"] = url
98
132
  if not report.ok:
99
133
  message = "; ".join(report.errors)
100
- send_to_projection_dead_letter_queue(item_id, unpublished, message)
134
+ schedule_projection_task(task, message)
101
135
  response["error"] = message
136
+ if report.follow_up_item_pages:
137
+ schedule_projection_task({"task": "item-pages", "exceptItemId": item_id}, "follow-up")
102
138
  return response
103
139
 
104
140
 
141
+ def run_projection_task(task: dict) -> dict:
142
+ """An asynchronous projection task. Raising makes Lambda retry it, then send the event to the dead letter queue."""
143
+ client, _claims = create_authoring_client()
144
+ if task.get("task") == "item-pages":
145
+ report = project_item_pages(projection_context(client), except_item_id=task.get("exceptItemId"))
146
+ elif task.get("task") == "item":
147
+ report = project(projection_context(client), task["itemId"], unpublished=bool(task.get("unpublished")))
148
+ if report.follow_up_item_pages:
149
+ schedule_projection_task({"task": "item-pages", "exceptItemId": task["itemId"]}, "follow-up")
150
+ else:
151
+ raise ProjectionTaskFailed(f"Unknown projection task {task.get('task')!r}")
152
+ if not report.ok:
153
+ raise ProjectionTaskFailed("; ".join(report.errors))
154
+ return {"ok": True, "paths": [*report.written, *report.deleted]}
155
+
156
+
105
157
  def save_item_draft_action(arguments: dict, actor: str) -> dict:
106
158
  client, _claims = create_authoring_client()
107
159
  fields = ItemFields(
@@ -183,6 +235,8 @@ def request_staging_build_action() -> dict:
183
235
 
184
236
 
185
237
  def handler(event, context):
238
+ if PROJECTION_TASK_KEY in event:
239
+ return run_projection_task(event[PROJECTION_TASK_KEY])
186
240
  field = event["fieldName"]
187
241
  arguments = parse_input(event["arguments"]["input"])
188
242
  actor = actor_from_event(event)
@@ -1,8 +1,9 @@
1
- import { Duration, RemovalPolicy } from "aws-cdk-lib";
1
+ import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
2
2
  import * as acm from "aws-cdk-lib/aws-certificatemanager";
3
3
  import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
4
4
  import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
5
- import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
5
+ import { Effect, Policy, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
6
+ import { SqsDestination } from "aws-cdk-lib/aws-lambda-destinations";
6
7
  import * as route53 from "aws-cdk-lib/aws-route53";
7
8
  import * as s3 from "aws-cdk-lib/aws-s3";
8
9
  import * as sqs from "aws-cdk-lib/aws-sqs";
@@ -124,3 +125,20 @@ export function addS3ProjectionPublication(scope, props, media) {
124
125
  policyStatements: s3ProjectionPolicyStatements(settingsInput).map((statement) => new PolicyStatement({ effect: Effect.ALLOW, actions: statement.actions, resources: statement.resources })),
125
126
  };
126
127
  }
128
+ /**
129
+ * The content-actions function runs a failed or follow-up projection again as an asynchronous
130
+ * invocation of itself: one retry, and an on-failure destination (the dead letter queue) for
131
+ * what still fails. The self-invoke permission is a separate policy on the role so that the
132
+ * function does not depend on a policy that refers to the function.
133
+ */
134
+ export function addProjectionRetries(lambda, deadLetterQueue) {
135
+ lambda.configureAsyncInvoke({
136
+ retryAttempts: 1,
137
+ maxEventAge: Duration.hours(1),
138
+ onFailure: new SqsDestination(deadLetterQueue),
139
+ });
140
+ new Policy(Stack.of(lambda), "ContentActionsProjectionSelfInvoke", {
141
+ statements: [new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [lambda.functionArn] })],
142
+ roles: [lambda.role],
143
+ });
144
+ }
@@ -1,9 +1,11 @@
1
- import { Duration, RemovalPolicy } from "aws-cdk-lib";
1
+ import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
2
2
  import * as acm from "aws-cdk-lib/aws-certificatemanager";
3
3
  import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
4
4
  import type { Code } from "aws-cdk-lib/aws-lambda";
5
5
  import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
6
- import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
6
+ import { Effect, Policy, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
7
+ import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
8
+ import { SqsDestination } from "aws-cdk-lib/aws-lambda-destinations";
7
9
  import * as route53 from "aws-cdk-lib/aws-route53";
8
10
  import * as s3 from "aws-cdk-lib/aws-s3";
9
11
  import * as sqs from "aws-cdk-lib/aws-sqs";
@@ -172,3 +174,21 @@ export function addS3ProjectionPublication(
172
174
  ),
173
175
  };
174
176
  }
177
+
178
+ /**
179
+ * The content-actions function runs a failed or follow-up projection again as an asynchronous
180
+ * invocation of itself: one retry, and an on-failure destination (the dead letter queue) for
181
+ * what still fails. The self-invoke permission is a separate policy on the role so that the
182
+ * function does not depend on a policy that refers to the function.
183
+ */
184
+ export function addProjectionRetries(lambda: LambdaFunction, deadLetterQueue: sqs.IQueue): void {
185
+ lambda.configureAsyncInvoke({
186
+ retryAttempts: 1,
187
+ maxEventAge: Duration.hours(1),
188
+ onFailure: new SqsDestination(deadLetterQueue),
189
+ });
190
+ new Policy(Stack.of(lambda), "ContentActionsProjectionSelfInvoke", {
191
+ statements: [new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [lambda.functionArn] })],
192
+ roles: [lambda.role!],
193
+ });
194
+ }
@@ -7,7 +7,7 @@ import { CfnIndex, CfnVectorBucket, CfnVectorBucketPolicy } from "aws-cdk-lib/aw
7
7
  import { dirname, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves.js";
10
- import { addS3ProjectionPublication } from "./publication/s3-projection.js";
10
+ import { addProjectionRetries, addS3ProjectionPublication } from "./publication/s3-projection.js";
11
11
  import { planRenditions } from "./publication/renditions-settings.js";
12
12
  import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings.js";
13
13
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings.js";
@@ -319,6 +319,9 @@ export function defineSiteBackend(papyrusSite) {
319
319
  });
320
320
  contentActionsLambda.node.defaultChild.memorySize = publicationPlan.contentActionsMemorySize;
321
321
  }
322
+ if (s3ProjectionPublication !== undefined) {
323
+ addProjectionRetries(contentActionsLambda, s3ProjectionPublication.deadLetterQueue);
324
+ }
322
325
  const publicationWiring = contentActionsPublicationWiring(s3ProjectionPublication);
323
326
  for (const [name, value] of Object.entries(publicationWiring.environment)) {
324
327
  contentActionsLambda.addEnvironment(name, value);
@@ -10,7 +10,7 @@ import type { Construct } from "constructs";
10
10
  import { dirname, resolve } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves";
13
- import { addS3ProjectionPublication, type S3ProjectionPublication } from "./publication/s3-projection";
13
+ import { addProjectionRetries, addS3ProjectionPublication, type S3ProjectionPublication } from "./publication/s3-projection";
14
14
  import { planRenditions } from "./publication/renditions-settings";
15
15
  import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings";
16
16
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings";
@@ -404,6 +404,9 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
404
404
  });
405
405
  (contentActionsLambda.node.defaultChild as CfnFunction).memorySize = publicationPlan.contentActionsMemorySize;
406
406
  }
407
+ if (s3ProjectionPublication !== undefined) {
408
+ addProjectionRetries(contentActionsLambda, s3ProjectionPublication.deadLetterQueue);
409
+ }
407
410
  const publicationWiring = contentActionsPublicationWiring(s3ProjectionPublication);
408
411
  for (const [name, value] of Object.entries(publicationWiring.environment)) {
409
412
  contentActionsLambda.addEnvironment(name, value);
package/package.json CHANGED
@@ -302,7 +302,7 @@
302
302
  "optional": true
303
303
  }
304
304
  },
305
- "version": "1.0.0-next.45",
305
+ "version": "1.0.0-next.47",
306
306
  "dependencies": {
307
307
  "@aws-amplify/adapter-nextjs": "^1.7.3",
308
308
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.45",
2
+ "version": "1.0.0-next.47",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",