Compare commits

...
3 Commits
Author SHA1 Message Date
james.maes 5f0dbae0be Merge pull request 'fix(admin): decide a station delete's refusal under the lock, before revoking' (#40) from fix/delete-refusal-under-lock into main
maven / build (push) Successful in 2m42s
build / maven (push) Successful in 2m42s
2026-11-12 15:11:14 +00:00
james.maesandClaude Fable 5.2 d7c7c203c3 fix(delete): write names only onto jobs that lack one; correct the changelog on the old backfill
maven / build (push) Successful in 2m22s
build / maven (push) Successful in 2m22s
Review of PR 40: the name backfill at delete rewrote every job of the station
while holding the tenant's admission lock, and the changelog called the old
startup backfill a no-op where it nulled a queue name.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:30:50 -06:00
james.maesandClaude Fable 5.2 b063f9a55f fix(admin): decide a station delete's refusal under the lock, before revoking
maven / build (push) Successful in 2m13s
build / maven (push) Successful in 2m13s
deleteDevice decided "connected and printing" from a read without a lock,
revoked the station, and only then locked and read again. A revoked station
never counts as connected, so the locked read could not refuse: a station
that claimed a job between the first read and the revocation was deleted and
the job closed as OUTCOME_UNKNOWN although the station had its content.

The delete is now one transaction: tenant admission lock, then the station's
row lock (the lock a claim, a job event and a heartbeat take first), the
refusal, the REVOKE control on the same connection, the job closing and the
row deletes, one commit. A refused or failed delete changes nothing; the
station is no longer left revoked when the delete's transaction fails.
PrintingDeviceControls.action gains an overload that runs inside the
caller's transaction. deletePrinter already decided under the station's row
lock and is unchanged.

Also: the printer approval route returns "Printer was removed; share it from
the device again before changing its approval" again (RC.3 changed an HTTP
API text); the startup name backfill only updates jobs whose station or
printer still exists.

Tests: the claim that lands just before the delete locks (station, printer),
the claim in flight that the delete waits for, a printer refused on a live
station, an already revoked station, rollback and retry of a failed
transaction, the 23503 mapping, the final step refusing after the review,
review plan errors, and a tenant-scoped aggregate over print jobs.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:20:44 -06:00
4 changed files with 746 additions and 167 deletions

No files matched your search

+50 -2
View File
@@ -60,8 +60,9 @@ before deploying this version.**
record it will not delete, with the reason. Service: `PrintingService.deleteDevice(deviceId,
actor)`, `deletePrinter(printerRowId, actor)`, and the read-only `deviceDeletionPlan` /
`printerDeletionPlan`.
- A station is first revoked through the existing REVOKE control (credential rotated,
disconnected, pending commands closed), then, in one transaction: its queued jobs become
- A station is revoked through the existing REVOKE control (credential rotated,
disconnected, pending commands closed) and, in the same transaction (RC.4, below; it was
a separate step before): its queued jobs become
`FAILED` ("Canceled before dispatch: its print station was deleted"); jobs it was sent
without a reported result (`CLAIMED`, `RECEIVED`, `SUBMITTING`) become `OUTCOME_UNKNOWN`;
each of those gets a `printing_event` row (`event_origin` `SERVER_DELETION`) naming the
@@ -135,6 +136,53 @@ before deploying this version.**
the step) before anything is revoked. The previous library keeps working on the new schema;
jobs it writes get their names when their station or printer is deleted.
### 1.2.0-RC.4: a station delete decides its refusal under the lock
No wire, contract or schema change; the schema step above is the same.
- **Fixed: deleting a station could close a job as `OUTCOME_UNKNOWN` that the station had
just been sent and was about to print.** `deleteDevice` decided "connected and printing"
from a read without a lock, revoked the station, and only then locked and read again; a
revoked station never counts as connected, so the second read could not refuse. A station
that was idle at the first read and claimed a job before the revocation was deleted with
that job in its hands.
- The delete is now one transaction. It takes the tenant admission lock and then the
station's row lock (the order the other paths use; a claim, a job event and a heartbeat
take the station's row lock before they change anything), decides the refusal there,
applies the REVOKE control on the same connection, closes the jobs, deletes the rows and
commits once. A claim either commits before the delete locks, and the delete is refused
(409, the station and its job untouched), or waits for the delete and is then answered
`404 Device not found`: the job was canceled by the delete and was never sent.
- A delete that is refused or fails changes nothing. Before, a delete whose transaction
failed left the station revoked; now the revocation rolls back with the rest and the
delete can simply be run again. A station that an earlier version left revoked, or that
was revoked with **Revoke print station access**, is deleted without a second revocation.
- The station's connection is woken once, after the commit. The revocation of a delete no
longer goes through the public `PrintingService.deviceAction`; a host that wraps that
method to observe revocations sees a delete in `printing_deletion` instead.
- `deletePrinter` did not have the window (it decides under the station's row lock and
revokes nothing) and is unchanged.
- **HTTP API text restored.** RC.3 changed one text an HTTP route returns, contrary to the
note above: approving or unapproving a removed printer (`PUT
/printing/v1/admin/devices/{deviceId}/printers/{printerId}`, 409) answers "Printer was
removed; share it from the device again before changing its approval" again. The other
text RC.3 changed ("Revoke the print station's access before removing it") is raised only
by `removeDevice`, which no HTTP or agent route calls, and stays.
- `initialize()` fills `device_name` / `printer_name` only on jobs whose station or printer
still exists. Before, jobs of a deleted station or printer that have no name were rewritten on
every start, and where such a job had a queue name but no printer name the queue name was
set to null. They are still read on every start; there is no index on the name columns.
- Deleting a station or a printer writes the names only onto its jobs that lack one, so a
long job history is not rewritten while the tenant's admission lock is held.
- Tests added for: the claim that lands just before the delete locks (station and printer)
and the claim in flight that the delete waits for; a printer refused while it prints on a
connected station; an already revoked station; a failed transaction rolled back and
retried; the foreign-key violation (`23503`) named as the missing schema step; the final
step refusing a record that passed the review; the review naming a record that went away
or conflicts; a grouped read of print jobs counting the caller's tenant only. They run on
the in-memory database of the test suite; the build has no PostgreSQL driver or container
support, so the delete and the schema step are not exercised on PostgreSQL here.
## 1.1.2
Runtime and publication move to the QRun-IO Gitea Maven registry; no printing contract
@@ -102,6 +102,41 @@ final class PrintingDeviceControls
/** A native authorized queue snapshot must still match after taking the device lock. */
JSONObject action(String deviceId, JSONObject request, String actor, @Nullable Set<String> authorizedQueue) throws SQLException
{
Validated valid = validated(request, actor);
try(Connection connection = source.getConnection())
{
connection.setAutoCommit(false);
try
{
JSONObject response = apply(connection, deviceId, request, valid, actor, authorizedQueue);
connection.commit();
return response;
}
catch(Exception exception)
{
connection.rollback();
throw exception;
}
}
}
/** The same action inside a transaction its caller began and will commit or roll back; nothing is committed here. */
JSONObject action(Connection connection, String deviceId, JSONObject request, String actor) throws SQLException
{
return apply(connection, deviceId, request, validated(request, actor), actor, null);
}
/** The checked parts of one action request. */
private record Validated(String requestId, String action, @Nullable String jobId)
{
}
/** Strict input is refused before a connection or a lock is taken. */
private static Validated validated(JSONObject request, String actor)
{
fields(request, Set.of("requestId", "issuedAt", "action", "jobId"));
String requestId = text(request, "requestId", 128);
@@ -119,141 +154,136 @@ final class PrintingDeviceControls
{
throw new PrintingException(400, "A bounded authenticated actor is required");
}
try(Connection connection = source.getConnection())
return new Validated(requestId, action, jobId);
}
/** Lock the device, replay a retained receipt or apply and record the action; the caller owns the transaction. */
private JSONObject apply(Connection connection, String deviceId, JSONObject request, Validated valid, String actor, @Nullable Set<String> authorizedQueue) throws SQLException
{
String requestId = valid.requestId();
String action = valid.action();
String jobId = valid.jobId();
JSONObject device = lock(connection, deviceId, false);
List<JSONObject> old = query(connection, "SELECT * FROM printing_device_command WHERE tenant_id=? AND device_id=? AND request_id=?", tenant, deviceId, requestId);
if(!old.isEmpty())
{
connection.setAutoCommit(false);
try
if(!new JSONObject(old.getFirst().getString("request_json")).similar(request))
{
JSONObject device = lock(connection, deviceId, false);
List<JSONObject> old = query(connection, "SELECT * FROM printing_device_command WHERE tenant_id=? AND device_id=? AND request_id=?", tenant, deviceId, requestId);
if(!old.isEmpty())
{
if(!new JSONObject(old.getFirst().getString("request_json")).similar(request))
{
throw new PrintingException(409, "Request identity was already used for a different action");
}
connection.commit();
return record(old.getFirst());
}
Instant issued;
try
{
issued = Instant.parse(utc(request, "issuedAt"));
}
catch(java.time.format.DateTimeParseException exception)
{
throw new PrintingException(400, "Invalid action time");
}
Instant current = Instant.now();
if(issued.isBefore(current.minusSeconds(900)))
{
throw new PrintingException(410, "Action request expired; review a new action explicitly");
}
if(issued.isAfter(current.plusSeconds(60)))
{
throw new PrintingException(400, "Action time is too far in the future");
}
if(device.getInt("enabled") != 1 || device.has("revoked_at"))
{
throw new PrintingException(409, "Revoked devices require a new local enrollment");
}
if(!REMOTE.contains(action) && !Set.of("CLEAR_QUEUE", "REVOKE").contains(action) && device.getLong("control_revision") >= 9007199254740991L)
{
throw new PrintingException(409, "Control revision capacity reached");
}
expire(connection, deviceId);
update(connection, "DELETE FROM printing_device_command WHERE id IN (SELECT id FROM printing_device_command WHERE tenant_id=? AND device_id=? AND status<>'PENDING' AND created_at<? AND expires_at<? ORDER BY created_at,id LIMIT 200)", tenant, deviceId, Instant.now().minusSeconds(7 * 86400).toString(), Instant.now().toString());
long history = count(connection, "SELECT COUNT(*) AS n FROM printing_device_command WHERE tenant_id=? AND device_id=?", tenant, deviceId);
if(history >= 1000 && !"REVOKE".equals(action))
{
throw new PrintingException(429, "Device control receipt capacity reached; receipts are retained and revocation remains available");
}
String id = UUID.randomUUID().toString();
String now = current.toString();
String expires = current.plusSeconds(900).toString();
String result = null;
Integer cancelled = null;
if(REMOTE.contains(action))
{
if(device.getInt("control_version") != 1 || !new JSONArray(device.getString("control_capabilities")).toList().contains(action))
{
throw new PrintingException(409, "CONTROL_UNSUPPORTED");
}
if(count(connection, "SELECT COUNT(*) AS n FROM printing_device_command WHERE tenant_id=? AND device_id=? AND status='PENDING'", tenant, deviceId) >= 100)
{
throw new PrintingException(429, "Too many pending device commands");
}
if(jobId != null)
{
List<JSONObject> jobs = query(connection, "SELECT id,status,spooler_job_id FROM printing_job WHERE tenant_id=? AND device_id=? AND id=?", tenant, deviceId, jobId);
if(jobs.isEmpty())
{
throw new PrintingException(404, "Job not found");
}
if(!Set.of("SPOOLED", "SPOOLER_COMPLETED").contains(jobs.getFirst().getString("status")) || jobs.getFirst().optString("spooler_job_id").isBlank())
{
throw new PrintingException(409, "Only an owned job with a recorded spool identity can be canceled");
}
}
}
else if("CLEAR_QUEUE".equals(action))
{
if(authorizedQueue != null)
{
Set<String> currentQueue = new java.util.HashSet<>();
for(JSONObject row : query(connection, "SELECT id FROM printing_job WHERE tenant_id=? AND device_id=? AND status='QUEUED' LIMIT 10001", tenant, deviceId))
{
currentQueue.add(row.getString("id"));
}
if(!currentQueue.equals(authorizedQueue))
{
throw new PrintingException(409, "The queue changed; review the current authorized jobs before retrying");
}
}
cancelled = update(connection, "UPDATE printing_job SET status='FAILED',message=? WHERE tenant_id=? AND device_id=? AND status='QUEUED'", PrintingService.CANCELED_BEFORE_DISPATCH, tenant, deviceId);
result = "QUEUED_JOBS_CANCELLED";
}
else if("REVOKE".equals(action))
{
update(connection, "UPDATE printing_device SET enabled=0,token_hash=?,revoked_at=?,dispatch_paused=1,connection_state='DISCONNECTED',control_revision=? WHERE tenant_id=? AND id=?", UUID.randomUUID().toString(), now, Math.min(9007199254740991L, device.getLong("control_revision") + 1), tenant, deviceId);
update(connection, "UPDATE printing_device_command SET status='OUTCOME_UNKNOWN' WHERE tenant_id=? AND device_id=? AND status='PENDING'", tenant, deviceId);
result = "CREDENTIAL_REVOKED";
}
else
{
int paused = device.getInt("dispatch_paused");
String connected = device.getString("connection_state");
if("PAUSE_DISPATCH".equals(action))
{
paused = 1;
}
if("RESUME_DISPATCH".equals(action))
{
paused = 0;
}
if("DISCONNECT".equals(action))
{
connected = "DISCONNECTED";
}
if("RECONNECT".equals(action))
{
connected = "CONNECTED";
}
update(connection, "UPDATE printing_device SET dispatch_paused=?,connection_state=?,control_revision=control_revision+1 WHERE tenant_id=? AND id=?", paused, connected, tenant, deviceId);
result = "DESIRED_STATE_UPDATED";
}
update(connection, "INSERT INTO printing_device_command (id,tenant_id,device_id,request_id,action,job_id,actor,request_json,status,created_at,expires_at,result_code,cancelled_job_count) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
id, tenant, deviceId, requestId, action, jobId, actor, request.toString(), REMOTE.contains(action) ? "PENDING" : "SUCCEEDED", now, expires, result, cancelled);
JSONObject response = record(command(connection, deviceId, id));
connection.commit();
return response;
throw new PrintingException(409, "Request identity was already used for a different action");
}
catch(Exception exception)
return record(old.getFirst());
}
Instant issued;
try
{
issued = Instant.parse(utc(request, "issuedAt"));
}
catch(java.time.format.DateTimeParseException exception)
{
throw new PrintingException(400, "Invalid action time");
}
Instant current = Instant.now();
if(issued.isBefore(current.minusSeconds(900)))
{
throw new PrintingException(410, "Action request expired; review a new action explicitly");
}
if(issued.isAfter(current.plusSeconds(60)))
{
throw new PrintingException(400, "Action time is too far in the future");
}
if(device.getInt("enabled") != 1 || device.has("revoked_at"))
{
throw new PrintingException(409, "Revoked devices require a new local enrollment");
}
if(!REMOTE.contains(action) && !Set.of("CLEAR_QUEUE", "REVOKE").contains(action) && device.getLong("control_revision") >= 9007199254740991L)
{
throw new PrintingException(409, "Control revision capacity reached");
}
expire(connection, deviceId);
update(connection, "DELETE FROM printing_device_command WHERE id IN (SELECT id FROM printing_device_command WHERE tenant_id=? AND device_id=? AND status<>'PENDING' AND created_at<? AND expires_at<? ORDER BY created_at,id LIMIT 200)", tenant, deviceId, Instant.now().minusSeconds(7 * 86400).toString(), Instant.now().toString());
long history = count(connection, "SELECT COUNT(*) AS n FROM printing_device_command WHERE tenant_id=? AND device_id=?", tenant, deviceId);
if(history >= 1000 && !"REVOKE".equals(action))
{
throw new PrintingException(429, "Device control receipt capacity reached; receipts are retained and revocation remains available");
}
String id = UUID.randomUUID().toString();
String now = current.toString();
String expires = current.plusSeconds(900).toString();
String result = null;
Integer cancelled = null;
if(REMOTE.contains(action))
{
if(device.getInt("control_version") != 1 || !new JSONArray(device.getString("control_capabilities")).toList().contains(action))
{
connection.rollback();
throw exception;
throw new PrintingException(409, "CONTROL_UNSUPPORTED");
}
if(count(connection, "SELECT COUNT(*) AS n FROM printing_device_command WHERE tenant_id=? AND device_id=? AND status='PENDING'", tenant, deviceId) >= 100)
{
throw new PrintingException(429, "Too many pending device commands");
}
if(jobId != null)
{
List<JSONObject> jobs = query(connection, "SELECT id,status,spooler_job_id FROM printing_job WHERE tenant_id=? AND device_id=? AND id=?", tenant, deviceId, jobId);
if(jobs.isEmpty())
{
throw new PrintingException(404, "Job not found");
}
if(!Set.of("SPOOLED", "SPOOLER_COMPLETED").contains(jobs.getFirst().getString("status")) || jobs.getFirst().optString("spooler_job_id").isBlank())
{
throw new PrintingException(409, "Only an owned job with a recorded spool identity can be canceled");
}
}
}
else if("CLEAR_QUEUE".equals(action))
{
if(authorizedQueue != null)
{
Set<String> currentQueue = new java.util.HashSet<>();
for(JSONObject row : query(connection, "SELECT id FROM printing_job WHERE tenant_id=? AND device_id=? AND status='QUEUED' LIMIT 10001", tenant, deviceId))
{
currentQueue.add(row.getString("id"));
}
if(!currentQueue.equals(authorizedQueue))
{
throw new PrintingException(409, "The queue changed; review the current authorized jobs before retrying");
}
}
cancelled = update(connection, "UPDATE printing_job SET status='FAILED',message=? WHERE tenant_id=? AND device_id=? AND status='QUEUED'", PrintingService.CANCELED_BEFORE_DISPATCH, tenant, deviceId);
result = "QUEUED_JOBS_CANCELLED";
}
else if("REVOKE".equals(action))
{
update(connection, "UPDATE printing_device SET enabled=0,token_hash=?,revoked_at=?,dispatch_paused=1,connection_state='DISCONNECTED',control_revision=? WHERE tenant_id=? AND id=?", UUID.randomUUID().toString(), now, Math.min(9007199254740991L, device.getLong("control_revision") + 1), tenant, deviceId);
update(connection, "UPDATE printing_device_command SET status='OUTCOME_UNKNOWN' WHERE tenant_id=? AND device_id=? AND status='PENDING'", tenant, deviceId);
result = "CREDENTIAL_REVOKED";
}
else
{
int paused = device.getInt("dispatch_paused");
String connected = device.getString("connection_state");
if("PAUSE_DISPATCH".equals(action))
{
paused = 1;
}
if("RESUME_DISPATCH".equals(action))
{
paused = 0;
}
if("DISCONNECT".equals(action))
{
connected = "DISCONNECTED";
}
if("RECONNECT".equals(action))
{
connected = "CONNECTED";
}
update(connection, "UPDATE printing_device SET dispatch_paused=?,connection_state=?,control_revision=control_revision+1 WHERE tenant_id=? AND id=?", paused, connected, tenant, deviceId);
result = "DESIRED_STATE_UPDATED";
}
update(connection, "INSERT INTO printing_device_command (id,tenant_id,device_id,request_id,action,job_id,actor,request_json,status,created_at,expires_at,result_code,cancelled_job_count) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
id, tenant, deviceId, requestId, action, jobId, actor, request.toString(), REMOTE.contains(action) ? "PENDING" : "SUCCEEDED", now, expires, result, cancelled);
return record(command(connection, deviceId, id));
}
@@ -331,8 +331,8 @@ public class PrintingService
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS computer_name VARCHAR(1024)",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS printer_name VARCHAR(1024)",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS queue_name VARCHAR(1024)",
"UPDATE printing_job SET device_name=(SELECT d.name FROM printing_device d WHERE d.id=printing_job.device_id), computer_name=(SELECT d.computer_name FROM printing_device d WHERE d.id=printing_job.device_id) WHERE device_name IS NULL",
"UPDATE printing_job SET printer_name=(SELECT p.name FROM printing_printer p WHERE p.id=printing_job.printer_row_id), queue_name=(SELECT p.queue_name FROM printing_printer p WHERE p.id=printing_job.printer_row_id) WHERE printer_name IS NULL",
"UPDATE printing_job SET device_name=(SELECT d.name FROM printing_device d WHERE d.id=printing_job.device_id), computer_name=(SELECT d.computer_name FROM printing_device d WHERE d.id=printing_job.device_id) WHERE device_name IS NULL AND EXISTS (SELECT 1 FROM printing_device d WHERE d.id=printing_job.device_id)",
"UPDATE printing_job SET printer_name=(SELECT p.name FROM printing_printer p WHERE p.id=printing_job.printer_row_id), queue_name=(SELECT p.queue_name FROM printing_printer p WHERE p.id=printing_job.printer_row_id) WHERE printer_name IS NULL AND EXISTS (SELECT 1 FROM printing_printer p WHERE p.id=printing_job.printer_row_id)",
"CREATE TABLE IF NOT EXISTS printing_deletion (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, kind VARCHAR(20) NOT NULL, record_id VARCHAR(36) NOT NULL, name VARCHAR(1024) NOT NULL, computer_name VARCHAR(1024), queue_name VARCHAR(1024), facility_id VARCHAR(200), device_id VARCHAR(36), actor VARCHAR(200) NOT NULL, deleted_time TIMESTAMP NOT NULL, printer_count INTEGER DEFAULT 0 NOT NULL, cancelled_job_count INTEGER DEFAULT 0 NOT NULL, unknown_job_count INTEGER DEFAULT 0 NOT NULL, kept_job_count INTEGER DEFAULT 0 NOT NULL)",
"CREATE INDEX IF NOT EXISTS printing_deletion_tenant_idx ON printing_deletion(tenant_id, deleted_time, id)",
"CREATE INDEX IF NOT EXISTS printing_job_dispatch_idx ON printing_job(tenant_id, device_id, status, created_at, id)",
@@ -922,7 +922,8 @@ public class PrintingService
{
if(!query(connection, "SELECT id FROM printing_printer WHERE device_id=? AND printer_id=?", deviceId, printerId).isEmpty())
{
throw new PrintingException(409, "Printer was removed; share it from its " + PrintingNames.STATION_LOWER + " again before changing its approval");
// The HTTP printer approval route returns this text; an existing client reads it as it always was.
throw new PrintingException(409, "Printer was removed; share it from the device again before changing its approval");
}
throw new PrintingException(404, "Printer not found");
}
@@ -1012,18 +1013,25 @@ public class PrintingService
/***************************************************************************
** Delete a print station and its printers for good.
**
** All of it is one transaction that takes the tenant admission lock and then
** the station's row lock, the lock a claim, a job event and a heartbeat take
** before they change anything. Nothing the station does can therefore land
** between the decision and the delete, and a delete that is refused or fails
** changes nothing: the station is not left revoked.
**
** 1. Refused (409) while the station is connected and has jobs it has been
** sent but not reported on: their result would be lost.
** sent but not reported on: their result would be lost. This is decided
** under the locks, before anything is revoked.
** 2. Its credential is revoked through the REVOKE control, exactly as Revoke
** access does, so the computer is disconnected and cannot come back as
** this identity. An already revoked station skips this step.
** 3. In one transaction: every job of the station gets the station and
** printer names it was printed under (if it does not have them yet);
** queued jobs become FAILED (canceled before dispatch); jobs sent to the
** station without a reported result become OUTCOME_UNKNOWN; each of those
** gets a job event naming the actor; the deletion is written to
** printing_deletion (one row for the station, one for each printer); the
** control receipts, the printers and the station row are deleted.
** 3. Every job of the station gets the station and printer names it was
** printed under (if it does not have them yet); queued jobs become FAILED
** (canceled before dispatch); jobs sent to the station without a reported
** result become OUTCOME_UNKNOWN; each of those gets a job event naming the
** actor; the deletion is written to printing_deletion (one row for the
** station, one for each printer); the control receipts, the printers and
** the station row are deleted.
**
** Jobs are never deleted and keep device_id, printer_id and printer_row_id
** as the identifiers the station and printer had. A computer enrolled again
@@ -1034,37 +1042,32 @@ public class PrintingService
public JSONObject deleteDevice(String deviceId, String actor) throws SQLException
{
requireActor(actor);
JSONObject plan;
try(Connection connection = dataSource.getConnection())
{
plan = deviceDeletionPlan(connection, deviceId, false);
// Checked before anything is revoked: a station is never left revoked but undeletable.
if(plan.getLong("jobs") > 0 && !jobDeviceForeignKeys(connection).isEmpty())
{
throw new PrintingException(409, SCHEMA_STEP_MISSING);
}
}
refuseIfPlanned(plan);
boolean revoked = false;
if(plan.getBoolean("credentialLive"))
{
revokeBeforeDelete(deviceId, actor);
revoked = true;
}
JSONObject result;
try(Connection connection = dataSource.getConnection())
{
// Schema metadata only, read before any lock is held.
boolean jobsReferenceStation = !jobDeviceForeignKeys(connection).isEmpty();
PrintingAdmission.initializeTenant(connection, tenantId);
connection.setAutoCommit(false);
try
{
PrintingAdmission.lock(connection, tenantId);
plan = deviceDeletionPlan(connection, deviceId, true);
JSONObject plan = deviceDeletionPlan(connection, deviceId, true);
if(jobsReferenceStation && plan.getLong("jobs") > 0)
{
throw new PrintingException(409, SCHEMA_STEP_MISSING);
}
// The station row is locked: a claim or a job event waits for this transaction, so what is decided here still holds when the station is revoked and deleted below.
refuseIfPlanned(plan);
boolean revoked = plan.getBoolean("credentialLive");
if(revoked)
{
revokeBeforeDelete(connection, deviceId, actor);
}
Instant now = Instant.now();
String name = plan.getString("name");
String computer = plan.getString("computerName");
update(connection, "UPDATE printing_job SET device_name=COALESCE(device_name,?), computer_name=COALESCE(computer_name,?) WHERE tenant_id=? AND device_id=?", name, computer, tenantId, deviceId);
update(connection, "UPDATE printing_job SET device_name=COALESCE(device_name,?), computer_name=COALESCE(computer_name,?) WHERE tenant_id=? AND device_id=? AND (device_name IS NULL OR computer_name IS NULL)", name, computer, tenantId, deviceId);
List<JSONObject> printers = query(connection, "SELECT id, printer_id, name, queue_name FROM printing_printer WHERE tenant_id=? AND device_id=? ORDER BY name, id", tenantId, deviceId);
for(JSONObject printer : printers)
{
@@ -1157,13 +1160,15 @@ public class PrintingService
/***************************************************************************
** Revoke exactly as the Revoke access action does (credential rotated,
** disconnected, pending commands closed, connection woken). Kept as its own
** step so that anything a host hangs on revocation also runs before delete.
** Revoke with the control the Revoke access action uses (credential rotated,
** disconnected, pending commands closed), inside the delete's transaction
** and under its station row lock: a delete that then fails rolls the
** revocation back with everything else. The connection is woken once, after
** the delete commits.
***************************************************************************/
private void revokeBeforeDelete(String deviceId, String actor) throws SQLException
private void revokeBeforeDelete(Connection connection, String deviceId, String actor) throws SQLException
{
deviceAction(deviceId, new JSONObject().put("requestId", "delete-" + UUID.randomUUID()).put("issuedAt", Instant.now().toString()).put("action", "REVOKE"), actor);
new PrintingDeviceControls(dataSource, tenantId).action(connection, deviceId, new JSONObject().put("requestId", "delete-" + UUID.randomUUID()).put("issuedAt", Instant.now().toString()).put("action", "REVOKE"), actor);
}
@@ -1289,7 +1294,7 @@ public class PrintingService
private void namePrinterJobs(Connection connection, JSONObject printer, String deviceId) throws SQLException
{
String name = printer.getString("name");
update(connection, "UPDATE printing_job SET printer_name=COALESCE(printer_name,?), queue_name=COALESCE(queue_name,?) WHERE tenant_id=? AND " + PRINTER_JOBS,
update(connection, "UPDATE printing_job SET printer_name=COALESCE(printer_name,?), queue_name=COALESCE(queue_name,?) WHERE tenant_id=? AND " + PRINTER_JOBS + " AND (printer_name IS NULL OR queue_name IS NULL)",
name, printer.optString("queue_name", name), tenantId, printer.getString("id"), deviceId, printer.getString("printer_id"));
}
@@ -21,6 +21,10 @@
package com.kingsrook.qbits.printing;
import javax.sql.DataSource;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.time.Instant;
@@ -29,6 +33,12 @@ import java.util.Base64;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
@@ -56,12 +66,15 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
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.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -485,6 +498,489 @@ class PrintingDeleteTest
/***************************************************************************
** A grouped read of print jobs counts the caller's tenant only, whichever
** tenant asks, although every tenant's jobs are in the same table.
***************************************************************************/
@Test
void aggregateOverPrintJobsCountsOnlyTheCallersTenant() throws Exception
{
var fixture = PrintingProcessesTest.fixture();
submit(fixture.service(), fixture.device());
submit(fixture.service(), fixture.device());
submit(fixture.service(), fixture.otherDevice());
PrintingService otherTenant = new PrintingService(source(fixture), "tenant-b");
for(int i = 0; i < 4; i++)
{
submit(otherTenant, fixture.foreignDevice());
}
assertEquals(7, count(fixture, "SELECT COUNT(*) FROM printing_job"));
Aggregate count = new Aggregate("id", AggregateOperator.COUNT);
GroupBy station = new GroupBy(QFieldType.STRING, "device_id");
List<AggregateResult> all = new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.JOB).withAggregate(count))).getResults();
assertEquals(1, all.size());
assertEquals("3", String.valueOf(all.getFirst().getAggregateValue(count)));
List<AggregateResult> byStation = new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.JOB).withAggregate(count).withGroupBy(station))).getResults();
assertEquals(Set.of(fixture.device() + "=2", fixture.otherDevice() + "=1"), new java.util.HashSet<>(byStation.stream().map(row -> row.getGroupByValue(station) + "=" + row.getAggregateValue(count)).toList()));
QContext.init(fixture.instance(), new QSession().withUser(new QUser().withIdReference("other-operator")).withPermissions("printing_admin").withSecurityKeyValue("printing_tenant", "tenant-b"));
List<AggregateResult> foreign = new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.JOB).withAggregate(count).withGroupBy(station))).getResults();
assertEquals(List.of(fixture.foreignDevice() + "=4"), foreign.stream().map(row -> row.getGroupByValue(station) + "=" + row.getAggregateValue(count)).toList());
}
/***************************************************************************
** The window the first version left open: the station is idle when the
** delete starts, and claims a job after everything the delete reads without
** a lock and before its first lock. The refusal is decided under the lock,
** so the delete is refused and neither the job nor the station changes.
***************************************************************************/
@Test
void stationThatClaimsAJobBeforeTheDeleteLocksIsRefusedAndKeepsItsJob() throws Exception
{
var fixture = PrintingProcessesTest.fixture();
PrintingService service = fixture.service();
service.heartbeat(fixture.device(), new JSONObject().put("agentVersion", "test"));
String job = submit(service, fixture.device());
assertFalse(service.deviceDeletionPlan(fixture.device()).has("refusal"));
PrintingService deleting = beforeFirstLock(fixture, () -> assertFalse(service.claim(fixture.device()).isNull("job")));
PrintingException refused = assertThrows(PrintingException.class, () -> deleting.deleteDevice(fixture.device(), "operator"));
assertEquals(409, refused.getStatus());
assertTrue(String.valueOf(refused.getMessage()).startsWith("Print station Packing station is connected and is printing 1 job right now"), refused.getMessage());
assertUntouched(fixture, fixture.device(), job, "CLAIMED");
service.event(fixture.device(), job, new JSONObject().put("eventId", "finished-" + job).put("status", "SPOOLED").put("spoolerJobId", "4"));
JSONObject deleted = service.deleteDevice(fixture.device(), "operator");
assertTrue(deleted.getBoolean("credentialRevoked"));
assertEquals(0, deleted.getInt("unknownJobs"));
assertEquals("SPOOLED", service.getJob(job).getString("status"));
}
/***************************************************************************
** The same moment for a printer: its station claims a job for it just
** before the delete locks, and the printer delete is refused.
***************************************************************************/
@Test
void printerWhoseStationClaimsAJobBeforeTheDeleteLocksIsRefused() throws Exception
{
var fixture = PrintingProcessesTest.fixture();
PrintingService service = fixture.service();
service.heartbeat(fixture.device(), new JSONObject().put("agentVersion", "test"));
String job = submit(service, fixture.device());
assertFalse(service.printerDeletionPlan(fixture.printerId()).has("refusal"));
PrintingService deleting = beforeFirstLock(fixture, () -> assertFalse(service.claim(fixture.device()).isNull("job")));
PrintingException refused = assertThrows(PrintingException.class, () -> deleting.deletePrinter(fixture.printerId(), "operator"));
assertEquals(409, refused.getStatus());
assertTrue(String.valueOf(refused.getMessage()).startsWith("Printer Packing laser is printing 1 job right now on print station Packing station"), refused.getMessage());
assertUntouched(fixture, fixture.device(), job, "CLAIMED");
}
/***************************************************************************
** A claim that is in flight holds the station's row lock with its job not
** yet committed as claimed. The delete is seen waiting for that lock instead
** of deciding from what it could read before, and refuses once the claim lands.
***************************************************************************/
@Test
void deleteWaitsForAClaimInFlightAndThenRefuses() throws Exception
{
var fixture = PrintingProcessesTest.fixture();
PrintingService service = fixture.service();
execute(fixture, "SET DEFAULT_LOCK_TIMEOUT 60000");
service.heartbeat(fixture.device(), new JSONObject().put("agentVersion", "test"));
String job = submit(service, fixture.device());
ExecutorService executor = Executors.newSingleThreadExecutor();
try(Connection claiming = connection(fixture); var statement = claiming.createStatement())
{
claiming.setAutoCommit(false);
statement.executeQuery("SELECT id FROM printing_device WHERE id='" + fixture.device() + "' FOR UPDATE").close();
statement.executeUpdate("UPDATE printing_job SET status='CLAIMED' WHERE id='" + job + "'");
Future<JSONObject> delete = executor.submit(() -> service.deleteDevice(fixture.device(), "operator"));
awaitSessionWaitingForALock(fixture);
assertFalse(delete.isDone());
claiming.commit();
ExecutionException failed = assertThrows(ExecutionException.class, () -> delete.get(60, TimeUnit.SECONDS));
PrintingException refused = assertInstanceOf(PrintingException.class, failed.getCause());
assertEquals(409, refused.getStatus());
assertTrue(String.valueOf(refused.getMessage()).startsWith("Print station Packing station is connected and is printing 1 job right now"), refused.getMessage());
}
finally
{
executor.shutdownNow();
}
assertUntouched(fixture, fixture.device(), job, "CLAIMED");
}
/***************************************************************************
** A printer that is printing on a connected station is refused by name, by
** the service, by the review and by the final step, while another printer
** of the selection is deleted. Once its station is disconnected the printer
** is deleted and its job is closed as outcome unknown.
***************************************************************************/
@Test
void printerThatIsPrintingOnALiveStationIsRefused() throws Exception
{
var fixture = PrintingProcessesTest.fixture();
grantDelete();
PrintingService service = fixture.service();
service.heartbeat(fixture.device(), new JSONObject().put("agentVersion", "test"));
String job = submit(service, fixture.device());
service.claim(fixture.device());
String refusal = "Printer Packing laser is printing 1 job right now on print station Packing station. Wait until it finishes, or disconnect the print station first (the result is then recorded as unknown), and delete it again.";
PrintingException refused = assertThrows(PrintingException.class, () -> service.deletePrinter(fixture.printerId(), "operator"));
assertEquals(409, refused.getStatus());
assertEquals(refusal, refused.getMessage());
assertEquals(refusal, service.printerDeletionPlan(fixture.printerId()).getString("refusal"));
RunBackendStepInput input = input(PrintingDeleteProcesses.PRINTERS, fixture.printerId(), fixture.otherPrinterId());
input.addValue(PrintingDeleteProcesses.CONFIRM, true);
PrintingDeleteProcesses.Transform review = new PrintingDeleteProcesses.Transform();
RunBackendStepOutput reviewed = new RunBackendStepOutput();
review.runOnePage(input, reviewed);
assertEquals(List.of(fixture.otherPrinterId()), reviewed.getRecords().stream().map(row -> row.getValueString("id")).toList());
assertEquals(List.of(refusal), refusals(review.getProcessSummary(reviewed, false)));
PrintingDeleteProcesses.Load load = new PrintingDeleteProcesses.Load();
load.runOnePage(input, new RunBackendStepOutput());
assertEquals(List.of(refusal), refusals(load.getProcessSummary(new RunBackendStepOutput(), true)));
assertNull(get(PrintingTables.PRINTER, fixture.otherPrinterId()));
assertNotNull(get(PrintingTables.PRINTER, fixture.printerId()));
assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_deletion WHERE record_id='" + fixture.printerId() + "'"));
assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_event WHERE job_id='" + job + "' AND event_origin='SERVER_DELETION'"));
assertEquals("CLAIMED", service.getJob(job).getString("status"));
service.deviceAction(fixture.device(), new JSONObject().put("requestId", "disconnect-1").put("issuedAt", Instant.now().toString()).put("action", "DISCONNECT"), "operator");
JSONObject deleted = service.deletePrinter(fixture.printerId(), "operator");
assertEquals(1, deleted.getInt("unknownJobs"));
assertEquals("OUTCOME_UNKNOWN", service.getJob(job).getString("status"));
assertEquals("Its printer was deleted before a result was reported; check the printer for output before printing again", get(PrintingTables.JOB, job).getValueString("message"));
assertNull(get(PrintingTables.PRINTER, fixture.printerId()));
assertNull(get(PrintingTables.DEVICE, fixture.device()).getValueString("revoked_at"));
}
/***************************************************************************
** A station whose access was revoked earlier is deleted without a second
** revocation, even with a job it never reported on. A delete of it whose
** transaction fails changes nothing and can simply be run again. This is
** also the state the first version left behind when its delete failed after
** its revocation had committed.
***************************************************************************/
@Test
void revokedStationIsDeletedWithoutASecondRevokeAndAFailedDeleteIsRetried() throws Exception
{
var fixture = PrintingProcessesTest.fixture();
PrintingService service = fixture.service();
service.heartbeat(fixture.device(), new JSONObject().put("agentVersion", "test"));
String sent = submit(service, fixture.device());
service.claim(fixture.device());
String queued = submit(service, fixture.device());
service.deviceAction(fixture.device(), new JSONObject().put("requestId", "revoke-1").put("issuedAt", Instant.now().toString()).put("action", "REVOKE"), "operator");
JSONObject plan = service.deviceDeletionPlan(fixture.device());
assertFalse(plan.getBoolean("credentialLive"));
assertFalse(plan.has("refusal"));
assertEquals(1, plan.getLong("jobsInProgress"));
execute(fixture, "ALTER TABLE printing_deletion ADD CONSTRAINT deletion_fails CHECK (kind='NOTHING')");
assertThrows(java.sql.SQLException.class, () -> service.deleteDevice(fixture.device(), "operator"));
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_device WHERE id='" + fixture.device() + "' AND enabled=0 AND revoked_at IS NOT NULL"));
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_printer WHERE id='" + fixture.printerId() + "'"));
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_device_command WHERE device_id='" + fixture.device() + "' AND action='REVOKE'"));
assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_event WHERE event_origin='SERVER_DELETION'"));
assertEquals("CLAIMED", service.getJob(sent).getString("status"));
assertEquals("QUEUED", service.getJob(queued).getString("status"));
execute(fixture, "ALTER TABLE printing_deletion DROP CONSTRAINT deletion_fails");
JSONObject deleted = service.deleteDevice(fixture.device(), "operator");
assertFalse(deleted.getBoolean("credentialRevoked"));
assertEquals(1, deleted.getInt("printers"));
assertEquals(1, deleted.getInt("unknownJobs"));
assertEquals(1, deleted.getInt("cancelledJobs"));
assertEquals("OUTCOME_UNKNOWN", service.getJob(sent).getString("status"));
assertEquals("FAILED", service.getJob(queued).getString("status"));
assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_device WHERE id='" + fixture.device() + "'"));
assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_device_command WHERE device_id='" + fixture.device() + "'"));
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_deletion WHERE kind='DEVICE' AND record_id='" + fixture.device() + "' AND unknown_job_count=1 AND cancelled_job_count=1"));
execute(fixture, "UPDATE printing_job SET device_name=NULL, computer_name=NULL, printer_name=NULL, queue_name=NULL WHERE id='" + sent + "'");
service.initialize();
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_job WHERE id='" + sent + "' AND device_name IS NULL AND printer_name IS NULL"));
}
/***************************************************************************
** A delete of a connected station whose transaction fails is rolled back
** whole: the station keeps its access, its printer and its queued job, and
** the final step says that nothing was changed for it. A foreign key from
** print jobs that the delete runs into is named as the missing schema step.
***************************************************************************/
@Test
void failedDeleteOfALiveStationChangesNothingAndTheForeignKeyIsNamed() throws Exception
{
var fixture = PrintingProcessesTest.fixture();
grantDelete();
PrintingService service = fixture.service();
service.heartbeat(fixture.device(), new JSONObject().put("agentVersion", "test"));
String job = submit(service, fixture.device());
execute(fixture, "ALTER TABLE printing_deletion ADD CONSTRAINT deletion_fails CHECK (kind='NOTHING')");
assertThrows(java.sql.SQLException.class, () -> service.deleteDevice(fixture.device(), "operator"));
assertUntouched(fixture, fixture.device(), job, "QUEUED");
RunBackendStepInput input = input(PrintingDeleteProcesses.DEVICES, fixture.device());
input.addValue(PrintingDeleteProcesses.CONFIRM, true);
QException failed = assertThrows(QException.class, () -> new PrintingDeleteProcesses.Load().runOnePage(input, new RunBackendStepOutput()));
assertEquals("Print station Packing station could not be deleted; nothing was changed for it. Earlier records in this selection were deleted.", failed.getMessage());
assertUntouched(fixture, fixture.device(), job, "QUEUED");
execute(fixture, "ALTER TABLE printing_deletion DROP CONSTRAINT deletion_fails");
execute(fixture, "ALTER TABLE printing_job ADD CONSTRAINT \"printing-job-station\" FOREIGN KEY (device_id) REFERENCES printing_device(id)");
PrintingException refused = assertThrows(PrintingException.class, () -> service.deleteDevice(fixture.device(), "operator"));
assertEquals(409, refused.getStatus());
assertEquals("Print jobs are still tied to their print station by a database foreign key (printing_job.device_id). Apply the schema step of this qbit-printing version, then delete again.", refused.getMessage());
assertUntouched(fixture, fixture.device(), job, "QUEUED");
execute(fixture, "ALTER TABLE printing_job DROP CONSTRAINT \"printing-job-station\"");
JSONObject deleted = service.deleteDevice(fixture.device(), "operator");
assertTrue(deleted.getBoolean("credentialRevoked"));
assertEquals(1, deleted.getInt("cancelledJobs"));
assertEquals("FAILED", service.getJob(job).getString("status"));
}
/***************************************************************************
** A station that passed the review and then started printing is refused by
** the final step, by name, and nothing changes for it.
***************************************************************************/
@Test
void finalStepRefusesAStationThatStartedPrintingAfterTheReview() throws Exception
{
var fixture = PrintingProcessesTest.fixture();
grantDelete();
PrintingService service = fixture.service();
service.heartbeat(fixture.device(), new JSONObject().put("agentVersion", "test"));
String job = submit(service, fixture.device());
RunBackendStepInput input = input(PrintingDeleteProcesses.DEVICES, fixture.device());
input.addValue(PrintingDeleteProcesses.CONFIRM, true);
PrintingDeleteProcesses.Transform review = new PrintingDeleteProcesses.Transform();
RunBackendStepOutput reviewed = new RunBackendStepOutput();
review.runOnePage(input, reviewed);
assertEquals(List.of(fixture.device()), reviewed.getRecords().stream().map(row -> row.getValueString("id")).toList());
assertTrue(refusals(review.getProcessSummary(reviewed, false)).isEmpty());
service.claim(fixture.device());
PrintingDeleteProcesses.Load load = new PrintingDeleteProcesses.Load();
RunBackendStepOutput loaded = new RunBackendStepOutput();
load.runOnePage(input, loaded);
assertTrue(loaded.getRecords().isEmpty());
List<String> refused = refusals(load.getProcessSummary(loaded, true));
assertEquals(1, refused.size());
assertTrue(refused.getFirst().startsWith("Print station Packing station is connected and is printing 1 job right now"), refused.toString());
assertUntouched(fixture, fixture.device(), job, "CLAIMED");
}
/***************************************************************************
** A record another administrator deletes while the review or the final step
** is working on it is reported as no longer existing, and a plan that is
** refused for another reason is reported with the record's name and the
** reason. Neither stops the step.
***************************************************************************/
@Test
void reviewAndFinalStepNameARecordThatWentAwayOrConflicts() throws Exception
{
var fixture = PrintingProcessesTest.fixture();
grantDelete();
String dock = enroll(fixture.service(), "Dock computer").getString("deviceId");
PrintingService racing = new PrintingService(source(fixture), "tenant-a")
{
// Another administrator deletes one station while its plan is read; the other is refused for a reason of its own.
@Override
public JSONObject deviceDeletionPlan(String deviceId) throws java.sql.SQLException
{
if(!deviceId.equals(fixture.otherDevice()))
{
throw new PrintingException(409, "It is being changed by another administrator");
}
super.deleteDevice(deviceId, "another administrator");
return super.deviceDeletionPlan(deviceId);
}
// Another administrator deletes the station just before this delete reaches it.
@Override
public JSONObject deleteDevice(String deviceId, String actor) throws java.sql.SQLException
{
super.deleteDevice(deviceId, "another administrator");
return super.deleteDevice(deviceId, actor);
}
};
fixture.instance().getQBits().values().stream().map(metadata -> metadata.getConfig()).filter(PrintingQBitConfig.class::isInstance).map(PrintingQBitConfig.class::cast).forEach(config -> config.withService(racing));
RunBackendStepInput input = input(PrintingDeleteProcesses.DEVICES, fixture.otherDevice(), fixture.device());
input.addValue(PrintingDeleteProcesses.CONFIRM, true);
PrintingDeleteProcesses.Transform review = new PrintingDeleteProcesses.Transform();
RunBackendStepOutput reviewed = new RunBackendStepOutput();
review.runOnePage(input, reviewed);
assertTrue(reviewed.getRecords().isEmpty());
assertEquals(List.of("Print station Other station no longer exists", "Print station Packing station: It is being changed by another administrator"), refusals(review.getProcessSummary(reviewed, false)));
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_device WHERE id='" + fixture.device() + "' AND enabled=1 AND revoked_at IS NULL"));
RunBackendStepInput last = input(PrintingDeleteProcesses.DEVICES, dock);
last.addValue(PrintingDeleteProcesses.CONFIRM, true);
PrintingDeleteProcesses.Load load = new PrintingDeleteProcesses.Load();
RunBackendStepOutput loaded = new RunBackendStepOutput();
load.runOnePage(last, loaded);
assertTrue(loaded.getRecords().isEmpty());
assertEquals(List.of("Print station Dock computer no longer exists"), refusals(load.getProcessSummary(loaded, true)));
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_deletion WHERE kind='DEVICE' AND record_id='" + dock + "' AND actor='another administrator'"));
}
/***************************************************************************
** The approval route of the HTTP API answers for a removed printer with the
** text it had before stations were renamed in the administrator's screens.
***************************************************************************/
@Test
void approvalOfARemovedPrinterKeepsItsHttpApiText() throws Exception
{
var fixture = PrintingProcessesTest.fixture();
fixture.service().removePrinter(fixture.device(), "laser");
PrintingException refused = assertThrows(PrintingException.class, () -> fixture.service().enablePrinter(fixture.device(), "laser", true));
assertEquals(409, refused.getStatus());
assertEquals("Printer was removed; share it from the device again before changing its approval", refused.getMessage());
}
/***************************************************************************
** Nothing of a refused or failed delete is left behind: the station keeps
** its access and its printers, the job its status, and there is no deletion
** record, deletion event or control receipt.
***************************************************************************/
private static void assertUntouched(PrintingProcessesTest.Fixture fixture, String station, String job, String status) throws Exception
{
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_device WHERE id='" + station + "' AND enabled=1 AND revoked_at IS NULL"));
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_printer WHERE device_id='" + station + "'"));
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_job WHERE id='" + job + "' AND status='" + status + "'"));
assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_deletion"));
assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_event WHERE event_origin='SERVER_DELETION'"));
assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_device_command WHERE device_id='" + station + "' AND action='REVOKE'"));
}
/***************************************************************************
** Return once a database session is waiting for a lock that another holds.
***************************************************************************/
private static void awaitSessionWaitingForALock(PrintingProcessesTest.Fixture fixture) throws Exception
{
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(60);
while(count(fixture, "SELECT COUNT(*) FROM INFORMATION_SCHEMA.SESSIONS WHERE BLOCKER_ID IS NOT NULL") == 0)
{
assertTrue(System.nanoTime() < deadline, "No session waited for a lock");
TimeUnit.MILLISECONDS.sleep(10);
}
}
/***************************************************************************
** The reasons listed on the line of records that are not deleted.
***************************************************************************/
private static List<String> refusals(List<ProcessSummaryLineInterface> lines)
{
return lines.stream().map(ProcessSummaryLine.class::cast).filter(line -> line.getStatus() == Status.ERROR).flatMap(line -> line.getBulletsOfText().stream()).toList();
}
/***************************************************************************
** The fixture's database as a data source, for another service on it.
***************************************************************************/
private static DriverDataSource source(PrintingProcessesTest.Fixture fixture)
{
var backend = (com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData) fixture.instance().getBackends().values().iterator().next();
return new DriverDataSource(backend.getJdbcUrl(), "sa", "");
}
/***************************************************************************
** A step that arranges what another actor does at a chosen moment.
***************************************************************************/
@FunctionalInterface
private interface Step
{
/************************************************************************
** Do it.
************************************************************************/
void run() throws Exception;
}
/***************************************************************************
** A service on the fixture's database that runs the step once, immediately
** before the first statement that takes a row lock (FOR UPDATE): whatever
** the service read earlier it read without a lock.
***************************************************************************/
private static PrintingService beforeFirstLock(PrintingProcessesTest.Fixture fixture, Step step)
{
DriverDataSource real = source(fixture);
AtomicBoolean ran = new AtomicBoolean();
DataSource hooked = (DataSource) Proxy.newProxyInstance(DataSource.class.getClassLoader(), new Class<?>[] {DataSource.class}, (proxy, method, arguments) ->
{
Object result = invoke(method, real, arguments);
if(!(result instanceof Connection connection))
{
return result;
}
return Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class<?>[] {Connection.class}, (ignored, call, values) ->
{
if("prepareStatement".equals(call.getName()) && String.valueOf(values[0]).contains("FOR UPDATE") && ran.compareAndSet(false, true))
{
step.run();
}
return invoke(call, connection, values);
});
});
return new PrintingService(hooked, "tenant-a");
}
/***************************************************************************
** Call through a proxy and surface the real failure, not its wrapper.
***************************************************************************/
private static Object invoke(Method method, Object target, Object[] arguments) throws Throwable
{
try
{
return method.invoke(target, arguments);
}
catch(InvocationTargetException exception)
{
throw exception.getCause();
}
}
/***************************************************************************
** The aggregate asks for permission enforcement as a user's request does.
***************************************************************************/