Skip to content

Commit e91221f

Browse files
committed
Fix trigger failure handling and connection cleanup; upgrade Aurora to 18.6
1 parent 9742487 commit e91221f

22 files changed

Lines changed: 1422 additions & 354 deletions

‎README.md‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Why even use this feature? If you have many code bases that are writing data to
99
Things to consider:
1010
- If you fire off lambdas for row updates that themselves update the DB then you can quickly exaust connection and lambda resources. If you update 1000 rows then you could have hundreds of lambdas fire and connect to the DB bringing everything to a grinding halt. Therefore you should set [concurrency limits](https://aws.amazon.com/blogs/compute/managing-aws-lambda-function-concurrency/) on these lambda functions in most cases.
1111
- When you exceed the limit, you get a throttle on the lambda, and these will delay processing. In some cases this is fine. In the Geo coding use case we don't need the Geo coding to complete immediately, so it's OK. In testing 1K+ row updates for Geo coding some row updates took over 1 minute to complete because of the retry back off.
12-
- If you need smoother execution of the events, then consider using a simple NodeJS Lambda (that won't be throttled) that takes the event payload and puts it on a SQS queue that another Lambda will process from the queue. See a NodeJS example of this in the [ForwardToSQS.js](ForwardToSQS.js) file.
12+
- If you need smoother execution of the events, then consider using a simple NodeJS Lambda (which remains subject to Lambda concurrency limits) that takes the event payload and puts it on a SQS queue that another Lambda will process from the queue. See a NodeJS example of this in the [ForwardToSQS.js](ForwardToSQS.js) file.
1313

1414
Three use cases are covered in this demo:
1515
- A Lambda([PostgresAddressTrigger.java](src/main/java/demo/PostgresAddressTrigger.java)) that will update the same row that the trigger is firing on. In this case, we have an address table, and when addresses are inserted or updated, they will be geo-coded by the [AWS Location API](https://docs.aws.amazon.com/location/latest/APIReference/API_SearchPlaceIndexForText.html). Special care is needed in this case to prevent recursive triggering of the function.
@@ -50,6 +50,74 @@ successful action redirects (`307`, `Location: /`) remain the same.
5050

5151
Run the offline routing and action-response tests with `mvn clean test` before `sam build`.
5252

53+
## Runtime behavior and failure handling
54+
55+
| Component | Success | Failure and retry behavior |
56+
| --- | --- | --- |
57+
| UI (`WebHandler`) | Delegates directly to the view/action; actions redirect to `/` | Acquisition and action/query errors return HTTP 500. Each request closes its JDBC connection. |
58+
| Direct database triggers | Validate the top-level event, perform the side effect, then acknowledge | Validation and persistence errors fail the invocation so the configured Lambda asynchronous retry policy applies. |
59+
| SQS audit consumer | Processes every record; successful records are acknowledged | Returns failed message IDs using `ReportBatchItemFailures`. After five receives, unsuccessful messages move to `AuditDeadLetterQueue`, which retains them for 14 days. |
60+
| Address geocoding | Looks up inserts and changes to street, city, district, or postal code | A zero-row update retries at most ten times, with 100–900 ms delays. Exhaustion, interruption, service errors, and SQL errors fail the invocation. |
61+
| Database setup | Loads all packaged SQL assets, executes them in order, then inserts the sample address | Stops at the first failure and returns CloudFormation `FAILED`; previously applied scripts may remain. UPDATE and DELETE do not rerun initialization. |
62+
63+
The SQS source queue's visibility timeout is 180 seconds, six times the consumer's
64+
30-second timeout, following [AWS's SQS configuration guidance](https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-configure.html).
65+
The consumer and its `FunctionResponseTypes: [ReportBatchItemFailures]` configuration must
66+
be deployed together for [partial batch retries](https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-errorhandling.html).
67+
The batch size remains one, but the implementation also handles larger standard-queue batches.
68+
Inspect the dead-letter queue and correct the cause before intentionally replaying its messages.
69+
70+
`PostgresDataSource.getDSL()` returns a `CloseableDSLContext` that owns its connection.
71+
Always use `try (var dsl = PostgresDataSource.getDSL()) { ... }`; passing an existing JDBC
72+
connection to `DSL.using(connection)` does not transfer connection ownership. No live JDBC
73+
connection is retained at initialization or across warm invocations. This is connection
74+
cleanup, not pooling: a connection pool or RDS Proxy would need separate load testing.
75+
76+
The geocoding SQL trigger still requires `requires_geo_coding = true`. Callers editing an
77+
already-geocoded address must set this flag if they want another lookup. A lookup with no
78+
match leaves the row unchanged. Each Location call has a five-second API timeout and each
79+
geocoding UPDATE a two-second query timeout; retry limits do not guarantee a fixed end-to-end
80+
latency because connection establishment and service execution also take time.
81+
82+
### Delivery and deployment limits
83+
84+
- Database-trigger invocations are asynchronous external side effects. They may run before
85+
the source transaction commits, and rolling back that transaction does not retract them.
86+
This demo does not implement a transactional outbox.
87+
- Delivery is at-least-once. Audit tables do not deduplicate; a successful write followed by
88+
a lost acknowledgement can be repeated. Standard SQS and asynchronous Lambda delivery also
89+
do not guarantee ordering, so concurrent address edits can race with geocoding results.
90+
- Direct-trigger retries remain finite (`MaximumRetryAttempts: 2`, event age one hour),
91+
and those functions have no failure destination configured. The new dead-letter queue
92+
covers the SQS consumer only.
93+
- Setup is not one atomic migration, and repeated CREATE processing can insert another sample
94+
row. Changing packaged SQL does not apply it to an existing database: the custom resource's
95+
UPDATE callback deliberately performs no SQL migrations. The trigger argument guard in
96+
`LambdaTriggerFuction.sql` therefore requires an explicit migration on an existing database.
97+
- This is an unauthenticated demo with GET routes that mutate data and a publicly reachable
98+
database security-group rule. Restrict access and add authentication/CSRF protection before
99+
using it with sensitive data. The UI currently renders all public tables without pagination.
100+
101+
### Local validation
102+
103+
Run `mvn clean test` for the offline tests, then `sam validate --lint` and `sam build --no-cached`.
104+
The JDBC fixture exercises statement execution and connection closure without a live database;
105+
service fakes exercise validation, retries, and failure responses. These checks do not prove a
106+
live Aurora migration, IAM permissions, AWS retry delivery, or geocoding latency.
107+
108+
For strict documentation validation, include production, private, and test declarations:
109+
110+
```bash
111+
mvn -q dependency:build-classpath -Dmdep.includeScope=test -Dmdep.outputFile=target/javadoc-classpath.txt
112+
javadoc -quiet -private -Xdoclint:all -Werror \
113+
-classpath "$(cat target/javadoc-classpath.txt)" -d target/javadoc \
114+
src/main/java/demo/*.java src/test/java/demo/*.java
115+
```
116+
117+
Before deploying merged PRs, switch to current `main` and rebuild; `sam deploy` can otherwise
118+
use stale `.aws-sam/build` artifacts. Verify `EngineVersion` in both `postgres.yaml` and
119+
`.aws-sam/build/RDSStack/template.yaml`, plus the handlers and runtimes in the built template.
120+
53121
## Contents
54122
This project contains source code and supporting files for a serverless application that you can deploy with the SAM CLI. It includes the following files and folders.
55123

‎postgres.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ Resources:
6868
Properties:
6969
Engine: aurora-postgresql
7070
Port: 5432
71-
EngineVersion: '17.10'
71+
EngineVersion: '18.6'
7272
DatabaseName: !Ref DBNAMEPARAM
7373
ManageMasterUserPassword: true
7474
MasterUsername: !Ref DBUSERPARAM

‎src/main/java/demo/CloudFormationCustomResource.java‎

Lines changed: 75 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
import com.amazonaws.services.lambda.runtime.Context;
44
import com.amazonaws.services.lambda.runtime.events.CloudFormationCustomResourceEvent;
55
import java.io.IOException;
6-
import java.nio.file.Files;
7-
import java.nio.file.Path;
8-
import java.util.LinkedList;
6+
import java.io.UncheckedIOException;
7+
import java.nio.charset.StandardCharsets;
8+
import java.util.List;
9+
import java.util.Objects;
10+
import java.util.function.Function;
11+
import java.util.function.Supplier;
12+
import org.jooq.CloseableDSLContext;
913
import org.apache.logging.log4j.LogManager;
1014
import org.apache.logging.log4j.Logger;
11-
import org.jooq.exception.DataAccessException;
1215
import org.jooq.impl.DSL;
1316
import software.amazon.lambda.powertools.cloudformation.AbstractCustomResourceHandler;
1417
import software.amazon.lambda.powertools.cloudformation.Response;
@@ -19,8 +22,8 @@
1922
* <p>The Powertools superclass dispatches lifecycle events and delivers responses to
2023
* CloudFormation. CREATE executes the packaged SQL scripts in dependency order and inserts
2124
* one sample address. UPDATE and DELETE acknowledge the event without modifying the database.
22-
* Caught setup errors are only logged, so the returned CREATE response is not proof that
23-
* every script or the sample insert succeeded.
25+
* CREATE stops at the first failure and returns a FAILED response. Scripts are loaded from
26+
* the packaged classpath, so initialization also works when resources are inside a JAR.
2427
*
2528
* @author sjensen
2629
*/
@@ -30,6 +33,28 @@ public class CloudFormationCustomResource extends AbstractCustomResourceHandler
3033
* Creates the database-initialization lifecycle handler and its Powertools response support.
3134
*/
3235
public CloudFormationCustomResource() {
36+
this(PostgresDataSource::getDSL, CloudFormationCustomResource::readScript);
37+
}
38+
39+
/** Connection factory invoked only during CREATE. */
40+
private final Supplier<CloseableDSLContext> database;
41+
/** Packaged SQL loader, replaceable within the package for offline tests. */
42+
private final Function<String, String> scripts;
43+
/** SQL assets in dependency order; all are read before any database changes. */
44+
private static final List<String> SQL_FILES = List.of("enableLambdaExtension.sql",
45+
"LambdaTriggerFuction.sql", "createAddressTable.sql", "createAuditLogTable.sql",
46+
"createAddressTrigger.sql");
47+
48+
/**
49+
* Supplies initialization boundaries for offline lifecycle and SQL failure tests.
50+
*
51+
* @param database factory producing a context owned by the CREATE callback
52+
* @param scripts SQL reader keyed by packaged filename
53+
* @throws NullPointerException if either dependency is null
54+
*/
55+
CloudFormationCustomResource(Supplier<CloseableDSLContext> database, Function<String, String> scripts) {
56+
this.database = Objects.requireNonNull(database);
57+
this.scripts = Objects.requireNonNull(scripts);
3358
}
3459

3560
/** Logger for CloudFormation lifecycle events and SQL initialization failures. */
@@ -41,56 +66,59 @@ public CloudFormationCustomResource() {
4166
/**
4267
* Executes the extension, trigger-function, table, and trigger scripts, then seeds an address.
4368
*
44-
* <p>Scripts are read from {@code LAMBDA_TASK_ROOT/scripts}. Each script's I/O or SQL
45-
* failure is logged and execution continues to the next script. An outer database exception
46-
* is also logged before returning the normal response. The scripts and seed insert are
47-
* not enclosed in a single transaction, and repeated CREATE processing can insert another
48-
* sample address. Uncaught runtime failures are left to the Powertools superclass.
69+
* <p>All resources are loaded before database work. Execution stops on the first failed
70+
* statement, and the seed insert runs only after all scripts succeed. The connection is
71+
* always closed. DDL and the seed are not one transaction: previously completed scripts
72+
* may remain after failure, and repeated CREATE callbacks can duplicate the sample row.
4973
*
50-
* @param cfcre CloudFormation CREATE event; supplied to debug logging but not used for SQL inputs
51-
* @param cntxt Lambda invocation metadata; unused by this callback
52-
* @return a response with the stable physical resource ID and the reason
53-
* {@code SQL files applied to database}, even after the caught failures described above
74+
* @param cfcre CloudFormation CREATE event; not used as a SQL input
75+
* @param cntxt Lambda metadata; unused
76+
* @return SUCCESS after all statements finish, or FAILED with a stable physical ID;
77+
* detailed errors are logged without exposing them in the response reason
5478
*/
5579
@Override
5680
protected Response create(CloudFormationCustomResourceEvent cfcre, Context cntxt) {
5781
try {
58-
log.debug("Received CREATE Event from Cloudformation", cfcre);
59-
final var dsl = PostgresDataSource.getDSL();
60-
final var task_root = System.getenv("LAMBDA_TASK_ROOT");
61-
62-
final var sqlFiles = new LinkedList<String>();
63-
// Process the list in order
64-
sqlFiles.add("enableLambdaExtension.sql");
65-
sqlFiles.add("LambdaTriggerFuction.sql");
66-
sqlFiles.add("createAddressTable.sql");
67-
sqlFiles.add("createAuditLogTable.sql");
68-
sqlFiles.add("createAddressTrigger.sql");
69-
70-
for (var file : sqlFiles) {
71-
try {
72-
dsl.execute(Files.readString(Path.of(task_root, "scripts", file)));
73-
} catch (IOException | DataAccessException e) {
74-
log.error("Error processing SQL file " + file, e);
82+
// A missing asset must be discovered before applying even the first script.
83+
var statements = SQL_FILES.stream().map(scripts).toList();
84+
try (var dsl = database.get()) {
85+
for (var statement : statements) {
86+
dsl.execute(statement);
7587
}
88+
dsl.insertInto(DSL.table(DSL.name("address")))
89+
.set(DSL.field("address_1"), "200 N Main St")
90+
.set(DSL.field("city"), "Wahkon")
91+
.set(DSL.field("district"), "MN")
92+
.set(DSL.field("address_notes"), "Mugg's of Mille Lacs")
93+
.execute();
7694
}
95+
return Response.builder().physicalResourceId(RESOURCE_ID)
96+
.status(Response.Status.SUCCESS).reason("SQL files applied to database").build();
97+
} catch (RuntimeException failure) {
98+
log.error("Database initialization failed", failure);
99+
// Return via Powertools so CloudFormation receives a failure callback, not a timeout.
100+
return Response.builder().physicalResourceId(RESOURCE_ID)
101+
.status(Response.Status.FAILED).reason("Database initialization failed; see Lambda logs").build();
102+
}
103+
}
77104

78-
// Just Insert One address to kick things off
79-
dsl.insertInto(DSL.table(DSL.name("address")))
80-
.set(DSL.field("address_1"), "200 N Main St")
81-
.set(DSL.field("city"), "Wahkon")
82-
.set(DSL.field("district"), "MN")
83-
.set(DSL.field("address_notes"), "Mugg's of Mille Lacs")
84-
.execute();
85-
86-
87-
} catch (DataAccessException e) {
88-
log.error("Could Not Process SQL Files", e);
105+
/**
106+
* Reads a SQL resource from the same classpath used by Lambda and local Java execution.
107+
*
108+
* @param filename packaged SQL filename under {@code /scripts/}
109+
* @return UTF-8 SQL text
110+
* @throws IllegalStateException if the resource is absent
111+
* @throws UncheckedIOException if the resource cannot be read or closed
112+
*/
113+
static String readScript(String filename) {
114+
try (var stream = CloudFormationCustomResource.class.getResourceAsStream("/scripts/" + filename)) {
115+
if (stream == null) {
116+
throw new IllegalStateException("Missing packaged SQL script: " + filename);
117+
}
118+
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
119+
} catch (IOException failure) {
120+
throw new UncheckedIOException("Cannot read packaged SQL script: " + filename, failure);
89121
}
90-
return Response.builder()
91-
.reason("SQL files applied to database")
92-
.physicalResourceId(RESOURCE_ID)
93-
.build();
94122
}
95123

96124
/**

‎src/main/java/demo/CreateAddressFrontEnd.java‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,14 @@ public CreateAddressFrontEnd() {
2727
@Override
2828
protected void performAction() {
2929
// Just Insert One address and return
30-
var dsl = PostgresDataSource.getDSL();
31-
dsl.insertInto(ADDRESS_TABLE)
32-
.set(DSL.field("address_1"), "1 Apple Park Way")
33-
.set(DSL.field("city"), "Cupertino")
34-
.set(DSL.field("district"), "CA")
35-
.set(DSL.field("address_notes"), "Apple HQ")
36-
.execute();
30+
try (var dsl = PostgresDataSource.getDSL()) {
31+
dsl.insertInto(ADDRESS_TABLE)
32+
.set(DSL.field("address_1"), "1 Apple Park Way")
33+
.set(DSL.field("city"), "Cupertino")
34+
.set(DSL.field("district"), "CA")
35+
.set(DSL.field("address_notes"), "Apple HQ")
36+
.execute();
37+
}
3738
}
3839

3940
}

0 commit comments

Comments
 (0)