Files
qbit-document-generation/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationQueueJdbcTest.java
T
ee782b7d6e
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Failing after 7s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Failing after 5s
QRun-IO/ci-policy: native / native-result (push) Successful in 6s
chore: preserve unfinished document generation source and status
Checkpoint implementation at user-requested stop; document known failures and incomplete release qualification.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-Authored-By: GPT-6 Codex <noreply@openai.com>
2026-11-04 10:45:09 -06:00

822 lines
44 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.nio.file.Files;
import java.nio.file.Path;
import java.sql.DriverManager;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Real independent file-H2 transactions, restart and concurrency. Worker/storage
** ports are fixed trusted fixtures; no hostile code, containers or print devices.
*******************************************************************************/
class GenerationQueueJdbcTest
{
protected QInstance instance;
protected DocumentGenerationQBitConfig config;
protected GenerationQueue queue;
protected Integer template;
protected Integer stock;
private String url;
protected RDBMSBackendMetaData jdbcBackend;
private final TestClock clock = new TestClock();
private final AtomicInteger renders = new AtomicInteger();
private final AtomicInteger retains = new AtomicInteger();
private final Map<String, GenerationPorts.Receipt> receipts = new HashMap<>();
private Runnable rendering = () -> {};
private boolean failRetainedAudit;
/***************************************************************************
** Create a synchronous file-backed database, native source authority and observable queue effect ports.
***************************************************************************/
@BeforeEach
void setup() throws Exception
{
ConnectionManager.closeConnectionProviders();
MemoryRecordStore.getInstance().reset();
Path directory = Files.createDirectories(Path.of("target", "queue-databases", UUID.randomUUID().toString())).toAbsolutePath();
url = "jdbc:h2:file:" + directory.resolve("queue").toString().replace('\\', '/') + ";DATABASE_TO_UPPER=FALSE;WRITE_DELAY=0;LOCK_TIMEOUT=5000";
instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
jdbcBackend = database("queue", url);
instance.addBackend(jdbcBackend);
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
instance.setEnforcePermissionsInCoreActions(true);
instance.addTable(new QTableMetaData().withName("orders").withBackendName("memory").withPrimaryKeyField("id")
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS))
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("name", QFieldType.STRING)));
config = new DocumentGenerationQBitConfig().withBackendName("queue").withAudit(event ->
{
if(failRetainedAudit && "GENERATION_RETAINED".equals(event.outcome()))
{
throw new QException("Injected audit outage");
}
new GenerationAudit().append(event);
}).withRenderer(new GenerationPorts.IsolatedRenderer()
{
/***************************************************************************
** Accept every revision; queue tests focus on durable state transitions, not renderer validation.
***************************************************************************/
public void validate(String actor, GenerationPorts.Revision revision) {}
/***************************************************************************
** Count the render, run the scenario's hook (for example a competing claim), and return fixed trusted bytes.
***************************************************************************/
public GenerationPorts.Content render(GenerationPorts.RenderRequest request)
{
renders.incrementAndGet();
rendering.run();
return (new GenerationPorts.Content("trusted fixture".getBytes(java.nio.charset.StandardCharsets.UTF_8)));
}
}).withIntake(new GenerationPorts.RetainedOutputIntake()
{
/***************************************************************************
** Count the retention and remember the receipt, so recovery tests can assert exactly-once intake across claims.
***************************************************************************/
public GenerationPorts.Receipt retain(GenerationPorts.IntakeRequest request)
{
retains.incrementAndGet();
var result = new GenerationPorts.Receipt("file", "version-" + request.requestId(), request.sha256(), (long)request.content().bytes().length, true, true);
receipts.put(result.versionId(), result);
return (result);
}
/***************************************************************************
** Return the remembered receipt, modelling an owner that still authorizes the retained version.
***************************************************************************/
public GenerationPorts.Receipt authorize(String actor, GenerationPorts.Source source, String version)
{
return (receipts.get(version));
}
}).withPrinting(request -> "job-" + request.requestId());
// Trusted test authority: production installs the exact retained-owner adapter.
config.withRetainedAccess(reference ->
{
GenerationSecurity.source(reference.source());
return (reference);
});
new DocumentGenerationQBitProducer().withQBitConfig(config).produce(instance);
GenerationJobs.register(instance);
for(var table : instance.getTables().values())
{
if(table.getName().startsWith("document_generation_"))
{
table.setBackendDetails(new RDBMSTableBackendDetails().withTableName(table.getName()));
}
}
new QInstanceEnricher(instance).enrich();
sql(schema(config));
login("alice", true);
new InsertAction().execute(new InsertInput("orders").withRecords(List.of(new QRecord().withValue("id", 1).withValue("name", "first"), new QRecord().withValue("id", 2).withValue("name", "second"))));
var definitions = new DocumentDefinitionService(config);
int type = definitions.createType("packing");
stock = definitions.createStock("stock", new GenerationPorts.Stock(101600,152400,0,0,0,0,203));
definitions.bind(type,"orders",true,true);
template = definitions.createTemplate("packing",type,stock,"orders");
definitions.saveDraft(template,stock,new DocumentDefinitionService.Draft("first template",null,null,List.of()));
definitions.publish(template);
queue = new GenerationQueue(config,clock,1,30);
}
/***************************************************************************
** Clear native context, pooled connections and memory records after each database scenario.
***************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
ConnectionManager.closeConnectionProviders();
MemoryRecordStore.getInstance().reset();
}
/***************************************************************************
** Install the requested actor with generation rights and optional source-read permission.
***************************************************************************/
private void login(String actor, boolean read)
{
QSession session = new QSession().withUser(new QUser().withIdReference(actor)).withPermissions("document-generation.view","document-generation.configure","document-generation.publish","document-generation.generate","document-generation.print","orders.write");
if(read)
{
session.withPermissions("orders.read");
}
QContext.init(instance,session);
}
/***************************************************************************
** Refuse output effects when the database rejects the resolved filename write.
***************************************************************************/
@Test
void databaseRejectingFilenamePreventsRenderingAndIntake() throws Exception
{
sql("ALTER TABLE document_generation_request ADD CONSTRAINT reject_name CHECK (resolved_file_name IS NULL)");
Integer id = enqueue("failed-name-write");
assertThat(queue.advance(id).state()).isEqualTo("RECONCILIATION_REQUIRED");
assertThat(renders.get()).isZero();
assertThat(retains.get()).isZero();
assertThat(value("SELECT resolved_file_name FROM document_generation_request WHERE kind='GENERATE'")).isNull();
}
/***************************************************************************
** Verify filename commit through an independent connection before render despite later draft publication.
***************************************************************************/
@Test
void filenameIsCommittedBeforeRenderAndDraftChangesDoNotAlterAcceptedPolicy() throws Exception
{
var definitions = new DocumentDefinitionService(config);
definitions.saveOutputNaming(template, "Accepted name", null, definitions.loadDraft(template).fingerprint());
definitions.publish(template);
Integer id = enqueue("named-queue");
definitions.saveOutputNaming(template, "Future name", null, definitions.loadDraft(template).fingerprint());
definitions.publish(template);
rendering = () ->
{
try
{
assertThat(value("SELECT resolved_file_name FROM document_generation_request WHERE kind='GENERATE'")).isEqualTo("Accepted name.pdf");
}
catch(Exception failure)
{
throw new IllegalStateException(failure);
}
};
assertThat(queue.advance(id).state()).isEqualTo("GENERATED");
assertThat(renders.get()).isEqualTo(1);
}
/***************************************************************************
** Refuse a tampered pinned filename policy before rendering or retaining output.
***************************************************************************/
@Test
void alteredPinnedFilenamePolicyCannotReachRenderer() throws Exception
{
var definitions = new DocumentDefinitionService(config);
definitions.saveOutputNaming(template, "Original", null, definitions.loadDraft(template).fingerprint());
int revision = definitions.publish(template);
Integer id = enqueue("name-pin");
sql("UPDATE document_generation_revision SET output_base_name='Changed' WHERE id=" + revision);
assertThat(queue.advance(id).state()).isEqualTo("RECONCILIATION_REQUIRED");
assertThat(renders.get()).isZero();
assertThat(retains.get()).isZero();
}
/***************************************************************************
** Actual additive DDL preserves legacy batch identity and unknown completion;
** exact-selection receipt replay then marks it complete without new effects.
***************************************************************************/
@Test
void batchCompletionMigrationPreservesLegacyRowsAndAllowsSafeReplay() throws Exception
{
var service = new DocumentGenerationService(config);
var selected = List.of(new GenerationPorts.Source("orders", "1"));
assertThat(service.generateBatch(template, selected, GenerationPorts.Format.PDF, "legacy-batch", null).complete()).isTrue();
String revision = value("SELECT revision_id FROM document_generation_batch WHERE request_key='legacy-batch'");
String selection = value("SELECT selection_hash FROM document_generation_batch WHERE request_key='legacy-batch'");
sql("ALTER TABLE document_generation_batch DROP COLUMN completed");
String vendor = List.of("mysql", "postgres").contains(jdbcBackend.getVendor()) ? jdbcBackend.getVendor() : "h2";
for(String statement : Files.readString(Path.of("src/main/resources/db/migrations/20261031-warm-batch-completion-" + vendor + ".sql")).split(";"))
{
if(!statement.isBlank())
{
sql(statement);
}
}
assertThat(value("SELECT completed FROM document_generation_batch WHERE request_key='legacy-batch'")).isNull();
assertThat(value("SELECT revision_id FROM document_generation_batch WHERE request_key='legacy-batch'")).isEqualTo(revision);
assertThat(value("SELECT selection_hash FROM document_generation_batch WHERE request_key='legacy-batch'")).isEqualTo(selection);
int before = renders.get();
assertThat(service.generateBatch(template, selected, GenerationPorts.Format.PDF, "legacy-batch", null).complete()).isTrue();
assertThat(renders.get()).isEqualTo(before);
assertThat(value("SELECT completed FROM document_generation_batch WHERE request_key='legacy-batch'")).isNotNull();
}
/***************************************************************************
** Apply filename migrations to existing queue rows while preserving pinned revision hashes.
***************************************************************************/
@Test
void explicitFilenameMigrationPreservesOldRowsAndQueueHashes() throws Exception
{
Integer id = enqueue("migration-name");
String hash = value("SELECT revision_hash FROM document_generation_queue WHERE id=" + id);
for(String table : List.of("document_generation_template", "document_generation_revision"))
{
sql("ALTER TABLE " + table + " DROP COLUMN output_base_name");
sql("ALTER TABLE " + table + " DROP COLUMN output_name_context_key");
}
sql("ALTER TABLE document_generation_request DROP COLUMN resolved_file_name");
String vendor = List.of("mysql", "postgres").contains(jdbcBackend.getVendor()) ? jdbcBackend.getVendor() : "h2";
for(String statement : Files.readString(Path.of("src/main/resources/db/migrations/20261031-output-filenames-" + vendor + ".sql")).split(";"))
{
if(!statement.isBlank())
{
sql(statement);
}
}
assertThat(value("SELECT revision_hash FROM document_generation_queue WHERE id=" + id)).isEqualTo(hash);
assertThat(new DocumentDefinitionService(config).published(template).outputBaseName()).isNull();
assertThat(queue.advance(id).state()).isEqualTo("GENERATED");
assertThat(value("SELECT resolved_file_name FROM document_generation_request WHERE kind='GENERATE'")).startsWith("generated-revision-").endsWith(".pdf");
}
/***************************************************************************
** Resume an old four-field asset snapshot using compatible font defaults without changing its hash.
***************************************************************************/
@Test
void legacyAssetSnapshotStillAdvancesAfterFontMetadataUpgrade() throws Exception
{
var definitions = new DocumentDefinitionService(config);
Integer image = definitions.addAsset(template, new GenerationPorts.Asset("logo", "image", "a".repeat(64), "image/png"));
Integer font = definitions.addAsset(template, new GenerationPorts.Asset("Regular", "font", "b".repeat(64), "font/ttf"));
definitions.saveDraft(template, stock, new DocumentDefinitionService.Draft("legacy", null, null, List.of(image, font)));
Integer revision = definitions.publish(template);
Integer id = queue.enqueue(template, List.of(new GenerationPorts.Source("orders", "1")), GenerationPorts.Format.PDF, "old-shape", null);
var mapper = new com.fasterxml.jackson.databind.ObjectMapper().enable(com.fasterxml.jackson.databind.SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS);
com.fasterxml.jackson.databind.node.ObjectNode legacy = mapper.valueToTree(definitions.revision(revision));
legacy.remove(List.of("outputBaseName", "outputNameContextKey"));
for(var asset : legacy.path("assets"))
{
((com.fasterxml.jackson.databind.node.ObjectNode) asset).remove(List.of("fontFamily", "fontWeight", "fontStyle"));
}
String hash = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(mapper.writeValueAsBytes(legacy)));
sql("UPDATE document_generation_queue SET revision_hash='" + hash + "' WHERE id=" + id);
assertThat(queue.advance(id).state()).isEqualTo("GENERATED");
assertThat(renders.get()).isEqualTo(1);
}
/***************************************************************************
** Explicit face semantics cannot be altered behind an accepted immutable pin.
***************************************************************************/
@Test
void explicitFontFaceRemainsInQueuedRevisionHash() throws Exception
{
var definitions = new DocumentDefinitionService(config);
Integer font = definitions.addAsset(template, new GenerationPorts.Asset("Bold", "font", "b".repeat(64), "font/ttf", "Warehouse", 700, "italic"));
definitions.saveDraft(template, stock, new DocumentDefinitionService.Draft("face", null, null, List.of(font)));
definitions.publish(template);
Integer id = queue.enqueue(template, List.of(new GenerationPorts.Source("orders", "1")), GenerationPorts.Format.PDF, "font-pin", null);
sql("UPDATE document_generation_asset SET font_weight=800 WHERE id=" + font);
assertThat(definitions.published(template).assets().getFirst().fontWeight()).isEqualTo(800);
GenerationQueue.Item refused = queue.advance(id);
assertThat(refused.state()).isEqualTo("RECONCILIATION_REQUIRED");
assertThat(refused.errorCode()).isEqualTo("GENERATION_INCOMPLETE");
assertThat(refused.stage()).isEqualTo("CLAIMED");
assertThat(refused.requestId()).isNull();
assertThat(queue.status(id, -1, 1).items()).containsExactly(refused);
assertThat(queue.advance(id)).isNull();
assertThat(renders.get()).isZero();
assertThat(retains.get()).isZero();
}
/*******************************************************************************
** Accepted print work is terminal for generation and cannot be claimed again.
*******************************************************************************/
@Test
void printQueuedItemIsNeverUntouchedQueuedWork() throws Exception
{
int id = queue.enqueue(template, List.of(new GenerationPorts.Source("orders", "1")), GenerationPorts.Format.PDF, "print-once", "printer");
assertThat(queue.advance(id).state()).isEqualTo("PRINT_QUEUED");
assertThat(queue.advance(id)).isNull();
assertThat(renders).hasValue(1);
assertThat(retains).hasValue(1);
}
/*******************************************************************************
** Queue/history recovery uses exact retained authority after source deletion;
** successful reconciliation neither renders nor stores the original again.
*******************************************************************************/
@Test
void archivedQueueHistoryAndKnownVersionReconciliationUseOwnerAuthority() throws Exception
{
int id = queue.enqueue(template, List.of(new GenerationPorts.Source("orders", "1")), GenerationPorts.Format.PDF, "archive-queue", null);
failRetainedAudit = true;
var failed = queue.advance(id);
assertThat(failed.state()).isEqualTo("RECONCILIATION_REQUIRED");
config.withRetainedAccess(reference ->
{
var receipt = receipts.get(reference.versionId());
assertThat(receipt).isNotNull();
assertThat(reference.fileId()).isEqualTo(receipt.fileId());
assertThat(reference.sha256()).isEqualTo(receipt.sha256());
assertThat(reference.length()).isEqualTo(receipt.length());
return (reference);
});
new com.kingsrook.qqq.backend.core.actions.tables.DeleteAction().execute(
new com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput("orders").withPrimaryKeys(List.of(1)));
assertThat(queue.status(id, -1, 50).items()).hasSize(1);
assertThat(new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(
new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(GenerationTables.ITEM)).getRecords()).hasSize(1);
failRetainedAudit = false;
assertThat(queue.reconcile(id, failed.id()).state()).isEqualTo("GENERATED");
assertThat(renders).hasValue(1);
assertThat(retains).hasValue(1);
}
/*******************************************************************************
** A core job step drains untouched items, preserving domain outcomes on rerun.
*******************************************************************************/
@Test
void jobDrainUsesExistingQueueAndNeverRepeatsCompletedItems() throws Exception
{
queue = new GenerationQueue(config);
int id = queue.enqueue(template, List.of(new GenerationPorts.Source("orders", "1"), new GenerationPorts.Source("orders", "2")), GenerationPorts.Format.PDF, "job-drain", null);
var input = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput();
input.setProcessName(GenerationJobs.PROCESS);
input.addValue("queueId", id);
var output = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput();
new GenerationJobs.Drain().run(input, output);
assertThat(output.getValueInteger("processedItems")).isEqualTo(2);
new GenerationJobs.Drain().run(input, new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput());
assertThat(renders).hasValue(2);
assertThat(retains).hasValue(2);
assertThatThrownBy(() -> GenerationJobs.launch(id)).hasMessageContaining("not configured");
assertThatThrownBy(() -> GenerationJobs.register(instance)).hasMessageContaining("already exists");
}
/*******************************************************************************
** Cooperative cancellation after render prevents intake and leaves review state.
** The try-with-resources scope is never referenced on purpose: opening it
** installs the thread-bound cancellation check the queue must observe, so only
** this method suppresses javac's [try] lint.
*******************************************************************************/
@Test
@SuppressWarnings("try")
void jobCancellationFencesIntakeAndUntouchedItemsStayQueued() throws Exception
{
queue = new GenerationQueue(config);
int id = queue.enqueue(template, List.of(new GenerationPorts.Source("orders", "1"), new GenerationPorts.Source("orders", "2")), GenerationPorts.Format.PDF, "job-cancel", null);
java.util.concurrent.atomic.AtomicBoolean stopped = new java.util.concurrent.atomic.AtomicBoolean();
rendering = () -> stopped.set(true);
try(var scope = com.kingsrook.qqq.backend.core.jobs.plans.JobChunkScope.open(null, () ->
{
if(stopped.get())
{
throw new com.kingsrook.qqq.backend.core.jobs.plans.JobInterruptedException(com.kingsrook.qqq.backend.core.jobs.plans.JobInterruptedException.Reason.CANCEL);
}
}))
{
assertThatThrownBy(() -> queue.advance(id)).isInstanceOf(com.kingsrook.qqq.backend.core.jobs.plans.JobInterruptedException.class);
}
assertThat(retains).hasValue(0);
assertThat(queue.status(id, -1, 50).items()).extracting(GenerationQueue.Item::state).containsExactly("RECONCILIATION_REQUIRED", "QUEUED");
assertThat(queue.advance(id).state()).isEqualTo("GENERATED");
var input = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput();
input.setProcessName(GenerationJobs.PROCESS);
input.addValue("queueId", id);
assertThatThrownBy(() -> new GenerationJobs.Drain().run(input, new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput()))
.hasMessageContaining("incomplete");
assertThat(renders).hasValue(2);
}
/*******************************************************************************
** Launch uses public core persistence under the initiating identity. No worker
** runtime is started; this fixture does not claim production job-host coverage.
*******************************************************************************/
@Test
void nativeBackgroundActionLaunchesThroughCoreKernel() throws Exception
{
queue = new GenerationQueue(config);
int id = queue.enqueue(template, List.of(new GenerationPorts.Source("orders", "1")), GenerationPorts.Format.PDF, "job-launch", null);
new com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaDataProvider().defineAll(instance, "memory", null);
instance.withSupplementalMetaData(new com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaData());
new QInstanceEnricher(instance).enrich();
login("alice", true);
var input = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput();
input.setProcessName(GenerationProcesses.name(GenerationTables.QUEUE, "background"));
input.addValue("selectionJson", "[\"" + id + "\"]");
var output = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput();
new GenerationProcesses.Execute().run(input, output);
int jobId = Integer.parseInt(output.getValueString("resultJson"));
QRecord job = com.kingsrook.qqq.backend.core.jobs.DurableJobs.status(jobId);
assertThat(job.getValueString("processName")).isEqualTo(GenerationJobs.PROCESS);
assertThat(job.getValueString("userId")).isEqualTo("alice");
assertThat(job.getValueString("idempotency")).isEqualTo("NONE");
assertThat(job.getValueString("paramsJson")).contains("queueId");
assertThat(renders).hasValue(0);
login("mallory", true);
assertThatThrownBy(() -> GenerationJobs.launch(id)).isInstanceOf(QException.class);
login("alice", false);
assertThatThrownBy(() -> GenerationJobs.launch(id)).isInstanceOf(QException.class);
}
/***************************************************************************
** Enqueue the two fixture orders as one pinned PDF selection under the supplied idempotency key.
***************************************************************************/
protected Integer enqueue(String key) throws QException
{
return (queue.enqueue(template,List.of(new GenerationPorts.Source("orders","1"),new GenerationPorts.Source("orders","2")),GenerationPorts.Format.PDF,key,null));
}
/***************************************************************************
** Persist selection and context snapshots across publication changes, retries and connection reopen.
***************************************************************************/
@Test
void durablePinnedSelectionAndPreparedSnapshot() throws Exception
{
int id=enqueue("same");
int revision=queue.status(id,-1,1).revisionId();
var definitions=new DocumentDefinitionService(config);
definitions.saveDraft(template,stock,new DocumentDefinitionService.Draft("new template",null,null,List.of()));
definitions.publish(template);
assertThat(enqueue("same")).isEqualTo(id);
assertThatThrownBy(() -> queue.enqueue(template,List.of(new GenerationPorts.Source("orders","2")),GenerationPorts.Format.PDF,"same",null)).hasMessageContaining("another selection");
var first=queue.advance(id);
assertThat(first.state()).isEqualTo("GENERATED");
assertThat(first.contextHash()).matches("[a-f0-9]{64}");
assertThat(value("SELECT context_json FROM document_generation_item WHERE id="+first.id())).contains("first");
assertThat(value("SELECT revision_id FROM document_generation_request WHERE id="+first.requestId())).isEqualTo(String.valueOf(revision));
ConnectionManager.closeConnectionProviders();
queue=new GenerationQueue(config,clock,1,30);
assertThat(queue.advance(id).ordinal()).isEqualTo(1);
assertThat(queue.advance(id)).isNull();
assertThat(renders.get()).isEqualTo(2);
assertThat(queue.status(id,-1,1).nextCursor()).isEqualTo(0);
assertThat(queue.status(id,0,1).nextCursor()).isNull();
}
/***************************************************************************
** Roll back the queue and every selection item when one item cannot be inserted.
***************************************************************************/
@Test
void selectionRollsBackCompletelyWhenAnItemCannotCommit() throws Exception
{
sql("ALTER TABLE document_generation_item ADD CONSTRAINT reject_second CHECK(record_id <> '2')");
assertThatThrownBy(() -> enqueue("rollback")).isInstanceOf(QException.class);
assertThat(value("SELECT COUNT(*) FROM document_generation_queue")).isEqualTo("0");
assertThat(value("SELECT COUNT(*) FROM document_generation_item")).isEqualTo("0");
}
/***************************************************************************
** Require the accepting actor and current source access for status, cancellation and queue advancement.
***************************************************************************/
@Test
void currentSourcePermissionsAndActorAreRequired() throws Exception
{
int id=enqueue("permission");
login("bob",true);
assertThatThrownBy(() -> queue.status(id,-1,10)).isInstanceOf(QException.class);
assertThatThrownBy(() -> queue.cancel(id)).isInstanceOf(QException.class);
login("alice",false);
assertThatThrownBy(() -> queue.status(id,-1,10)).isInstanceOf(QException.class);
assertThat(queue.advance(id).state()).isEqualTo("RECONCILIATION_REQUIRED");
assertThat(renders.get()).isZero();
}
/***************************************************************************
** Fence retention when cancellation occurs during rendering and cancel untouched selection items.
***************************************************************************/
@Test
void cancellationFencesRetentionAndCancelsUntouchedItems() throws Exception
{
int id=enqueue("cancel");
rendering=() ->
{
try
{
queue.cancel(id);
}
catch(QException e)
{
throw new IllegalStateException(e);
}
};
assertThat(queue.advance(id).state()).isEqualTo("RECONCILIATION_REQUIRED");
assertThat(retains.get()).isZero();
assertThat(queue.status(id,-1,10).items().get(1).state()).isEqualTo("CANCELLED");
assertThat(queue.advance(id)).isNull();
}
/***************************************************************************
** Require explicit termination evidence after lease expiry and avoid repeating unknown intake effects.
***************************************************************************/
@Test
void expiredCrashRequiresTerminationEvidenceAndNeverRerendersUnknownIntake() throws Exception
{
int id=enqueue("crash");
rendering=() ->
{
throw new AssertionError("Synthetic abrupt process death");
};
assertThatThrownBy(() -> queue.advance(id)).isInstanceOf(AssertionError.class);
clock.now=clock.now.plusSeconds(31);
var abandoned=queue.status(id,-1,10).items().getFirst();
assertThat(abandoned.state()).isEqualTo("RECONCILIATION_REQUIRED");
assertThatThrownBy(() -> queue.advance(id)).hasMessageContaining("admission");
assertThatThrownBy(() -> queue.recoverTerminated(id,abandoned.id())).hasMessageContaining("termination evidence unavailable");
config.withAttemptTermination((item,token) -> assertThat(token).matches("[a-f0-9-]{36}"));
queue.recoverTerminated(id,abandoned.id());
assertThat(queue.reconcile(id,abandoned.id()).state()).isEqualTo("RECONCILIATION_REQUIRED");
rendering=() -> {};
assertThat(queue.advance(id).ordinal()).isEqualTo(1);
assertThat(renders.get()).isEqualTo(2);
assertThat(retains.get()).isEqualTo(1);
}
/***************************************************************************
** Reconcile a known retained version after audit recovery without a second render or intake.
***************************************************************************/
@Test
void knownVersionReconcilesWithoutSecondRenderOrIntake() throws Exception
{
int id=enqueue("retained");
failRetainedAudit=true;
var failed=queue.advance(id);
assertThat(failed.state()).isEqualTo("RECONCILIATION_REQUIRED");
failRetainedAudit=false;
assertThat(queue.reconcile(id,failed.id()).state()).isEqualTo("GENERATED");
assertThat(renders.get()).isEqualTo(1);
assertThat(retains.get()).isEqualTo(1);
}
/***************************************************************************
** Serialize concurrent idempotent submission and deny a second worker while admission is occupied.
***************************************************************************/
@Test
void crossThreadAdmissionAndIdempotentSubmissionAreSerialized() throws Exception
{
try(var executor=Executors.newFixedThreadPool(2))
{
var left=executor.submit(() ->
{
login("alice",true); try
{
return (enqueue("concurrent"));
}
finally
{
QContext.clear();
}
});
var right=executor.submit(() ->
{
login("alice",true); try
{
return (enqueue("concurrent"));
}
finally
{
QContext.clear();
}
});
int id=left.get(10,TimeUnit.SECONDS);
assertThat(right.get(10,TimeUnit.SECONDS)).isEqualTo(id);
CountDownLatch entered=new CountDownLatch(1);
CountDownLatch release=new CountDownLatch(1);
rendering=() ->
{
entered.countDown();
try
{
if(!release.await(10,TimeUnit.SECONDS))
{
throw new IllegalStateException("fixture timeout");
}
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
};
var work=executor.submit(() ->
{
login("alice",true); try
{
return (queue.advance(id));
}
finally
{
QContext.clear();
}
});
assertThat(entered.await(10,TimeUnit.SECONDS)).isTrue();
try
{
assertThatThrownBy(() -> queue.advance(id)).hasMessageContaining("admission");
}
finally
{
release.countDown();
}
assertThat(work.get(10,TimeUnit.SECONDS).state()).isEqualTo("GENERATED");
}
}
/***************************************************************************
** Reject invalid page, lease and selection bounds plus conflicting persisted admission settings.
***************************************************************************/
@Test
void rejectsInvalidBoundsAndInconsistentOperatorAdmission() throws Exception
{
assertThatThrownBy(() -> new GenerationQueue(config,clock,0,30)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new GenerationQueue(config,clock,1,601)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> queue.enqueue(template,List.of(),GenerationPorts.Format.PDF,"empty",null)).isInstanceOf(QException.class);
int id=enqueue("bounds");
assertThatThrownBy(() -> queue.status(id,-2,1)).isInstanceOf(QException.class);
assertThatThrownBy(() -> queue.status(id,-1,51)).isInstanceOf(QException.class);
queue.advance(id);
assertThatThrownBy(() -> new GenerationQueue(config,clock,2,30).advance(id)).hasMessageContaining("persisted backend admission");
assertThatThrownBy(() -> queue.reconcile(id,999)).isInstanceOf(QException.class);
assertThatThrownBy(() -> queue.recoverTerminated(id,queue.status(id,-1,1).items().getFirst().id())).isInstanceOf(QException.class);
}
/***************************************************************************
** Execute fixture DDL or corruption setup through an independent database connection.
***************************************************************************/
protected void sql(String sql) throws Exception
{
try(var connection=connection();var statement=connection.createStatement())
{
statement.execute(sql);
}
}
/*******************************************************************************
** Global pending admission is serialized and bounded independently of workers.
*******************************************************************************/
@Test
void boundsQueuedBacklogBeforeAnySelectionRowsAreInserted() throws Exception
{
sql("INSERT INTO document_generation_item (state) SELECT 'QUEUED' FROM SYSTEM_RANGE(1,10000)");
assertThatThrownBy(() -> enqueue("full")).hasMessageContaining("backlog is full");
assertThat(value("SELECT COUNT(*) FROM document_generation_queue")).isEqualTo("0");
}
/*******************************************************************************
** Read-only status remains useful while every semantic queue mutation denies.
*******************************************************************************/
@Test
void queueJdbcCannotBypassCredentialMutationCeiling() throws Exception
{
int id=enqueue("readonly");
QContext.getQSession().withCredentialScope(new com.kingsrook.qqq.backend.core.model.session.QCredentialScope().withReadOnly(true));
assertThat(queue.status(id,-1,10).items()).hasSize(2);
assertThatThrownBy(() -> queue.cancel(id)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
assertThatThrownBy(() -> queue.advance(id)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
assertThatThrownBy(() -> enqueue("blocked")).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
assertThat(value("SELECT CASE WHEN cancel_requested THEN 'TRUE' ELSE 'FALSE' END FROM document_generation_queue WHERE id="+id)).isEqualTo("FALSE");
assertThat(renders.get()).isZero();
}
/***************************************************************************
** Read one scalar through an independent connection to observe committed database state.
***************************************************************************/
private String value(String sql) throws Exception
{
try(var connection=connection();var statement=connection.createStatement();var rows=statement.executeQuery(sql))
{
rows.next();
return (rows.getString(1));
}
}
/***************************************************************************
** Database fixture seam; inherited tests also run against actual MySQL.
***************************************************************************/
protected RDBMSBackendMetaData database(String name, String defaultUrl) throws Exception
{
return (new RDBMSBackendMetaData().withName(name).withVendor("h2").withJdbcUrl(defaultUrl).withUsername("sa"));
}
/***************************************************************************
** Independent fixture connection; never production credentials or services.
***************************************************************************/
protected java.sql.Connection connection() throws Exception
{
return (DriverManager.getConnection(jdbcBackend.getJdbcUrl(), jdbcBackend.getUsername(), jdbcBackend.getPassword()));
}
/***************************************************************************
** Bootstrap matches the selected actual database dialect.
***************************************************************************/
protected String schema(DocumentGenerationQBitConfig configuration)
{
return (DocumentGenerationSchema.postgresScript(configuration).replace("TIMESTAMPTZ", "TIMESTAMP"));
}
/***************************************************************************
** Provide a deterministic UTC clock whose instant can advance to exercise lease expiry.
***************************************************************************/
private static class TestClock extends Clock
{
private Instant now=Instant.parse("2026-10-30T00:00:00Z");
/***************************************************************************
** Keep fixture timestamps in UTC so lease assertions do not depend on the host zone.
***************************************************************************/
public ZoneId getZone()
{
return (ZoneId.of("UTC"));
}
/***************************************************************************
** Retain the same deterministic clock when a caller requests a zone view.
***************************************************************************/
public Clock withZone(ZoneId zone)
{
return (this);
}
/***************************************************************************
** Return the test-controlled instant used for queue lease and expiry decisions.
***************************************************************************/
public Instant instant()
{
return (now);
}
}
}