A request could not say which printer resolution to render a label for:
RenderRequest.targetDpi existed and nothing set it.
DocumentGenerationService.Request gains a trailing targetDpi (absent, 203,
300 or 600; every earlier constructor stays and means none). It is stored
in a new nullable column, target_dpi, on document_generation_request,
compared on retry and lookup beside destination, media and copies, framed
into the intent digest as target-dpi/v1 when stated, and passed to the
renderer. A request without a target has exactly the digest it had, so
earlier rows and their retries are unchanged; the same key with another
target, or with none, is another intent.
Nobody who asks for 300 dpi may silently get another resolution, so a
target is refused before any row exists when it cannot be honoured: for a
PDF, and for any renderer that does not declare the new
IsolatedRenderer.supportsRenderTarget() (false by default, so an adapter
written before the target cannot be handed one and ignore it).
A host applies one statement, from the shipped script for its database:
db/migrations/20261112-render-target-dpi-{h2,mysql,postgres}.sql
(ALTER TABLE document_generation_request ADD COLUMN target_dpi INTEGER
NULL). The generate process form offers the target and the request status
shows it. Batches, the queue and reprints carry none.
Also from the review of the ports: RenderRequest and PreviewRequest hold
their own unmodifiable copy of the context (null values allowed) and
print only their format and render target (and the request id), never the
record data or the template source. Neither wire form changes.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
291 lines
14 KiB
Java
291 lines
14 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.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");
|
|
}
|
|
}
|