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>
230 lines
11 KiB
Java
230 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.SQLException;
|
|
import java.util.HashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Objects;
|
|
import java.util.Set;
|
|
import com.kingsrook.qqq.backend.core.context.QContext;
|
|
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
|
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
|
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
|
|
|
|
|
/*******************************************************************************
|
|
** Qualification of the native QQQ PostgreSQL17 backend (vendor and backend type
|
|
** "postgres") and this QBit's owned tables. Observes the live connection only;
|
|
** it never changes session or server settings, schema or data. Amazon Aurora
|
|
** PostgreSQL is the one accepted variant: see durable().
|
|
*******************************************************************************/
|
|
final class GenerationPostgres
|
|
{
|
|
static final String VENDOR = "postgres";
|
|
|
|
private static final String SETTINGS = "SELECT current_setting('server_version_num')::int, current_setting('transaction_read_only'), pg_is_in_recovery(),"
|
|
+ " current_setting('synchronous_commit'), current_setting('fsync'), current_setting('full_page_writes'), current_setting('TimeZone'),"
|
|
+ " (SELECT setting::bigint FROM pg_settings WHERE name='lock_timeout'), current_setting('transaction_isolation'), current_schema(), current_database(),"
|
|
+ " to_regprocedure('aurora_version()') IS NOT NULL";
|
|
|
|
private static final String REQUIREMENTS = "Generation requires synchronous PostgreSQL commits, full-page writes (except on Amazon Aurora PostgreSQL),"
|
|
+ " a primary writable server, READ COMMITTED and UTC sessions";
|
|
|
|
private static final String TABLE = "SELECT c.relpersistence FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace"
|
|
+ " WHERE n.nspname=current_schema() AND c.relname=? AND c.relkind='r'";
|
|
|
|
private static final String COLLATIONS = "SELECT COUNT(*) FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid JOIN pg_namespace n ON n.oid=c.relnamespace"
|
|
+ " JOIN pg_collation co ON co.oid=a.attcollation WHERE n.nspname=current_schema() AND c.relname=? AND a.attnum>0 AND NOT a.attisdropped AND NOT co.collisdeterministic";
|
|
|
|
private static final String UNIQUES = "SELECT i.relname, a.attname, ix.indisprimary FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid"
|
|
+ " JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace n ON n.oid=t.relnamespace"
|
|
+ " CROSS JOIN LATERAL unnest(ix.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord)"
|
|
+ " JOIN pg_attribute a ON a.attrelid=t.oid AND a.attnum=k.attnum"
|
|
+ " WHERE n.nspname=current_schema() AND t.relname=? AND ix.indisunique AND ix.indisvalid AND ix.indpred IS NULL AND ix.indexprs IS NULL AND k.ord<=ix.indnkeyatts"
|
|
+ " ORDER BY i.relname, k.ord";
|
|
|
|
/***************************************************************************
|
|
** Static policy, not a configurable SQL or connection provider.
|
|
***************************************************************************/
|
|
private GenerationPostgres() {}
|
|
|
|
/***************************************************************************
|
|
** The native postgres module identity, not merely a vendor string.
|
|
***************************************************************************/
|
|
static boolean is(RDBMSBackendMetaData backend)
|
|
{
|
|
return (VENDOR.equals(backend.getVendor()) && VENDOR.equals(backend.getBackendType()));
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Backend metadata must name one concrete, credential-free PostgreSQL target.
|
|
***************************************************************************/
|
|
static boolean configured(RDBMSBackendMetaData backend)
|
|
{
|
|
try
|
|
{
|
|
String url = ConnectionManager.getJdbcUrl(backend);
|
|
return (is(backend) && url != null && url.startsWith("jdbc:postgresql://") && backend.getDatabaseName() != null && !backend.getDatabaseName().isBlank());
|
|
}
|
|
catch(RuntimeException unsupported)
|
|
{
|
|
return (false);
|
|
}
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Commit durability of the observed server. Every server needs
|
|
** synchronous_commit on or remote_apply and fsync on. Stock PostgreSQL also
|
|
** needs full_page_writes on, which protects crash recovery from torn pages.
|
|
** Amazon Aurora PostgreSQL runs with full_page_writes off and offers no
|
|
** parameter to change it, because its storage layer does not write torn
|
|
** pages; so off is accepted there, and only there. Aurora is detected in the
|
|
** settings query by to_regprocedure('aurora_version()') IS NOT NULL, which is
|
|
** true on Aurora, false on stock PostgreSQL and never raises an error (calling
|
|
** a missing function would abort the transaction).
|
|
***************************************************************************/
|
|
static boolean durable(String synchronousCommit, String fsync, String fullPageWrites, boolean aurora)
|
|
{
|
|
return (("on".equals(synchronousCommit) || "remote_apply".equals(synchronousCommit)) && "on".equals(fsync)
|
|
&& ("on".equals(fullPageWrites) || aurora && "off".equals(fullPageWrites)));
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Exact server, durability, session and owned-table checks for PostgreSQL 17,
|
|
** with the single Amazon Aurora PostgreSQL allowance stated on durable().
|
|
***************************************************************************/
|
|
static void qualify(Connection connection, RDBMSBackendMetaData backend) throws SQLException, QException
|
|
{
|
|
if(!"PostgreSQL".equals(connection.getMetaData().getDatabaseProductName()) || connection.getMetaData().getDatabaseMajorVersion() != 17
|
|
|| !Objects.equals(backend.getDatabaseName(), connection.getCatalog())
|
|
|| !ConnectionManager.getJdbcUrl(backend).split("\\?")[0].equals(connection.getMetaData().getURL().split("\\?")[0]))
|
|
{
|
|
throw new QException("Generation requires the configured native PostgreSQL17 database.");
|
|
}
|
|
try(var query = GenerationJdbc.prepare(connection, SETTINGS); var row = query.executeQuery())
|
|
{
|
|
if(!row.next())
|
|
{
|
|
throw new QException(REQUIREMENTS + ".");
|
|
}
|
|
List<String> weak = new java.util.ArrayList<>();
|
|
if(row.getInt(1) / 10000 != 17 || row.getString(10) == null || !Objects.equals(backend.getDatabaseName(), row.getString(11)))
|
|
{
|
|
weak.add("database");
|
|
}
|
|
if(!"off".equals(row.getString(2)) || row.getBoolean(3))
|
|
{
|
|
weak.add("writable primary");
|
|
}
|
|
if(!durable(row.getString(4), row.getString(5), row.getString(6), row.getBoolean(12)))
|
|
{
|
|
weak.add("synchronous commit");
|
|
}
|
|
if(!Set.of("UTC", "Etc/UTC").contains(row.getString(7)))
|
|
{
|
|
weak.add("UTC session");
|
|
}
|
|
if(!"read committed".equals(row.getString(9)))
|
|
{
|
|
weak.add("READ COMMITTED");
|
|
}
|
|
if(!weak.isEmpty())
|
|
{
|
|
throw new QException(REQUIREMENTS + "; not satisfied: " + String.join(", ", weak));
|
|
}
|
|
long lockTimeout = row.getLong(8);
|
|
if(lockTimeout < 1 || lockTimeout > 5000)
|
|
{
|
|
throw new QException("Generation requires a bounded PostgreSQL lock_timeout of one to five seconds.");
|
|
}
|
|
}
|
|
qualifyTables(connection, backend);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Permanent tables only (unlogged tables are truncated by crash recovery),
|
|
** deterministic comparison and complete native idempotency constraints.
|
|
***************************************************************************/
|
|
private static void qualifyTables(Connection connection, RDBMSBackendMetaData backend) throws SQLException, QException
|
|
{
|
|
GenerationJdbc.validateMappings(backend);
|
|
var config = DocumentGenerationQBitConfig.from(QContext.getQInstance());
|
|
for(var table : GenerationTables.tables(config))
|
|
{
|
|
if(!Objects.equals(backend.getName(), QContext.getQInstance().getTable(table.getName()).getBackendName()))
|
|
{
|
|
throw new QException("Generation owned tables require one qualified PostgreSQL backend.");
|
|
}
|
|
try(var query = GenerationJdbc.prepare(connection, TABLE))
|
|
{
|
|
query.setString(1, table.getName());
|
|
try(var row = query.executeQuery())
|
|
{
|
|
if(!row.next() || !"p".equals(row.getString(1)) || row.next())
|
|
{
|
|
throw new QException("Generation tables require permanent (logged) PostgreSQL tables.");
|
|
}
|
|
}
|
|
}
|
|
try(var query = GenerationJdbc.prepare(connection, COLLATIONS))
|
|
{
|
|
query.setString(1, table.getName());
|
|
try(var row = query.executeQuery())
|
|
{
|
|
if(!row.next() || row.getInt(1) != 0)
|
|
{
|
|
throw new QException("Generation columns require exact deterministic comparison.");
|
|
}
|
|
}
|
|
}
|
|
Map<String, String> indexes = new HashMap<>();
|
|
try(var query = GenerationJdbc.prepare(connection, UNIQUES))
|
|
{
|
|
query.setString(1, table.getName());
|
|
try(var row = query.executeQuery())
|
|
{
|
|
while(row.next())
|
|
{
|
|
indexes.merge(row.getBoolean(3) ? "PRIMARY" : row.getString(1), row.getString(2), (left, right) -> left + "," + right);
|
|
}
|
|
}
|
|
}
|
|
if(!"id".equals(indexes.get("PRIMARY")))
|
|
{
|
|
throw new QException("Generation requires native immutable primary keys.");
|
|
}
|
|
for(UniqueKey key : table.getUniqueKeys() == null ? List.<UniqueKey>of() : table.getUniqueKeys())
|
|
{
|
|
String expected = String.join(",", key.getFieldNames().stream().map(name -> table.getField(name).getBackendName()).toList());
|
|
if(!indexes.containsValue(expected))
|
|
{
|
|
throw new QException("Generation requires complete database idempotency constraints.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|