Compare commits
6
Commits
v1.1.2
...
v1.2.0-RC.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40089937bf
|
||
|
|
d9f0c357a7
|
||
|
|
bd08d03654
|
||
|
|
107a566891
|
||
|
|
31e36b0b5b
|
||
|
|
c2f6fc300a
|
No files matched your search
@@ -1,5 +1,43 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased (1.2.0)
|
||||
|
||||
Print jobs have a real creation date-time. No wire or contract change (extension 2.4.0, wire
|
||||
v1, subprotocol unchanged); connectors need no update. **Schema change: a host that owns the
|
||||
printing schema must add one column before deploying this version** (below).
|
||||
|
||||
- `printing_job.created_at` is ISO-8601 text and its QQQ field is a string, so QQQ could not
|
||||
apply a relative date filter ("created in the last day"), a date sort or a date bucket to
|
||||
print jobs. The table gains `created_time`, a `TIMESTAMP` without time zone holding UTC
|
||||
(the QQQ date-time convention), written by the same `INSERT` from the same instant as
|
||||
`created_at`. It is the read-only QQQ field **Created** (`created_time`, `DATE_TIME`) and
|
||||
replaces the text field in the Print jobs default columns and Summary section.
|
||||
- `created_at` is unchanged: the queue order, history cursors, admission seeding, the Printing
|
||||
home and the `createdAt` value of the job API still read it. Its QQQ field stays in Details,
|
||||
relabelled **Created at (ISO text)**.
|
||||
- `initialize()` adds the column (`ADD COLUMN IF NOT EXISTS`), backfills every existing row
|
||||
from `created_at` on each start (so a row written by an older node during a rolling restart
|
||||
is repaired) and creates `printing_job_created_time_idx (tenant_id, created_time, id)`.
|
||||
- A host that owns the schema (it never calls `initialize()`) appends the equivalent to its
|
||||
own migrations. For PostgreSQL, in one transaction:
|
||||
|
||||
```sql
|
||||
ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS created_time TIMESTAMP WITHOUT TIME ZONE;
|
||||
UPDATE printing_job SET created_time = (created_at::timestamptz AT TIME ZONE 'UTC') WHERE created_time IS NULL;
|
||||
ALTER TABLE printing_job ALTER COLUMN created_time SET DEFAULT (now() AT TIME ZONE 'UTC');
|
||||
ALTER TABLE printing_job ALTER COLUMN created_time SET NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS printing_job_created_time_idx ON printing_job(tenant_id, created_time, id);
|
||||
```
|
||||
|
||||
The default and `NOT NULL` are optional hardening for a rolling deployment: a node still on
|
||||
an older library omits the column, and the default gives its rows the database clock in UTC
|
||||
instead of a null that date filters would skip. The library itself needs only the nullable
|
||||
column and always writes it.
|
||||
- On a schema without the column this version fails loudly and stores nothing: every
|
||||
submission is rolled back with the database's missing-column error for `created_time`, and
|
||||
QQQ cannot read the Print jobs table. Apply the schema step first; the previous library
|
||||
keeps working on the new schema.
|
||||
|
||||
## 1.1.2
|
||||
|
||||
Runtime and publication move to the QRun-IO Gitea Maven registry; no printing contract
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://git.kof22.com/QRun-IO/qqq-print-agent) 1.2.1 and the [shared protocol](https://git.kof22.com/QRun-IO/qqq-print-protocol) 1.1.0 (1.0.1 / 1.0.0 remain compatible). The Windows connector opens an outbound, authenticated two-way WebSocket; documents download separately over HTTPS. The cloud needs no inbound route into the facility. Version **1.1.0** supports native printing **2.4**, wire payload v1, real-time jobs and controls, native browser sign-in through QQQ, and pushes each exposed printer's approval to connectors that advertise `printer-approval-v1`. Upgrade the server before the matching Windows connector.
|
||||
|
||||
**Released:** [QQQ Printing 1.1.1](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.1), with a library JAR, matching POM and `SHA256SUMS` ([1.1.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.0) is superseded by the realtime upgrade fix; [1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0) remains available for rollback). The same JAR/POM are published in the QRun-IO Gitea Maven registry. Gitea requires sign-in for downloads. The QBit itself is not published to Maven Central and is not a standalone executable/fat JAR. See [installation and release access](docs/RELEASES.md).
|
||||
**Released:** [QQQ Printing 1.1.2](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.2), with a library JAR, matching POM and `SHA256SUMS`, built on QQQ 4.1.0-RC.22 from the Gitea registry ([1.1.1](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.1) is the same code on RC.21 from Maven Central; [1.1.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.0) is superseded by the realtime upgrade fix; [1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0) remains available for rollback). The same JAR/POM are published in the QRun-IO Gitea Maven registry. Gitea requires sign-in for downloads. The QBit itself is not published to Maven Central and is not a standalone executable/fat JAR. See [installation and release access](docs/RELEASES.md).
|
||||
|
||||
**Runtime:** Java 21 and QQQ **4.1.0-RC.22**, published in the QRun-IO Gitea Maven registry (not on Maven Central) with native session login. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**, also served from that registry. Product version 1.1.2, native extension 2.4.0 and wire payload v1 are separate version contracts.
|
||||
|
||||
@@ -98,6 +98,8 @@ Device and printer names, descriptions and locations are edited in the Windows c
|
||||
|
||||
`initialize()` adds profile columns, backfills physical names, widens existing name/title columns and creates dispatch/history indexes without deleting records. The existing `name` column becomes the effective display label; `computer_name`/`queue_name` retain technical names and `display_name` holds the optional override. Back up the database and run schema initialization during a maintenance/deployment window; column widening and index creation can lock populated tables.
|
||||
|
||||
Print jobs carry their creation instant twice: `created_at` is the ISO-8601 text the queue, history cursors and job API use, and `created_time` is the same instant as a UTC `TIMESTAMP` (no time zone, the QQQ convention), exposed as the date-time field **Created** so native filters ("created in the last day"), sorting and date buckets work. `initialize()` adds the column, backfills existing rows from `created_at` and creates `printing_job_created_time_idx (tenant_id, created_time, id)`. A host that owns the schema must add the column before deploying this version (see the [changelog](CHANGELOG.md) for the statements): without it every submission fails with the database's missing-column error and stores nothing, and the Print jobs table cannot be read.
|
||||
|
||||
Job history uses `GET /printing/v1/admin/jobs?limit=200&before=<nextCursor>` with opaque keyset cursors and a maximum 200 rows per page. Claims read one candidate, pending inspection reads 50, and all these queries project metadata without fetching stored documents. Native dashboard lists already cap attention rows at 10 per category and recent work at 25. Dispatch/history indexes support growing histories; the synthetic 10,000-row regression test checks bounded pages and claim correctness, **not** a certified 10,000-print/day throughput measurement. Driver speed, document size, database hardware and retention policy still determine capacity. Document cleanup follows the configured retention policy and host schedule; the audit does not run a live purge or delete job history.
|
||||
|
||||
```java
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Real-time printing host integration
|
||||
|
||||
QQQ Printing 1.1.1 (the released library is [1.1.1](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.1))
|
||||
QQQ Printing 1.1.2 (the released library is [1.1.2](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.2))
|
||||
implements native printing extension **2.4.0** (wire envelope v1), paired with Windows
|
||||
connector **1.2.1**; connector 1.0.1 remains compatible. Upgrade the server before the connector; a real-time connector
|
||||
rejects a server that advertises only extension 1.x. There is no timed job/control
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# Installation and release operations
|
||||
|
||||
The stable server library is [QQQ Printing 1.1.1](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.1) (1.1.0 is superseded: its realtime upgrade fails behind an ingress that lowercases header names), paired with protocol 1.1.0 and Windows connector 1.2.1 (1.0.x/1.1.x connectors keep working on the unchanged 2.3 behaviour). It requires Java 21 and the QQQ 4.1.0-RC.22 dependency tuple, which is published only in the QRun-IO Gitea Maven registry. Its native printing extension is 2.4.0; the WebSocket subprotocol remains `qqq-printing.v1`. The previous stable library, [1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0), remains available for rollback.
|
||||
The stable server library is [QQQ Printing 1.1.2](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.2) (1.1.1 is the same code on QQQ RC.21 from Maven Central; 1.1.0 is superseded: its realtime upgrade fails behind an ingress that lowercases header names), paired with protocol 1.1.0 and Windows connector 1.2.1 (1.0.x/1.1.x connectors keep working on the unchanged 2.3 behaviour). It requires Java 21 and the QQQ 4.1.0-RC.22 dependency tuple, which is published only in the QRun-IO Gitea Maven registry. Its native printing extension is 2.4.0; the WebSocket subprotocol remains `qqq-printing.v1`. The previous stable library, [1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0), remains available for rollback.
|
||||
|
||||
Gitea is primary for source, pull requests, CI, packages and server releases. This forge requires sign-in even for download links on public repositories. An HTML sign-in page is not an artifact. Use your existing approved forge identity; never commit its token or put it in a command line.
|
||||
|
||||
@@ -22,7 +22,7 @@ Merge this entry into the existing settings; preserve other servers, mirrors and
|
||||
|
||||
## Download and verify release files
|
||||
|
||||
Sign in to Gitea and download all three assets from the stable release: `qbit-printing-1.1.1.jar`, `qbit-printing-1.1.1.pom` and `SHA256SUMS`. In their download directory:
|
||||
Sign in to Gitea and download all three assets from the stable release: `qbit-printing-1.1.2.jar`, `qbit-printing-1.1.2.pom` and `SHA256SUMS`. In their download directory:
|
||||
|
||||
```powershell
|
||||
Get-Content ./SHA256SUMS | ForEach-Object {
|
||||
@@ -45,6 +45,8 @@ Since 1.1.2 the repository builds on the shared `KofTwentyTwo/ci-workflows` Mave
|
||||
4. Read the registry JAR/POM back with the approved forge identity, create the Gitea release on the tag, attach the same JAR/POM and a flat `SHA256SUMS`, read them back, then publish. Never replace different bytes under a released version.
|
||||
5. Record the source, tag build, release and verified hashes here. Publication is separate from installing a host or accepting physical printer output.
|
||||
|
||||
For 1.1.2, source `f5639a98be9a2f15d4c61cf0955fe4ecef85824f` (main; merge of PR 33: QQQ 4.1.0-RC.22 from the Gitea registry, shared ci-workflows pipeline), push build `6435` (`-Pui-demo verify`, 200 tests, 0 failures, 0 errors, 1 skipped, Checkstyle clean), tag build `6475` (tests again, deploy to the registry), release `v1.1.2`. Registry files and release assets match byte-for-byte on authenticated readback. The JAR SHA-256 is `786e6e570c5df29b01529562efb246a4f4562bb1d9fff6bea77d264e0d7536ce`; the POM SHA-256 is `7691a4e1cb7b03c7a559b0e2b92eacd92f86545b98bddafac86fc70f8602b03d`. RC.22 is not on Maven Central, so the green builds prove resolution from the registry.
|
||||
|
||||
For 1.1.1, source `2a561815fcec8b7c95e76bb4b36fba314f512027` (main; merge of PR 30, the realtime upgrade header-case fix), main run `5976` (verify, secrets scan, gate and post-publish succeeded), reactor artifact `4426`. The trusted publisher first refused the run because the 1.1.0 documentation merge had re-pinned the protocol fixture in `qrun-release.yaml`, changing the caller hash its enrollment verifies; the enrollment was re-reviewed in cluster-gitops (PRs 283 and 285) and the capture was then published unchanged. Registry files and release assets match the capture byte-for-byte. The JAR SHA-256 is `156720da6928f62d77101521a2953f6e1026d58d02e7fc7815a8c8fcbcedaefa`; the POM SHA-256 is `2fa451260a26f5b5ede30800d46c78050e223694d3d8dda640f9ca875d120ab0`.
|
||||
|
||||
For 1.1.0, source `eb2c16785d0723a47d4a22c68b8b04503108fc58` (main; merges of PR 25 printer approval and PR 28 printer/device removal), main run `5473` (verify, secrets scan, gate and post-publish all succeeded after the CI quota incident of the same afternoon), reactor artifact `4306`, release assets read back by the approved forge identity and matching the registry files byte-for-byte. The JAR SHA-256 is `3a51b7e7cf457cce5db16f138c4cc65b901730bda17ccaf1d3c714cf11d2bbf4`; the POM SHA-256 is `52e5a588e788bbe123dad574455493aea0a7df6cc473c78f15be09343048a5d4`.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Session state — 2026-11-06
|
||||
|
||||
## In progress — 1.1.2: QQQ 4.1.0-RC.22 from the Gitea registry, shared CI
|
||||
## Current — 1.1.2 released (2026-11-06)
|
||||
|
||||
James's direction (2026-11-06): everything moves to the QRun-IO Gitea Maven registry; no more Maven Central for QRun artifacts. 1.1.1 was certified on QQQ 4.1.0-RC.21 from Central while Forge pins RC.22 (Gitea only), so 1.1.2 pins `qqq.version` 4.1.0-RC.22 and moves CI to the shared `ci-workflows` Maven workflow (the protected `qrun-release` channel resolves only Central and cannot build RC.22). No printing contract change. Gate: the push build's `-Pui-demo verify` on the Gitea registry; then tag v1.1.2, registry readback, Gitea release, handoff update. `qqq-frontend-next` 1.0.0-RC.37 (ui-demo profile) was mirrored into the registry byte-for-byte from Central (JAR `c9fa2eb2…`, POM `4761d2fd…`, verified against Central's SHA-1 and read back), so every QRun artifact now resolves from Gitea; only third-party dependencies still come through the Central mirror until the registry proxies them.
|
||||
[QQQ Printing 1.1.2](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.2) is published in the QRun-IO Gitea Maven registry (`com.kingsrook.qbits:qbit-printing:1.1.2`) and as a Gitea release. It is the 1.1.1 code built on QQQ **4.1.0-RC.22**, which exists only in that registry (James's direction: everything moves to Gitea, no more Maven Central), on the shared `ci-workflows` Maven pipeline (`build.yaml@v1`, `-Pui-demo`; the protected `qrun-release` caller and the trusted-publisher enrollment are retired, cluster-gitops PR 288). `qqq-frontend-next` 1.0.0-RC.37 was mirrored into the registry byte-for-byte from Central so the ui-demo profile resolves from Gitea too; only third-party libraries still come through the Central mirror. Source `f5639a98be9a2f15d4c61cf0955fe4ecef85824f` (PR 33), push build 6435 (200 tests, 0 failures), tag build 6475 (deploy); JAR `786e6e570c5df29b01529562efb246a4f4562bb1d9fff6bea77d264e0d7536ce`, POM `7691a4e1cb7b03c7a559b0e2b92eacd92f86545b98bddafac86fc70f8602b03d`. No printing contract change.
|
||||
|
||||
## Current — 1.1.1 released (2026-11-06)
|
||||
## Superseded — 1.1.1 released (2026-11-06)
|
||||
|
||||
[QQQ Printing 1.1.1](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.1) is published on primary Gitea and its Maven registry (`com.kingsrook.qbits:qbit-printing:1.1.1`). It supersedes 1.1.0: Forge's adoption qualification of 1.1.0 (`~/.cache/gg-forge-printing-cohort-20261106/HANDOFF.md`) reproduced a connection failure before WELCOME when the WebSocket upgrade headers arrive lowercase, because the socket callbacks read `X-QQQ-Device-Id` from the WebSocket context, whose header lookup is case-sensitive. 1.1.1 resolves the device once on the authorized HTTP upgrade and carries it as a connection attribute; `PrintingRealtimeTest.lowercaseUpgradeHeadersReachWelcomeAndFrames` fails on the old code and passes on the fix. No contract change: extension 2.4.0, wire v1, subprotocol unchanged. Source `2a561815fcec8b7c95e76bb4b36fba314f512027` (PR 30), main run `5976`, reactor artifact `4426`, release `1381`; JAR `156720da6928f62d77101521a2953f6e1026d58d02e7fc7815a8c8fcbcedaefa`, POM `2fa451260a26f5b5ede30800d46c78050e223694d3d8dda640f9ca875d120ab0`, read back against the capture and the registry. The publisher had refused the run until its enrollment hash was re-reviewed (cluster-gitops PRs 283 and 285) after the 1.1.0 docs merge re-pinned the protocol fixture in the release caller.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent><groupId>com.kingsrook</groupId><artifactId>qbit-build-parent</artifactId><version>2.0.0</version><relativePath/></parent>
|
||||
<parent><groupId>com.kingsrook</groupId><artifactId>qbit-build-parent</artifactId><version>2.2.1</version><relativePath/></parent>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-printing</artifactId>
|
||||
<version>1.1.2</version>
|
||||
@@ -15,7 +15,7 @@
|
||||
<properties>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<qqq.version>4.1.0-RC.22</qqq.version>
|
||||
<qqq.version>4.1.0-RC.25</qqq.version>
|
||||
<maven.compiler.proc>none</maven.compiler.proc>
|
||||
</properties>
|
||||
<dependencyManagement><dependencies>
|
||||
@@ -37,7 +37,7 @@
|
||||
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>6.0.1</version><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
<profiles><profile><id>ui-demo</id><dependencies>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId><version>1.0.0-RC.37</version><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId><version>1.0.0-RC.38</version><scope>runtime</scope></dependency>
|
||||
</dependencies></profile></profiles>
|
||||
<build>
|
||||
<resources>
|
||||
|
||||
@@ -116,6 +116,9 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
printers.withField(new QFieldMetaData("media_profiles_json", QFieldType.STRING).withLabel("Reported queue default media"));
|
||||
QTableMetaData jobs = table(PrintingTables.JOB, "Print jobs", List.of("id", "tenant_id", "device_id", "printer_row_id", "title", "format", "content_hash", "options_json", "idempotency_key", "created_at", "status", "message", "spooler_job_id"));
|
||||
jobs.withField(new QFieldMetaData("content_length", QFieldType.INTEGER).withLabel("Document size (bytes)"));
|
||||
// created_at stays the ISO-8601 text the queue SQL orders by; created_time is the same instant as a real date-time for relative filters, sorting and date buckets.
|
||||
jobs.withField(new QFieldMetaData("created_time", QFieldType.DATE_TIME).withLabel("Created"));
|
||||
jobs.getField("created_at").withLabel("Created at (ISO text)");
|
||||
QTableMetaData commands = table(PrintingTables.COMMAND, "Device control history", List.of("id", "tenant_id", "device_id", "request_id", "action", "job_id", "actor", "status", "created_at", "expires_at", "result_code"));
|
||||
commands.withField(new QFieldMetaData("cancelled_job_count", QFieldType.INTEGER).withLabel("Queued jobs canceled"));
|
||||
commands.getField("device_id").withLabel("Print device").withPossibleValueSourceName(PrintingTables.DEVICE);
|
||||
@@ -163,7 +166,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
instance.addPossibleValueSource(com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource.newForTable(PrintingTables.JOB));
|
||||
layout(devices, List.of("name", "device_status", "control_status", "location", "facility_id", "last_seen_at"));
|
||||
layout(printers, List.of("name", "location", "description", "device_id", "status", "enabled", "available", "last_seen_at"));
|
||||
layout(jobs, List.of("title", "device_id", "printer_row_id", "status", "created_at", "format"));
|
||||
layout(jobs, List.of("title", "device_id", "printer_row_id", "status", "created_time", "format"));
|
||||
layout(commands, List.of("device_id", "action", "command_status", "created_at", "expires_at", "result_code", "cancelled_job_count"));
|
||||
instance.addTable(devices);
|
||||
instance.addTable(printers);
|
||||
|
||||
@@ -297,6 +297,8 @@ public class PrintingService
|
||||
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS content_available INTEGER DEFAULT 1 NOT NULL",
|
||||
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS completed_at VARCHAR(40)",
|
||||
"UPDATE printing_job SET completed_at=(SELECT MAX(e.created_at) FROM printing_event e WHERE e.job_id=printing_job.id AND e.status IN ('SPOOLED','SPOOLER_COMPLETED')) WHERE completed_at IS NULL AND status IN ('SPOOLED','SPOOLER_COMPLETED')",
|
||||
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS created_time TIMESTAMP",
|
||||
"UPDATE printing_job SET created_time=CAST(REPLACE(REPLACE(created_at, 'T', ' '), 'Z', '') AS TIMESTAMP) WHERE created_time IS NULL",
|
||||
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS content_storage_table VARCHAR(200)",
|
||||
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS content_reference VARCHAR(500)",
|
||||
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS content_binding VARCHAR(64)",
|
||||
@@ -323,6 +325,7 @@ public class PrintingService
|
||||
"UPDATE printing_printer SET queue_name=name WHERE queue_name IS NULL",
|
||||
"CREATE INDEX IF NOT EXISTS printing_job_dispatch_idx ON printing_job(tenant_id, device_id, status, created_at, id)",
|
||||
"CREATE INDEX IF NOT EXISTS printing_job_history_idx ON printing_job(tenant_id, created_at, id)",
|
||||
"CREATE INDEX IF NOT EXISTS printing_job_created_time_idx ON printing_job(tenant_id, created_time, id)",
|
||||
"CREATE INDEX IF NOT EXISTS printing_job_retention_completed_idx ON printing_job(tenant_id, content_available, status, completed_at, id)",
|
||||
"CREATE INDEX IF NOT EXISTS printing_event_job_idx ON printing_event(job_id)",
|
||||
"CREATE INDEX IF NOT EXISTS printing_device_tenant_idx ON printing_device(tenant_id, last_seen_at)",
|
||||
@@ -1223,7 +1226,9 @@ public class PrintingService
|
||||
configuredStorage().put(storageTableName, reference, format, content);
|
||||
}
|
||||
checkSubmission(guard);
|
||||
update(connection, "INSERT INTO printing_job (id, tenant_id, device_id, printer_id, title, format, content_hash, content_base64, content_length, options_json, idempotency_key, created_at, status, printer_row_id, content_storage_table, content_reference, content_binding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?, ?, ?)", id, tenantId, deviceId, printerId, title, format, digest, storageTableName == null ? Base64.getEncoder().encodeToString(content) : null, content.length, options.toString(), key, Instant.now().toString(), printers.getFirst().getString("id"), storageTableName, storageTableName == null ? null : reference, storageTableName == null ? null : binding);
|
||||
// One instant, two columns: created_at is the ISO-8601 text the queue SQL and protocol compare; created_time is the UTC timestamp QQQ filters as a date-time.
|
||||
Instant createdAt = Instant.now();
|
||||
update(connection, "INSERT INTO printing_job (id, tenant_id, device_id, printer_id, title, format, content_hash, content_base64, content_length, options_json, idempotency_key, created_at, created_time, status, printer_row_id, content_storage_table, content_reference, content_binding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?, ?, ?)", id, tenantId, deviceId, printerId, title, format, digest, storageTableName == null ? Base64.getEncoder().encodeToString(content) : null, content.length, options.toString(), key, createdAt.toString(), java.time.LocalDateTime.ofInstant(createdAt, java.time.ZoneOffset.UTC), printers.getFirst().getString("id"), storageTableName, storageTableName == null ? null : reference, storageTableName == null ? null : binding);
|
||||
if(bound != null)
|
||||
{
|
||||
String eventId = UUID.randomUUID().toString();
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
/*
|
||||
* 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.printing;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.DateBucket;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.DateBucketUnit;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.NowWithOffset;
|
||||
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.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.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Print jobs carry their creation instant as a real UTC timestamp that QQQ
|
||||
** filters, sorts and date-buckets, beside the ISO-8601 text the queue SQL uses.
|
||||
*******************************************************************************/
|
||||
class PrintingJobCreatedTimeTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** A submitted job stores one instant in both columns, the timestamp in UTC.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void submittedJobStoresTheCreationInstantAsAUtcTimestamp() throws Exception
|
||||
{
|
||||
DriverDataSource source = source();
|
||||
PrintingService service = new PrintingService(source, "tenant-a");
|
||||
service.initialize();
|
||||
String device = enroll(service);
|
||||
Instant before = Instant.now();
|
||||
String job = submit(service, device, "recent");
|
||||
Instant after = Instant.now();
|
||||
Instant text = Instant.parse(text(source, job));
|
||||
Instant timestamp = timestamp(source, job).toInstant(ZoneOffset.UTC);
|
||||
assertFalse(text.isBefore(before) || text.isAfter(after), text + " is outside " + before + " to " + after);
|
||||
assertTrue(Duration.between(text, timestamp).abs().compareTo(Duration.ofNanos(1000)) <= 0, text + " and " + timestamp + " are different instants");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The job table exposes a DATE_TIME field in the default columns; the text stays available.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void metadataExposesCreatedAsADateTimeField() throws Exception
|
||||
{
|
||||
QInstance instance = instance(source());
|
||||
QFieldMetaData created = instance.getTable(PrintingTables.JOB).getField("created_time");
|
||||
assertEquals(QFieldType.DATE_TIME, created.getType());
|
||||
assertEquals("Created", created.getLabel());
|
||||
assertEquals(QFieldType.STRING, instance.getTable(PrintingTables.JOB).getField("created_at").getType());
|
||||
var columns = (PrintingQBitProducer.DefaultColumns) instance.getTable(PrintingTables.JOB).getSupplementalMetaData("nextUi");
|
||||
assertTrue(columns.getDefaultColumns().contains("created_time"));
|
||||
assertFalse(columns.getDefaultColumns().contains("created_at"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** "Created in the last day" selects, sorts and counts by format through QQQ.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void relativeDateCriteriaSelectRecentJobsOnly() throws Exception
|
||||
{
|
||||
DriverDataSource source = source();
|
||||
QInstance instance = instance(source);
|
||||
PrintingService service = new PrintingService(source, "tenant-a");
|
||||
String device = enroll(service);
|
||||
String recent = submit(service, device, "recent");
|
||||
String old = submit(service, device, "old");
|
||||
age(source, old, Instant.now().minus(3, ChronoUnit.DAYS));
|
||||
QContext.init(instance, viewer());
|
||||
try
|
||||
{
|
||||
QQueryFilter lastDay = new QQueryFilter(new QFilterCriteria("created_time", QCriteriaOperator.GREATER_THAN_OR_EQUALS, NowWithOffset.minus(1, ChronoUnit.DAYS)));
|
||||
List<QRecord> rows = new QueryAction().execute(new QueryInput().withTableName(PrintingTables.JOB).withFilter(lastDay)).getRecords();
|
||||
assertEquals(List.of(recent), ids(rows));
|
||||
Instant created = rows.getFirst().getValueInstant("created_time");
|
||||
assertTrue(Duration.between(created, Instant.now()).abs().compareTo(Duration.ofMinutes(5)) < 0, created + " is not the current UTC instant");
|
||||
QQueryFilter earlier = new QQueryFilter(new QFilterCriteria("created_time", QCriteriaOperator.LESS_THAN, NowWithOffset.minus(1, ChronoUnit.DAYS)));
|
||||
assertEquals(List.of(old), ids(new QueryAction().execute(new QueryInput().withTableName(PrintingTables.JOB).withFilter(earlier)).getRecords()));
|
||||
QQueryFilter newestFirst = new QQueryFilter().withOrderBy(new QFilterOrderBy("created_time", false));
|
||||
assertEquals(List.of(recent, old), ids(new QueryAction().execute(new QueryInput().withTableName(PrintingTables.JOB).withFilter(newestFirst)).getRecords()));
|
||||
Aggregate count = new Aggregate("id", AggregateOperator.COUNT);
|
||||
GroupBy format = new GroupBy(QFieldType.STRING, "format");
|
||||
List<AggregateResult> byFormat = new AggregateAction().execute(new AggregateInput(PrintingTables.JOB).withFilter(lastDay).withAggregate(count).withGroupBy(format)).getResults();
|
||||
assertEquals(1, byFormat.size());
|
||||
assertEquals("ZPL", byFormat.getFirst().getGroupByValue(format));
|
||||
assertEquals(1, ValueUtils.getValueAsInteger(byFormat.getFirst().getAggregateValue(count)));
|
||||
GroupBy day = new GroupBy(QFieldType.DATE_TIME, "created_time").withBucket(new DateBucket(DateBucketUnit.DAY));
|
||||
List<AggregateResult> byDay = new AggregateAction().execute(new AggregateInput(PrintingTables.JOB).withAggregate(count).withGroupBy(day)).getResults();
|
||||
assertEquals(2, byDay.size());
|
||||
for(AggregateResult bucket : byDay)
|
||||
{
|
||||
assertNotNull(bucket.getGroupByValue(day));
|
||||
assertEquals(1, ValueUtils.getValueAsInteger(bucket.getAggregateValue(count)));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Rows written before the column existed receive their timestamp from the text.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void initializeBackfillsExistingJobs() throws Exception
|
||||
{
|
||||
DriverDataSource source = source();
|
||||
PrintingService service = new PrintingService(source, "tenant-a");
|
||||
service.initialize();
|
||||
String device = enroll(service);
|
||||
String fractional = submit(service, device, "fractional");
|
||||
String whole = submit(service, device, "whole");
|
||||
dropCreatedTime(source);
|
||||
execute(source, "UPDATE printing_job SET created_at=? WHERE id=?", "2026-01-02T03:04:05.123456Z", fractional);
|
||||
execute(source, "UPDATE printing_job SET created_at=? WHERE id=?", "2025-12-31T23:59:59Z", whole);
|
||||
service.initialize();
|
||||
service.initialize();
|
||||
assertEquals(LocalDateTime.parse("2026-01-02T03:04:05.123456"), timestamp(source, fractional));
|
||||
assertEquals(LocalDateTime.parse("2025-12-31T23:59:59"), timestamp(source, whole));
|
||||
assertNotNull(timestamp(source, submit(service, device, "after")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A host schema without the column refuses submissions and reads; no job is stored without its timestamp.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void schemaWithoutTheColumnFailsLoudly() throws Exception
|
||||
{
|
||||
DriverDataSource source = source();
|
||||
QInstance instance = instance(source);
|
||||
PrintingService service = new PrintingService(source, "tenant-a");
|
||||
String device = enroll(service);
|
||||
dropCreatedTime(source);
|
||||
SQLException refused = assertThrows(SQLException.class, () -> submit(service, device, "refused"));
|
||||
assertTrue(String.valueOf(refused.getMessage()).toLowerCase(Locale.ROOT).contains("created_time"), String.valueOf(refused.getMessage()));
|
||||
try(Connection connection = source.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) FROM printing_job"); ResultSet rows = statement.executeQuery())
|
||||
{
|
||||
assertTrue(rows.next());
|
||||
assertEquals(0, rows.getInt(1));
|
||||
}
|
||||
QContext.init(instance, viewer());
|
||||
try
|
||||
{
|
||||
assertThrows(QException.class, () -> new QueryAction().execute(new QueryInput().withTableName(PrintingTables.JOB)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Independent shared-connection H2 database for each test.
|
||||
***************************************************************************/
|
||||
private static DriverDataSource source()
|
||||
{
|
||||
return new DriverDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE", "sa", "");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A real RDBMS instance binds the QBit and service to one isolated database.
|
||||
***************************************************************************/
|
||||
private static QInstance instance(DriverDataSource source) throws Exception
|
||||
{
|
||||
PrintingService service = new PrintingService(source, "tenant-a");
|
||||
service.initialize();
|
||||
QInstance instance = new QInstance();
|
||||
String backend = "printing-test-" + UUID.randomUUID();
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
|
||||
instance.addBackend(new RDBMSBackendMetaData().withName(backend).withVendor("h2").withHostName("mem").withDatabaseName("test").withUsername("sa").withPassword("").withJdbcUrl(source.url()));
|
||||
new PrintingQBitProducer().withConfig(new PrintingQBitConfig().withBackendName(backend).withService(service)).produce(instance, "printing");
|
||||
new QInstanceValidator().validate(instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An ordinary scoped administrator, never the all-powerful system session.
|
||||
***************************************************************************/
|
||||
private static QSession viewer()
|
||||
{
|
||||
return new QSession().withUser(new QUser().withIdReference("viewer")).withSecurityKeyValue("printing_tenant", "tenant-a").withPermission("printing_admin");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Enroll one device with an approved synthetic label queue.
|
||||
***************************************************************************/
|
||||
private static String enroll(PrintingService service) throws Exception
|
||||
{
|
||||
JSONObject code = service.createEnrollment("warehouse", "station");
|
||||
String device = service.enroll(code.put("deviceName", "station").put("agentVersion", "test")).getString("deviceId");
|
||||
service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "labels").put("name", "Labels").put("status", "READY").put("formats", new JSONArray(List.of("ZPL")))));
|
||||
service.enablePrinter(device, "labels", true);
|
||||
return device;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Submit one synthetic label through the service; nothing leaves the test database.
|
||||
***************************************************************************/
|
||||
private static String submit(PrintingService service, String device, String key) throws Exception
|
||||
{
|
||||
return service.submit(new JSONObject().put("deviceId", device).put("printerId", "labels").put("title", key + " job").put("format", "ZPL")
|
||||
.put("contentBase64", Base64.getEncoder().encodeToString("^XA^XZ".getBytes(StandardCharsets.UTF_8))).put("idempotencyKey", key)).getString("jobId");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Record identifiers in result order.
|
||||
***************************************************************************/
|
||||
private static List<String> ids(List<QRecord> rows)
|
||||
{
|
||||
return rows.stream().map(row -> row.getValueString("id")).toList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Move the creation of one job to an earlier instant in both columns.
|
||||
***************************************************************************/
|
||||
private static void age(DriverDataSource source, String job, Instant created) throws SQLException
|
||||
{
|
||||
execute(source, "UPDATE printing_job SET created_at=?, created_time=? WHERE id=?", created.toString(), LocalDateTime.ofInstant(created, ZoneOffset.UTC), job);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Return the schema to its shape before the column was added.
|
||||
***************************************************************************/
|
||||
private static void dropCreatedTime(DriverDataSource source) throws SQLException
|
||||
{
|
||||
execute(source, "DROP INDEX printing_job_created_time_idx");
|
||||
execute(source, "ALTER TABLE printing_job DROP COLUMN created_time");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The stored ISO-8601 text of one job.
|
||||
***************************************************************************/
|
||||
private static String text(DriverDataSource source, String job) throws SQLException
|
||||
{
|
||||
try(Connection connection = source.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT created_at FROM printing_job WHERE id=?"))
|
||||
{
|
||||
statement.setString(1, job);
|
||||
try(ResultSet rows = statement.executeQuery())
|
||||
{
|
||||
assertTrue(rows.next());
|
||||
return rows.getString(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The stored timestamp of one job, without any time zone conversion.
|
||||
***************************************************************************/
|
||||
private static LocalDateTime timestamp(DriverDataSource source, String job) throws SQLException
|
||||
{
|
||||
try(Connection connection = source.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT created_time FROM printing_job WHERE id=?"))
|
||||
{
|
||||
statement.setString(1, job);
|
||||
try(ResultSet rows = statement.executeQuery())
|
||||
{
|
||||
assertTrue(rows.next());
|
||||
return rows.getObject(1, LocalDateTime.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Run one statement with bound values.
|
||||
***************************************************************************/
|
||||
private static void execute(DriverDataSource source, String sql, Object... values) throws SQLException
|
||||
{
|
||||
try(Connection connection = source.getConnection())
|
||||
{
|
||||
PrintingService.update(connection, sql, values);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user