@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.
- package/amplify/functions/content-actions/handler.py +62 -8
- package/amplify/publication/s3-projection.js +20 -2
- package/amplify/publication/s3-projection.ts +22 -2
- package/amplify/site-backend.js +4 -1
- package/amplify/site-backend.ts +4 -1
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/amplify/site-backend.js
CHANGED
|
@@ -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);
|
package/amplify/site-backend.ts
CHANGED
|
@@ -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
package/routes.manifest.json
CHANGED