Files
qbit-document-generation/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/PostgresGenerationDatabase.java
T
james.maesandClaude Fable 5.2 77c95b1f62
maven / build (push) Successful in 10m35s
build / maven (push) Successful in 10m35s
fix(postgres): accept full_page_writes off on Amazon Aurora PostgreSQL only
Generation qualification refused every Amazon Aurora PostgreSQL server:
Aurora always reports full_page_writes = off and has no parameter to change
it, because its storage layer does not write torn pages.

The settings query now also returns
to_regprocedure('aurora_version()') IS NOT NULL (true on Aurora, false on
stock PostgreSQL, never an error). full_page_writes = off is accepted only
when that is true. Stock PostgreSQL must still report it on, and fsync on,
synchronous_commit on or remote_apply and every other check are unchanged
on both. The refusal text now says full-page writes are not required on
Aurora.

Tests run on a real postgres:17 engine whose settings are really changed;
only Aurora is simulated, by creating a function named aurora_version() in
the test database.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-09 20:25:27 -06:00

246 lines
11 KiB
Java

/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.documentgeneration;
import java.sql.Connection;
import java.sql.DriverManager;
import java.util.UUID;
import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
/*******************************************************************************
** Disposable synthetic PostgreSQL 17 engine on a dynamic loopback port. The
** superuser is fixture-only migration authority; native QQQ connections use a
** separate DML-only role with bounded lock_timeout. No shared volume or data.
*******************************************************************************/
final class PostgresGenerationDatabase implements AutoCloseable
{
private static final String IMAGE = "postgres:17";
private static final String OPTIONS = "?connectTimeout=5&socketTimeout=20&options=-c%20TimeZone%3DUTC";
private final String rootPassword = UUID.randomUUID().toString();
private final String appPassword = UUID.randomUUID().toString();
private final GenericContainer<?> container;
private String schema;
private String server;
private RDBMSBackendMetaData backend;
private final java.util.TimeZone previousZone;
/***************************************************************************
** Starts only this suite's loopback container with durable settings.
***************************************************************************/
PostgresGenerationDatabase()
{
this(true);
}
/***************************************************************************
** Pinned durability settings come from the server command line, which no
** ALTER SYSTEM can override. An unpinned engine starts with the same values
** (the PostgreSQL defaults) and lets a test weaken them through system().
***************************************************************************/
PostgresGenerationDatabase(boolean pinned)
{
// pgjdbc announces the JVM zone as the session TimeZone; qualification requires UTC.
previousZone = java.util.TimeZone.getDefault();
java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("UTC"));
container = new GenericContainer<>(IMAGE)
.withEnv("POSTGRES_PASSWORD", rootPassword).withEnv("POSTGRES_DB", "bootstrap")
.withCommand(pinned ? new String[] { "postgres", "-c", "fsync=on", "-c", "synchronous_commit=on", "-c", "full_page_writes=on", "-c", "max_connections=60" }
: new String[] { "postgres", "-c", "max_connections=60" })
.withExposedPorts(5432)
.withCreateContainerCmdModifier(command -> command.getHostConfig().withMemory(768L * 1024 * 1024).withNanoCPUs(2_000_000_000L)
.withPortBindings(FixtureEndpoint.publishedBinding(5432)))
.waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*\\s", 2).withStartupTimeout(java.time.Duration.ofMinutes(2)));
try
{
container.start();
refreshEndpoint();
try(var connection = DriverManager.getConnection(server + "bootstrap" + OPTIONS, "postgres", rootPassword); var statement = connection.createStatement())
{
statement.execute("CREATE ROLE generation_app LOGIN PASSWORD '" + appPassword + "' NOSUPERUSER NOCREATEDB NOCREATEROLE");
statement.execute("ALTER ROLE generation_app SET lock_timeout='5s'");
}
}
catch(Exception failure)
{
try
{
close();
}
catch(RuntimeException cleanup)
{
failure.addSuppressed(cleanup);
}
throw new IllegalStateException("PostgreSQL fixture failed to start", failure);
}
}
/***************************************************************************
** Every inherited test receives a fresh database and the DML-only identity.
***************************************************************************/
RDBMSBackendMetaData database(String name) throws Exception
{
schema = "generation_" + UUID.randomUUID().toString().replace("-", "");
try(var connection = DriverManager.getConnection(server + "bootstrap" + OPTIONS, "postgres", rootPassword); var statement = connection.createStatement())
{
statement.execute("CREATE DATABASE " + schema);
statement.execute("REVOKE ALL ON DATABASE " + schema + " FROM PUBLIC");
statement.execute("GRANT CONNECT ON DATABASE " + schema + " TO generation_app");
}
try(var connection = connection(); var statement = connection.createStatement())
{
statement.execute("GRANT USAGE ON SCHEMA public TO generation_app");
statement.execute("ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO generation_app");
statement.execute("ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO generation_app");
}
backend = new PostgreSQLBackendMetaData();
backend.setName(name);
backend.setDatabaseName(schema);
backend.setUsername("generation_app");
backend.setPassword(appPassword);
backend.setJdbcUrl(server + schema + OPTIONS);
return (backend);
}
/***************************************************************************
** Separate owned fixture connection for migrations/assertions/fault injection.
***************************************************************************/
Connection connection() throws Exception
{
return (DriverManager.getConnection(server + schema + OPTIONS, "postgres", rootPassword));
}
/***************************************************************************
** Translate only the known fixture-specific H2 statements, never application SQL.
***************************************************************************/
void sql(String source) throws Exception
{
if(source.equals("INSERT INTO document_generation_item (state) SELECT 'QUEUED' FROM SYSTEM_RANGE(1,10000)"))
{
source = "INSERT INTO document_generation_item (state) SELECT 'QUEUED' FROM generate_series(1,10000)";
}
try(var connection = connection(); var statement = connection.createStatement())
{
for(String sql : source.split(";"))
{
if(!sql.isBlank())
{
statement.execute(sql);
}
}
}
}
/***************************************************************************
** Change one reloadable server setting of an unpinned engine (ALTER SYSTEM and
** a configuration reload), then wait until a new session actually reports it.
** Fixed fixture names and values only, never application input.
***************************************************************************/
void system(String name, String value) throws Exception
{
ConnectionManager.closeConnectionProviders();
try(var connection = DriverManager.getConnection(server + "bootstrap" + OPTIONS, "postgres", rootPassword); var statement = connection.createStatement())
{
statement.execute("ALTER SYSTEM SET " + name + " = '" + value + "'");
statement.execute("SELECT pg_reload_conf()");
}
long deadline = System.nanoTime() + java.time.Duration.ofSeconds(30).toNanos();
while(true)
{
try(var connection = DriverManager.getConnection(server + "bootstrap" + OPTIONS, "postgres", rootPassword); var statement = connection.createStatement();
var row = statement.executeQuery("SELECT current_setting('" + name + "')"))
{
if(row.next() && value.equals(row.getString(1)))
{
return;
}
}
if(System.nanoTime() >= deadline)
{
throw new IllegalStateException("PostgreSQL fixture did not apply " + name);
}
Thread.sleep(100);
}
}
/***************************************************************************
** Cold server restart retains only this container's data layer (committed
** data must survive; Docker may publish a different loopback port).
***************************************************************************/
void restart() throws Exception
{
ConnectionManager.closeConnectionProviders();
container.getDockerClient().stopContainerCmd(container.getContainerId()).withTimeout(10).exec();
container.getDockerClient().startContainerCmd(container.getContainerId()).exec();
refreshEndpoint();
backend.setJdbcUrl(server + schema + OPTIONS);
long deadline = System.nanoTime() + java.time.Duration.ofSeconds(60).toNanos();
while(true)
{
try(var connection = connection())
{
if(connection.isValid(2))
{
return;
}
}
catch(java.sql.SQLException unavailable)
{
if(System.nanoTime() >= deadline)
{
throw unavailable;
}
}
Thread.sleep(200);
}
}
/***************************************************************************
** Native inspect avoids Testcontainers' stale mapped-port cache after restart.
***************************************************************************/
private void refreshEndpoint()
{
var address = FixtureEndpoint.of(container, 5432);
server = "jdbc:postgresql://" + address.host() + ":" + address.port() + "/";
}
/***************************************************************************
** Removes only this exact disposable test container and its anonymous volumes.
***************************************************************************/
@Override
public void close()
{
try
{
container.close();
}
finally
{
java.util.TimeZone.setDefault(previousZone);
}
}
}