diff --git a/pom.xml b/pom.xml
index 1438195..f2e31e8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -72,6 +72,20 @@
qqq-backend-core
+
+
+ com.kingsrook.qqq
+ qqq-middleware-health
+ true
+
+
+
+
+ com.kingsrook.qqq
+ qqq-esb
+ true
+
+
org.junit.jupiter
junit-jupiter
diff --git a/src/main/java/com/kingsrook/qbits/backups/BackupsQBitConfig.java b/src/main/java/com/kingsrook/qbits/backups/BackupsQBitConfig.java
index a67d580..31338a4 100644
--- a/src/main/java/com/kingsrook/qbits/backups/BackupsQBitConfig.java
+++ b/src/main/java/com/kingsrook/qbits/backups/BackupsQBitConfig.java
@@ -19,6 +19,9 @@ package com.kingsrook.qbits.backups;
import java.util.List;
import java.util.function.Function;
+import java.util.function.Predicate;
+import com.kingsrook.qbits.backups.codec.ArtifactTransform;
+import com.kingsrook.qbits.backups.verify.ScratchTarget;
import com.kingsrook.qbits.backups.storage.BackupStorage;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
@@ -52,6 +55,12 @@ public class BackupsQBitConfig implements QBitConfig
private Function storageResolver;
+ private Integer fileReverifyDays = 30;
+ private Function artifactTransformResolver;
+ private ScratchTarget scratchTarget;
+ private Predicate planOwnerValidator;
+ private String esbDestinationName;
+
/*******************************************************************************
@@ -315,4 +324,109 @@ public class BackupsQBitConfig implements QBitConfig
return (this);
}
+ /*******************************************************************************
+ ** Getter for fileReverifyDays: CONTENT verification skips file-store objects
+ ** verified more recently than this.
+ *******************************************************************************/
+ public Integer getFileReverifyDays()
+ {
+ return (fileReverifyDays);
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for fileReverifyDays
+ *******************************************************************************/
+ public BackupsQBitConfig withFileReverifyDays(Integer fileReverifyDays)
+ {
+ this.fileReverifyDays = fileReverifyDays;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for artifactTransformResolver (the decrypt hook for verify)
+ *******************************************************************************/
+ public Function getArtifactTransformResolver()
+ {
+ return (artifactTransformResolver);
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for artifactTransformResolver: encryption key reference (the name
+ ** of a key in a key provider) to the transform that decrypts what that key wrote.
+ *******************************************************************************/
+ public BackupsQBitConfig withArtifactTransformResolver(Function artifactTransformResolver)
+ {
+ this.artifactTransformResolver = artifactTransformResolver;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for scratchTarget (where TEST_RESTORE restores to; null means MEMORY)
+ *******************************************************************************/
+ public ScratchTarget getScratchTarget()
+ {
+ return (scratchTarget);
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for scratchTarget
+ *******************************************************************************/
+ public BackupsQBitConfig withScratchTarget(ScratchTarget scratchTarget)
+ {
+ this.scratchTarget = scratchTarget;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for planOwnerValidator (true when the owner still exists and holds backups.admin)
+ *******************************************************************************/
+ public Predicate getPlanOwnerValidator()
+ {
+ return (planOwnerValidator);
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for planOwnerValidator: with none, plan owners are not checked.
+ *******************************************************************************/
+ public BackupsQBitConfig withPlanOwnerValidator(Predicate planOwnerValidator)
+ {
+ this.planOwnerValidator = planOwnerValidator;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for esbDestinationName (ESB events are published here, when set)
+ *******************************************************************************/
+ public String getEsbDestinationName()
+ {
+ return (esbDestinationName);
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for esbDestinationName
+ *******************************************************************************/
+ public BackupsQBitConfig withEsbDestinationName(String esbDestinationName)
+ {
+ this.esbDestinationName = esbDestinationName;
+ return (this);
+ }
+
}
diff --git a/src/main/java/com/kingsrook/qbits/backups/BackupsQBitProducer.java b/src/main/java/com/kingsrook/qbits/backups/BackupsQBitProducer.java
index 7d45d87..44710e3 100644
--- a/src/main/java/com/kingsrook/qbits/backups/BackupsQBitProducer.java
+++ b/src/main/java/com/kingsrook/qbits/backups/BackupsQBitProducer.java
@@ -119,6 +119,14 @@ public class BackupsQBitProducer implements QBitMetaDataProducer
+ ** no SPI classes on the class path: {@link #bindIfPresent()} binds nothing
+ ** and the sink stays the no-op;
+ ** the SPI is present but the instance has no alerts meta-data (the Alerts
+ ** qbit is not installed): every call is a no-op;
+ ** any reflective or SPI failure is logged once and swallowed, because an
+ ** alerting problem must never fail a backup.
+ **
+ ** Replace with a direct binding (QAlerts.raise and friends) when RC.5 ships.
+ **
+ ** Mapping: raise to QAlerts.raise(AlertSignal), resolve to QAlerts.resolve(type,
+ ** dedupKey, note), heartbeat to QAlerts.heartbeat(key). The SPI has no per-plan
+ ** heartbeat registration (monitors are declared on the alert type), so the
+ ** expected interval and grace are not passed on; the SLA checker raises
+ ** backups.plan.missed itself, independent of any heartbeat (spec 10.4).
+ *******************************************************************************/
+public class AlertsQBitAlertSink implements BackupAlertSink
+{
+ private static final QLogger LOG = QLogger.getLogger(AlertsQBitAlertSink.class);
+
+ /** The package of the Alerts producer SPI in qqq-backend-core. */
+ public static final String DEFAULT_API_PACKAGE = "com.kingsrook.qqq.backend.core.alerts";
+
+ private final String apiPackage;
+
+ private boolean warned = false;
+
+ private Class> qAlerts;
+ private Class> alertSignal;
+ private Class> alertSeverity;
+ private Class> instanceMetaData;
+
+
+
+ /*******************************************************************************
+ ** Constructor for the real SPI package.
+ *******************************************************************************/
+ public AlertsQBitAlertSink()
+ {
+ this(DEFAULT_API_PACKAGE);
+ }
+
+
+
+ /*******************************************************************************
+ ** Constructor for another package (a test double of the SPI shape).
+ *******************************************************************************/
+ public AlertsQBitAlertSink(String apiPackage)
+ {
+ this.apiPackage = apiPackage;
+ }
+
+
+
+ /*******************************************************************************
+ ** Whether the SPI classes are on the class path.
+ *******************************************************************************/
+ public static boolean isPresent(String apiPackage)
+ {
+ try
+ {
+ Class.forName(apiPackage + ".QAlerts");
+ Class.forName(apiPackage + ".AlertSignal");
+ return (true);
+ }
+ catch(ClassNotFoundException | LinkageError e)
+ {
+ return (false);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Bind this adapter as the runtime's alert sink when the SPI is present and no
+ ** other sink was bound already. Returns whether it bound.
+ *******************************************************************************/
+ public static boolean bindIfPresent()
+ {
+ if(!(BackupsRuntime.getAlertSink() instanceof NoOpBackupAlertSink) || !isPresent(DEFAULT_API_PACKAGE))
+ {
+ return (false);
+ }
+ BackupsRuntime.setAlertSink(new AlertsQBitAlertSink());
+ return (true);
+ }
+
+
+
+ /*******************************************************************************
+ ** Declare the backup alert types with the Alerts qbit (QAlertTypeMetaData), when
+ ** the SPI is present. Returns how many were added.
+ *******************************************************************************/
+ public int registerAlertTypes(QInstance qInstance)
+ {
+ int added = 0;
+ try
+ {
+ Class> typeClass = Class.forName(apiPackage + ".QAlertTypeMetaData");
+ load();
+ for(BackupAlerts.AlertType alertType : BackupAlerts.alertTypes())
+ {
+ try
+ {
+ Object type = typeClass.getConstructor().newInstance();
+ typeClass.getMethod("withName", String.class).invoke(type, alertType.name());
+ typeClass.getMethod("withLabel", String.class).invoke(type, alertType.label());
+ typeClass.getMethod("withGroupName", String.class).invoke(type, "Backups");
+ typeClass.getMethod("withHowToFix", String.class).invoke(type, alertType.howToFix());
+ typeClass.getMethod("withDefaultSeverity", alertSeverity).invoke(type, severity(alertType.defaultSeverity()));
+ typeClass.getMethod("addSelfToInstance", QInstance.class).invoke(type, qInstance);
+ added++;
+ }
+ catch(ReflectiveOperationException | RuntimeException e)
+ {
+ warnOnce("Could not declare the alert type " + alertType.name(), e);
+ }
+ }
+ }
+ catch(ReflectiveOperationException | LinkageError e)
+ {
+ warnOnce("The alerts SPI is not available", e);
+ }
+ return (added);
+ }
+
+
+
+ /*******************************************************************************
+ ** {@inheritDoc}
+ *******************************************************************************/
+ @Override
+ public void raise(String type, String dedupeKey, Severity severity, String title, Map detail)
+ {
+ if(!isAlertsQBitInstalled())
+ {
+ return;
+ }
+ try
+ {
+ Object signal = alertSignal.getConstructor(String.class).newInstance(type);
+ alertSignal.getMethod("setDedupKey", String.class).invoke(signal, dedupeKey);
+ alertSignal.getMethod("setSeverity", alertSeverity).invoke(signal, severity(severity));
+ alertSignal.getMethod("setTitle", String.class).invoke(signal, title);
+ if(detail != null && !detail.isEmpty())
+ {
+ alertSignal.getMethod("setSummary", String.class).invoke(signal, detail.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining(", ")));
+ Method withAttribute = alertSignal.getMethod("withAttribute", String.class, String.class);
+ for(Map.Entry entry : detail.entrySet())
+ {
+ withAttribute.invoke(signal, entry.getKey(), entry.getValue());
+ }
+ }
+ qAlerts.getMethod("raise", alertSignal).invoke(null, signal);
+ }
+ catch(ReflectiveOperationException | RuntimeException e)
+ {
+ warnOnce("Could not raise an alert", e);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** {@inheritDoc}
+ *******************************************************************************/
+ @Override
+ public void resolve(String type, String dedupeKey)
+ {
+ if(!isAlertsQBitInstalled())
+ {
+ return;
+ }
+ try
+ {
+ qAlerts.getMethod("resolve", String.class, String.class, String.class).invoke(null, type, dedupeKey, "Cleared by the Backups qbit");
+ }
+ catch(ReflectiveOperationException | RuntimeException e)
+ {
+ warnOnce("Could not resolve an alert", e);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** {@inheritDoc}
+ *******************************************************************************/
+ @Override
+ public void heartbeat(String monitorKey, long expectedEverySeconds, long graceSeconds)
+ {
+ if(!isAlertsQBitInstalled())
+ {
+ return;
+ }
+ try
+ {
+ qAlerts.getMethod("heartbeat", String.class).invoke(null, monitorKey);
+ }
+ catch(ReflectiveOperationException | RuntimeException e)
+ {
+ warnOnce("Could not check in a heartbeat", e);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** The SPI classes load, and the instance has alerts meta-data (the Alerts qbit
+ ** is installed). Checked on every call so the order qbits are produced in
+ ** does not matter.
+ *******************************************************************************/
+ boolean isAlertsQBitInstalled()
+ {
+ try
+ {
+ load();
+ QInstance qInstance = QContext.getQInstance();
+ if(qInstance == null)
+ {
+ return (false);
+ }
+ return (instanceMetaData.getMethod("of", QInstance.class).invoke(null, qInstance) != null);
+ }
+ catch(ReflectiveOperationException | RuntimeException | LinkageError e)
+ {
+ warnOnce("The alerts SPI is not usable", e);
+ return (false);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private synchronized void load() throws ClassNotFoundException
+ {
+ if(qAlerts == null)
+ {
+ qAlerts = Class.forName(apiPackage + ".QAlerts");
+ alertSignal = Class.forName(apiPackage + ".AlertSignal");
+ alertSeverity = Class.forName(apiPackage + ".AlertSeverity");
+ instanceMetaData = Class.forName(apiPackage + ".AlertsInstanceMetaData");
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Map our severity to the SPI's enum constant of the same name.
+ *******************************************************************************/
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ private Object severity(Severity severity)
+ {
+ return (Enum.valueOf((Class) alertSeverity, severity.name()));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private void warnOnce(String message, Throwable t)
+ {
+ if(!warned)
+ {
+ warned = true;
+ LOG.warn(message + " (the Alerts qbit adapter will keep failing quietly)", t, logPair("apiPackage", apiPackage));
+ }
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/alerts/BackupAlerts.java b/src/main/java/com/kingsrook/qbits/backups/alerts/BackupAlerts.java
new file mode 100644
index 0000000..e429635
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/alerts/BackupAlerts.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.alerts;
+
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import com.kingsrook.qbits.backups.BackupsRuntime;
+
+
+/*******************************************************************************
+ ** Every alert the Backups qbit raises (design spec section 10.4), with stable
+ ** dedupe keys, and helpers that send them through the bound BackupAlertSink.
+ **
+ ** A dedupe key is the alert type plus a plan key (or a run key, or a plan key
+ ** and a verify level), so a repeated condition refreshes one alert and a cleared
+ ** condition resolves exactly that alert. Details carry counts and names only:
+ ** never row values, object keys with secrets in them, or presigned URLs.
+ *******************************************************************************/
+public final class BackupAlerts
+{
+ public static final String PLAN_MISSED = "backups.plan.missed";
+ public static final String RUN_FAILED = "backups.run.failed";
+ public static final String RUN_MISSING = "backups.run.missing_in_storage";
+ public static final String FILES_MISSING = "backups.files.missing";
+ public static final String VERIFY_FAILED = "backups.verify.failed";
+ public static final String VERIFY_OVERDUE = "backups.verify.overdue";
+ public static final String PLAN_OWNER_INVALID = "backups.plan.owner_invalid";
+ public static final String STORAGE_DEBRIS = "backups.storage.debris";
+ public static final String RESTORE_COMPLETED = "backups.restore.completed";
+
+ /** The prefix of the heartbeat monitor key of a plan. */
+ public static final String HEARTBEAT_PREFIX = "backups.plan:";
+
+
+
+ /***************************************************************************
+ ** A declared alert type, for the Alerts qbit's type registry.
+ **
+ ** @param name the alert type name
+ ** @param label a short label
+ ** @param defaultSeverity the severity when a signal does not say
+ ** @param howToFix one sentence for the person looking at the alert
+ ***************************************************************************/
+ public record AlertType(String name, String label, BackupAlertSink.Severity defaultSeverity, String howToFix)
+ {
+ }
+
+
+
+ /*******************************************************************************
+ ** Utility class.
+ *******************************************************************************/
+ private BackupAlerts()
+ {
+ }
+
+
+
+ /*******************************************************************************
+ ** The alert types of the spec table (section 10.4), in table order.
+ *******************************************************************************/
+ public static List alertTypes()
+ {
+ return (List.of(
+ new AlertType(PLAN_MISSED, "Backup did not run", BackupAlertSink.Severity.ERROR,
+ "Open the plan and its latest runs, check the schedule and the scheduler, then run the backup by hand."),
+ new AlertType(RUN_FAILED, "Backup run failed", BackupAlertSink.Severity.WARNING,
+ "Open the failed run for the error, fix the cause, and run the plan again."),
+ new AlertType(RUN_MISSING, "Backup is missing from storage", BackupAlertSink.Severity.CRITICAL,
+ "The catalog says the run succeeded but its manifest is gone from the bucket. Check the bucket, its lifecycle rules and the credentials, then run a new backup."),
+ new AlertType(FILES_MISSING, "Backed up files were missing at the source", BackupAlertSink.Severity.WARNING,
+ "Open the run's file index: records point at files that no longer exist."),
+ new AlertType(VERIFY_FAILED, "Backup verification failed", BackupAlertSink.Severity.ERROR,
+ "Open the verification for the mismatches. Treat the run as unusable and take a new backup."),
+ new AlertType(VERIFY_OVERDUE, "Backup verification is overdue", BackupAlertSink.Severity.WARNING,
+ "Run Verify backup on a recent run of the plan, or set a verify schedule on the plan."),
+ new AlertType(PLAN_OWNER_INVALID, "Backup plan owner is not valid", BackupAlertSink.Severity.WARNING,
+ "Set an owner who exists and holds backups.admin on the plan."),
+ new AlertType(STORAGE_DEBRIS, "Backup storage has debris or orphans", BackupAlertSink.Severity.INFO,
+ "Prune clears debris. Orphan prefixes can be imported into the catalog or deleted."),
+ new AlertType(RESTORE_COMPLETED, "A restore finished", BackupAlertSink.Severity.INFO,
+ "Nothing to fix unless the restore failed. Open the restore run.")));
+ }
+
+
+
+ /*******************************************************************************
+ ** The stable dedupe key for a type and an id (plan key, run key...).
+ *******************************************************************************/
+ public static String key(String type, String id)
+ {
+ return (type + ":" + id);
+ }
+
+
+
+ /*******************************************************************************
+ ** The dedupe key of a verify failure: it includes the level so a pass at a
+ ** higher level clears the lower failures, and a cheap pass never hides a
+ ** content failure.
+ *******************************************************************************/
+ public static String verifyKey(String planKey, String level)
+ {
+ return (VERIFY_FAILED + ":" + planKey + ":" + level);
+ }
+
+
+
+ /*******************************************************************************
+ ** Raise (or refresh) an alert. Never throws: an alerting problem must not fail
+ ** a backup or a check.
+ *******************************************************************************/
+ public static void raise(String type, String id, BackupAlertSink.Severity severity, String title, String... detailPairs)
+ {
+ try
+ {
+ Map detail = new LinkedHashMap<>();
+ for(int i = 0; i + 1 < detailPairs.length; i += 2)
+ {
+ detail.put(detailPairs[i], detailPairs[i + 1]);
+ }
+ BackupsRuntime.getAlertSink().raise(type, key(type, id), severity, title, detail);
+ }
+ catch(RuntimeException e)
+ {
+ // fire and forget
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Raise with an explicit dedupe key.
+ *******************************************************************************/
+ public static void raiseWithKey(String type, String dedupeKey, BackupAlertSink.Severity severity, String title, Map detail)
+ {
+ try
+ {
+ BackupsRuntime.getAlertSink().raise(type, dedupeKey, severity, title, detail);
+ }
+ catch(RuntimeException e)
+ {
+ // fire and forget
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Resolve the alert of a type and id. Never throws.
+ *******************************************************************************/
+ public static void resolve(String type, String id)
+ {
+ resolveKey(type, key(type, id));
+ }
+
+
+
+ /*******************************************************************************
+ ** Resolve an alert by its dedupe key. Never throws.
+ *******************************************************************************/
+ public static void resolveKey(String type, String dedupeKey)
+ {
+ try
+ {
+ BackupsRuntime.getAlertSink().resolve(type, dedupeKey);
+ }
+ catch(RuntimeException e)
+ {
+ // fire and forget
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** A restore finished: an info alert, so restores are never silent (error when
+ ** it failed). The Alerts qbit clears it after 7 days; nothing resolves it here.
+ *******************************************************************************/
+ public static void restoreCompleted(String restoreRunKey, String status, String planKey, long rowsRestored)
+ {
+ boolean failed = "FAILED".equals(status);
+ raise(RESTORE_COMPLETED, restoreRunKey, failed ? BackupAlertSink.Severity.ERROR : BackupAlertSink.Severity.INFO,
+ failed ? "A restore failed" : "A restore finished",
+ "restoreRun", restoreRunKey, "status", String.valueOf(status), "plan", String.valueOf(planKey), "rows", String.valueOf(rowsRestored));
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/events/BackupEvents.java b/src/main/java/com/kingsrook/qbits/backups/events/BackupEvents.java
new file mode 100644
index 0000000..3a18da3
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/events/BackupEvents.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.events;
+
+
+import java.io.Serializable;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.function.BiConsumer;
+import com.kingsrook.qbits.backups.BackupsRuntime;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+
+
+/*******************************************************************************
+ ** ESB events of the Backups qbit (design spec section 10.4): qqq.backups.run.succeeded,
+ ** qqq.backups.run.failed and qqq.backups.restore.completed. They are for
+ ** integrations, not alerting, and are FIRE AND FORGET: a failed publish is logged
+ ** at most once and never fails a backup or a restore.
+ **
+ ** Events go out only when BackupsQBitConfig.esbDestinationName is set and the ESB
+ ** (qqq-esb, an optional dependency) is on the class path; otherwise publish() does
+ ** nothing. A test, or an app with its own bus, can bind a publisher with
+ ** {@link #setPublisher}.
+ *******************************************************************************/
+public final class BackupEvents
+{
+ private static final QLogger LOG = QLogger.getLogger(BackupEvents.class);
+
+ public static final String RUN_SUCCEEDED = "qqq.backups.run.succeeded";
+ public static final String RUN_FAILED = "qqq.backups.run.failed";
+ public static final String RESTORE_COMPLETED = "qqq.backups.restore.completed";
+
+ private static volatile BiConsumer> publisher;
+ private static volatile boolean warned = false;
+
+
+
+ /*******************************************************************************
+ ** Utility class.
+ *******************************************************************************/
+ private BackupEvents()
+ {
+ }
+
+
+
+ /*******************************************************************************
+ ** Bind a publisher (event type, data), or null to go back to the ESB default.
+ *******************************************************************************/
+ public static void setPublisher(BiConsumer> newPublisher)
+ {
+ publisher = newPublisher;
+ warned = false;
+ }
+
+
+
+ /*******************************************************************************
+ ** A run finished. Succeeded and warnings publish run.succeeded; failed, timed
+ ** out and abandoned publish run.failed; cancelled publishes nothing.
+ *******************************************************************************/
+ public static void runFinished(String planKey, String runKey, String status, long rows, long bytesStored)
+ {
+ String type;
+ if("SUCCEEDED".equals(status) || "SUCCEEDED_WITH_WARNINGS".equals(status))
+ {
+ type = RUN_SUCCEEDED;
+ }
+ else if("CANCELLED".equals(status))
+ {
+ return;
+ }
+ else
+ {
+ type = RUN_FAILED;
+ }
+ Map data = new LinkedHashMap<>();
+ data.put("plan", planKey);
+ data.put("run", runKey);
+ data.put("status", status);
+ data.put("rows", rows);
+ data.put("bytesStored", bytesStored);
+ publish(type, data);
+ }
+
+
+
+ /*******************************************************************************
+ ** A restore finished (any status).
+ *******************************************************************************/
+ public static void restoreCompleted(String restoreRunKey, String status, long rowsRestored)
+ {
+ Map data = new LinkedHashMap<>();
+ data.put("restoreRun", restoreRunKey);
+ data.put("status", status);
+ data.put("rows", rowsRestored);
+ publish(RESTORE_COMPLETED, data);
+ }
+
+
+
+ /*******************************************************************************
+ ** Publish an event. Never throws.
+ *******************************************************************************/
+ public static void publish(String type, Map data)
+ {
+ try
+ {
+ BiConsumer> bound = publisher;
+ if(bound != null)
+ {
+ bound.accept(type, data);
+ return;
+ }
+
+ String destination = BackupsRuntime.getConfig() == null ? null : BackupsRuntime.getConfig().getEsbDestinationName();
+ if(destination == null)
+ {
+ return;
+ }
+ EsbBackupEventPublisher.publish(destination, type, data);
+ }
+ catch(Exception | LinkageError e)
+ {
+ if(!warned)
+ {
+ warned = true;
+ LOG.warn("A backup event could not be published (events are best effort)", e);
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/events/EsbBackupEventPublisher.java b/src/main/java/com/kingsrook/qbits/backups/events/EsbBackupEventPublisher.java
new file mode 100644
index 0000000..89e66d6
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/events/EsbBackupEventPublisher.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.events;
+
+
+import java.io.Serializable;
+import java.util.Map;
+import com.kingsrook.qqq.esb.publish.EsbPublishAction;
+import com.kingsrook.qqq.esb.publish.EsbPublishInput;
+
+
+/*******************************************************************************
+ ** Publishes a backup event on the QQQ ESB. This is the only class that touches
+ ** qqq-esb (an optional dependency); BackupEvents calls it inside a guard that
+ ** swallows a missing class (NoClassDefFoundError), so apps without the ESB never
+ ** notice.
+ *******************************************************************************/
+final class EsbBackupEventPublisher
+{
+ /*******************************************************************************
+ ** Utility class.
+ *******************************************************************************/
+ private EsbBackupEventPublisher()
+ {
+ }
+
+
+
+ /*******************************************************************************
+ ** Publish one event to a destination.
+ *******************************************************************************/
+ static void publish(String destinationName, String type, Map data)
+ {
+ new EsbPublishAction().execute(new EsbPublishInput()
+ .withDestinationName(destinationName)
+ .withType(type)
+ .withSourcePath("/backups")
+ .withData(data));
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/health/BackupsHealthIndicator.java b/src/main/java/com/kingsrook/qbits/backups/health/BackupsHealthIndicator.java
new file mode 100644
index 0000000..26511f1
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/health/BackupsHealthIndicator.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.health;
+
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import com.kingsrook.qbits.backups.model.BackupsTables;
+import com.kingsrook.qbits.backups.run.BackupCatalog;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+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.QQueryFilter;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.session.QSession;
+import com.kingsrook.qqq.middleware.health.HealthIndicator;
+import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult;
+import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
+
+
+/*******************************************************************************
+ ** The Backups health indicator (design spec section 10.4), for
+ ** qqq-middleware-health (an optional dependency: an app that does not have that
+ ** module never loads this class). It reports DOWN if any active plan marked
+ ** critical is LATE, FAILING or MISSING_IN_STORAGE, and UP with details otherwise.
+ ** It reads the slaStatus the SLA checker maintains; it never touches storage, so
+ ** a health call is cheap.
+ **
+ ** It is OFF the liveness path by design. Add it to the indicators of the
+ ** instance's health check (the readiness and monitoring view), and do not point a
+ ** Kubernetes liveness probe at a check that includes it: a backup problem must
+ ** never restart pods.
+ *******************************************************************************/
+public class BackupsHealthIndicator implements HealthIndicator
+{
+ public static final String NAME = "backups";
+
+ private static final Set CRITICAL_STATUSES = Set.of("LATE", "FAILING", "MISSING_IN_STORAGE");
+
+
+
+ /*******************************************************************************
+ ** {@inheritDoc}
+ *******************************************************************************/
+ @Override
+ public String getName()
+ {
+ return (NAME);
+ }
+
+
+
+ /*******************************************************************************
+ ** {@inheritDoc}
+ *******************************************************************************/
+ @Override
+ public HealthCheckResult check(QInstance qInstance) throws QException
+ {
+ long started = System.currentTimeMillis();
+ boolean ownsContext = QContext.getQInstance() == null;
+ if(ownsContext)
+ {
+ QContext.init(qInstance, new QSession());
+ }
+ try
+ {
+ List plans = new BackupCatalog().query(BackupsTables.PLAN, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)));
+
+ List criticalProblems = new ArrayList<>();
+ Map byStatus = new LinkedHashMap<>();
+ for(QRecord plan : plans)
+ {
+ String status = plan.getValueString("slaStatus") == null ? "UNKNOWN" : plan.getValueString("slaStatus");
+ byStatus.merge(status, 1, Integer::sum);
+ if(Boolean.TRUE.equals(plan.getValueBoolean("critical")) && CRITICAL_STATUSES.contains(status))
+ {
+ criticalProblems.add(plan.getValueString("planKey") + ": " + status);
+ }
+ }
+
+ Map details = new LinkedHashMap<>();
+ details.put("activePlans", plans.size());
+ details.put("plansByStatus", byStatus);
+ if(!criticalProblems.isEmpty())
+ {
+ details.put("criticalPlansWithProblems", criticalProblems);
+ }
+ return (new HealthCheckResult()
+ .withStatus(criticalProblems.isEmpty() ? HealthStatus.UP : HealthStatus.DOWN)
+ .withDurationMs(System.currentTimeMillis() - started)
+ .withDetails(details));
+ }
+ finally
+ {
+ if(ownsContext)
+ {
+ QContext.clear();
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/metrics/BackupsMetrics.java b/src/main/java/com/kingsrook/qbits/backups/metrics/BackupsMetrics.java
new file mode 100644
index 0000000..55abc26
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/metrics/BackupsMetrics.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.metrics;
+
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import com.kingsrook.qbits.backups.BackupsQBitConfig;
+import com.kingsrook.qbits.backups.BackupsRuntime;
+import com.kingsrook.qbits.backups.model.BackupsTables;
+import com.kingsrook.qbits.backups.run.BackupCatalog;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+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.QQueryFilter;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+
+
+/*******************************************************************************
+ ** The optional Prometheus text for operators who already run Alertmanager (design
+ ** spec section 10.4), computed from the tables and cached for 60 seconds. Gauges,
+ ** all labeled {instance_name, plan}:
+ **
+ ** - qqq_backup_last_success_timestamp_seconds
+ ** - qqq_backup_last_run_failed (1 when the latest finished run failed)
+ ** - qqq_backup_sla_seconds
+ ** - qqq_backup_last_verify_success_timestamp_seconds
+ ** - qqq_backup_last_success_bytes
+ **
+ ** The rule worth having even alongside the Alerts qbit is
+ ** absent_over_time(qqq_backup_last_success_timestamp_seconds[30m]): the one signal
+ ** that still fires when the whole app, and so the Alerts qbit with it, is down.
+ **
+ ** ENDPOINT STUB: this class renders the text. Serving it at GET /metrics/backups
+ ** (when BackupsQBitConfig.withPrometheusEndpoint(true)) is a route in
+ ** qqq-middleware-javalin, which this qbit does not depend on; the app's Javalin
+ ** route provider calls {@link #renderCached()}. {@link #isEnabled()} says whether
+ ** the config asks for the endpoint.
+ *******************************************************************************/
+public final class BackupsMetrics
+{
+ public static final String ENDPOINT_PATH = "/metrics/backups";
+
+ private static final Duration CACHE = Duration.ofSeconds(60);
+
+ private static String cachedText;
+ private static Instant cachedAt;
+
+
+
+ /*******************************************************************************
+ ** Utility class.
+ *******************************************************************************/
+ private BackupsMetrics()
+ {
+ }
+
+
+
+ /*******************************************************************************
+ ** Whether the config asks for the endpoint.
+ *******************************************************************************/
+ public static boolean isEnabled()
+ {
+ BackupsQBitConfig config = BackupsRuntime.getConfig();
+ return (config != null && Boolean.TRUE.equals(config.getPrometheusEndpoint()));
+ }
+
+
+
+ /*******************************************************************************
+ ** Forget the cached text (tests).
+ *******************************************************************************/
+ public static synchronized void resetCache()
+ {
+ cachedText = null;
+ cachedAt = null;
+ }
+
+
+
+ /*******************************************************************************
+ ** The text, from the cache when it is under 60 seconds old.
+ *******************************************************************************/
+ public static synchronized String renderCached() throws QException
+ {
+ Instant now = BackupsRuntime.getClock().instant();
+ if(cachedText == null || cachedAt == null || cachedAt.plus(CACHE).isBefore(now))
+ {
+ cachedText = render();
+ cachedAt = now;
+ }
+ return (cachedText);
+ }
+
+
+
+ /*******************************************************************************
+ ** Render the text format now.
+ *******************************************************************************/
+ public static String render() throws QException
+ {
+ BackupCatalog catalog = new BackupCatalog();
+ BackupsQBitConfig config = BackupsRuntime.getConfig();
+ String instance = config != null && config.getInstanceName() != null ? config.getInstanceName() : "qqq";
+
+ StringBuilder lastSuccess = header("qqq_backup_last_success_timestamp_seconds", "Unix time of the last successful backup run");
+ StringBuilder lastFailed = header("qqq_backup_last_run_failed", "1 when the latest finished run of the plan failed");
+ StringBuilder sla = header("qqq_backup_sla_seconds", "Maximum age of the last successful run before the plan is late");
+ StringBuilder lastVerify = header("qqq_backup_last_verify_success_timestamp_seconds", "Unix time of the last passed content or test-restore verification");
+ StringBuilder lastBytes = header("qqq_backup_last_success_bytes", "Bytes stored by the last successful run");
+
+ List plans = catalog.query(BackupsTables.PLAN, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)));
+ for(QRecord plan : plans)
+ {
+ String labels = "{instance_name=\"" + escape(instance) + "\",plan=\"" + escape(plan.getValueString("planKey")) + "\"}";
+
+ if(plan.getValueInstant("lastSuccessDate") != null)
+ {
+ lastSuccess.append("qqq_backup_last_success_timestamp_seconds").append(labels).append(' ').append(plan.getValueInstant("lastSuccessDate").getEpochSecond()).append('\n');
+ }
+ List runs = catalog.where(BackupsTables.RUN, "backupPlanId", plan.getValueInteger("id"));
+ QRecord latest = runs.stream().filter(r -> List.of("SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "TIMED_OUT", "ABANDONED").contains(r.getValueString("status"))).findFirst().orElse(null);
+ if(latest != null)
+ {
+ boolean failed = List.of("FAILED", "TIMED_OUT", "ABANDONED").contains(latest.getValueString("status"));
+ lastFailed.append("qqq_backup_last_run_failed").append(labels).append(' ').append(failed ? 1 : 0).append('\n');
+ }
+ Integer slaHours = plan.getValueInteger("slaHours");
+ if(slaHours != null)
+ {
+ sla.append("qqq_backup_sla_seconds").append(labels).append(' ').append(slaHours * 3600L).append('\n');
+ }
+ if(plan.getValueInstant("lastVerifiedDate") != null)
+ {
+ lastVerify.append("qqq_backup_last_verify_success_timestamp_seconds").append(labels).append(' ').append(plan.getValueInstant("lastVerifiedDate").getEpochSecond()).append('\n');
+ }
+ if(plan.getValueInteger("lastSuccessRunId") != null)
+ {
+ QRecord run = catalog.get(BackupsTables.RUN, plan.getValueInteger("lastSuccessRunId")).orElse(null);
+ if(run != null && run.getValueLong("bytesStored") != null)
+ {
+ lastBytes.append("qqq_backup_last_success_bytes").append(labels).append(' ').append(run.getValueLong("bytesStored")).append('\n');
+ }
+ }
+ }
+ return (lastSuccess.toString() + lastFailed + sla + lastVerify + lastBytes);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static StringBuilder header(String name, String help)
+ {
+ return (new StringBuilder("# HELP ").append(name).append(' ').append(help).append('\n').append("# TYPE ").append(name).append(" gauge\n"));
+ }
+
+
+
+ /*******************************************************************************
+ ** Escape a label value for the text format.
+ *******************************************************************************/
+ static String escape(String value)
+ {
+ return (value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n"));
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/model/BackupsTables.java b/src/main/java/com/kingsrook/qbits/backups/model/BackupsTables.java
index f1d5047..9ffab88 100644
--- a/src/main/java/com/kingsrook/qbits/backups/model/BackupsTables.java
+++ b/src/main/java/com/kingsrook/qbits/backups/model/BackupsTables.java
@@ -319,6 +319,7 @@ public final class BackupsTables
.withField(roInt("scheduledJobId"))
.withField(new QFieldMetaData("slaHours", QFieldType.INTEGER).withDefaultValue(26))
.withField(new QFieldMetaData("verifySlaDays", QFieldType.INTEGER).withDefaultValue(8))
+ .withField(new QFieldMetaData("verifyFileSamplePercent", QFieldType.INTEGER).withDefaultValue(100))
.withField(new QFieldMetaData("verifyCronExpression", QFieldType.STRING).withMaxLength(100))
.withField(enumField("verifyLevel", "verify_level", "CONTENT"))
.withField(new QFieldMetaData("retainDaily", QFieldType.INTEGER).withDefaultValue(7))
diff --git a/src/main/java/com/kingsrook/qbits/backups/processes/BackupsProcesses.java b/src/main/java/com/kingsrook/qbits/backups/processes/BackupsProcesses.java
index 7827ab0..1c0843c 100644
--- a/src/main/java/com/kingsrook/qbits/backups/processes/BackupsProcesses.java
+++ b/src/main/java/com/kingsrook/qbits/backups/processes/BackupsProcesses.java
@@ -73,6 +73,7 @@ public final class BackupsProcesses
List processes = new ArrayList<>();
processes.add(permission(etl(RUN_PLAN, "Run backup now", BackupsTables.PLAN, RunPlanTransform.class, RunPlanLoad.class,
"This is a preview of what the backup will include. Nothing is written until you continue."), BackupsPermissions.RUN));
+ processes.addAll(VerifyProcesses.processes());
return (processes);
}
diff --git a/src/main/java/com/kingsrook/qbits/backups/processes/VerifyProcesses.java b/src/main/java/com/kingsrook/qbits/backups/processes/VerifyProcesses.java
new file mode 100644
index 0000000..08600bd
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/processes/VerifyProcesses.java
@@ -0,0 +1,271 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.processes;
+
+
+import java.util.ArrayList;
+import java.util.List;
+import com.kingsrook.qbits.backups.BackupsQBitConfig;
+import com.kingsrook.qbits.backups.BackupsRuntime;
+import com.kingsrook.qbits.backups.model.BackupsTables;
+import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
+import com.kingsrook.qbits.backups.run.BackupCatalog;
+import com.kingsrook.qbits.backups.sla.SlaChecker;
+import com.kingsrook.qbits.backups.verify.BackupVerifier;
+import com.kingsrook.qbits.backups.verify.VerificationOutcome;
+import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
+import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
+import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
+import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.processes.QStepMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
+import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
+import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
+import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
+import static com.kingsrook.qbits.backups.processes.BackupsProcesses.line;
+
+
+/*******************************************************************************
+ ** The verification and SLA processes (design spec section 10):
+ **
+ ** - backupVerify: the record action "Verify backup" on backups_run, a standard
+ ** streamed ETL. Review lists what will be checked (nothing is read); the run
+ ** does the STORAGE level and then, if that passes, the CONTENT level. The input
+ ** value verifyLevel (STORAGE, CONTENT or TEST_RESTORE) picks how far to go;
+ ** CONTENT is the default and TEST_RESTORE is for the API and schedules.
+ ** - backupCheckSla: the scheduled SLA check, every slaCheckIntervalSeconds on
+ ** the configured scheduler, independent of the backup trigger. It is not in the
+ ** nav.
+ **
+ *******************************************************************************/
+public final class VerifyProcesses
+{
+ public static final String VERIFY = "backupVerify";
+ public static final String CHECK_SLA = "backupCheckSla";
+
+ public static final String FIELD_VERIFY_LEVEL = "verifyLevel";
+
+
+
+ /*******************************************************************************
+ ** Utility class.
+ *******************************************************************************/
+ private VerifyProcesses()
+ {
+ }
+
+
+
+ /*******************************************************************************
+ ** The processes of this area.
+ *******************************************************************************/
+ public static List processes()
+ {
+ List processes = new ArrayList<>();
+ processes.add(BackupsProcesses.permission(BackupsProcesses.etl(VERIFY, "Verify backup", BackupsTables.RUN, VerifyTransform.class, VerifyLoad.class,
+ "This checks that the backup's objects are all in storage, then reads every byte back, decrypts and decodes it, and compares counts and hashes with the manifest. Nothing is changed in the backup."),
+ BackupsPermissions.RUN));
+ processes.add(checkSlaProcess(BackupsRuntime.getConfig()));
+ return (processes);
+ }
+
+
+
+ /*******************************************************************************
+ ** The SLA check process. With a scheduler configured it is scheduled every
+ ** slaCheckIntervalSeconds.
+ *******************************************************************************/
+ public static QProcessMetaData checkSlaProcess(BackupsQBitConfig config)
+ {
+ QProcessMetaData process = new QProcessMetaData()
+ .withName(CHECK_SLA)
+ .withLabel("Check backup SLAs")
+ .withStepList(List.of(new QBackendStepMetaData().withName("check").withCode(new QCodeReference(CheckSlaStep.class))));
+ process.setPermissionRules(BackupsTables.permissionRules());
+
+ if(config != null && config.getSchedulerName() != null && config.getSlaCheckIntervalSeconds() != null)
+ {
+ process.setSchedule(new QScheduleMetaData()
+ .withSchedulerName(config.getSchedulerName())
+ .withRepeatSeconds(config.getSlaCheckIntervalSeconds())
+ .withInitialDelaySeconds(30)
+ .withDescription("Backup SLA check (independent of the backup trigger)"));
+ }
+ return (BackupsProcesses.permission(process, BackupsPermissions.RUN));
+ }
+
+
+
+ /*******************************************************************************
+ ** The scheduled SLA check.
+ *******************************************************************************/
+ public static class CheckSlaStep implements BackendStep
+ {
+ /*******************************************************************************
+ ** Check every active plan.
+ *******************************************************************************/
+ @Override
+ public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
+ {
+ List results = new SlaChecker().checkAll();
+ long attention = results.stream().filter(r -> !SlaChecker.OK.equals(r.status()) && !SlaChecker.UNKNOWN.equals(r.status())).count();
+ output.addValue("plansChecked", results.size());
+ output.addValue("plansNeedingAttention", attention);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Verify backup, review: which runs can be verified and how much will be read.
+ *******************************************************************************/
+ public static class VerifyTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
+ {
+ private final ProcessSummaryLine runs = line(Status.OK, "backup will be verified", "backups will be verified", "backup was verified", "backups were verified");
+ private final ProcessSummaryLine objects = line(Status.INFO, "stored object will be checked", "stored objects will be checked", "stored object was checked", "stored objects were checked");
+ private final ProcessSummaryLine files = line(Status.INFO, "referenced file will be checked", "referenced files will be checked", "referenced file was checked", "referenced files were checked");
+ private final ProcessSummaryLine noManifest = line(Status.ERROR, "backup has no manifest, so it is not usable and cannot be verified", "backups have no manifest, so they are not usable and cannot be verified",
+ "backup had no manifest", "backups had no manifest");
+ private final ProcessSummaryLine pruned = line(Status.ERROR, "backup was pruned, so its objects are gone", "backups were pruned, so their objects are gone", "backup was pruned", "backups were pruned");
+ private final BackupCatalog catalog = new BackupCatalog();
+
+
+
+ /*******************************************************************************
+ ** Look at each run without reading any backup bytes.
+ *******************************************************************************/
+ @Override
+ public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
+ {
+ for(QRecord run : input.getRecords())
+ {
+ java.io.Serializable id = run.getValue("id");
+ if(run.getValueInstant("prunedDate") != null)
+ {
+ pruned.incrementCountAndAddPrimaryKey(id);
+ }
+ else if(run.getValueString("manifestKey") == null)
+ {
+ noManifest.incrementCountAndAddPrimaryKey(id);
+ }
+ else
+ {
+ runs.incrementCountAndAddPrimaryKey(id);
+ objects.incrementCount(catalog.where(BackupsTables.ARTIFACT, "backupRunId", run.getValueInteger("id")).size());
+ files.incrementCount(run.getValueInteger("filesReferenced") == null ? 0 : run.getValueInteger("filesReferenced"));
+ output.getRecords().add(run);
+ }
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Review lines.
+ *******************************************************************************/
+ @Override
+ public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
+ {
+ ArrayList lines = new ArrayList<>();
+ runs.addSelfToListIfAnyCount(lines);
+ objects.addSelfToListIfAnyCount(lines);
+ files.addSelfToListIfAnyCount(lines);
+ noManifest.addSelfToListIfAnyCount(lines);
+ pruned.addSelfToListIfAnyCount(lines);
+ return (lines);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Verify backup, execute: the storage level, then the content level.
+ *******************************************************************************/
+ public static class VerifyLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
+ {
+ private final ProcessSummaryLine passed = line(Status.OK, "backup will pass verification", "backups passed verification", "backup passed verification", "backups passed verification");
+ private final ProcessSummaryLine warnings = line(Status.WARNING, "backup passed with warnings", "backups passed with warnings", "backup passed with warnings", "backups passed with warnings");
+ private final ProcessSummaryLine failed = line(Status.ERROR, "backup failed verification", "backups failed verification", "backup failed verification", "backups failed verification");
+ private final ProcessSummaryLine errored = line(Status.ERROR, "backup could not be verified", "backups could not be verified", "backup could not be verified", "backups could not be verified");
+ private final ProcessSummaryLine rows = line(Status.INFO, "row was decoded and checked", "rows were decoded and checked", "row was decoded and checked", "rows were decoded and checked");
+ private final ProcessSummaryLine files = line(Status.INFO, "file was checked", "files were checked", "file was checked", "files were checked");
+
+
+
+ /*******************************************************************************
+ ** Verify each run.
+ *******************************************************************************/
+ @Override
+ public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
+ {
+ String level = input.getValueString(FIELD_VERIFY_LEVEL) == null ? BackupVerifier.CONTENT : input.getValueString(FIELD_VERIFY_LEVEL);
+ String user = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? "system" : QContext.getQSession().getUser().getIdReference();
+ for(QRecord run : input.getRecords())
+ {
+ List outcomes = new BackupVerifier().verifyThrough(run.getValueInteger("id"), level, "MANUAL", user);
+ VerificationOutcome last = outcomes.get(outcomes.size() - 1);
+ switch(last.status())
+ {
+ case "PASSED" -> passed.incrementCountAndAddPrimaryKey(run.getValue("id"));
+ case "PASSED_WITH_WARNINGS" -> warnings.incrementCountAndAddPrimaryKey(run.getValue("id"));
+ case "ERROR" ->
+ {
+ errored.incrementCountAndAddPrimaryKey(run.getValue("id"));
+ errored.setMessageSuffix(last.messages().isEmpty() ? null : ": " + last.messages().get(0));
+ }
+ default ->
+ {
+ failed.incrementCountAndAddPrimaryKey(run.getValue("id"));
+ failed.setMessageSuffix(last.messages().isEmpty() ? null : ": " + last.messages().get(0));
+ }
+ }
+ for(VerificationOutcome outcome : outcomes)
+ {
+ rows.incrementCount((int) Math.min(Integer.MAX_VALUE, outcome.rowsChecked()));
+ files.incrementCount(outcome.filesChecked());
+ }
+ output.getRecords().add(run);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Result lines.
+ *******************************************************************************/
+ @Override
+ public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
+ {
+ ArrayList lines = new ArrayList<>();
+ passed.addSelfToListIfAnyCount(lines);
+ warnings.addSelfToListIfAnyCount(lines);
+ failed.addSelfToListIfAnyCount(lines);
+ errored.addSelfToListIfAnyCount(lines);
+ rows.addSelfToListIfAnyCount(lines);
+ files.addSelfToListIfAnyCount(lines);
+ return (lines);
+ }
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/run/BackupEngine.java b/src/main/java/com/kingsrook/qbits/backups/run/BackupEngine.java
index 1e7449c..1e491e4 100644
--- a/src/main/java/com/kingsrook/qbits/backups/run/BackupEngine.java
+++ b/src/main/java/com/kingsrook/qbits/backups/run/BackupEngine.java
@@ -35,6 +35,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.backups.BackupsRuntime;
import com.kingsrook.qbits.backups.alerts.BackupAlertSink;
import com.kingsrook.qbits.backups.codec.HashingOutputStream;
+import com.kingsrook.qbits.backups.events.BackupEvents;
import com.kingsrook.qbits.backups.codec.TableArtifactWriter;
import com.kingsrook.qbits.backups.files.FileCopier;
import com.kingsrook.qbits.backups.files.FileReference;
@@ -561,6 +562,7 @@ public class BackupEngine
BackupAlertSink sink = BackupsRuntime.getAlertSink();
String planKey = request.getSettings().planKey();
+ BackupEvents.runFinished(planKey, result.getRunKey(), status, result.getRows(), result.getBytesStored());
if(ok)
{
sink.resolve("backups.run.failed", "backups.run.failed:" + planKey);
diff --git a/src/main/java/com/kingsrook/qbits/backups/sla/SlaChecker.java b/src/main/java/com/kingsrook/qbits/backups/sla/SlaChecker.java
new file mode 100644
index 0000000..b79195b
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/sla/SlaChecker.java
@@ -0,0 +1,543 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.sla;
+
+
+import java.io.Serializable;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import com.kingsrook.qbits.backups.BackupsQBitConfig;
+import com.kingsrook.qbits.backups.BackupsRuntime;
+import com.kingsrook.qbits.backups.alerts.BackupAlertSink;
+import com.kingsrook.qbits.backups.alerts.BackupAlerts;
+import com.kingsrook.qbits.backups.model.BackupsTables;
+import com.kingsrook.qbits.backups.run.BackupCatalog;
+import com.kingsrook.qbits.backups.storage.BackupStorage;
+import com.kingsrook.qbits.backups.storage.StoredObjectInfo;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+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.QQueryFilter;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
+
+
+/*******************************************************************************
+ ** The SLA checker (design spec section 10.2): backupCheckSla. A scheduled process
+ ** that is independent of the backup trigger, so a broken trigger cannot also
+ ** silence the check. For each active plan it computes slaStatus:
+ **
+ ** - MISSING_IN_STORAGE: the catalog says success but the manifest is gone
+ ** (a head on manifestKey, a positive answer cached for an hour);
+ ** - FAILING: the last two finished runs failed;
+ ** - ABANDONED: a RUNNING run has a heartbeat older than ten minutes (the run
+ ** is marked ABANDONED);
+ ** - LATE: no success within slaHours;
+ ** - OWNER_INVALID: the plan owner no longer validates (the configured
+ ** planOwnerValidator);
+ ** - UNVERIFIED: no passed CONTENT or TEST_RESTORE verification within
+ ** verifySlaDays;
+ ** - OK otherwise (UNKNOWN for a new plan that has not had time to run yet).
+ **
+ ** When several apply, the status shown is the first in that order; the alerts are
+ ** independent, one per condition. Once a day it also lists each plan's prefix in
+ ** the bucket and reports orphans (run prefixes that are not in the catalog) and
+ ** debris (prefixes with no manifest older than 24 hours): look at the bucket, not
+ ** at the job.
+ *******************************************************************************/
+public class SlaChecker
+{
+ private static final QLogger LOG = QLogger.getLogger(SlaChecker.class);
+
+ public static final String OK = "OK";
+ public static final String LATE = "LATE";
+ public static final String FAILING = "FAILING";
+ public static final String MISSING_IN_STORAGE = "MISSING_IN_STORAGE";
+ public static final String UNVERIFIED = "UNVERIFIED";
+ public static final String ABANDONED = "ABANDONED";
+ public static final String OWNER_INVALID = "OWNER_INVALID";
+ public static final String UNKNOWN = "UNKNOWN";
+
+ /** A RUNNING run whose heartbeat is older than this is abandoned. */
+ public static final Duration STALE_HEARTBEAT = Duration.ofMinutes(10);
+
+ /** How long a successful manifest head is trusted. */
+ public static final Duration MANIFEST_CACHE = Duration.ofHours(1);
+
+ /** How often the bucket is listed for orphans and debris. */
+ public static final Duration BUCKET_AUDIT_INTERVAL = Duration.ofHours(24);
+
+ /** A prefix with no manifest older than this is debris. */
+ public static final Duration DEBRIS_AGE = Duration.ofHours(24);
+
+ private static final Set FAILED_STATUSES = Set.of("FAILED", "TIMED_OUT", "ABANDONED");
+ private static final Set FINISHED_STATUSES = Set.of("SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "TIMED_OUT", "ABANDONED");
+
+ private static final Map MANIFEST_OK_AT = new ConcurrentHashMap<>();
+ private static final Map LAST_AUDIT_AT = new ConcurrentHashMap<>();
+
+ private final BackupCatalog catalog = new BackupCatalog();
+
+
+
+ /***************************************************************************
+ ** The outcome of checking one plan.
+ **
+ ** @param planId the plan id
+ ** @param planKey the plan key
+ ** @param previousStatus slaStatus before the check
+ ** @param status slaStatus now
+ ** @param abandonedRuns runs marked ABANDONED by this check
+ ** @param orphans orphan run prefixes found by the bucket audit (-1 when the audit did not run)
+ ** @param debris debris prefixes found by the bucket audit (-1 when the audit did not run)
+ ***************************************************************************/
+ public record PlanResult(Integer planId, String planKey, String previousStatus, String status, int abandonedRuns, int orphans, int debris)
+ {
+ }
+
+
+
+ /*******************************************************************************
+ ** Forget the cached manifest heads and bucket audit times (tests).
+ *******************************************************************************/
+ public static void resetCaches()
+ {
+ MANIFEST_OK_AT.clear();
+ LAST_AUDIT_AT.clear();
+ }
+
+
+
+ /*******************************************************************************
+ ** Check every active plan.
+ *******************************************************************************/
+ public List checkAll() throws QException
+ {
+ List results = new ArrayList<>();
+ for(QRecord plan : catalog.query(BackupsTables.PLAN, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true))))
+ {
+ try
+ {
+ results.add(check(plan));
+ }
+ catch(Exception e)
+ {
+ LOG.warn("The SLA check of a plan failed", e, logPair("plan", plan.getValueString("planKey")));
+ }
+ }
+ return (results);
+ }
+
+
+
+ /*******************************************************************************
+ ** Check one plan: mark stale runs, compute the status, update the plan, raise and
+ ** resolve alerts, and (once a day) audit its prefix in the bucket.
+ *******************************************************************************/
+ public PlanResult check(QRecord plan) throws QException
+ {
+ Instant now = BackupsRuntime.getClock().instant();
+ Integer planId = plan.getValueInteger("id");
+ String planKey = plan.getValueString("planKey");
+ boolean critical = Boolean.TRUE.equals(plan.getValueBoolean("critical"));
+
+ List runs = catalog.where(BackupsTables.RUN, "backupPlanId", planId);
+
+ /////////////////////////////////////////////////////////////////////
+ // a RUNNING run that stopped reporting is abandoned (pod died etc) //
+ /////////////////////////////////////////////////////////////////////
+ int abandonedNow = 0;
+ for(QRecord run : runs)
+ {
+ if("RUNNING".equals(run.getValueString("status")))
+ {
+ Instant lastSeen = run.getValueInstant("heartbeatDate") != null ? run.getValueInstant("heartbeatDate") : run.getValueInstant("startDate");
+ if(lastSeen != null && lastSeen.isBefore(now.minus(STALE_HEARTBEAT)))
+ {
+ Map update = new LinkedHashMap<>();
+ update.put("status", "ABANDONED");
+ update.put("endDate", now);
+ update.put("errorMessage", "The run stopped reporting a heartbeat (the process or pod probably died); resume it or run the plan again");
+ catalog.update(BackupsTables.RUN, run.getValueInteger("id"), update);
+ abandonedNow++;
+ }
+ }
+ }
+ if(abandonedNow > 0)
+ {
+ runs = catalog.where(BackupsTables.RUN, "backupPlanId", planId);
+ }
+
+ List finished = runs.stream().filter(r -> FINISHED_STATUSES.contains(r.getValueString("status"))).toList();
+ boolean latestFailed = !finished.isEmpty() && FAILED_STATUSES.contains(finished.get(0).getValueString("status"));
+ boolean failing = finished.size() >= 2 && latestFailed && FAILED_STATUSES.contains(finished.get(1).getValueString("status"));
+ boolean abandoned = abandonedNow > 0 || (!finished.isEmpty() && "ABANDONED".equals(finished.get(0).getValueString("status")));
+
+ boolean missing = isManifestMissing(plan, now);
+ boolean late = isLate(plan, now);
+ boolean ownerBad = isOwnerInvalid(plan);
+ boolean unverified = isUnverified(plan, now);
+
+ String status;
+ if(missing)
+ {
+ status = MISSING_IN_STORAGE;
+ }
+ else if(failing)
+ {
+ status = FAILING;
+ }
+ else if(abandoned)
+ {
+ status = ABANDONED;
+ }
+ else if(late)
+ {
+ status = LATE;
+ }
+ else if(ownerBad)
+ {
+ status = OWNER_INVALID;
+ }
+ else if(unverified)
+ {
+ status = UNVERIFIED;
+ }
+ else if(plan.getValueInstant("lastSuccessDate") == null)
+ {
+ status = UNKNOWN;
+ }
+ else
+ {
+ status = OK;
+ }
+
+ String previous = plan.getValueString("slaStatus");
+ if(!status.equals(previous))
+ {
+ catalog.update(BackupsTables.PLAN, planId, Map.of("slaStatus", status));
+ }
+
+ signal(plan, planKey, critical, missing, late, ownerBad, unverified, latestFailed, failing, abandoned, finished.isEmpty() ? null : finished.get(0));
+
+ int[] audit = auditBucket(plan, planKey, runs, now);
+ return (new PlanResult(planId, planKey, previous, status, abandonedNow, audit[0], audit[1]));
+ }
+
+
+
+ /*******************************************************************************
+ ** Raise and resolve the plan's alerts, one per condition.
+ *******************************************************************************/
+ private void signal(QRecord plan, String planKey, boolean critical, boolean missing, boolean late, boolean ownerBad, boolean unverified,
+ boolean latestFailed, boolean failing, boolean abandoned, QRecord latestFinished)
+ {
+ String name = plan.getValueString("name") == null ? planKey : plan.getValueString("name");
+
+ if(missing)
+ {
+ BackupAlerts.raise(BackupAlerts.RUN_MISSING, planKey, BackupAlertSink.Severity.CRITICAL, "The latest backup is missing from storage",
+ "plan", planKey, "planName", name);
+ }
+ else
+ {
+ BackupAlerts.resolve(BackupAlerts.RUN_MISSING, planKey);
+ }
+
+ if(late)
+ {
+ BackupAlerts.raise(BackupAlerts.PLAN_MISSED, planKey, critical ? BackupAlertSink.Severity.CRITICAL : BackupAlertSink.Severity.ERROR,
+ "A backup did not run in time", "plan", planKey, "planName", name, "slaHours", String.valueOf(slaHours(plan)));
+ }
+ else
+ {
+ BackupAlerts.resolve(BackupAlerts.PLAN_MISSED, planKey);
+ }
+
+ if(latestFailed || abandoned)
+ {
+ BackupAlerts.raise(BackupAlerts.RUN_FAILED, planKey, failing ? BackupAlertSink.Severity.ERROR : BackupAlertSink.Severity.WARNING,
+ failing ? "Backups have failed twice in a row" : "A backup run failed", "plan", planKey, "planName", name);
+ }
+ else if(latestFinished != null)
+ {
+ BackupAlerts.resolve(BackupAlerts.RUN_FAILED, planKey);
+ }
+
+ if(ownerBad)
+ {
+ BackupAlerts.raise(BackupAlerts.PLAN_OWNER_INVALID, planKey, BackupAlertSink.Severity.WARNING, "The backup plan owner is not valid",
+ "plan", planKey, "planName", name);
+ }
+ else
+ {
+ BackupAlerts.resolve(BackupAlerts.PLAN_OWNER_INVALID, planKey);
+ }
+
+ if(unverified)
+ {
+ BackupAlerts.raise(BackupAlerts.VERIFY_OVERDUE, planKey, BackupAlertSink.Severity.WARNING, "No backup has been verified recently",
+ "plan", planKey, "planName", name, "verifySlaDays", String.valueOf(plan.getValueInteger("verifySlaDays")));
+ }
+ else
+ {
+ BackupAlerts.resolve(BackupAlerts.VERIFY_OVERDUE, planKey);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** The catalog says the last success exists; is its manifest still in the bucket?
+ ** A positive answer is trusted for an hour; a negative one is always re-checked.
+ ** A storage that cannot be reached does not mean the manifest is gone.
+ *******************************************************************************/
+ private boolean isManifestMissing(QRecord plan, Instant now) throws QException
+ {
+ Integer successId = plan.getValueInteger("lastSuccessRunId");
+ if(successId == null)
+ {
+ return (false);
+ }
+ QRecord run = catalog.get(BackupsTables.RUN, successId).orElse(null);
+ if(run == null || run.getValueString("manifestKey") == null || run.getValueString("storageBucketAlias") == null || run.getValueInstant("prunedDate") != null)
+ {
+ return (false);
+ }
+
+ String cacheKey = run.getValueString("storageBucketAlias") + "|" + run.getValueString("manifestKey");
+ Instant okAt = MANIFEST_OK_AT.get(cacheKey);
+ if(okAt != null && okAt.plus(MANIFEST_CACHE).isAfter(now))
+ {
+ return (false);
+ }
+
+ try
+ {
+ BackupStorage storage = BackupsRuntime.storageFor(run.getValueString("storageBucketAlias"));
+ if(storage.head(run.getValueString("manifestKey")).isPresent())
+ {
+ MANIFEST_OK_AT.put(cacheKey, now);
+ return (false);
+ }
+ MANIFEST_OK_AT.remove(cacheKey);
+ return (true);
+ }
+ catch(RuntimeException e)
+ {
+ LOG.warn("Could not check the manifest of the last backup", e, logPair("plan", plan.getValueString("planKey")));
+ return (false);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** No success within slaHours. A manual-only plan (no cron) with no slaHours has
+ ** no SLA. A new plan gets slaHours to run its first backup.
+ *******************************************************************************/
+ private boolean isLate(QRecord plan, Instant now)
+ {
+ Integer hours = slaHours(plan);
+ if(hours == null)
+ {
+ return (false);
+ }
+ Instant lastSuccess = plan.getValueInstant("lastSuccessDate");
+ Instant reference = lastSuccess != null ? lastSuccess : plan.getValueInstant("createDate");
+ return (reference != null && reference.plus(Duration.ofHours(hours)).isBefore(now));
+ }
+
+
+
+ /*******************************************************************************
+ ** slaHours of a plan: its value, else 26 for a scheduled plan, else none.
+ *******************************************************************************/
+ private Integer slaHours(QRecord plan)
+ {
+ if(plan.getValueInteger("slaHours") != null)
+ {
+ return (plan.getValueInteger("slaHours"));
+ }
+ String cron = plan.getValueString("cronExpression");
+ return (cron != null && !cron.isBlank() ? 26 : null);
+ }
+
+
+
+ /*******************************************************************************
+ ** The plan has a successful backup but no passed CONTENT or TEST_RESTORE
+ ** verification within verifySlaDays.
+ *******************************************************************************/
+ private boolean isUnverified(QRecord plan, Instant now)
+ {
+ Integer days = plan.getValueInteger("verifySlaDays");
+ if(days == null || plan.getValueInstant("lastSuccessDate") == null)
+ {
+ return (false);
+ }
+ Instant reference = plan.getValueInstant("lastVerifiedDate") != null ? plan.getValueInstant("lastVerifiedDate") : plan.getValueInstant("createDate");
+ return (reference != null && reference.plus(Duration.ofDays(days)).isBefore(now));
+ }
+
+
+
+ /*******************************************************************************
+ ** The plan owner no longer validates.
+ *******************************************************************************/
+ private boolean isOwnerInvalid(QRecord plan)
+ {
+ BackupsQBitConfig config = BackupsRuntime.getConfig();
+ String owner = plan.getValueString("ownerUserId");
+ if(config == null || config.getPlanOwnerValidator() == null || owner == null || owner.isBlank())
+ {
+ return (false);
+ }
+ try
+ {
+ return (!config.getPlanOwnerValidator().test(owner));
+ }
+ catch(RuntimeException e)
+ {
+ LOG.warn("The plan owner validator failed", e, logPair("plan", plan.getValueString("planKey")));
+ return (false);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Once a day, list the plan's prefix in the bucket: orphans (run prefixes not in
+ ** the catalog) and debris (no manifest, older than 24 hours). Returns
+ * {orphans, debris}, or {-1, -1} when it did not run.
+ *******************************************************************************/
+ private int[] auditBucket(QRecord plan, String planKey, List runs, Instant now)
+ {
+ String alias = plan.getValueString("storageBucketAlias");
+ if(alias == null)
+ {
+ return (new int[] { -1, -1 });
+ }
+ String auditKey = alias + "|" + planKey;
+ Instant last = LAST_AUDIT_AT.get(auditKey);
+ if(last != null && last.plus(BUCKET_AUDIT_INTERVAL).isAfter(now))
+ {
+ return (new int[] { -1, -1 });
+ }
+
+ try
+ {
+ BackupStorage storage = BackupsRuntime.storageFor(alias);
+ String planPrefix = planPrefix(plan, planKey);
+
+ Set known = new HashSet<>();
+ Set active = new HashSet<>();
+ for(QRecord run : runs)
+ {
+ known.add(run.getValueString("runPrefix"));
+ if("RUNNING".equals(run.getValueString("status")) || "QUEUED".equals(run.getValueString("status")))
+ {
+ active.add(run.getValueString("runPrefix"));
+ }
+ }
+
+ Map hasManifest = new LinkedHashMap<>();
+ Map newest = new LinkedHashMap<>();
+ for(StoredObjectInfo object : storage.list(planPrefix))
+ {
+ String[] parts = object.key().substring(planPrefix.length()).split("/");
+ if(parts.length < 5)
+ {
+ continue;
+ }
+ String runPrefix = planPrefix + parts[0] + "/" + parts[1] + "/" + parts[2] + "/" + parts[3] + "/";
+ hasManifest.merge(runPrefix, "manifest.json".equals(parts[4]), Boolean::logicalOr);
+ if(object.lastModified() != null)
+ {
+ newest.merge(runPrefix, object.lastModified(), (a, b) -> a.isAfter(b) ? a : b);
+ }
+ }
+
+ int orphans = 0;
+ int debris = 0;
+ for(Map.Entry entry : hasManifest.entrySet())
+ {
+ String runPrefix = entry.getKey();
+ if(active.contains(runPrefix))
+ {
+ continue;
+ }
+ if(!known.contains(runPrefix))
+ {
+ orphans++;
+ }
+ Instant modified = newest.get(runPrefix);
+ if(!entry.getValue() && modified != null && modified.plus(DEBRIS_AGE).isBefore(now))
+ {
+ debris++;
+ }
+ }
+
+ LAST_AUDIT_AT.put(auditKey, now);
+ if(orphans + debris > 0)
+ {
+ BackupAlerts.raise(BackupAlerts.STORAGE_DEBRIS, planKey, BackupAlertSink.Severity.INFO, "Backup storage has orphans or debris",
+ "plan", planKey, "orphans", String.valueOf(orphans), "debris", String.valueOf(debris));
+ }
+ else
+ {
+ BackupAlerts.resolve(BackupAlerts.STORAGE_DEBRIS, planKey);
+ }
+ return (new int[] { orphans, debris });
+ }
+ catch(RuntimeException e)
+ {
+ LOG.warn("The bucket audit failed", e, logPair("plan", planKey));
+ return (new int[] { -1, -1 });
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** The prefix of a plan's runs in its bucket, the same way the backup engine
+ ** builds it: (plan prefix)/(instance name)/(planKey)/.
+ *******************************************************************************/
+ static String planPrefix(QRecord plan, String planKey)
+ {
+ BackupsQBitConfig config = BackupsRuntime.getConfig();
+ String instance = config != null && config.getInstanceName() != null ? config.getInstanceName() : "qqq";
+ String prefix = plan.getValueString("prefix") != null ? plan.getValueString("prefix") : "qqq-backups";
+ while(prefix.startsWith("/"))
+ {
+ prefix = prefix.substring(1);
+ }
+ while(prefix.endsWith("/"))
+ {
+ prefix = prefix.substring(0, prefix.length() - 1);
+ }
+ return (prefix + "/" + instance + "/" + planKey + "/");
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/verify/BackupVerifier.java b/src/main/java/com/kingsrook/qbits/backups/verify/BackupVerifier.java
new file mode 100644
index 0000000..ece7cc5
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/verify/BackupVerifier.java
@@ -0,0 +1,630 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.verify;
+
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.Serializable;
+import java.security.DigestInputStream;
+import java.security.MessageDigest;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.kingsrook.qbits.backups.BackupsQBitConfig;
+import com.kingsrook.qbits.backups.BackupsRuntime;
+import com.kingsrook.qbits.backups.alerts.BackupAlerts;
+import com.kingsrook.qbits.backups.alerts.BackupAlertSink;
+import com.kingsrook.qbits.backups.codec.ArtifactTransform;
+import com.kingsrook.qbits.backups.codec.HashingOutputStream;
+import com.kingsrook.qbits.backups.codec.JsonlRowCodec;
+import com.kingsrook.qbits.backups.manifest.BackupManifest;
+import com.kingsrook.qbits.backups.manifest.TableFingerprints;
+import com.kingsrook.qbits.backups.model.BackupsTables;
+import com.kingsrook.qbits.backups.run.BackupCatalog;
+import com.kingsrook.qbits.backups.storage.BackupStorage;
+import com.kingsrook.qbits.backups.storage.BackupStorageException;
+import com.kingsrook.qbits.backups.storage.StoredObjectInfo;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
+
+
+/*******************************************************************************
+ ** Verifies backup runs (design spec section 10.1). Three levels, each a row in
+ ** backups_verification:
+ **
+ ** - STORAGE: head every object of the run, including every file-store object
+ ** the run's file index references; sizes (and a provider checksum, where the
+ ** provider stores one) must match the manifest.
+ ** - CONTENT: read every byte. Each part is decrypted (the decrypt hook,
+ ** ArtifactTransform.unwrap), gunzipped and parsed row by row with the codec;
+ ** row counts, sha256Stored, sha256Raw and the table rowsHash are recomputed and
+ ** compared with the manifest. Every referenced file-store object is decrypted
+ ** and re-hashed against its SHA-256 key, except those verified within
+ ** fileReverifyDays (backups_file_object.lastVerifiedDate).
+ ** - TEST_RESTORE: see {@link TestRestoreVerifier}.
+ **
+ ** The result updates backups_run.verificationStatus and backups_plan.lastVerified*,
+ ** and a failure raises backups.verify.failed. A mismatch is a FAILED verification;
+ ** a verification that could not run (storage unreachable, no key) is an ERROR.
+ *******************************************************************************/
+public class BackupVerifier
+{
+ private static final QLogger LOG = QLogger.getLogger(BackupVerifier.class);
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ public static final String STORAGE = "STORAGE";
+ public static final String CONTENT = "CONTENT";
+ public static final String TEST_RESTORE = "TEST_RESTORE";
+
+ /** The levels in order; each includes the ones before it when run through. */
+ public static final List LEVELS = List.of(STORAGE, CONTENT, TEST_RESTORE);
+
+ private static final List STATUS_RANK = List.of("UNVERIFIED", "STORAGE_OK", "CONTENT_OK", "TEST_RESTORE_OK");
+
+ private final BackupCatalog catalog = new BackupCatalog();
+
+
+
+ /*******************************************************************************
+ ** Verify a run at each level up to and including the given one, stopping at the
+ ** first level that does not pass. Returns one outcome per level that ran.
+ *******************************************************************************/
+ public List verifyThrough(Integer runId, String maxLevel, String trigger, String requestedBy) throws QException
+ {
+ List outcomes = new ArrayList<>();
+ for(String level : LEVELS.subList(0, LEVELS.indexOf(maxLevel) + 1))
+ {
+ VerificationOutcome outcome = verify(runId, level, trigger, requestedBy);
+ outcomes.add(outcome);
+ if(!outcome.passed())
+ {
+ break;
+ }
+ }
+ return (outcomes);
+ }
+
+
+
+ /*******************************************************************************
+ ** Verify a run at one level. Never throws for a backup problem: the outcome
+ ** carries the status and the first messages.
+ *******************************************************************************/
+ public VerificationOutcome verify(Integer runId, String level, String trigger, String requestedBy) throws QException
+ {
+ if(!LEVELS.contains(level))
+ {
+ throw (new QException("Unknown verification level: " + level));
+ }
+ QRecord run = catalog.get(BackupsTables.RUN, runId).orElseThrow(() -> new QException("No backup run with id " + runId));
+ QRecord plan = run.getValueInteger("backupPlanId") == null ? null : catalog.get(BackupsTables.PLAN, run.getValueInteger("backupPlanId")).orElse(null);
+
+ Instant started = BackupsRuntime.getClock().instant();
+ Map row = new LinkedHashMap<>();
+ row.put("backupRunId", runId);
+ row.put("level", level);
+ row.put("trigger", trigger);
+ row.put("requestedByUserId", requestedBy);
+ row.put("startDate", started);
+ row.put("status", "RUNNING");
+ Integer verificationId = catalog.insert(BackupsTables.VERIFICATION, row);
+
+ VerifyFindings findings = new VerifyFindings();
+ String status;
+ String scratch = null;
+ try
+ {
+ RunBundle bundle = RunBundle.load(run, plan, findings);
+ if(bundle != null)
+ {
+ switch(level)
+ {
+ case STORAGE -> storageLevel(bundle, findings);
+ case CONTENT -> contentLevel(bundle, findings);
+ default -> scratch = new TestRestoreVerifier().verify(bundle, findings);
+ }
+ }
+ status = findings.status();
+ }
+ catch(VerifyException | BackupStorageException | QException e)
+ {
+ status = "ERROR";
+ findings.mismatch("The verification could not be carried out: " + e.getMessage());
+ }
+ catch(RuntimeException e)
+ {
+ LOG.warn("Verification crashed", e, logPair("runId", runId), logPair("level", level));
+ status = "ERROR";
+ findings.mismatch("The verification crashed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
+ }
+
+ Instant ended = BackupsRuntime.getClock().instant();
+ VerificationOutcome outcome = new VerificationOutcome(verificationId, runId, level, status, findings.tablesChecked, findings.rowsChecked, findings.mismatches,
+ findings.objectsChecked, findings.filesChecked, findings.filesSkipped, findings.messages(), scratch);
+
+ Map done = new LinkedHashMap<>();
+ done.put("endDate", ended);
+ done.put("status", status);
+ done.put("tablesChecked", findings.tablesChecked);
+ done.put("rowsChecked", findings.rowsChecked);
+ done.put("mismatches", findings.mismatches);
+ done.put("scratchTarget", scratch);
+ done.put("detailJson", detailJson(findings));
+ catalog.update(BackupsTables.VERIFICATION, verificationId, done);
+
+ applyOutcome(run, plan, outcome, ended);
+ return (outcome);
+ }
+
+
+
+ /*******************************************************************************
+ ** STORAGE level: head every object, including every file-store object.
+ *******************************************************************************/
+ void storageLevel(RunBundle bundle, VerifyFindings findings)
+ {
+ BackupManifest manifest = bundle.manifest();
+ BackupStorage storage = bundle.storage();
+ findings.tablesChecked = manifest.tables().size();
+ findings.objectsChecked++;
+
+ headOrMismatch(storage, bundle.runPrefix() + "checksums.sha256", "checksums.sha256", findings);
+
+ for(BackupManifest.TableEntry table : manifest.tables())
+ {
+ for(BackupManifest.Part part : table.parts() == null ? List.of() : table.parts())
+ {
+ findings.objectsChecked++;
+ Optional head = storage.head(bundle.runPrefix() + part.key());
+ if(head.isEmpty())
+ {
+ findings.mismatch("Missing object: " + part.key() + " (table " + table.name() + ")");
+ continue;
+ }
+ if(head.get().size() != part.bytesStored())
+ {
+ findings.mismatch("Size mismatch for " + part.key() + ": the manifest says " + part.bytesStored() + " bytes, the bucket has " + head.get().size());
+ }
+ String providerSha = head.get().metadata() == null ? null : head.get().metadata().get("sha256");
+ if(providerSha != null && !providerSha.equalsIgnoreCase(part.sha256Stored()))
+ {
+ findings.mismatch("Checksum mismatch for " + part.key() + ": the provider's stored SHA-256 differs from the manifest");
+ }
+ }
+ }
+
+ if(manifest.files() != null && manifest.files().indexKeys() != null)
+ {
+ for(String indexKey : manifest.files().indexKeys())
+ {
+ headOrMismatch(storage, bundle.runPrefix() + indexKey, indexKey, findings);
+ }
+ }
+
+ int missingFiles = 0;
+ for(RunBundle.FileIndexEntry entry : bundle.distinctStoredFiles(findings))
+ {
+ findings.objectsChecked++;
+ findings.filesChecked++;
+ Optional head = storage.head(entry.casKey());
+ if(head.isEmpty())
+ {
+ missingFiles++;
+ findings.mismatch("File store object missing: sha256 " + entry.sha256() + " (referenced by " + entry.table() + ")");
+ }
+ else if(!bundle.isEncrypted() && entry.size() >= 0 && head.get().size() != entry.size())
+ {
+ findings.mismatch("File store object has the wrong size: sha256 " + entry.sha256() + " should be " + entry.size() + " bytes, the bucket has " + head.get().size());
+ }
+ }
+ findings.extra.put("filesMissingFromStore", missingFiles);
+ }
+
+
+
+ /*******************************************************************************
+ ** CONTENT level: decrypt, gunzip, parse, recount and re-hash everything.
+ *******************************************************************************/
+ void contentLevel(RunBundle bundle, VerifyFindings findings) throws QException
+ {
+ BackupManifest manifest = bundle.manifest();
+ QInstance qInstance = QContext.getQInstance();
+ findings.tablesChecked = manifest.tables().size();
+
+ checkChecksumsFile(bundle, findings);
+
+ for(BackupManifest.TableEntry entry : manifest.tables())
+ {
+ QTableMetaData table = qInstance.getTable(entry.name());
+ JsonlRowCodec codec = null;
+ boolean roundTrip = false;
+ if(table == null)
+ {
+ findings.warn("Table " + entry.name() + " no longer exists in this instance; its rows were checked as JSON, not with the codec");
+ }
+ else
+ {
+ List excluded = entry.excludedFields() == null ? List.of() : entry.excludedFields().stream().map(BackupManifest.ExcludedField::name).toList();
+ codec = new JsonlRowCodec(table, excluded);
+ String backendType = qInstance.getBackend(table.getBackendName()) == null ? null : qInstance.getBackend(table.getBackendName()).getBackendType();
+ roundTrip = TableFingerprints.of(table, backendType).equals(entry.fingerprint());
+ if(!roundTrip)
+ {
+ findings.warn("Table " + entry.name() + " has changed since this backup; rows were parsed but not checked for an exact round trip");
+ }
+ }
+ verifyTable(bundle, entry, codec, roundTrip, findings);
+ }
+
+ verifyFiles(bundle, findings);
+ }
+
+
+
+ /*******************************************************************************
+ ** All parts of one table.
+ *******************************************************************************/
+ private void verifyTable(RunBundle bundle, BackupManifest.TableEntry entry, JsonlRowCodec codec, boolean roundTrip, VerifyFindings findings)
+ {
+ MessageDigest rowsDigest = HashingOutputStream.newSha256();
+ long rows = 0;
+ boolean hasJsonl = false;
+ int partCount = 0;
+ int before = findings.mismatches;
+
+ for(BackupManifest.Part part : entry.parts() == null ? List.of() : entry.parts())
+ {
+ partCount++;
+ findings.objectsChecked++;
+ boolean jsonl = !"CSV".equals(part.format());
+ hasJsonl |= jsonl;
+ long[] parsedRows = { 0 };
+ try
+ {
+ PartScanner.Scan scan = PartScanner.scan(bundle, part, jsonl ? line ->
+ {
+ parsedRows[0]++;
+ rowsDigest.update((line + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ checkRow(entry.name(), part.key(), parsedRows[0], line, codec, roundTrip, findings);
+ } : null);
+
+ if(!scan.sha256Stored().equals(part.sha256Stored()))
+ {
+ findings.mismatch("Stored checksum mismatch for " + part.key() + ": the bytes in the bucket are not the bytes that were written (corrupted or replaced)");
+ }
+ if(!scan.sha256Raw().equals(part.sha256Raw()))
+ {
+ findings.mismatch("Content checksum mismatch for " + part.key() + ": the decoded content differs from what was written");
+ }
+ if(jsonl && scan.lines() != part.rows())
+ {
+ findings.mismatch("Row count mismatch for " + part.key() + ": the manifest says " + part.rows() + " rows, the part has " + scan.lines());
+ }
+ if(jsonl)
+ {
+ rows += scan.lines();
+ }
+ }
+ catch(IOException | RuntimeException e)
+ {
+ findings.mismatch("The part " + part.key() + " could not be read, decrypted or decompressed (" + e.getClass().getSimpleName() + (e.getMessage() == null ? "" : ": " + e.getMessage())
+ + "); it is corrupted, or the key is wrong");
+ }
+ catch(QException e)
+ {
+ findings.mismatch("The part " + part.key() + " could not be read: " + e.getMessage());
+ }
+ }
+
+ String actualHash = hasJsonl ? "sha256:" + HashingOutputStream.hex(rowsDigest.digest()) : null;
+ if(hasJsonl)
+ {
+ findings.rowsChecked += rows;
+ if(rows != entry.rowCount())
+ {
+ findings.mismatch("Row count mismatch for table " + entry.name() + ": the manifest says " + entry.rowCount() + ", the parts hold " + rows);
+ }
+ if(entry.rowsHash() != null && !entry.rowsHash().equals(actualHash))
+ {
+ findings.mismatch("Row content hash mismatch for table " + entry.name() + ": the rows are not the rows that were backed up");
+ }
+ }
+ else
+ {
+ findings.warn("Table " + entry.name() + " has only CSV parts; their bytes were hashed but rows were not recounted");
+ }
+
+ Map detail = new LinkedHashMap<>();
+ detail.put("table", entry.name());
+ detail.put("parts", partCount);
+ detail.put("expectedRows", entry.rowCount());
+ detail.put("actualRows", hasJsonl ? rows : null);
+ detail.put("expectedRowsHash", entry.rowsHash());
+ detail.put("actualRowsHash", actualHash);
+ detail.put("ok", findings.mismatches == before);
+ findings.tables.add(detail);
+ }
+
+
+
+ /*******************************************************************************
+ ** Parse one row with the codec (and check the exact round trip when the table
+ ** is unchanged). Without a table, check only that it is a JSON object.
+ *******************************************************************************/
+ private void checkRow(String table, String partKey, long rowNumber, String line, JsonlRowCodec codec, boolean roundTrip, VerifyFindings findings) throws QException
+ {
+ try
+ {
+ if(codec == null)
+ {
+ if(!MAPPER.readTree(line).isObject())
+ {
+ throw (new QException("not a JSON object"));
+ }
+ return;
+ }
+ QRecord record = codec.decode(line);
+ if(roundTrip && !codec.encode(record).equals(line))
+ {
+ findings.mismatch("Row " + rowNumber + " of " + partKey + " does not survive a decode and encode round trip (table " + table + ")");
+ }
+ }
+ catch(IOException | QException e)
+ {
+ findings.mismatch("Row " + rowNumber + " of " + partKey + " cannot be parsed (table " + table + ")");
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** The checksums.sha256 file must list every part with its stored SHA-256.
+ *******************************************************************************/
+ private void checkChecksumsFile(RunBundle bundle, VerifyFindings findings)
+ {
+ String key = bundle.runPrefix() + "checksums.sha256";
+ findings.objectsChecked++;
+ try(InputStream in = bundle.storage().openRead(key))
+ {
+ String text = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
+ for(BackupManifest.TableEntry table : bundle.manifest().tables())
+ {
+ for(BackupManifest.Part part : table.parts() == null ? List.of() : table.parts())
+ {
+ if(!text.contains(part.sha256Stored() + " " + part.key()))
+ {
+ findings.mismatch("checksums.sha256 does not list " + part.key() + " with the manifest's checksum");
+ }
+ }
+ }
+ }
+ catch(BackupStorageException e)
+ {
+ findings.mismatch("checksums.sha256 is missing from the bucket");
+ }
+ catch(IOException e)
+ {
+ findings.mismatch("checksums.sha256 cannot be read: " + e.getClass().getSimpleName());
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Re-hash every referenced file-store object against its SHA-256 key, skipping
+ ** those verified within fileReverifyDays.
+ *******************************************************************************/
+ private void verifyFiles(RunBundle bundle, VerifyFindings findings) throws QException
+ {
+ BackupsQBitConfig config = BackupsRuntime.getConfig();
+ int days = config == null || config.getFileReverifyDays() == null ? 30 : config.getFileReverifyDays();
+ Instant now = BackupsRuntime.getClock().instant();
+ Instant cutoff = now.minus(Duration.ofDays(days));
+ int failed = 0;
+
+ for(RunBundle.FileIndexEntry entry : bundle.distinctStoredFiles(findings))
+ {
+ QRecord fileObject = catalog.where(BackupsTables.FILE_OBJECT, "sha256", entry.sha256()).stream().filter(r -> r.getValue("deletedDate") == null).findFirst().orElse(null);
+ if(fileObject != null && "PASSED".equals(fileObject.getValueString("lastVerifyStatus"))
+ && fileObject.getValueInstant("lastVerifiedDate") != null && fileObject.getValueInstant("lastVerifiedDate").isAfter(cutoff))
+ {
+ findings.filesSkipped++;
+ continue;
+ }
+
+ findings.filesChecked++;
+ findings.objectsChecked++;
+ boolean encrypted = fileObject != null ? Boolean.TRUE.equals(fileObject.getValueBoolean("encrypted")) : bundle.isEncrypted();
+ boolean ok = false;
+ try
+ {
+ String actual = hashStoredFile(bundle, entry.casKey(), encrypted);
+ ok = actual.equals(entry.sha256());
+ if(!ok)
+ {
+ findings.mismatch("File store object sha256 " + entry.sha256() + " has the wrong content: re-hashing it gives " + actual);
+ }
+ }
+ catch(BackupStorageException e)
+ {
+ findings.mismatch("File store object missing: sha256 " + entry.sha256() + " (referenced by " + entry.table() + ")");
+ }
+ catch(IOException | RuntimeException e)
+ {
+ findings.mismatch("File store object sha256 " + entry.sha256() + " could not be read or decrypted (" + e.getClass().getSimpleName() + ")");
+ }
+
+ if(!ok)
+ {
+ failed++;
+ }
+ if(fileObject != null)
+ {
+ Map update = new LinkedHashMap<>();
+ update.put("lastVerifiedDate", now);
+ update.put("lastVerifyStatus", ok ? "PASSED" : "FAILED");
+ catalog.update(BackupsTables.FILE_OBJECT, fileObject.getValueInteger("id"), update);
+ }
+ }
+ findings.extra.put("filesFailed", failed);
+ }
+
+
+
+ /*******************************************************************************
+ ** SHA-256 of a stored file's plaintext (decrypted when it was encrypted).
+ *******************************************************************************/
+ static String hashStoredFile(RunBundle bundle, String key, boolean encrypted) throws IOException
+ {
+ return (hashObject(bundle.storage(), key, encrypted ? bundle.transform() : ArtifactTransform.IDENTITY));
+ }
+
+
+
+ /*******************************************************************************
+ ** SHA-256 of an object read through a transform.
+ *******************************************************************************/
+ static String hashObject(BackupStorage storage, String key, ArtifactTransform transform) throws IOException
+ {
+ MessageDigest digest = HashingOutputStream.newSha256();
+ try(InputStream raw = storage.openRead(key);
+ DigestInputStream plain = new DigestInputStream(transform.unwrap(raw), digest))
+ {
+ plain.transferTo(OutputStream.nullOutputStream());
+ }
+ return (HashingOutputStream.hex(digest.digest()));
+ }
+
+
+
+ /*******************************************************************************
+ ** Head an object, recording a mismatch when it is missing.
+ *******************************************************************************/
+ private void headOrMismatch(BackupStorage storage, String key, String label, VerifyFindings findings)
+ {
+ findings.objectsChecked++;
+ if(storage.head(key).isEmpty())
+ {
+ findings.mismatch("Missing object: " + label);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** The per-table detail and the messages, as JSON text.
+ *******************************************************************************/
+ private String detailJson(VerifyFindings findings)
+ {
+ Map detail = new LinkedHashMap<>();
+ detail.put("tables", findings.tables);
+ detail.put("mismatches", findings.mismatchMessages);
+ detail.put("warnings", findings.warnings);
+ detail.put("objectsChecked", findings.objectsChecked);
+ detail.put("filesChecked", findings.filesChecked);
+ detail.put("filesSkipped", findings.filesSkipped);
+ detail.putAll(findings.extra);
+ try
+ {
+ return (MAPPER.writeValueAsString(detail));
+ }
+ catch(IOException e)
+ {
+ return ("{}");
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Update the run, the plan and the alerts from an outcome.
+ *******************************************************************************/
+ private void applyOutcome(QRecord run, QRecord plan, VerificationOutcome outcome, Instant when) throws QException
+ {
+ Integer runId = run.getValueInteger("id");
+ String current = run.getValueString("verificationStatus");
+ String newState = null;
+ if(outcome.passed())
+ {
+ String reached = switch(outcome.level())
+ {
+ case STORAGE -> "STORAGE_OK";
+ case CONTENT -> "CONTENT_OK";
+ default -> "TEST_RESTORE_OK";
+ };
+ boolean currentFailed = "FAILED".equals(current);
+ // a cheap pass never clears a failure found by a deeper level
+ if(!currentFailed || !STORAGE.equals(outcome.level()))
+ {
+ newState = !currentFailed && STATUS_RANK.indexOf(current) > STATUS_RANK.indexOf(reached) ? current : reached;
+ }
+ }
+ else
+ {
+ newState = "FAILED";
+ }
+ if(newState != null && !newState.equals(current))
+ {
+ catalog.update(BackupsTables.RUN, runId, Map.of("verificationStatus", newState));
+ }
+
+ if(plan == null)
+ {
+ return;
+ }
+ String planKey = plan.getValueString("planKey");
+ int rank = LEVELS.indexOf(outcome.level());
+ if(outcome.passed())
+ {
+ for(int i = 0; i <= rank; i++)
+ {
+ BackupAlerts.resolveKey(BackupAlerts.VERIFY_FAILED, BackupAlerts.verifyKey(planKey, LEVELS.get(i)));
+ }
+ if(rank >= LEVELS.indexOf(CONTENT))
+ {
+ Map planUpdate = new LinkedHashMap<>();
+ planUpdate.put("lastVerifiedRunId", runId);
+ planUpdate.put("lastVerifiedDate", when);
+ catalog.update(BackupsTables.PLAN, plan.getValueInteger("id"), planUpdate);
+ BackupAlerts.resolve(BackupAlerts.VERIFY_OVERDUE, planKey);
+ }
+ }
+ else
+ {
+ Map detail = new LinkedHashMap<>();
+ detail.put("plan", planKey);
+ detail.put("run", run.getValueString("runKey"));
+ detail.put("level", outcome.level());
+ detail.put("mismatches", String.valueOf(outcome.mismatches()));
+ BackupAlerts.raiseWithKey(BackupAlerts.VERIFY_FAILED, BackupAlerts.verifyKey(planKey, outcome.level()), BackupAlertSink.Severity.ERROR,
+ "Backup verification failed (" + outcome.level() + ")", detail);
+ }
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/verify/MemoryScratchTarget.java b/src/main/java/com/kingsrook/qbits/backups/verify/MemoryScratchTarget.java
new file mode 100644
index 0000000..2126b76
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/verify/MemoryScratchTarget.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.verify;
+
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
+import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
+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.model.actions.tables.delete.DeleteInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
+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.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
+
+
+/*******************************************************************************
+ ** The MEMORY scratch target (design spec section 10.1): QQQ's memory backend. It
+ ** proves the files and the codecs, not the database. Each source table is cloned
+ ** into a temporary in-memory QTableMetaData (a different name, the memory backend,
+ ** no customizers, automation, behaviors, unique keys or associations) that is
+ ** registered in the instance for the length of the verification and removed by
+ ** drop().
+ *******************************************************************************/
+public class MemoryScratchTarget implements ScratchTarget
+{
+ public static final String BACKEND_NAME = "qqq_verify_memory";
+ public static final String TYPE = "MEMORY";
+
+ private final Map> tablesByRun = new LinkedHashMap<>();
+
+
+
+ /*******************************************************************************
+ ** {@inheritDoc}
+ *******************************************************************************/
+ @Override
+ public String type()
+ {
+ return (TYPE);
+ }
+
+
+
+ /*******************************************************************************
+ ** {@inheritDoc}
+ *******************************************************************************/
+ @Override
+ public String describe(String runKey)
+ {
+ return ("memory:" + prefix(runKey));
+ }
+
+
+
+ /*******************************************************************************
+ ** {@inheritDoc}
+ *******************************************************************************/
+ @Override
+ public synchronized ScratchTable create(QTableMetaData source, String runKey) throws QException
+ {
+ QInstance qInstance = QContext.getQInstance();
+ if(qInstance.getBackend(BACKEND_NAME) == null)
+ {
+ qInstance.addBackend(new QBackendMetaData().withName(BACKEND_NAME).withBackendType(MemoryBackendModule.class));
+ }
+
+ String scratchName = prefix(runKey) + "_" + source.getName();
+ QTableMetaData clone = source.clone();
+ clone.setName(scratchName);
+ clone.setBackendName(BACKEND_NAME);
+ clone.setBackendDetails(null);
+ clone.setAutomationDetails(null);
+ clone.setCustomizers(null);
+ clone.setUniqueKeys(null);
+ clone.setAssociations(new ArrayList<>());
+ clone.setRecordSecurityLocks(new ArrayList<>());
+ clone.setExposedJoins(new ArrayList<>());
+ clone.setSupplementalMetaData(null);
+ clone.setSourceQBitName(null);
+ clone.getFields().values().forEach(field -> field.setBehaviors(null));
+ qInstance.getTables().put(scratchName, clone);
+ tablesByRun.computeIfAbsent(runKey, k -> new ArrayList<>()).add(scratchName);
+
+ return (new ScratchTable()
+ {
+ /*******************************************************************************
+ ** {@inheritDoc}
+ *******************************************************************************/
+ @Override
+ public void insert(List records) throws QException
+ {
+ InsertInput input = new InsertInput(scratchName);
+ input.setRecords(records);
+ input.withPermissionBypass("backups verify scratch");
+ List inserted = new InsertAction().execute(input).getRecords();
+ for(QRecord record : inserted)
+ {
+ if(record.getErrors() != null && !record.getErrors().isEmpty())
+ {
+ throw (new QException("A row could not be restored into the scratch table for " + source.getName() + ": " + record.getErrors().get(0).getMessage()));
+ }
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** {@inheritDoc}
+ *******************************************************************************/
+ @Override
+ public void readInPrimaryKeyOrder(int pageSize, Consumer consumer) throws QException
+ {
+ int skip = 0;
+ while(true)
+ {
+ QueryInput input = new QueryInput(scratchName);
+ input.setFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy(source.getPrimaryKeyField(), true)).withSkip(skip).withLimit(pageSize));
+ input.withPermissionBypass("backups verify scratch");
+ List page = new QueryAction().execute(input).getRecords();
+ page.forEach(consumer);
+ if(page.size() < pageSize)
+ {
+ return;
+ }
+ skip += pageSize;
+ }
+ }
+ });
+ }
+
+
+
+ /*******************************************************************************
+ ** {@inheritDoc}
+ *******************************************************************************/
+ @Override
+ public synchronized void drop(String runKey) throws QException
+ {
+ List names = tablesByRun.remove(runKey);
+ if(names == null)
+ {
+ return;
+ }
+ for(String name : names)
+ {
+ DeleteInput input = new DeleteInput(name);
+ input.setQueryFilter(new QQueryFilter());
+ input.withPermissionBypass("backups verify scratch");
+ new DeleteAction().execute(input);
+ QContext.getQInstance().getTables().remove(name);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** The scratch name prefix of a run: qqq_verify_ plus the run key made safe.
+ *******************************************************************************/
+ static String prefix(String runKey)
+ {
+ return ("qqq_verify_" + runKey.replaceAll("[^A-Za-z0-9]", "_").toLowerCase());
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/verify/PartScanner.java b/src/main/java/com/kingsrook/qbits/backups/verify/PartScanner.java
new file mode 100644
index 0000000..b1eb1ad
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/verify/PartScanner.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.verify;
+
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.DigestInputStream;
+import java.security.MessageDigest;
+import java.util.zip.GZIPInputStream;
+import com.kingsrook.qbits.backups.codec.HashingOutputStream;
+import com.kingsrook.qbits.backups.manifest.BackupManifest;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+
+
+/*******************************************************************************
+ ** Reads one stored part end to end the way a restore would: from storage,
+ ** through the decrypt hook, through gunzip, and hands each line to a consumer,
+ ** while hashing the stored bytes and the decoded bytes.
+ *******************************************************************************/
+final class PartScanner
+{
+ /***************************************************************************
+ ** What a scan saw.
+ **
+ ** @param lines the number of lines handed to the consumer (0 when none was given)
+ ** @param sha256Stored SHA-256 of the bytes in the bucket
+ ** @param sha256Raw SHA-256 of the bytes after decrypting and decompressing
+ ***************************************************************************/
+ record Scan(long lines, String sha256Stored, String sha256Raw)
+ {
+ }
+
+
+
+ /***************************************************************************
+ ** Receives each line of a part (no trailing newline).
+ ***************************************************************************/
+ @FunctionalInterface
+ interface LineConsumer
+ {
+ /***************************************************************************
+ ** One line.
+ ***************************************************************************/
+ void accept(String line) throws QException;
+ }
+
+
+
+ /*******************************************************************************
+ ** Utility class.
+ *******************************************************************************/
+ private PartScanner()
+ {
+ }
+
+
+
+ /*******************************************************************************
+ ** Scan a part. With no consumer the part is read to its end and hashed only (a
+ ** CSV part, where lines are not rows).
+ *******************************************************************************/
+ static Scan scan(RunBundle bundle, BackupManifest.Part part, LineConsumer consumer) throws IOException, QException
+ {
+ MessageDigest storedDigest = HashingOutputStream.newSha256();
+ MessageDigest rawDigest = HashingOutputStream.newSha256();
+ long lines = 0;
+
+ try(InputStream fromStorage = bundle.storage().openRead(bundle.runPrefix() + part.key());
+ DigestInputStream stored = new DigestInputStream(fromStorage, storedDigest))
+ {
+ InputStream plain = part.encrypted() ? bundle.transform().unwrap(stored) : stored;
+ InputStream decompressed = "GZIP".equals(part.compression()) ? new GZIPInputStream(plain) : plain;
+
+ try(DigestInputStream raw = new DigestInputStream(decompressed, rawDigest))
+ {
+ if(consumer == null)
+ {
+ raw.transferTo(OutputStream.nullOutputStream());
+ }
+ else
+ {
+ BufferedReader reader = new BufferedReader(new InputStreamReader(raw, StandardCharsets.UTF_8));
+ String line;
+ while((line = reader.readLine()) != null)
+ {
+ lines++;
+ consumer.accept(line);
+ }
+ }
+ // anything after the decoded stream (a gzip trailer, padding) still counts toward the stored hash
+ stored.transferTo(OutputStream.nullOutputStream());
+ }
+ }
+
+ return (new Scan(lines, HashingOutputStream.hex(storedDigest.digest()), HashingOutputStream.hex(rawDigest.digest())));
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/verify/RdbmsSchemaScratchTarget.java b/src/main/java/com/kingsrook/qbits/backups/verify/RdbmsSchemaScratchTarget.java
new file mode 100644
index 0000000..6808979
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/verify/RdbmsSchemaScratchTarget.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.verify;
+
+
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+
+
+/*******************************************************************************
+ ** The RDBMS_SCHEMA scratch target (design spec section 10.1): a configured scratch
+ ** backend whose dedicated database user may CREATE SCHEMA and DROP SCHEMA only for
+ ** qqq_verify_*. The qbit creates qqq_verify_(runKey), builds each table there
+ ** from a DDL-only copy generated from QQQ metadata (columns by type, primary key,
+ ** unique keys), restores through a temporary in-memory QTableMetaData clone that
+ ** points at the scratch schema, and drops the schema.
+ **
+ ** STUB / TODO: this needs the restore agent's writer (the restore-mode insert,
+ ** QRun-IO/qqq PR #1111 RestoreWriteFlag, and the table writer built on it) and the
+ ** schema DDL generator. Until those land, create() refuses with a clear message,
+ ** so a plan that selects this target fails its TEST_RESTORE verification with an
+ ** ERROR instead of silently passing. Replace the body with the real
+ ** implementation when the restore writer is available.
+ *******************************************************************************/
+public class RdbmsSchemaScratchTarget implements ScratchTarget
+{
+ public static final String TYPE = "RDBMS_SCHEMA";
+
+ private final String backendName;
+
+
+
+ /*******************************************************************************
+ ** Constructor.
+ **
+ ** @param backendName the configured scratch RDBMS backend
+ *******************************************************************************/
+ public RdbmsSchemaScratchTarget(String backendName)
+ {
+ this.backendName = backendName;
+ }
+
+
+
+ /*******************************************************************************
+ ** {@inheritDoc}
+ *******************************************************************************/
+ @Override
+ public String type()
+ {
+ return (TYPE);
+ }
+
+
+
+ /*******************************************************************************
+ ** {@inheritDoc}
+ *******************************************************************************/
+ @Override
+ public String describe(String runKey)
+ {
+ return (backendName + ":schema=qqq_verify_" + runKey.replaceAll("[^A-Za-z0-9]", "_").toLowerCase());
+ }
+
+
+
+ /*******************************************************************************
+ ** {@inheritDoc}
+ *******************************************************************************/
+ @Override
+ public ScratchTable create(QTableMetaData source, String runKey) throws QException
+ {
+ // TODO: CREATE SCHEMA, generate the DDL-only table copy, and return a ScratchTable that restores
+ // through the restore writer. Needs the restore agent's writer.
+ throw (new QException("The RDBMS_SCHEMA scratch target is not implemented yet (it needs the restore writer); use the MEMORY scratch target."));
+ }
+
+
+
+ /*******************************************************************************
+ ** {@inheritDoc}
+ *******************************************************************************/
+ @Override
+ public void drop(String runKey) throws QException
+ {
+ // TODO: DROP SCHEMA qqq_verify_(runKey) once create() is implemented.
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/verify/RunBundle.java b/src/main/java/com/kingsrook/qbits/backups/verify/RunBundle.java
new file mode 100644
index 0000000..e1cecc6
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/verify/RunBundle.java
@@ -0,0 +1,367 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.verify;
+
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.BufferedReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.zip.GZIPInputStream;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.kingsrook.qbits.backups.BackupsRuntime;
+import com.kingsrook.qbits.backups.codec.ArtifactTransform;
+import com.kingsrook.qbits.backups.codec.HashingOutputStream;
+import com.kingsrook.qbits.backups.manifest.BackupManifest;
+import com.kingsrook.qbits.backups.storage.BackupStorage;
+import com.kingsrook.qbits.backups.storage.StoredObjectInfo;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+
+
+/*******************************************************************************
+ ** Everything a verification needs to know about one run, loaded once: the run
+ ** row, its storage, its manifest (read and hashed), the transform that undoes
+ ** the plan's encryption, and (lazily) the file index.
+ *******************************************************************************/
+class RunBundle
+{
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private final QRecord run;
+ private final QRecord plan;
+ private final BackupStorage storage;
+ private final String runPrefix;
+ private final BackupManifest manifest;
+ private final ArtifactTransform transform;
+ private final boolean encrypted;
+
+ private List fileIndex;
+
+
+
+ /***************************************************************************
+ ** One line of the per-run file index.
+ **
+ ** @param table the referencing table
+ ** @param recordKey the referencing record's primary key
+ ** @param field the referencing field
+ ** @param sha256 the content hash, null when not known
+ ** @param size the size in bytes, -1 when not known
+ ** @param status COPIED, DEDUPLICATED, MISSING or REFERENCE_ONLY
+ ** @param casKey the content-addressed key in the bucket, null when not stored
+ ***************************************************************************/
+ record FileIndexEntry(String table, String recordKey, String field, String sha256, long size, String status, String casKey)
+ {
+ /*******************************************************************************
+ ** Whether the file is in the file store (so it can be checked).
+ *******************************************************************************/
+ boolean isStored()
+ {
+ return (casKey != null && sha256 != null && ("COPIED".equals(status) || "DEDUPLICATED".equals(status)));
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Constructor.
+ *******************************************************************************/
+ private RunBundle(QRecord run, QRecord plan, BackupStorage storage, String runPrefix, BackupManifest manifest, ArtifactTransform transform, boolean encrypted)
+ {
+ this.run = run;
+ this.plan = plan;
+ this.storage = storage;
+ this.runPrefix = runPrefix;
+ this.manifest = manifest;
+ this.transform = transform;
+ this.encrypted = encrypted;
+ }
+
+
+
+ /*******************************************************************************
+ ** Load a run. Returns null, after recording a mismatch, when the run has no
+ ** readable manifest (so it is not usable and there is nothing more to check).
+ ** Throws VerifyException when the run cannot be examined at all.
+ *******************************************************************************/
+ static RunBundle load(QRecord run, QRecord plan, VerifyFindings findings)
+ {
+ String alias = run.getValueString("storageBucketAlias");
+ String runPrefix = run.getValueString("runPrefix");
+ if(alias == null || runPrefix == null)
+ {
+ throw (new VerifyException("The run has no storage bucket or run prefix recorded"));
+ }
+ BackupStorage storage = BackupsRuntime.storageFor(alias);
+
+ String manifestKey = run.getValueString("manifestKey") != null ? run.getValueString("manifestKey") : runPrefix + "manifest.json";
+ Optional head = storage.head(manifestKey);
+ if(head.isEmpty())
+ {
+ findings.mismatch("The manifest is missing from the bucket, so this backup is not usable");
+ return (null);
+ }
+
+ byte[] bytes;
+ try(InputStream in = storage.openRead(manifestKey))
+ {
+ bytes = in.readAllBytes();
+ }
+ catch(IOException e)
+ {
+ throw (new VerifyException("The manifest could not be read: " + e.getMessage(), e));
+ }
+
+ String sha = HashingOutputStream.sha256Hex(bytes);
+ if(run.getValueString("manifestSha256") != null && !run.getValueString("manifestSha256").equals(sha))
+ {
+ findings.mismatch("The manifest changed after it was written (its SHA-256 no longer matches the catalog)");
+ }
+
+ BackupManifest manifest;
+ try
+ {
+ manifest = BackupManifest.read(bytes);
+ }
+ catch(IOException e)
+ {
+ findings.mismatch("The manifest is not valid JSON: " + e.getMessage());
+ return (null);
+ }
+ if(manifest.tables() == null)
+ {
+ findings.mismatch("The manifest lists no tables section");
+ return (null);
+ }
+
+ boolean encrypted = manifest.encryption() != null && manifest.encryption().mode() != null && !"NONE".equals(manifest.encryption().mode());
+ ArtifactTransform transform = ArtifactTransform.IDENTITY;
+ if(encrypted)
+ {
+ String keyRef = manifest.encryption().keyRef();
+ var config = BackupsRuntime.getConfig();
+ if(config == null || config.getArtifactTransformResolver() == null)
+ {
+ throw (new VerifyException("This backup is encrypted but no decrypt hook is configured (BackupsQBitConfig.withArtifactTransformResolver), so it cannot be verified"));
+ }
+ transform = config.getArtifactTransformResolver().apply(keyRef);
+ if(transform == null)
+ {
+ throw (new VerifyException("No key is available for the key reference " + keyRef + ", so this backup cannot be decrypted"));
+ }
+ }
+ return (new RunBundle(run, plan, storage, runPrefix, manifest, transform, encrypted));
+ }
+
+
+
+ /*******************************************************************************
+ ** The distinct stored files the run's file index references, in first-seen order
+ ** (one entry per hash). Reads the index once.
+ *******************************************************************************/
+ synchronized List distinctStoredFiles(VerifyFindings findings)
+ {
+ if(fileIndex == null)
+ {
+ fileIndex = readIndex(findings);
+ }
+ Map bySha = new LinkedHashMap<>();
+ for(FileIndexEntry entry : fileIndex)
+ {
+ if(entry.isStored())
+ {
+ bySha.putIfAbsent(entry.sha256(), entry);
+ }
+ }
+ return (new ArrayList<>(bySha.values()));
+ }
+
+
+
+ /*******************************************************************************
+ ** Every index entry, stored or not.
+ *******************************************************************************/
+ synchronized List allFileEntries(VerifyFindings findings)
+ {
+ if(fileIndex == null)
+ {
+ fileIndex = readIndex(findings);
+ }
+ return (fileIndex);
+ }
+
+
+
+ /*******************************************************************************
+ ** Read the file index objects. The index is gzip JSON Lines; it is read as plain
+ ** gzip first and, if that is not readable and the plan encrypts, through the
+ ** decrypt hook.
+ *******************************************************************************/
+ private List readIndex(VerifyFindings findings)
+ {
+ List entries = new ArrayList<>();
+ if(manifest.files() == null || manifest.files().indexKeys() == null)
+ {
+ return (entries);
+ }
+ for(String relative : manifest.files().indexKeys())
+ {
+ String key = runPrefix + relative;
+ if(storage.head(key).isEmpty())
+ {
+ continue;
+ }
+ try
+ {
+ byte[] bytes;
+ try(InputStream in = storage.openRead(key))
+ {
+ bytes = in.readAllBytes();
+ }
+ try
+ {
+ parseIndex(new GZIPInputStream(new ByteArrayInputStream(bytes)), entries);
+ }
+ catch(IOException plainFailed)
+ {
+ if(!encrypted)
+ {
+ throw (plainFailed);
+ }
+ entries.clear();
+ parseIndex(new GZIPInputStream(transform.unwrap(new ByteArrayInputStream(bytes))), entries);
+ }
+ }
+ catch(IOException e)
+ {
+ findings.mismatch("The file index " + relative + " cannot be read: " + e.getClass().getSimpleName());
+ }
+ }
+ return (entries);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private void parseIndex(InputStream in, List entries) throws IOException
+ {
+ try(BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)))
+ {
+ String line;
+ while((line = reader.readLine()) != null)
+ {
+ if(line.isBlank())
+ {
+ continue;
+ }
+ JsonNode node = MAPPER.readTree(line);
+ entries.add(new FileIndexEntry(text(node, "table"), text(node, "recordKey"), text(node, "field"), text(node, "sha256"),
+ node.hasNonNull("size") ? node.get("size").asLong() : -1L, text(node, "status"), text(node, "casKey")));
+ }
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static String text(JsonNode node, String name)
+ {
+ return (node.hasNonNull(name) ? node.get(name).asText() : null);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for run
+ *******************************************************************************/
+ QRecord run()
+ {
+ return (run);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for plan (null for an ad-hoc run)
+ *******************************************************************************/
+ QRecord plan()
+ {
+ return (plan);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for storage
+ *******************************************************************************/
+ BackupStorage storage()
+ {
+ return (storage);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for runPrefix
+ *******************************************************************************/
+ String runPrefix()
+ {
+ return (runPrefix);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for manifest
+ *******************************************************************************/
+ BackupManifest manifest()
+ {
+ return (manifest);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for transform (the decrypt hook, or the identity)
+ *******************************************************************************/
+ ArtifactTransform transform()
+ {
+ return (transform);
+ }
+
+
+
+ /*******************************************************************************
+ ** Whether the run's artifacts are encrypted.
+ *******************************************************************************/
+ boolean isEncrypted()
+ {
+ return (encrypted);
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/verify/ScratchTable.java b/src/main/java/com/kingsrook/qbits/backups/verify/ScratchTable.java
new file mode 100644
index 0000000..9c44891
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/verify/ScratchTable.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.verify;
+
+
+import java.util.List;
+import java.util.function.Consumer;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+
+
+/*******************************************************************************
+ ** One restored table inside a scratch target.
+ *******************************************************************************/
+public interface ScratchTable
+{
+ /***************************************************************************
+ ** Restore rows into the scratch table, preserving their primary keys.
+ ***************************************************************************/
+ void insert(List records) throws QException;
+
+
+ /***************************************************************************
+ ** Read every row back in primary key order, a page at a time.
+ ***************************************************************************/
+ void readInPrimaryKeyOrder(int pageSize, Consumer consumer) throws QException;
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/verify/ScratchTarget.java b/src/main/java/com/kingsrook/qbits/backups/verify/ScratchTarget.java
new file mode 100644
index 0000000..d127f9e
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/verify/ScratchTarget.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.verify;
+
+
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+
+
+/*******************************************************************************
+ ** Where a TEST_RESTORE verification restores to (design spec section 10.1). The
+ ** target builds a scratch copy of each table from the source table's metadata,
+ ** takes the restored rows, reads them back in primary key order, and is dropped
+ ** afterwards. Nothing a scratch target does may touch the real tables.
+ **
+ ** Implementations: MEMORY (MemoryScratchTarget, small apps and CI) and RDBMS_SCHEMA
+ ** (RdbmsSchemaScratchTarget, a stub until the restore agent's writer ships).
+ *******************************************************************************/
+public interface ScratchTarget
+{
+ /***************************************************************************
+ ** The target type: MEMORY, RDBMS_SCHEMA or SEPARATE_DATABASE.
+ ***************************************************************************/
+ String type();
+
+
+ /***************************************************************************
+ ** A description for backups_verification.scratchTarget, for example
+ ** "memory:qqq_verify_20261012t020000z_7f3a".
+ ***************************************************************************/
+ String describe(String runKey);
+
+
+ /***************************************************************************
+ ** Create the scratch copy of a table for a run.
+ ***************************************************************************/
+ ScratchTable create(QTableMetaData source, String runKey) throws QException;
+
+
+ /***************************************************************************
+ ** Drop everything created for the run. Safe to call more than once.
+ ***************************************************************************/
+ void drop(String runKey) throws QException;
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/verify/TestRestoreVerifier.java b/src/main/java/com/kingsrook/qbits/backups/verify/TestRestoreVerifier.java
new file mode 100644
index 0000000..8b993c1
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/verify/TestRestoreVerifier.java
@@ -0,0 +1,282 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.verify;
+
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import com.kingsrook.qbits.backups.BackupsRuntime;
+import com.kingsrook.qbits.backups.codec.ArtifactTransform;
+import com.kingsrook.qbits.backups.codec.HashingOutputStream;
+import com.kingsrook.qbits.backups.codec.JsonlRowCodec;
+import com.kingsrook.qbits.backups.manifest.BackupManifest;
+import com.kingsrook.qbits.backups.storage.BackupStorage;
+import com.kingsrook.qbits.backups.storage.WriteOptions;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+
+
+/*******************************************************************************
+ ** TEST_RESTORE verification (design spec section 10.1): restore every table of the
+ ** run into a scratch target (MEMORY by default), read it back in primary key order,
+ ** and compare each table's row count and rowsHash with the manifest; restore the
+ ** run's files into the scratch prefix qqq-verify/(runKey)/ in the backup bucket,
+ ** re-hash each one, and check that every file reference of the index resolves to a
+ ** restored file. Then drop the scratch tables and delete the scratch files, even
+ ** when something fails.
+ **
+ ** The scratch target is BackupsQBitConfig.getScratchTarget(); the MEMORY target
+ ** proves the files and the codecs, not the database. RDBMS_SCHEMA is a stub until
+ ** the restore agent's writer ships (see RdbmsSchemaScratchTarget). Sequence resync
+ ** is part of the real restore (spec 5.4) and not exercised by the MEMORY target.
+ *******************************************************************************/
+public class TestRestoreVerifier
+{
+ private static final int PAGE_SIZE = 1000;
+
+ public static final String SCRATCH_FILE_PREFIX = "qqq-verify/";
+
+
+
+ /*******************************************************************************
+ ** Run the test restore, recording what it finds. Returns the scratch target
+ ** description for backups_verification.scratchTarget.
+ *******************************************************************************/
+ String verify(RunBundle bundle, VerifyFindings findings) throws QException
+ {
+ ScratchTarget target = BackupsRuntime.getConfig() != null && BackupsRuntime.getConfig().getScratchTarget() != null
+ ? BackupsRuntime.getConfig().getScratchTarget() : new MemoryScratchTarget();
+ String runKey = bundle.manifest().runKey();
+ String scratch = target.describe(runKey);
+
+ List scratchFileKeys = new ArrayList<>();
+ try
+ {
+ findings.tablesChecked = bundle.manifest().tables().size();
+ restoreTables(bundle, target, runKey, findings);
+ restoreFiles(bundle, runKey, scratchFileKeys, findings);
+ }
+ finally
+ {
+ try
+ {
+ target.drop(runKey);
+ }
+ finally
+ {
+ for(String key : scratchFileKeys)
+ {
+ bundle.storage().deletePermanent(key);
+ }
+ }
+ }
+ return (scratch);
+ }
+
+
+
+ /*******************************************************************************
+ ** Restore each table into the scratch target and compare it with the manifest.
+ *******************************************************************************/
+ private void restoreTables(RunBundle bundle, ScratchTarget target, String runKey, VerifyFindings findings) throws QException
+ {
+ BackupManifest manifest = bundle.manifest();
+ List order = new ArrayList<>(manifest.restoreOrder() == null ? List.of() : manifest.restoreOrder());
+ for(BackupManifest.TableEntry entry : manifest.tables())
+ {
+ if(!order.contains(entry.name()))
+ {
+ order.add(entry.name());
+ }
+ }
+
+ for(String name : order)
+ {
+ BackupManifest.TableEntry entry = manifest.table(name);
+ if(entry == null)
+ {
+ continue;
+ }
+ QTableMetaData table = QContext.getQInstance().getTable(name);
+ if(table == null)
+ {
+ findings.mismatch("Table " + name + " no longer exists in this instance, so it cannot be test-restored");
+ continue;
+ }
+ boolean hasJsonl = entry.parts() != null && entry.parts().stream().anyMatch(p -> !"CSV".equals(p.format()));
+ if(!hasJsonl)
+ {
+ findings.warn("Table " + name + " has only CSV parts and was not test-restored");
+ continue;
+ }
+
+ List excluded = entry.excludedFields() == null ? List.of() : entry.excludedFields().stream().map(BackupManifest.ExcludedField::name).toList();
+ JsonlRowCodec codec = new JsonlRowCodec(table, excluded);
+ ScratchTable scratchTable = target.create(table, runKey);
+
+ int before = findings.mismatches;
+ restoreRows(bundle, entry, codec, scratchTable, findings);
+
+ MessageDigest digest = HashingOutputStream.newSha256();
+ long[] count = { 0 };
+ scratchTable.readInPrimaryKeyOrder(PAGE_SIZE, record ->
+ {
+ count[0]++;
+ digest.update((codec.encode(record) + "\n").getBytes(StandardCharsets.UTF_8));
+ });
+ String actualHash = "sha256:" + HashingOutputStream.hex(digest.digest());
+
+ if(count[0] != entry.rowCount())
+ {
+ findings.mismatch("Test restore of table " + name + " has " + count[0] + " rows, the manifest says " + entry.rowCount());
+ }
+ if(entry.rowsHash() != null && !entry.rowsHash().equals(actualHash))
+ {
+ findings.mismatch("Test restore of table " + name + " does not reproduce the backed up rows (row content hash differs)");
+ }
+ findings.rowsChecked += count[0];
+
+ Map detail = new LinkedHashMap<>();
+ detail.put("table", name);
+ detail.put("expectedRows", entry.rowCount());
+ detail.put("actualRows", count[0]);
+ detail.put("expectedRowsHash", entry.rowsHash());
+ detail.put("actualRowsHash", actualHash);
+ detail.put("ok", findings.mismatches == before);
+ findings.tables.add(detail);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Decode the JSONL parts of one table and insert them into the scratch table.
+ *******************************************************************************/
+ private void restoreRows(RunBundle bundle, BackupManifest.TableEntry entry, JsonlRowCodec codec, ScratchTable scratchTable, VerifyFindings findings) throws QException
+ {
+ for(BackupManifest.Part part : entry.parts())
+ {
+ if("CSV".equals(part.format()))
+ {
+ continue;
+ }
+ List batch = new ArrayList<>();
+ try
+ {
+ PartScanner.scan(bundle, part, line ->
+ {
+ batch.add(codec.decode(line));
+ if(batch.size() >= PAGE_SIZE)
+ {
+ scratchTable.insert(new ArrayList<>(batch));
+ batch.clear();
+ }
+ });
+ if(!batch.isEmpty())
+ {
+ scratchTable.insert(batch);
+ }
+ }
+ catch(IOException | RuntimeException e)
+ {
+ findings.mismatch("The part " + part.key() + " could not be restored (" + e.getClass().getSimpleName() + "); it is corrupted, or the key is wrong");
+ }
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Restore the run's files into the scratch prefix, re-hash each, and check that
+ ** every index reference resolves to a restored file. verifyFileSamplePercent
+ ** (on the plan, default 100) limits how many files are restored.
+ *******************************************************************************/
+ private void restoreFiles(RunBundle bundle, String runKey, List scratchFileKeys, VerifyFindings findings)
+ {
+ int percent = 100;
+ if(bundle.plan() != null && bundle.plan().getValueInteger("verifyFileSamplePercent") != null)
+ {
+ percent = Math.max(0, Math.min(100, bundle.plan().getValueInteger("verifyFileSamplePercent")));
+ }
+
+ BackupStorage storage = bundle.storage();
+ String prefix = SCRATCH_FILE_PREFIX + runKey + "/";
+ Set restored = new LinkedHashSet<>();
+ int sampled = 0;
+ for(RunBundle.FileIndexEntry entry : bundle.distinctStoredFiles(findings))
+ {
+ if(Math.floorMod(entry.sha256().hashCode(), 100) >= percent)
+ {
+ continue;
+ }
+ sampled++;
+ String scratchKey = prefix + entry.sha256();
+ scratchFileKeys.add(scratchKey);
+ try
+ {
+ ArtifactTransform transform = bundle.isEncrypted() ? bundle.transform() : ArtifactTransform.IDENTITY;
+ String written;
+ try(InputStream raw = storage.openRead(entry.casKey());
+ InputStream plain = transform.unwrap(raw);
+ HashingOutputStream out = new HashingOutputStream(storage.openWrite(scratchKey, WriteOptions.create())))
+ {
+ plain.transferTo(out);
+ out.close();
+ written = out.getSha256();
+ }
+ String reread = BackupVerifier.hashObject(storage, scratchKey, ArtifactTransform.IDENTITY);
+ if(written.equals(entry.sha256()) && reread.equals(entry.sha256()))
+ {
+ restored.add(entry.sha256());
+ }
+ else
+ {
+ findings.mismatch("Test restore of file sha256 " + entry.sha256() + " does not reproduce its content");
+ }
+ }
+ catch(IOException | RuntimeException e)
+ {
+ findings.mismatch("Test restore of file sha256 " + entry.sha256() + " failed (" + e.getClass().getSimpleName() + ")");
+ }
+ }
+
+ int unresolved = 0;
+ for(RunBundle.FileIndexEntry entry : bundle.allFileEntries(findings))
+ {
+ if(entry.isStored() && Math.floorMod(entry.sha256().hashCode(), 100) < percent && !restored.contains(entry.sha256()))
+ {
+ unresolved++;
+ }
+ }
+ if(unresolved > 0)
+ {
+ findings.mismatch(unresolved + " file references in the index do not resolve to a restored file");
+ }
+ findings.filesChecked += sampled;
+ findings.extra.put("filesRestored", restored.size());
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/verify/VerificationOutcome.java b/src/main/java/com/kingsrook/qbits/backups/verify/VerificationOutcome.java
new file mode 100644
index 0000000..8779662
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/verify/VerificationOutcome.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.verify;
+
+
+import java.util.List;
+
+
+/*******************************************************************************
+ ** The result of verifying one run at one level; also what was written to the
+ ** backups_verification row.
+ **
+ ** @param verificationId the backups_verification id
+ ** @param runId the backups_run id
+ ** @param level STORAGE, CONTENT or TEST_RESTORE
+ ** @param status PASSED, PASSED_WITH_WARNINGS, FAILED or ERROR
+ ** @param tablesChecked tables checked
+ ** @param rowsChecked rows decoded and counted
+ ** @param mismatches how many things did not match (the messages list is capped)
+ ** @param objectsChecked storage objects examined
+ ** @param filesChecked file-store objects examined (head, or read and re-hashed)
+ ** @param filesSkipped file-store objects skipped because they were verified recently
+ ** @param messages the first mismatches and warnings, in plain words
+ ** @param scratchTarget for TEST_RESTORE, the scratch target used
+ *******************************************************************************/
+public record VerificationOutcome(Integer verificationId, Integer runId, String level, String status, int tablesChecked, long rowsChecked, int mismatches,
+ int objectsChecked, int filesChecked, int filesSkipped, List messages, String scratchTarget)
+{
+ /*******************************************************************************
+ ** Whether the verification passed (with or without warnings).
+ *******************************************************************************/
+ public boolean passed()
+ {
+ return ("PASSED".equals(status) || "PASSED_WITH_WARNINGS".equals(status));
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/verify/VerifyException.java b/src/main/java/com/kingsrook/qbits/backups/verify/VerifyException.java
new file mode 100644
index 0000000..e5c7689
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/verify/VerifyException.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.verify;
+
+
+/*******************************************************************************
+ ** A verification could not be carried out (as opposed to carried out and found
+ ** a mismatch): the storage is unreachable, there is no key to decrypt with. The
+ ** verification ends with status ERROR.
+ *******************************************************************************/
+public class VerifyException extends RuntimeException
+{
+ /*******************************************************************************
+ ** Constructor.
+ *******************************************************************************/
+ public VerifyException(String message)
+ {
+ super(message);
+ }
+
+
+
+ /*******************************************************************************
+ ** Constructor.
+ *******************************************************************************/
+ public VerifyException(String message, Throwable cause)
+ {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/backups/verify/VerifyFindings.java b/src/main/java/com/kingsrook/qbits/backups/verify/VerifyFindings.java
new file mode 100644
index 0000000..0055a2c
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/backups/verify/VerifyFindings.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * 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
+ *
+ * http://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.backups.verify;
+
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+
+/*******************************************************************************
+ ** What one verification found: counts, the first mismatches and warnings (capped,
+ ** so a ruined bucket does not write megabytes of messages), and per-table detail
+ ** for backups_verification.detailJson. Messages never contain row values.
+ *******************************************************************************/
+class VerifyFindings
+{
+ static final int MAX_MESSAGES = 100;
+
+ final List mismatchMessages = new ArrayList<>();
+ final List warnings = new ArrayList<>();
+ final List