/* * 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.util.List; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; /*************************************************************************** ** Run the unchanged native authorization/transaction/concurrency suite on a ** real PostgreSQL 17 engine, plus PostgreSQL-specific durability and drift cases. ***************************************************************************/ class PostgresGenerationQueueTest extends GenerationQueueJdbcTest { private static PostgresGenerationDatabase postgres; // The engine the fixture calls of the running test address: the shared pinned one unless that test started its own. private PostgresGenerationDatabase current; /*********************************************************************** ** One exact disposable engine per suite; all test cases use new databases. ***********************************************************************/ @BeforeAll static void startPostgres() { postgres = new PostgresGenerationDatabase(); } /*********************************************************************** ** Remove only the fixture-owned engine after every inherited assertion. ***********************************************************************/ @AfterAll static void stopPostgres() { if(postgres != null) { postgres.close(); } } /*********************************************************************** ** Native actions receive only the DML identity, never migration authority. ***********************************************************************/ @Override protected RDBMSBackendMetaData database(String name, String defaultUrl) throws Exception { return (engine().database(name)); } /*********************************************************************** ** Assertion and fault-injection connections remain separate from QQQ. ***********************************************************************/ @Override protected Connection connection() throws Exception { return (engine().connection()); } /*********************************************************************** ** Execute fixed fixture DDL, with no compatibility/emulation database mode. ***********************************************************************/ @Override protected void sql(String source) throws Exception { engine().sql(source); } /*********************************************************************** ** The shared pinned engine, or the unpinned one a single test started. ***********************************************************************/ private PostgresGenerationDatabase engine() { return (current == null ? postgres : current); } /*********************************************************************** ** The shipped migration scripts for this engine. ***********************************************************************/ @Override protected String dialect() { return ("postgres"); } /*********************************************************************** ** Exercise the shipped native schema without the H2 timestamp rewrite. ***********************************************************************/ @Override protected String schema(DocumentGenerationQBitConfig configuration) { return (DocumentGenerationSchema.postgresScript(configuration)); } /*************************************************************************** ** Cold server restart preserves committed results and untouched work. ***************************************************************************/ @Test void serverRestartPreservesQueueAndIndependentAudit() throws Exception { int id = enqueue("restart"); var first = queue.advance(id); assertThat(first.state()).isEqualTo("GENERATED"); postgres.restart(); assertThat(queue.status(id, -1, 10).items().getFirst()).isEqualTo(first); assertThat(queue.advance(id).state()).isEqualTo("GENERATED"); assertThat(queue.advance(id)).isNull(); try(var connection = connection(); var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT COUNT(*) FROM document_generation_event WHERE outcome='GENERATION_RETAINED'")) { rows.next(); assertThat(rows.getInt(1)).isEqualTo(2); } } /*************************************************************************** ** Exact text comparison keeps case and trailing-space keys distinct, and a retry is idempotent. ***************************************************************************/ @Test void caseAndTrailingSpaceRemainDistinctIdempotencyKeys() throws Exception { int upper = enqueue("Case"); int lower = enqueue("case"); int spaced = enqueue("Case "); assertThat(List.of(upper, lower, spaced)).doesNotHaveDuplicates(); assertThat(enqueue("Case")).isEqualTo(upper); } /*************************************************************************** ** Unlogged tables, nondeterministic collations and missing idempotency keys fail closed before any write. ***************************************************************************/ @Test void realSchemaDriftFailsClosed() throws Exception { sql("ALTER TABLE document_generation_stock SET UNLOGGED"); assertThatThrownBy(() -> enqueue("unlogged")).isInstanceOf(QException.class).hasMessageContaining("logged"); assertThatThrownBy(() -> new DocumentDefinitionService(config).createType("must not persist")).isInstanceOf(QException.class).hasMessageContaining("logged"); sql("ALTER TABLE document_generation_stock SET LOGGED"); sql("CREATE COLLATION fixture_ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false)"); sql("ALTER TABLE document_generation_queue ALTER COLUMN request_key TYPE VARCHAR(130) COLLATE fixture_ci"); assertThatThrownBy(() -> enqueue("collation")).isInstanceOf(QException.class).hasMessageContaining("deterministic"); sql("ALTER TABLE document_generation_queue ALTER COLUMN request_key TYPE VARCHAR(130) COLLATE \"default\""); sql("DROP INDEX uq_document_generation_queue_1"); assertThatThrownBy(() -> enqueue("index")).isInstanceOf(QException.class).hasMessageContaining("idempotency"); } /*************************************************************************** ** Role-level settings are observed from the actual server, never assumed from fixture setup. ***************************************************************************/ @Test void weakCommitAndUnboundedLockSettingsAreRejectedBeforeWriting() throws Exception { try { sql("ALTER ROLE generation_app SET synchronous_commit='off'"); ConnectionManager.closeConnectionProviders(); assertThatThrownBy(() -> enqueue("weak-commit")).isInstanceOf(QException.class).hasMessageContaining("synchronous PostgreSQL"); sql("ALTER ROLE generation_app SET synchronous_commit='on'"); sql("ALTER ROLE generation_app RESET lock_timeout"); ConnectionManager.closeConnectionProviders(); assertThatThrownBy(() -> enqueue("unbounded-lock")).isInstanceOf(QException.class).hasMessageContaining("lock_timeout"); sql("ALTER ROLE generation_app SET lock_timeout='60s'"); ConnectionManager.closeConnectionProviders(); assertThatThrownBy(() -> enqueue("long-lock")).isInstanceOf(QException.class).hasMessageContaining("lock_timeout"); } finally { sql("ALTER ROLE generation_app SET synchronous_commit='on'"); sql("ALTER ROLE generation_app SET lock_timeout='5s'"); ConnectionManager.closeConnectionProviders(); } assertThat(queue.status(enqueue("restored"), -1, 10).items()).hasSize(2); } /*************************************************************************** ** Amazon Aurora PostgreSQL always runs with full_page_writes off (its storage ** layer does not write torn pages), so that one setting is accepted there and ** nowhere else. The engine is a real PostgreSQL 17 whose settings are really ** changed. Only Aurora itself is simulated: a function named aurora_version() ** is created in the test database, so the same to_regprocedure detection that ** is true on Aurora resolves here. ***************************************************************************/ @Test void fullPageWritesOffIsAcceptedOnlyOnAuroraAndNothingElseIsRelaxed() throws Exception { current = new PostgresGenerationDatabase(false); try { // Rebuild the instance, schema and definitions on the unpinned engine (durable defaults, so this qualifies). cleanup(); setup(); assertThat(queue.advance(enqueue("stock-durable")).state()).isEqualTo("GENERATED"); // Stock PostgreSQL with full_page_writes off is refused, and the refusal states the rule. current.system("full_page_writes", "off"); assertThatThrownBy(() -> enqueue("stock-torn-pages")).isInstanceOf(QException.class) .hasMessageContaining("full-page writes (except on Amazon Aurora PostgreSQL)").hasMessageContaining("not satisfied: synchronous commit"); // The same server, now resolving aurora_version(), is accepted and work really commits. sql("CREATE FUNCTION aurora_version() RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT '17.9'::text $$"); ConnectionManager.closeConnectionProviders(); assertThat(queue.advance(enqueue("aurora")).state()).isEqualTo("GENERATED"); // Aurora relaxes nothing else: fsync off is refused. current.system("fsync", "off"); assertThatThrownBy(() -> enqueue("aurora-no-fsync")).isInstanceOf(QException.class).hasMessageContaining("not satisfied: synchronous commit"); current.system("fsync", "on"); // Aurora relaxes nothing else: asynchronous commit is refused. sql("ALTER ROLE generation_app SET synchronous_commit='off'"); ConnectionManager.closeConnectionProviders(); assertThatThrownBy(() -> enqueue("aurora-async-commit")).isInstanceOf(QException.class).hasMessageContaining("not satisfied: synchronous commit"); sql("ALTER ROLE generation_app SET synchronous_commit='on'"); ConnectionManager.closeConnectionProviders(); assertThat(enqueue("aurora-restored")).isNotNull(); // Without the function the server is stock PostgreSQL again and is refused again. sql("DROP FUNCTION aurora_version()"); ConnectionManager.closeConnectionProviders(); assertThatThrownBy(() -> enqueue("stock-again")).isInstanceOf(QException.class).hasMessageContaining("not satisfied: synchronous commit"); } finally { ConnectionManager.closeConnectionProviders(); current.close(); current = null; } } /*************************************************************************** ** The durability decision itself, for the combinations the live case does ** not reach: no server and nothing simulated, only the values of one row. ***************************************************************************/ @Test void durabilityRuleAcceptsOnlyTheAuroraFullPageWritesException() { assertThat(GenerationPostgres.durable("on", "on", "on", false)).isTrue(); assertThat(GenerationPostgres.durable("remote_apply", "on", "on", false)).isTrue(); assertThat(GenerationPostgres.durable("on", "on", "on", true)).isTrue(); assertThat(GenerationPostgres.durable("on", "on", "off", true)).isTrue(); assertThat(GenerationPostgres.durable("remote_apply", "on", "off", true)).isTrue(); assertThat(GenerationPostgres.durable("on", "on", "off", false)).isFalse(); assertThat(GenerationPostgres.durable("on", "off", "on", false)).isFalse(); assertThat(GenerationPostgres.durable("on", "off", "off", true)).isFalse(); for(String weak : List.of("off", "local", "remote_write")) { assertThat(GenerationPostgres.durable(weak, "on", "on", false)).isFalse(); assertThat(GenerationPostgres.durable(weak, "on", "off", true)).isFalse(); } assertThat(GenerationPostgres.durable(null, "on", "on", true)).isFalse(); assertThat(GenerationPostgres.durable("on", null, "on", true)).isFalse(); assertThat(GenerationPostgres.durable("on", "on", null, true)).isFalse(); } /*************************************************************************** ** The native identity must be the postgres module on a real PostgreSQL17 server, not a vendor label. ***************************************************************************/ @Test void vendorLabelWithoutPostgresModuleIsRejected() throws Exception { jdbcBackend.setBackendType("rdbms"); assertThatThrownBy(() -> enqueue("wrong-module")).isInstanceOf(QException.class).hasMessageContaining("not qualified"); } }