Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 664f467d11 build(deps): qbit-object-storage 0.1.0-RC.2 (AWS SDK GHSA-443w-3rq3-5m5h)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:05:06 -05:00
james.maes 4fb182e824 Merge pull request #16 from QRun-IO/chore/object-storage-rc1
build: depend on qbit-object-storage 0.1.0-RC.1 (no SNAPSHOT dependency)
2026-10-12 19:15:20 -05:00
james.maesandClaude Sonnet 5.5 1de38f03ae build: depend on qbit-object-storage 0.1.0-RC.1 (Central), not a SNAPSHOT
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:11:25 -05:00
james.maes cf2d027f9a Merge pull request #9 from QRun-IO/feature/home-polish
feat(home): readable Backups home, record screens, runnable demo
2026-10-12 19:10:47 -05:00
james.maesandClaude Sonnet 5.5 714ef906fe feat(home): drop duplicate app section, bounded trend bars, screenshots
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:07:45 -05:00
james.maesandClaude Sonnet 5.5 26c174f7bf feat(home): null-safe home, readable plan rows, record sections and child lists, runnable demo
- Fix the NPE in BackupsHomeData.plansNeedingAttention when slaStatus is null (and null plan ids)
- Home: full-width plan status rows, derived lateness, links that work in the Next UI (tablePathPrefix), bars for the size trend
- The Backups app attaches itself to its parent app (Admin) so the nav shows it
- Nav lists the main tables only; artifacts, verifications, restores and per-table results are child lists on their parents
- Record screens grouped into sections
- Test-scope demo server (memory backend, real engine history incl. a failed run)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:07:45 -05:00
james.maes 284f0cec8c Merge pull request #8 from QRun-IO/feature/object-storage-adapter
feat(storage): BackupStorage adapter over qbit-object-storage (MinIO tested)
2026-10-12 19:07:11 -05:00
james.maesandClaude Sonnet 5.5 2f16bd6515 test(storage): skip, do not fail, when the MinIO image cannot be started
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:04:44 -05:00
james.maesandClaude Sonnet 5.5 d4ad39f19d test(storage): skip the MinIO tests when Docker is not available
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:02:10 -05:00
james.maesandClaude Sonnet 5.5 ba3959e7dc feat(storage): BackupStorage adapter over qbit-object-storage, tested on MinIO
A streamed write of unknown length is bridged to the storage qbit put; permanent
delete reports locked objects; failures map to BackupStorageException reasons.
Object storage is an optional dependency. Tested against a MinIO container with
object lock: the adapter contract and a full backup run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:00:59 -05:00
james.maes a67e4c4a57 Merge pull request #7 from QRun-IO/feature/prune-schedule
feat: run lifecycle - schedule, lease, cancel, resume, GFS prune, object lock, plan validation
2026-10-12 19:00:25 -05:00
james.maesandClaude Sonnet 5.5 4022fa1eee test(verify): do not depend on which mismatch is reported first for a flipped byte
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 18:57:50 -05:00
james.maesandClaude Sonnet 5.5 6017f3ed2d test(sla): save the plan with a bucket that exists, then remove it from the config
Plan save now validates the bucket alias (lifecycle), so the SLA test creates the
plan while the bucket is configured and removes it afterwards.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 18:55:21 -05:00
james.maesandClaude Sonnet 5.5 01b60708f8 feat: run lifecycle - plan schedule, run lease, cancel, resume, GFS prune, object lock, plan validation
- BackupPlanSyncToScheduledJobProcess (+ verify variant) and the plan table wiring
- BackupRunCoordinator: one run per plan (live-heartbeat lease, QUEUED to RUNNING,
  SKIPPED_CONCURRENT warning), maxConcurrentRuns queueing, scheduled entry point
  (backupRunScheduledPlan) as the system session with plan-owner validation
- plan save validation (admin and read on resolved tables, object lock vs bucket
  capabilities, encryption rules, unsupported formats, planKey immutable after a run)
- PruneCalculator (GFS) and BackupPruner (retention classes, deletePermanent on the
  prune alias, kept: locked until, debris, file store mark-and-sweep)
- object lock applied at write time in the engine, parts catalogued as they complete
- Back up these records, Cancel, Resume (PER_TABLE, from the last complete part),
  Test destination and Prune processes with Review and Result lines

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 18:54:24 -05:00
james.maes 06c87afa20 Merge pull request #6 from QRun-IO/feature/restore
feat(restore): restore, catalog import and download (spec 9.3-9.7)
2026-10-12 18:53:53 -05:00
118 changed files with 8680 additions and 148 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Backups home and record screenshots
Taken 2026-10-12 against `BackupsDemoServer` (src/test/.../demo, memory backend, real engine history) and the Next UI 1.0.0-RC.14 jar.
Light and dark at 390, 600, 768, 1024, 1440, 1920 px (home) and 390, 1440 px (records).
- `.` : this branch (foundation base): home, plan, run, failed run, lists.
- `with-restore-verify/` : a scratch merge of PRs #5 (verify), #6 (restore), #7 (prune/schedule) onto this branch, so verifications, a restore run and retention classes exist. Not part of this PR.
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+40
View File
@@ -86,6 +86,35 @@
<optional>true</optional>
</dependency>
<!-- Optional: the production BackupStorage adapter. Apps that install this qbit -->
<!-- with object storage already have it; others use their own BackupStorage. -->
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-object-storage</artifactId>
<version>0.1.0-RC.2</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>2.0.3</version>
<scope>test</scope>
</dependency>
<!-- Demo server only (src/test/.../demo): never shipped. -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.14</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -101,6 +130,17 @@
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- the MinIO test container default credentials, resolved through ${env.X} refs like production -->
<environmentVariables>
<STORAGE_TEST_ACCESS_KEY>minioadmin</STORAGE_TEST_ACCESS_KEY>
<STORAGE_TEST_SECRET_KEY>minioadmin</STORAGE_TEST_SECRET_KEY>
</environmentVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
@@ -45,6 +45,7 @@ public class BackupsQBitConfig implements QBitConfig
private String appName = "backups";
private String appLabel = "Backups";
private String parentAppName;
private String tablePathPrefix;
private String schedulerName;
private String instanceName;
@@ -179,6 +180,28 @@ public class BackupsQBitConfig implements QBitConfig
/*******************************************************************************
** Getter for tablePathPrefix (the front of the links the home makes to tables and
** records, "/app" for the Next dashboard, which routes /app/{table}/{id}).
*******************************************************************************/
public String getTablePathPrefix()
{
return (tablePathPrefix == null ? "/app" : tablePathPrefix);
}
/*******************************************************************************
** Fluent setter for tablePathPrefix
*******************************************************************************/
public BackupsQBitConfig withTablePathPrefix(String tablePathPrefix)
{
this.tablePathPrefix = tablePathPrefix;
return (this);
}
/*******************************************************************************
** Getter for schedulerName (the QQQ scheduler used for plan jobs and the SLA checker)
*******************************************************************************/
@@ -149,12 +149,29 @@ public class BackupsQBitProducer implements QBitMetaDataProducer<BackupsQBitConf
process.setSourceQBitName(qBit.getName());
output.add(process);
}
for(var process : com.kingsrook.qbits.backups.processes.ExportTableProcess.exportProcesses(qInstance))
{
process.setSourceQBitName(qBit.getName());
output.add(process);
}
for(var widget : com.kingsrook.qbits.backups.app.BackupsApp.widgets())
{
output.add(widget);
}
output.add(com.kingsrook.qbits.backups.app.BackupsApp.app(qBitConfig, tables));
for(var widget : com.kingsrook.qbits.backups.model.BackupsTables.widgets())
{
output.add(widget);
}
com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData app = com.kingsrook.qbits.backups.app.BackupsApp.app(qBitConfig, tables);
output.add(app);
output.addSelfToInstance(qInstance);
// A parent app only lists the apps that are its children, so attach ourselves (for example Admin, then Backups).
com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData parent = qBitConfig.getParentAppName() == null ? null : qInstance.getApp(qBitConfig.getParentAppName());
if(parent != null && (parent.getChildren() == null || parent.getChildren().stream().noneMatch(child -> app.getName().equals(child.getName()))))
{
parent.withChild(app);
}
return (new com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput());
}
@@ -29,7 +29,6 @@ import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
@@ -62,7 +61,7 @@ public final class BackupsApp
public static List<QWidgetMetaData> widgets()
{
return (List.of(
new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Backups").withType(WidgetType.HTML.getType())
new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").withType(WidgetType.HTML.getType())
.withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false).withPermissionRules(BackupsTables.permissionRules()),
new QWidgetMetaData().withName(HEALTH_CARD_WIDGET).withLabel("Backups").withType(WidgetType.HTML.getType())
.withCodeReference(new QCodeReference(HealthCardRenderer.class)).withGridColumns(12).withIsCard(true).withPermissionRules(BackupsTables.permissionRules())));
@@ -80,13 +79,7 @@ public final class BackupsApp
.withLabel(config.getAppLabel())
.withIcon(new QIcon("backup"))
.withPermissionRules(BackupsTables.permissionRules())
.withWidgets(List.of(HOME_WIDGET))
.withSection(new QAppSection().withName("backups").withLabel("Backups").withIcon(new QIcon("backup"))
.withTables(List.of(BackupsTables.PLAN, BackupsTables.RUN)))
.withSection(new QAppSection().withName("recovery").withLabel("Restore and verify").withIcon(new QIcon("restore"))
.withTables(List.of(BackupsTables.RESTORE_RUN, BackupsTables.VERIFICATION)))
.withSection(new QAppSection().withName("files").withLabel("Files").withIcon(new QIcon("attach_file"))
.withTables(List.of(BackupsTables.FILE_OBJECT)));
.withWidgets(List.of(HOME_WIDGET));
if(config.getParentAppName() != null)
{
app.setParentAppName(config.getParentAppName());
@@ -143,10 +136,6 @@ public final class BackupsApp
static String pathPrefix()
{
var config = com.kingsrook.qbits.backups.BackupsRuntime.getConfig();
if(config == null)
{
return ("/backups");
}
return (config.getParentAppName() != null ? "/" + config.getParentAppName() + "/" + config.getAppName() : "/" + config.getAppName());
return (config == null ? "/app" : config.getTablePathPrefix());
}
}
@@ -159,12 +159,53 @@ public class BackupsHomeData
/*******************************************************************************
** The status to show for a plan: the stored slaStatus when it is a problem state,
** otherwise LATE when the age of the last success is past the SLA (the SLA
** checker may not have run yet), otherwise the stored status (UNKNOWN when none).
*******************************************************************************/
public String effectiveStatus(QRecord plan)
{
String stored = plan.getValueString("slaStatus");
if(stored != null && ATTENTION.contains(stored))
{
return (stored);
}
Long age = ageHours(plan);
Integer sla = plan.getValueInteger("slaHours");
if(age != null && sla != null && age > sla)
{
return ("LATE");
}
return (stored == null ? "UNKNOWN" : stored);
}
/*******************************************************************************
** Plans that need attention.
*******************************************************************************/
public List<QRecord> plansNeedingAttention()
{
return (plans.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("isActive")) && ATTENTION.contains(p.getValueString("slaStatus"))).toList());
return (plans.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("isActive")) && ATTENTION.contains(effectiveStatus(p))).toList());
}
/*******************************************************************************
** The last run of a plan (from the recent runs), or null.
*******************************************************************************/
public QRecord lastRun(QRecord plan)
{
Integer lastRunId = plan.getValueInteger("lastRunId");
for(QRecord run : recentRuns)
{
if(lastRunId != null && lastRunId.equals(run.getValueInteger("id")))
{
return (run);
}
}
return (null);
}
@@ -206,7 +247,7 @@ public class BackupsHomeData
Integer runId = verification.getValueInteger("backupRunId");
for(QRecord run : recentRuns)
{
if(runId != null && runId.equals(run.getValueInteger("id")) && planId.equals(run.getValueInteger("backupPlanId")))
if(runId != null && planId != null && runId.equals(run.getValueInteger("id")) && planId.equals(run.getValueInteger("backupPlanId")))
{
return (verification);
}
@@ -224,6 +265,10 @@ public class BackupsHomeData
public List<QRecord> successfulRuns(Integer planId)
{
List<QRecord> rs = new ArrayList<>();
if(planId == null)
{
return (rs);
}
for(QRecord run : recentRuns)
{
if(planId.equals(run.getValueInteger("backupPlanId")) && run.getValueString("status") != null && run.getValueString("status").startsWith("SUCCEEDED"))
@@ -242,6 +287,10 @@ public class BackupsHomeData
public Map<String, Integer> retentionCounts(Integer planId)
{
Map<String, Integer> counts = new LinkedHashMap<>();
if(planId == null)
{
return (counts);
}
for(QRecord run : recentRuns)
{
if(planId.equals(run.getValueInteger("backupPlanId")) && run.getValueString("status") != null && run.getValueString("status").startsWith("SUCCEEDED")
@@ -27,21 +27,30 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Renders the Backups home: one card per concern, each with an honest empty
** state. Cards stack to one column on narrow screens, long names wrap, and
** there are no tables and no inner horizontal scroll (definition lists and
** lists only), so it reads at every width in light and dark mode (colors are
** inherited, not set).
** Renders the Backups home: a plan status row per plan across the full width,
** then cards for what needs attention, the latest test restore, recent activity,
** the size trend and retention. Every card has an honest empty state. Layout
** is flow and grid only (no tables, no fixed widths, no inner scrolling): plan
** rows wrap their cells and the cards stack to one column on narrow screens,
** long names wrap, and colors are inherited (only the status dot is colored), so
** it reads at every width in light and dark mode.
*******************************************************************************/
public final class BackupsHomeView
{
private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'").withZone(ZoneOffset.UTC);
private static final String GRID = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr));gap:1rem;";
private static final String ROOT = "display:grid;gap:1rem;min-width:0;";
private static final String GRID = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,24rem),1fr));gap:1rem;align-items:start;min-width:0;";
private static final String CARD = "border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:1rem;min-width:0;overflow-wrap:anywhere;";
private static final String ROW = "border:1px solid rgba(127,127,127,.25);border-radius:6px;padding:.75rem;margin:0 0 .75rem;min-width:0;";
private static final String CELLS = "display:grid;grid-template-columns:repeat(auto-fit,minmax(9.5rem,1fr));gap:.6rem 1rem;margin:.6rem 0 0;font-size:.9rem;";
private static final String LIST = "display:grid;grid-template-columns:minmax(7rem,auto) 1fr;gap:.25rem .75rem;margin:0 0 .75rem;font-size:.9rem;";
private static final String ITEMS = "margin:0 0 .75rem;padding-left:1.25rem;font-size:.9rem;";
private static final String ITEMS = "margin:0 0 .75rem;padding-left:1.25rem;list-style:disc;font-size:.9rem;display:grid;gap:.35rem;";
private static final String MUTED = "opacity:.75;";
private static final String LINK = "color:inherit;text-decoration:underline;text-underline-offset:2px;";
private static final String CHIP = "display:inline-flex;align-items:center;gap:.4rem;padding:.05rem .6rem;border-radius:999px;border:1px solid rgba(127,127,127,.4);font-size:.8rem;white-space:nowrap;";
private static final int TREND_BARS = 30;
@@ -59,14 +68,15 @@ public final class BackupsHomeView
*******************************************************************************/
public static String render(BackupsHomeData data, String pathPrefix)
{
StringBuilder html = new StringBuilder("<div class=\"backups-home\" style=\"" + GRID + "\">");
StringBuilder html = new StringBuilder("<div class=\"backups-home\" style=\"" + ROOT + "\">");
planStatus(html, data, pathPrefix);
html.append("<div style=\"").append(GRID).append("\">");
needsAttention(html, data, pathPrefix);
latestTestRestore(html, data, pathPrefix);
trend(html, data);
recentActivity(html, data, pathPrefix);
trend(html, data);
retention(html, data);
return (html.append("</div>").toString());
return (html.append("</div></div>").toString());
}
@@ -82,13 +92,13 @@ public final class BackupsHomeView
case "ATTENTION" -> "Needs attention: see the Backups home.";
default -> "All backups are within their SLA.";
};
return ("<div style=\"" + CARD + "\"><strong>Backups</strong><p>" + esc(text) + "</p><a href=\"" + esc(pathPrefix) + "\">Open the Backups home</a></div>");
return ("<div style=\"" + CARD + "\"><strong>Backups</strong><p>" + esc(text) + "</p><a style=\"" + LINK + "\" href=\"" + esc(pathPrefix) + "\">Open the Backups home</a></div>");
}
/*******************************************************************************
** Plan status: last successful backup, age against the SLA, last run, verification.
** Plan status: one row per plan, the cells wrap to the width.
*******************************************************************************/
private static void planStatus(StringBuilder html, BackupsHomeData data, String prefix)
{
@@ -99,24 +109,29 @@ public final class BackupsHomeView
}
for(QRecord plan : data.plans())
{
Long age = data.ageHours(plan);
Integer sla = plan.getValueInteger("slaHours");
html.append("<dl style=\"").append(LIST).append("\">");
html.append("<dt><strong>Plan</strong></dt><dd><a href=\"").append(esc(prefix + "/" + BackupsTables.PLAN + "/" + plan.getValue("id"))).append("\">")
.append(esc(plan.getValueString("name"))).append("</a>").append(Boolean.TRUE.equals(plan.getValueBoolean("isActive")) ? "" : " (not active)").append("</dd>");
row(html, "Status", label(plan.getValueString("slaStatus")));
row(html, "Last backup", plan.getValueInstant("lastSuccessDate") == null ? "No successful backup yet" : WHEN.format(plan.getValueInstant("lastSuccessDate")));
if(age != null && sla != null)
Long age = data.ageHours(plan);
Integer sla = plan.getValueInteger("slaHours");
String status = data.effectiveStatus(plan);
QRecord last = data.lastRun(plan);
html.append("<div style=\"").append(ROW).append("\"><div style=\"display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem;\">");
html.append("<strong>").append(recordLink(prefix, BackupsTables.PLAN, plan.getValue("id"), plan.getValueString("name"))).append("</strong>");
html.append(chip(status));
if(!Boolean.TRUE.equals(plan.getValueBoolean("isActive")))
{
row(html, "Age against SLA", age + " h of " + sla + " h" + (age > sla ? " - late" : ""));
html.append("<span style=\"").append(MUTED).append("\">Not active</span>");
}
row(html, "Last verified", plan.getValueInstant("lastVerifiedDate") == null ? "Not verified yet" : WHEN.format(plan.getValueInstant("lastVerifiedDate")));
row(html, "Schedule", plan.getValueString("cronExpression") == null ? "Manual only" : plan.getValueString("cronExpression"));
html.append("</div><div style=\"").append(CELLS).append("\">");
cell(html, "Last backup", plan.getValueInstant("lastSuccessDate") == null ? "No successful backup yet" : WHEN.format(plan.getValueInstant("lastSuccessDate")));
cell(html, "Age against SLA", age != null && sla != null ? age + " h of " + sla + " h" : (sla == null ? "No SLA set" : "Not run yet"));
cell(html, "Last run", last == null ? "None yet" : label(last.getValueString("status")) + (last.getValueInstant("startDate") == null ? "" : ", " + WHEN.format(last.getValueInstant("startDate"))));
cell(html, "Schedule", plan.getValueString("cronExpression") == null ? "Manual only" : plan.getValueString("cronExpression"));
cell(html, "Last verified", plan.getValueInstant("lastVerifiedDate") == null ? "Not verified yet" : WHEN.format(plan.getValueInstant("lastVerifiedDate")));
if(plan.getValueString("objectLockMode") != null && !"NONE".equals(plan.getValueString("objectLockMode")))
{
row(html, "Object lock", plan.getValueString("objectLockMode") + ", " + plan.getValueInteger("objectLockDays") + " days");
cell(html, "Object lock", plan.getValueString("objectLockMode") + (plan.getValueInteger("objectLockDays") == null ? "" : ", " + plan.getValueInteger("objectLockDays") + " days"));
}
html.append("</dl>");
html.append("</div></div>");
}
link(html, prefix, BackupsTables.PLAN, "Backup plans");
close(html);
@@ -144,13 +159,13 @@ public final class BackupsHomeView
html.append("<ul style=\"").append(ITEMS).append("\">");
for(QRecord plan : plans)
{
html.append("<li><a href=\"").append(esc(prefix + "/" + BackupsTables.PLAN + "/" + plan.getValue("id"))).append("\">").append(esc(plan.getValueString("name")))
.append("</a>: ").append(esc(label(plan.getValueString("slaStatus")))).append("</li>");
html.append("<li>").append(recordLink(prefix, BackupsTables.PLAN, plan.getValue("id"), plan.getValueString("name")))
.append(": ").append(esc(label(data.effectiveStatus(plan)))).append("</li>");
}
for(QRecord run : data.failedRuns())
{
html.append("<li><a href=\"").append(esc(prefix + "/" + BackupsTables.RUN + "/" + run.getValue("id"))).append("\">Run ").append(esc(run.getValueString("runKey")))
.append("</a> ").append(esc(label(run.getValueString("status")))).append("</li>");
html.append("<li>Run ").append(recordLink(prefix, BackupsTables.RUN, run.getValue("id"), run.getValueString("runKey")))
.append(": ").append(esc(label(run.getValueString("status")))).append("</li>");
}
html.append("</ul>");
}
@@ -178,7 +193,8 @@ public final class BackupsHomeView
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Plan", plan.getValueString("name"));
row(html, "Result", label(v.getValueString("status")));
row(html, "Compared", v.getValueInteger("tablesChecked") + " tables, " + v.getValueLong("rowsChecked") + " rows, " + v.getValueInteger("mismatches") + " mismatches");
row(html, "Compared", number(v.getValueLong("rowsChecked")) + " rows in " + number(toLong(v.getValueInteger("tablesChecked"))) + " tables, "
+ number(toLong(v.getValueInteger("mismatches"))) + " mismatches");
row(html, "Scratch target", v.getValueString("scratchTarget") == null ? "Not recorded" : v.getValueString("scratchTarget"));
html.append("</dl>");
}
@@ -193,7 +209,8 @@ public final class BackupsHomeView
/*******************************************************************************
** Size and duration trend per plan, as numbers next to each other.
** Size trend per plan: bars for the stored size of each successful run (oldest
** left), then the numbers.
*******************************************************************************/
private static void trend(StringBuilder html, BackupsHomeData data)
{
@@ -209,13 +226,14 @@ public final class BackupsHomeView
any = true;
QRecord first = runs.get(0);
QRecord last = runs.get(runs.size() - 1);
html.append("<div style=\"margin:0 0 .75rem;\"><strong style=\"font-size:.9rem;\">").append(esc(plan.getValueString("name"))).append("</strong>");
bars(html, runs);
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Plan", plan.getValueString("name"));
row(html, "Successful runs", String.valueOf(runs.size()));
row(html, "Stored (first, latest)", bytes(first.getValueLong("bytesStored")) + ", " + bytes(last.getValueLong("bytesStored")));
row(html, "Rows (first, latest)", number(first.getValueLong("rowsTotal")) + ", " + number(last.getValueLong("rowsTotal")));
row(html, "Duration (latest)", last.getValueLong("durationMs") == null ? "Not recorded" : last.getValueLong("durationMs") / 1000 + " s");
html.append("</dl>");
row(html, "Stored", bytes(first.getValueLong("bytesStored")) + " at first, " + bytes(last.getValueLong("bytesStored")) + " latest");
row(html, "Rows", number(first.getValueLong("rowsTotal")) + " at first, " + number(last.getValueLong("rowsTotal")) + " latest");
row(html, "Duration", last.getValueLong("durationMs") == null ? "Not recorded" : duration(last.getValueLong("durationMs")) + " latest");
html.append("</dl></div>");
}
if(!any)
{
@@ -227,7 +245,31 @@ public final class BackupsHomeView
/*******************************************************************************
** Recent activity: the last runs and restores.
** A row of bars, one per run, tall in proportion to the stored bytes.
*******************************************************************************/
private static void bars(StringBuilder html, List<QRecord> runs)
{
List<QRecord> shown = runs.size() > TREND_BARS ? runs.subList(runs.size() - TREND_BARS, runs.size()) : runs;
long max = 1;
for(QRecord run : shown)
{
max = Math.max(max, run.getValueLong("bytesStored") == null ? 0 : run.getValueLong("bytesStored"));
}
html.append("<div role=\"img\" aria-label=\"Stored size of the last ").append(shown.size()).append(" successful runs\" style=\"display:flex;align-items:flex-end;gap:2px;height:2.5rem;margin:.4rem 0 .6rem;\">");
for(QRecord run : shown)
{
long value = run.getValueLong("bytesStored") == null ? 0 : run.getValueLong("bytesStored");
long pct = Math.max(4, Math.round(100.0 * value / max));
String title = (run.getValueInstant("startDate") == null ? "" : WHEN.format(run.getValueInstant("startDate")) + ": ") + bytes(run.getValueLong("bytesStored"));
html.append("<span title=\"").append(esc(title)).append("\" style=\"flex:1 1 0;min-width:2px;max-width:1.5rem;height:").append(pct).append("%;background:currentColor;opacity:.55;border-radius:2px 2px 0 0;\"></span>");
}
html.append("</div>");
}
/*******************************************************************************
** Recent activity: the last runs, restores and verifications.
*******************************************************************************/
private static void recentActivity(StringBuilder html, BackupsHomeData data, String prefix)
{
@@ -242,13 +284,13 @@ public final class BackupsHomeView
data.recentRuns().stream().limit(6).forEach(run ->
{
Instant start = run.getValueInstant("startDate");
html.append("<li>Backup <a href=\"").append(esc(prefix + "/" + BackupsTables.RUN + "/" + run.getValue("id"))).append("\">").append(esc(run.getValueString("runKey")))
.append("</a>: ").append(esc(label(run.getValueString("status")))).append(start == null ? "" : ", " + WHEN.format(start)).append("</li>");
html.append("<li>Backup ").append(recordLink(prefix, BackupsTables.RUN, run.getValue("id"), run.getValueString("runKey")))
.append(": ").append(esc(label(run.getValueString("status")))).append(start == null ? "" : ", " + esc(WHEN.format(start))).append("</li>");
});
data.restores().stream().limit(2).forEach(restore -> html.append("<li>Restore <a href=\"").append(esc(prefix + "/" + BackupsTables.RESTORE_RUN + "/" + restore.getValue("id")))
.append("\">#").append(esc(String.valueOf(restore.getValue("id")))).append("</a>: ").append(esc(label(restore.getValueString("status")))).append("</li>"));
data.verifications().stream().limit(2).forEach(v -> html.append("<li>Verify <a href=\"").append(esc(prefix + "/" + BackupsTables.VERIFICATION + "/" + v.getValue("id")))
.append("\">").append(esc(label(v.getValueString("level")))).append("</a>: ").append(esc(label(v.getValueString("status")))).append("</li>"));
data.restores().stream().limit(2).forEach(restore -> html.append("<li>Restore ").append(recordLink(prefix, BackupsTables.RESTORE_RUN, restore.getValue("id"), "#" + restore.getValue("id")))
.append(": ").append(esc(label(restore.getValueString("status")))).append("</li>"));
data.verifications().stream().limit(2).forEach(v -> html.append("<li>Verify ").append(recordLink(prefix, BackupsTables.VERIFICATION, v.getValue("id"), label(v.getValueString("level"))))
.append(": ").append(esc(label(v.getValueString("status")))).append("</li>"));
html.append("</ul>");
}
link(html, prefix, BackupsTables.RUN, "All runs");
@@ -274,9 +316,10 @@ public final class BackupsHomeView
any = true;
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Plan", plan.getValueString("name"));
row(html, "Kept", counts.entrySet().stream().map(e -> e.getValue() + " " + label(e.getKey()).toLowerCase()).reduce((a, b) -> a + ", " + b).orElse(""));
List<QRecord> runs = data.successfulRuns(plan.getValueInteger("id"));
Instant oldest = runs.isEmpty() ? null : runs.get(0).getValueInstant("startDate");
row(html, "Kept", counts.entrySet().stream().map(e -> e.getValue() + " " + ("UNCLASSIFIED".equals(e.getKey()) ? "not classified yet (no prune has run)" : label(e.getKey()).toLowerCase()))
.reduce((a, b) -> a + ", " + b).orElse(""));
List<QRecord> runs = data.successfulRuns(plan.getValueInteger("id"));
Instant oldest = runs.isEmpty() ? null : runs.get(0).getValueInstant("startDate");
row(html, "Oldest restorable", oldest == null ? "Not known" : WHEN.format(oldest));
html.append("</dl>");
}
@@ -290,17 +333,17 @@ public final class BackupsHomeView
/*******************************************************************************
**
** Open a card.
*******************************************************************************/
private static void open(StringBuilder html, String title)
{
html.append("<section style=\"").append(CARD).append("\"><h3 style=\"margin:0 0 .75rem;font-size:1rem;\">").append(esc(title)).append("</h3>");
html.append("<section style=\"").append(CARD).append("\"><h3 style=\"margin:0 0 .75rem;font-size:1rem;font-weight:600;\">").append(esc(title)).append("</h3>");
}
/*******************************************************************************
**
** Close a card.
*******************************************************************************/
private static void close(StringBuilder html)
{
@@ -310,17 +353,17 @@ public final class BackupsHomeView
/*******************************************************************************
**
** A muted empty-state line.
*******************************************************************************/
private static void empty(StringBuilder html, String text)
{
html.append("<p style=\"").append(MUTED).append("margin:0 0 .75rem;\">").append(esc(text)).append("</p>");
html.append("<p style=\"").append(MUTED).append("margin:0 0 .75rem;font-size:.9rem;\">").append(esc(text)).append("</p>");
}
/*******************************************************************************
**
** A name and value pair in a definition list.
*******************************************************************************/
private static void row(StringBuilder html, String name, String value)
{
@@ -330,17 +373,53 @@ public final class BackupsHomeView
/*******************************************************************************
**
** A small label over its value (a plan row cell).
*******************************************************************************/
private static void link(StringBuilder html, String prefix, String table, String text)
private static void cell(StringBuilder html, String name, String value)
{
html.append("<a href=\"").append(esc(prefix + "/" + table)).append("\">").append(esc(text)).append("</a>");
html.append("<div style=\"min-width:0;\"><div style=\"").append(MUTED).append("font-size:.75rem;\">").append(esc(name)).append("</div><div>").append(esc(value)).append("</div></div>");
}
/*******************************************************************************
** SOME_STATUS to Some status.
** A status chip: a colored dot (the only color) and the status label.
*******************************************************************************/
private static String chip(String status)
{
String color = switch(status == null ? "UNKNOWN" : status)
{
case "OK" -> "#16a34a";
case "LATE", "UNVERIFIED", "UNKNOWN" -> "#d97706";
default -> "#dc2626";
};
return ("<span style=\"" + CHIP + "\"><span aria-hidden=\"true\" style=\"width:.55rem;height:.55rem;border-radius:50%;background:" + color + ";display:inline-block;\"></span>" + esc(label(status)) + "</span>");
}
/*******************************************************************************
** A link to the table list at the end of a card.
*******************************************************************************/
private static void link(StringBuilder html, String prefix, String table, String text)
{
html.append("<a style=\"").append(LINK).append("font-size:.9rem;\" href=\"").append(esc(prefix + "/" + table)).append("\">").append(esc(text)).append("</a>");
}
/*******************************************************************************
** A link to a record.
*******************************************************************************/
private static String recordLink(String prefix, String table, Object id, String text)
{
return ("<a style=\"" + LINK + "\" href=\"" + esc(prefix + "/" + table + "/" + id) + "\">" + esc(text == null ? String.valueOf(id) : text) + "</a>");
}
/*******************************************************************************
** SOME_STATUS to Some status (OK stays OK).
*******************************************************************************/
static String label(String status)
{
@@ -348,6 +427,10 @@ public final class BackupsHomeView
{
return ("Unknown");
}
if("OK".equals(status))
{
return ("OK");
}
String s = status.replace('_', ' ').toLowerCase();
return (Character.toUpperCase(s.charAt(0)) + s.substring(1));
}
@@ -355,7 +438,32 @@ public final class BackupsHomeView
/*******************************************************************************
**
** An Integer as a Long, null-safe.
*******************************************************************************/
private static Long toLong(Integer value)
{
return (value == null ? null : value.longValue());
}
/*******************************************************************************
** Milliseconds as a short duration.
*******************************************************************************/
static String duration(long millis)
{
if(millis < 1000)
{
return (millis + " ms");
}
long seconds = millis / 1000;
return (seconds < 60 ? seconds + " s" : seconds / 60 + " min " + seconds % 60 + " s");
}
/*******************************************************************************
** Bytes as a short size.
*******************************************************************************/
static String bytes(Long value)
{
@@ -363,7 +471,7 @@ public final class BackupsHomeView
{
return ("Not recorded");
}
double v = value;
double v = value;
String[] units = { "B", "KB", "MB", "GB", "TB" };
int i = 0;
while(v >= 1024 && i < units.length - 1)
@@ -377,7 +485,7 @@ public final class BackupsHomeView
/*******************************************************************************
**
** A number with separators.
*******************************************************************************/
static String number(Long value)
{
@@ -387,7 +495,7 @@ public final class BackupsHomeView
/*******************************************************************************
**
** HTML-escape.
*******************************************************************************/
static String esc(String s)
{
@@ -22,9 +22,11 @@ import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.function.Consumer;
import java.util.zip.GZIPOutputStream;
import com.kingsrook.qbits.backups.storage.BackupStorage;
import com.kingsrook.qbits.backups.storage.WriteOptions;
@@ -65,6 +67,10 @@ public class TableArtifactWriter implements Closeable
private String firstKeyOfTable;
private String lastKeyOfTable;
private Consumer<PartResult> partListener;
private String objectLockMode;
private Instant objectLockUntil;
/***************************************************************************
@@ -122,6 +128,63 @@ public class TableArtifactWriter implements Closeable
/*******************************************************************************
** Be told about each part as it is committed to storage (so the catalog records
** a part the moment it is complete, which is what a resume starts from).
*******************************************************************************/
public TableArtifactWriter withPartListener(Consumer<PartResult> partListener)
{
this.partListener = partListener;
return (this);
}
/*******************************************************************************
** Apply an object lock to every part written.
*******************************************************************************/
public TableArtifactWriter withObjectLock(String mode, Instant until)
{
this.objectLockMode = mode;
this.objectLockUntil = until;
return (this);
}
/*******************************************************************************
** Continue after parts that were already written (resume): numbering carries on
** after them and the table totals include them. The caller then feeds the rows
** hash with seedRowsHash.
*******************************************************************************/
public void resumeFrom(List<PartResult> priorParts)
{
if(priorParts.isEmpty())
{
return;
}
parts.addAll(priorParts);
partNumber = priorParts.get(priorParts.size() - 1).partNumber();
for(PartResult part : priorParts)
{
totalRows += part.rows();
}
firstKeyOfTable = priorParts.get(0).firstKey();
lastKeyOfTable = priorParts.get(priorParts.size() - 1).lastKey();
}
/*******************************************************************************
** Feed already-written canonical JSONL bytes into the rows hash (resume).
*******************************************************************************/
public void seedRowsHash(byte[] bytes, int offset, int length)
{
rowsDigest.update(bytes, offset, length);
}
/*******************************************************************************
** Write one record.
*******************************************************************************/
@@ -245,6 +308,10 @@ public class TableArtifactWriter implements Closeable
WriteOptions options = WriteOptions.create().withContentType("application/octet-stream")
.withMetadata("qqq-backup-run", runKey)
.withMetadata("qqq-backup-table", table.getName());
if(objectLockMode != null)
{
options.withObjectLock(objectLockMode, objectLockUntil);
}
OutputStream toStorage = storage.openWrite(current.key, options);
current.stored = new HashingOutputStream(toStorage);
OutputStream transformed = transform.wrap(current.stored);
@@ -266,9 +333,14 @@ public class TableArtifactWriter implements Closeable
private void closePart() throws IOException
{
current.raw.close();
parts.add(new PartResult(partNumber, current.key, current.rows, current.raw.getCount(), current.stored.getCount(),
PartResult result = new PartResult(partNumber, current.key, current.rows, current.raw.getCount(), current.stored.getCount(),
current.raw.getSha256(), current.stored.getSha256(), current.firstKey, current.lastKey, transform.encrypts(),
csv ? "CSV" : "JSONL", gzip ? "GZIP" : "NONE"));
csv ? "CSV" : "JSONL", gzip ? "GZIP" : "NONE");
parts.add(result);
current = null;
if(partListener != null)
{
partListener.accept(result);
}
}
}
@@ -57,6 +57,8 @@ public class FileCopier
private final FileStoreRegistry registry;
private final boolean referenceOnly;
private final String runKey;
private String objectLockMode;
private java.time.Instant objectLockUntil;
@@ -119,6 +121,19 @@ public class FileCopier
/*******************************************************************************
** Lock every object this copier writes (a server-side copy cannot set a lock,
** so with a lock the bytes are streamed instead).
*******************************************************************************/
public FileCopier withObjectLock(String mode, java.time.Instant until)
{
this.objectLockMode = mode;
this.objectLockUntil = until;
return (this);
}
/*******************************************************************************
** The content-addressed key for a hash: prefix/ab/cd/sha256.
*******************************************************************************/
@@ -177,7 +192,7 @@ public class FileCopier
*******************************************************************************/
private String copy(BackupStorage source, FileReference ref, String key) throws IOException
{
if(source == destination && !transform.encrypts())
if(source == destination && !transform.encrypts() && objectLockMode == null)
{
destination.copy(ref.key(), key);
return (null);
@@ -185,6 +200,10 @@ public class FileCopier
WriteOptions options = WriteOptions.create().withContentType(ref.contentType() == null ? "application/octet-stream" : ref.contentType())
.withMetadata("qqq-backup-run", runKey);
if(objectLockMode != null)
{
options.withObjectLock(objectLockMode, objectLockUntil);
}
try(InputStream in = source.openRead(ref.key());
HashingOutputStream plain = new HashingOutputStream(transform.wrap(destination.openWrite(key, options))))
{
@@ -23,8 +23,20 @@ import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.backups.BackupsQBitConfig;
import com.kingsrook.qbits.backups.permissions.BackupsPermissionChecker;
import com.kingsrook.qbits.backups.plan.BackupPlanTableCustomizer;
import com.kingsrook.qbits.backups.schedule.BackupPlanSyncToScheduledJobProcess;
import com.kingsrook.qbits.backups.schedule.BackupPlanVerifySyncToScheduledJobProcess;
import com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
import com.kingsrook.qqq.backend.core.scheduler.processes.BaseSyncToScheduledJobTableCustomizer;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
@@ -63,7 +75,7 @@ public final class BackupsTables
public static final List<String> PROCESS_WRITTEN = List.of(RUN, ARTIFACT, VERIFICATION, RESTORE_RUN, RESTORE_RUN_TABLE, FILE_OBJECT, ID_MAP);
/** The tables shown in the nav (sub-tables appear as relations on their parents). */
public static final List<String> NAV_TABLES = List.of(PLAN, RUN, RESTORE_RUN, VERIFICATION, FILE_OBJECT);
public static final List<String> NAV_TABLES = List.of(PLAN, RUN, RESTORE_RUN, VERIFICATION);
public static final List<String> ALL = List.of(PLAN, PLAN_SCOPE, RUN, ARTIFACT, VERIFICATION, RESTORE_RUN, RESTORE_RUN_TABLE, FILE_OBJECT, ID_MAP);
@@ -91,14 +103,14 @@ public final class BackupsTables
ENUMS.put("lock_mode", List.of("NONE", "GOVERNANCE", "COMPLIANCE"));
ENUMS.put("sla_status", List.of("OK", "LATE", "FAILING", "MISSING_IN_STORAGE", "UNVERIFIED", "ABANDONED", "OWNER_INVALID", "UNKNOWN"));
ENUMS.put("run_trigger", List.of("SCHEDULED", "MANUAL", "PRE_RESTORE", "RESUME", "IMPORTED"));
ENUMS.put("run_status", List.of("QUEUED", "RUNNING", "SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "TIMED_OUT", "CANCELLED", "ABANDONED"));
ENUMS.put("run_status", List.of("QUEUED", "RUNNING", "SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "TIMED_OUT", "CANCELLING", "CANCELLED", "ABANDONED"));
ENUMS.put("retention_class", List.of("DAILY", "WEEKLY", "MONTHLY", "YEARLY", "NONE"));
ENUMS.put("verification_status", List.of("UNVERIFIED", "STORAGE_OK", "CONTENT_OK", "TEST_RESTORE_OK", "FAILED"));
ENUMS.put("artifact_type", List.of("TABLE_DATA", "TABLE_BLOBS", "FILE_OBJECTS_INDEX", "NATIVE_DUMP", "MANIFEST", "CHECKSUMS"));
ENUMS.put("artifact_status", List.of("WRITING", "COMPLETE", "FAILED"));
ENUMS.put("verify_level", List.of("STORAGE", "CONTENT", "TEST_RESTORE"));
ENUMS.put("verify_result", List.of("PASSED", "PASSED_WITH_WARNINGS", "FAILED", "RUNNING", "ERROR"));
ENUMS.put("restore_status", List.of("PREVIEWED", "QUEUED", "RUNNING", "SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "CANCELLED"));
ENUMS.put("restore_status", List.of("PREVIEWED", "QUEUED", "RUNNING", "SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "CANCELLING", "CANCELLED"));
ENUMS.put("restore_mode", List.of("INSERT_NEW", "UPSERT", "REPLACE"));
ENUMS.put("id_strategy", List.of("PRESERVE", "REMAP_NEW_IDS"));
ENUMS.put("schema_status", List.of("MATCH", "COMPATIBLE", "MISMATCH"));
@@ -203,7 +215,111 @@ public final class BackupsTables
public static List<QTableMetaData> tables(BackupsQBitConfig config)
{
return (List.of(plan(config), planScope(config), run(config), artifact(config), verification(config),
restoreRun(config), restoreRunTable(config), fileObject(config), idMap(config)));
restoreRun(config), restoreRunTable(config), fileObject(config), idMap(config)).stream().map(BackupsTables::withSections).toList());
}
/*******************************************************************************
** Group a table's fields into record-screen sections (the identity and status
** first, then one section per concern, then the system fields), so a record reads
** as a few short groups instead of one long list. Fields not named here land in
** the last section.
*******************************************************************************/
static QTableMetaData withSections(QTableMetaData table)
{
List<Object[]> layout = switch(table.getName())
{
case PLAN -> List.of(
new Object[] { "plan", "Plan", "backup", Tier.T1, "name", "description", "planKey", "isActive", "scopeType", "slaStatus", "lastSuccessDate", "lastVerifiedDate" },
new Object[] { "schedule", "Schedule and SLA", "schedule", Tier.T2, "cronExpression", "cronTimeZoneId", "scheduledJobId", "slaHours", "verifySlaDays", "verifyCronExpression", "verifyLevel" },
new Object[] { "where", "Scope and storage", "storage", Tier.T2, "includeRoles", "storageBucketAlias", "pruneBucketAlias", "prefix", "critical", "consistency", "useReadReplica", "incrementalMode", "nativeDump" },
new Object[] { "format", "Format and safety", "shield", Tier.T2, "format", "compression", "encryptionMode", "encryptionKeyRef", "sensitiveFieldPolicy", "heavyFieldPolicy", "fileObjectPolicy", "fileMissingPolicy" },
new Object[] { "retention", "Retention and lock", "lock_clock", Tier.T2, "retainDaily", "retainWeekly", "retainMonthly", "retainYearly", "retainMinimum", "objectLockMode", "objectLockDays" },
new Object[] { "system", "Limits and history", "history", Tier.T3, "maxDurationMinutes", "pageSize", "targetPartBytes", "ownerUserId", "lastRunId", "lastSuccessRunId", "lastVerifiedRunId", "id", "createDate", "modifyDate" });
case RUN -> List.of(
new Object[] { "run", "Run", "history", Tier.T1, "runKey", "backupPlanId", "status", "trigger", "startDate", "endDate", "durationMs", "errorMessage" },
new Object[] { "volume", "Rows and size", "data_usage", Tier.T2, "tablesPlanned", "tablesDone", "rowsTotal", "bytesRaw", "bytesStored", "consistencyAchieved", "warningCount" },
new Object[] { "files", "Files", "attach_file", Tier.T2, "filesReferenced", "filesCopied", "filesDeduplicated", "filesMissing", "fileBytesCopied", "fileBytesReferenced" },
new Object[] { "storage", "Storage and integrity", "storage", Tier.T2, "storageBucketAlias", "runPrefix", "manifestKey", "manifestSha256", "snapshotId", "schemaFingerprint", "securityScope", "encryptionKeyRef", "encryptionKeyFingerprint", "verificationStatus" },
new Object[] { "retention", "Retention", "lock_clock", Tier.T2, "retentionClass", "lockedUntil", "prunedDate", "prunedByUserId" },
new Object[] { "system", "System", "settings", Tier.T3, "requestedByUserId", "heartbeatDate", "qqqVersion", "appVersion", "instanceName", "errorDetail", "id", "createDate", "modifyDate" });
case VERIFICATION -> List.of(
new Object[] { "result", "Result", "verified", Tier.T1, "backupRunId", "level", "status", "tablesChecked", "rowsChecked", "mismatches", "scratchTarget", "startDate", "endDate" },
new Object[] { "system", "Detail", "settings", Tier.T3, "trigger", "requestedByUserId", "detailJson", "id", "createDate", "modifyDate" });
case RESTORE_RUN -> List.of(
new Object[] { "restore", "Restore", "restore", Tier.T1, "sourceBackupRunId", "status", "mode", "idStrategy", "matchOn", "targetInstanceName", "startDate", "endDate", "errorMessage" },
new Object[] { "rows", "Rows", "table_rows", Tier.T2, "rowsInserted", "rowsUpdated", "rowsUnchanged", "rowsDeleted", "rowsSkipped", "rowsErrored", "sequencesResynced" },
new Object[] { "files", "Files", "attach_file", Tier.T2, "filesRestored", "filesAlreadyPresent", "filesConflicting", "filesMissingFromBackup", "fileBytesRestored" },
new Object[] { "system", "Safety and system", "settings", Tier.T3, "preRestoreBackupRunId", "scopeSummary", "automationsSuppressed", "esbSuppressed", "confirmationText", "requestedByUserId", "id", "createDate", "modifyDate" });
default -> List.of();
};
if(layout.isEmpty())
{
return (table);
}
java.util.Set<String> placed = new java.util.HashSet<>();
for(int i = 0; i < layout.size(); i++)
{
Object[] spec = layout.get(i);
List<String> fields = new ArrayList<>();
for(int f = 4; f < spec.length; f++)
{
fields.add((String) spec[f]);
}
if(i == layout.size() - 1)
{
table.getFields().keySet().stream().filter(name -> !placed.contains(name) && !fields.contains(name)).forEach(fields::add);
}
placed.addAll(fields);
table.withSection(new QFieldSection((String) spec[0], (String) spec[1], new QIcon((String) spec[2]), (Tier) spec[3], fields));
}
for(ChildList childList : CHILD_LISTS)
{
if(childList.parent().equals(table.getName()))
{
table.withSection(new QFieldSection(childList.key(), new QIcon(childList.icon()), Tier.T2).withLabel(childList.label()).withWidgetName(childList.joinName()));
}
}
return (table);
}
/** A child list on a parent record: its join, the columns shown, and the order. */
record ChildList(String parent, String joinName, String key, String label, String icon, int maxRows, boolean canAdd, String orderField, List<String> columns)
{
}
/** The relations shown on the record screens (sub-tables are child lists here, not in the nav). */
static final List<ChildList> CHILD_LISTS = List.of(
new ChildList(PLAN, "backupsPlanScopeJoinPlan", "scopeRules", "Scope rules", "rule", 50, true, "id", List.of("action", "selectorType", "selectorValue", "includeAssociatedChildren", "fieldExclusions")),
new ChildList(PLAN, "backupsRunJoinPlan", "runs", "Runs", "history", 10, false, "id", List.of("runKey", "status", "startDate", "durationMs", "rowsTotal", "bytesStored")),
new ChildList(RUN, "backupsArtifactJoinRun", "artifacts", "Artifacts", "inventory_2", 100, false, "id", List.of("tableName", "artifactType", "partNumber", "rowCount", "bytesStored", "status")),
new ChildList(RUN, "backupsVerificationJoinRun", "verifications", "Verifications", "verified", 20, false, "id", List.of("level", "status", "tablesChecked", "rowsChecked", "mismatches", "startDate")),
new ChildList(RUN, "backupsRestoreRunJoinRun", "restores", "Restores", "restore", 20, false, "id", List.of("status", "mode", "startDate", "rowsInserted", "rowsUpdated", "rowsErrored")),
new ChildList(RESTORE_RUN, "backupsRestoreRunTableJoinRestoreRun", "restoreTables", "Tables restored", "table_rows", 100, false, "id",
List.of("tableName", "targetTableName", "rowsInserted", "rowsUpdated", "rowsUnchanged", "rowsErrored", "schemaStatus")));
/*******************************************************************************
** The child-list widgets for the record screens (see CHILD_LISTS). Each shows a
** few readable columns, newest first, and never scrolls sideways.
*******************************************************************************/
public static List<QWidgetMetaData> widgets()
{
Map<String, QJoinMetaData> byName = new LinkedHashMap<>();
joins().forEach(join -> byName.put(join.getName(), join));
List<QWidgetMetaData> rs = new ArrayList<>();
for(ChildList childList : CHILD_LISTS)
{
rs.add(ChildRecordListRenderer.widgetMetaDataBuilder(byName.get(childList.joinName())).withLabel(childList.label()).withMaxRows(childList.maxRows())
.withCanAddChildRecord(childList.canAdd()).withOnlyIncludeFieldNames(childList.columns())
.withOrderBys(List.of(new QFilterOrderBy(childList.orderField(), !PLAN.equals(childList.parent()) || childList.canAdd())))
.getWidgetMetaData().withPermissionRules(permissionRules()));
}
return (rs);
}
@@ -286,6 +402,34 @@ public final class BackupsTables
** backups_plan: what, how, where, when and how long (spec 4.1).
*******************************************************************************/
static QTableMetaData plan(BackupsQBitConfig config)
{
QTableMetaData table = planBase(config);
////////////////////////////////////////////////////////////////////////////////////////
// save validation (spec 4.1, 11.1), and the plan -> scheduled job sync when the qbit //
// has a scheduler (spec 9.1): one job per active plan, and one for the verify cron //
////////////////////////////////////////////////////////////////////////////////////////
QCodeReference validation = new QCodeReference(BackupPlanTableCustomizer.class);
table.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, validation);
table.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, validation);
if(StringUtils.hasContent(config.getSchedulerName()))
{
QCodeReferenceWithProperties sync = MultiCustomizer.of(
BaseSyncToScheduledJobTableCustomizer.makeCodeReference(table, new BackupPlanSyncToScheduledJobProcess()),
BaseSyncToScheduledJobTableCustomizer.makeCodeReference(table, new BackupPlanVerifySyncToScheduledJobProcess()));
table.withCustomizer(TableCustomizers.POST_INSERT_RECORD, sync);
table.withCustomizer(TableCustomizers.POST_UPDATE_RECORD, sync);
table.withCustomizer(TableCustomizers.POST_DELETE_RECORD, sync);
}
return (table);
}
/*******************************************************************************
** The plan table before the customizers.
*******************************************************************************/
private static QTableMetaData planBase(BackupsQBitConfig config)
{
return (base(config, PLAN, "Backup Plan", "backup")
.withRecordLabelFormatAndFields("%s", "name")
@@ -369,8 +513,6 @@ public final class BackupsTables
return (base(config, RUN, "Backup Run", "history")
.withRecordLabelFormatAndFields("%s", "runKey")
.withUniqueKey(new UniqueKey("runKey"))
.withAssociation(new Association().withName("artifacts").withAssociatedTableName(ARTIFACT).withJoinName("backupsArtifactJoinRun"))
.withAssociation(new Association().withName("verifications").withAssociatedTableName(VERIFICATION).withJoinName("backupsVerificationJoinRun"))
.withField(ro("backupPlanId", QFieldType.INTEGER).withPossibleValueSourceName(PLAN))
.withField(ro("runKey", QFieldType.STRING))
.withField(roEnum("trigger", "run_trigger"))
@@ -476,7 +618,6 @@ public final class BackupsTables
{
return (base(config, RESTORE_RUN, "Backup Restore", "restore")
.withRecordLabelFormatAndFields("Restore %s", "id")
.withAssociation(new Association().withName("tables").withAssociatedTableName(RESTORE_RUN_TABLE).withJoinName("backupsRestoreRunTableJoinRestoreRun"))
.withField(ro("sourceBackupRunId", QFieldType.INTEGER).withPossibleValueSourceName(RUN))
.withField(ro("requestedByUserId", QFieldType.STRING))
.withField(ro("startDate", QFieldType.DATE_TIME))
@@ -0,0 +1,144 @@
/*
* 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.plan;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import com.kingsrook.qbits.backups.model.BackupsTables;
import com.kingsrook.qbits.backups.run.BackupCatalog;
import com.kingsrook.qbits.backups.run.BackupRunFactory;
import com.kingsrook.qbits.backups.scope.PlanSettings;
import com.kingsrook.qbits.backups.scope.ScopeResolver;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
import com.kingsrook.qqq.backend.core.model.statusmessages.QWarningMessage;
/*******************************************************************************
** Plan save validation (pre insert and pre update of backups_plan). See
** BackupPlanValidator for the rules. An update that touches none of the plan
** configuration (the processes write the last run and SLA fields) is not
** validated again.
**
** The saving user becomes the plan owner when none is given.
*******************************************************************************/
public class BackupPlanTableCustomizer implements TableCustomizerInterface
{
private static final List<String> CONFIGURATION_FIELDS = List.of("planKey", "isActive", "scopeType", "includeRoles", "storageBucketAlias", "pruneBucketAlias",
"format", "compression", "encryptionMode", "encryptionKeyRef", "sensitiveFieldPolicy", "incrementalMode", "nativeDump", "cronExpression", "cronTimeZoneId",
"verifyCronExpression", "retainDaily", "retainWeekly", "retainMonthly", "retainYearly", "retainMinimum", "objectLockMode", "objectLockDays",
"slaHours", "pageSize", "ownerUserId");
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
{
Map<Serializable, QRecord> oldById = oldRecordList.isPresent() ? oldRecordListToMap("id", oldRecordList).orElse(Map.of()) : Map.of();
BackupCatalog catalog = new BackupCatalog();
for(QRecord record : records)
{
QRecord old = oldRecordList.isPresent() ? oldById.get(record.getValue("id")) : null;
if(old != null && !touchesConfiguration(record))
{
continue;
}
QRecord merged = new QRecord();
if(old != null)
{
merged.getValues().putAll(old.getValues());
}
merged.getValues().putAll(record.getValues());
if(merged.getValue("ownerUserId") == null && Boolean.TRUE.equals(merged.getValueBoolean("isActive")))
{
QSession session = QContext.getQSession();
String owner = session == null || session.getUser() == null ? null : session.getUser().getIdReference();
if(owner != null)
{
record.setValue("ownerUserId", owner);
merged.setValue("ownerUserId", owner);
}
}
boolean hasRun = old != null && !catalog.where(BackupsTables.RUN, "backupPlanId", old.getValueInteger("id")).isEmpty();
BackupPlanValidator.Result result = BackupPlanValidator.validate(PlanSettings.of(merged), old == null ? null : old.getValueString("planKey"), hasRun, scopeRules(record, old));
for(String error : result.errors())
{
record.addError(new BadInputStatusMessage(error));
}
for(String warning : result.warnings())
{
record.addWarning(new QWarningMessage(warning));
}
}
return (records);
}
/*******************************************************************************
**
*******************************************************************************/
private static boolean touchesConfiguration(QRecord record)
{
for(String field : CONFIGURATION_FIELDS)
{
if(record.getValues().containsKey(field))
{
return (true);
}
}
return (false);
}
/*******************************************************************************
** The scope rules to resolve: the ones sent with the plan (associated rows), or
** the saved ones.
*******************************************************************************/
private static List<ScopeResolver.ScopeRule> scopeRules(QRecord record, QRecord old) throws QException
{
List<QRecord> sent = record.getAssociatedRecords() == null ? null : record.getAssociatedRecords().get("scopes");
if(sent != null)
{
List<ScopeResolver.ScopeRule> rules = new ArrayList<>();
for(QRecord row : sent)
{
rules.add(new ScopeResolver.ScopeRule(!"EXCLUDE".equals(row.getValueString("action")), row.getValueString("selectorType"), row.getValueString("selectorValue"),
row.getValueString("filterJson"), !Boolean.FALSE.equals(row.getValueBoolean("includeAssociatedChildren")), row.getValueString("fieldExclusions")));
}
return (rules);
}
return (old == null ? List.of() : BackupRunFactory.scopeRules(old.getValueInteger("id")));
}
}
@@ -0,0 +1,354 @@
/*
* 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.plan;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import com.kingsrook.qbits.backups.BackupsRuntime;
import com.kingsrook.qbits.backups.permissions.BackupsPermissionChecker;
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
import com.kingsrook.qbits.backups.scope.PlanSettings;
import com.kingsrook.qbits.backups.scope.ScopeResolver;
import com.kingsrook.qbits.backups.storage.BackupStorage;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
/*******************************************************************************
** The rules a backup plan must satisfy when it is saved or activated (spec 4.1,
** 6.2, 11.1, 11.3): the refusals are clear messages, not run-time failures.
**
** - the saving user has backups.admin and may read every table the plan resolves
** to (a scheduled run is full-fidelity, so whoever activates the plan must be
** allowed to read all of it)
** - an object lock mode is checked against what the storage bucket can do
** - INCLUDE_ENCRYPTED_ONLY needs encryption
** - formats, compressions and options that are not built yet are refused
** - the plan key is a slug, and cannot change after the first run (it is in every
** object key)
*******************************************************************************/
public final class BackupPlanValidator
{
private static final Pattern PLAN_KEY = Pattern.compile("[a-z0-9][a-z0-9_-]{0,59}");
private static final Set<String> FORMATS = Set.of("JSONL", "CSV", "JSONL_AND_CSV");
private static final Set<String> COMPRESSIONS = Set.of("GZIP", "NONE");
private static final Set<String> LOCK_MODES = Set.of("NONE", "GOVERNANCE", "COMPLIANCE");
/** Replace with the real check when client-side encryption ships (spec 11.2). */
private static volatile boolean encryptionAvailable = false;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private BackupPlanValidator()
{
}
/*******************************************************************************
** Tell the validator that client-side encryption is available (the encryption
** work calls this when it installs a cipher).
*******************************************************************************/
public static void setEncryptionAvailable(boolean available)
{
encryptionAvailable = available;
}
/*******************************************************************************
** The outcome of a validation.
*******************************************************************************/
public record Result(List<String> errors, List<String> warnings)
{
/*******************************************************************************
**
*******************************************************************************/
public boolean ok()
{
return (errors.isEmpty());
}
}
/*******************************************************************************
** Validate the complete settings of a plan.
**
** @param settings the plan values as they will be after the save
** @param oldPlanKey the plan key before the save, null for a new plan
** @param hasRun whether the plan already has a run
** @param rules the scope rules of the plan
*******************************************************************************/
public static Result validate(PlanSettings settings, String oldPlanKey, boolean hasRun, List<ScopeResolver.ScopeRule> rules)
{
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
validateKey(settings, oldPlanKey, hasRun, errors);
validateFormats(settings, errors);
validateEncryption(settings, errors);
validateStorage(settings, errors, warnings);
validateSchedule(settings, errors);
validateNumbers(settings, errors);
if(settings.bool("isActive", true))
{
validateOwner(settings, rules, errors);
}
return (new Result(errors, warnings));
}
/*******************************************************************************
**
*******************************************************************************/
private static void validateKey(PlanSettings settings, String oldPlanKey, boolean hasRun, List<String> errors)
{
String planKey = settings.string("planKey", null);
if(planKey == null || !PLAN_KEY.matcher(planKey).matches())
{
errors.add("The plan key must be lowercase letters, digits, dash or underscore (up to 60 characters): it is part of every object key.");
}
if(oldPlanKey != null && planKey != null && !oldPlanKey.equals(planKey) && hasRun)
{
errors.add("The plan key cannot change after the plan has run: it is part of the object keys of its backups. Create a new plan instead.");
}
}
/*******************************************************************************
**
*******************************************************************************/
private static void validateFormats(PlanSettings settings, List<String> errors)
{
String format = settings.string("format", "JSONL");
if("PARQUET".equals(format))
{
errors.add("The PARQUET format is not available yet. Use JSONL, CSV or JSONL_AND_CSV.");
}
else if(!FORMATS.contains(format))
{
errors.add("Unknown format " + format + ". Use JSONL, CSV or JSONL_AND_CSV.");
}
String compression = settings.string("compression", "GZIP");
if("ZSTD".equals(compression))
{
errors.add("ZSTD compression is not available yet. Use GZIP or NONE.");
}
else if(!COMPRESSIONS.contains(compression))
{
errors.add("Unknown compression " + compression + ". Use GZIP or NONE.");
}
if("CHANGED_SINCE_LAST".equals(settings.string("incrementalMode", "FULL")))
{
errors.add("Incremental backups are not available yet. Use FULL.");
}
if(!"NONE".equals(settings.string("nativeDump", "NONE")))
{
errors.add("Native database dumps are not available yet. Use NONE.");
}
}
/*******************************************************************************
**
*******************************************************************************/
private static void validateEncryption(PlanSettings settings, List<String> errors)
{
String mode = settings.string("encryptionMode", "NONE");
String policy = settings.string("sensitiveFieldPolicy", "EXCLUDE");
if("CLIENT_AES256_GCM".equals(mode))
{
if(!encryptionAvailable)
{
errors.add("Client-side encryption is not available in this version. Use NONE (and keep the sensitive field policy at EXCLUDE).");
}
else if(settings.string("encryptionKeyRef", null) == null)
{
errors.add("Client-side encryption needs an encryption key reference (the name of the key, never the key itself).");
}
}
if("INCLUDE_ENCRYPTED_ONLY".equals(policy) && !"CLIENT_AES256_GCM".equals(mode))
{
errors.add("The sensitive field policy INCLUDE_ENCRYPTED_ONLY requires client-side encryption (CLIENT_AES256_GCM).");
}
if("INCLUDE".equals(policy) && !BackupsPermissionChecker.has(BackupsPermissions.ADMIN))
{
errors.add("Including sensitive fields in the clear needs the backups.admin permission.");
}
}
/*******************************************************************************
** The destination: the bucket exists, and an object lock is something the bucket can do.
*******************************************************************************/
private static void validateStorage(PlanSettings settings, List<String> errors, List<String> warnings)
{
String alias = settings.string("storageBucketAlias", null);
if(alias == null)
{
errors.add("Choose a storage bucket for the backups.");
return;
}
BackupStorage storage = null;
try
{
storage = BackupsRuntime.storageFor(alias);
}
catch(RuntimeException e)
{
errors.add("The storage bucket " + alias + " cannot be used: " + e.getMessage());
}
String lockMode = settings.string("objectLockMode", "NONE");
if(!LOCK_MODES.contains(lockMode))
{
errors.add("Unknown object lock mode " + lockMode + ". Use NONE, GOVERNANCE or COMPLIANCE.");
}
else if(!"NONE".equals(lockMode))
{
Integer days = settings.integer("objectLockDays", null);
if(days == null || days < 1)
{
errors.add("Object lock needs a number of days to keep each object locked (objectLockDays).");
}
if(storage != null && !storage.capabilities().contains(BackupStorage.StorageCapability.OBJECT_LOCK))
{
errors.add("Object lock " + lockMode + " was asked for, but the storage bucket " + alias + " does not support object lock (its capabilities: "
+ storage.capabilities() + "). Choose NONE, or a bucket whose Test connection found object lock supported.");
}
}
String pruneAlias = settings.string("pruneBucketAlias", null);
if(pruneAlias == null)
{
warnings.add("No prune bucket is set: Prune will use the backup bucket, so one key can both write and delete backups. Give the plan a separate delete-capable alias.");
}
else
{
try
{
BackupsRuntime.storageFor(pruneAlias);
}
catch(RuntimeException e)
{
errors.add("The prune bucket " + pruneAlias + " cannot be used: " + e.getMessage());
}
}
}
/*******************************************************************************
**
*******************************************************************************/
private static void validateSchedule(PlanSettings settings, List<String> errors)
{
for(String field : List.of("cronExpression", "verifyCronExpression"))
{
String cron = settings.string(field, null);
if(cron != null && !org.quartz.CronExpression.isValidExpression(cron))
{
errors.add("The " + field + " is not a valid cron expression: " + cron);
}
}
}
/*******************************************************************************
**
*******************************************************************************/
private static void validateNumbers(PlanSettings settings, List<String> errors)
{
for(String field : List.of("retainDaily", "retainWeekly", "retainMonthly", "retainYearly", "retainMinimum"))
{
Integer value = settings.integer(field, 0);
if(value < 0)
{
errors.add(field + " cannot be negative.");
}
}
if(settings.integer("slaHours", 26) < 1)
{
errors.add("slaHours must be at least 1.");
}
if(settings.integer("pageSize", 5000) < 1)
{
errors.add("pageSize must be at least 1.");
}
}
/*******************************************************************************
** Saving or activating a plan: the saving user must have backups.admin and read
** access to every table the plan resolves to (spec 11.1).
*******************************************************************************/
private static void validateOwner(PlanSettings settings, List<ScopeResolver.ScopeRule> rules, List<String> errors)
{
if(!BackupsPermissionChecker.has(BackupsPermissions.ADMIN))
{
errors.add("Saving an active plan needs the backups.admin permission: the plan runs on its own with full access.");
return;
}
ScopeResolver.ResolvedScope scope;
try
{
scope = new ScopeResolver(QContext.getQInstance()).resolve(settings, rules);
}
catch(RuntimeException e)
{
errors.add("The plan scope cannot be resolved: " + e.getMessage());
return;
}
List<String> unreadable = new ArrayList<>();
for(ScopeResolver.ResolvedTable table : scope.tables())
{
if(!PermissionsHelper.hasTablePermission(new QueryInput(table.name()), table.name(), TablePermissionSubType.READ))
{
unreadable.add(table.name());
}
}
if(!unreadable.isEmpty())
{
errors.add("The plan covers tables that its owner is not allowed to read, so it could not back them up in full: "
+ String.join(", ", unreadable.stream().limit(10).toList()) + (unreadable.size() > 10 ? " and " + (unreadable.size() - 10) + " more" : "")
+ ". Remove them from the scope, or save the plan as a user who can read them.");
}
}
}
@@ -0,0 +1,260 @@
/*
* 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.plan;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import com.kingsrook.qbits.backups.BackupsRuntime;
import com.kingsrook.qbits.backups.prune.BackupPruner;
import com.kingsrook.qbits.backups.storage.BackupStorage;
import com.kingsrook.qbits.backups.storage.StoredObjectInfo;
import com.kingsrook.qbits.backups.storage.WriteOptions;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Test destination (spec 9): proves the plan destination works before the first
** night depends on it. Writes a small probe object, reads it back, lists it, and
** deletes it through the prune alias (so the delete credential is exercised too).
**
** On a bucket that is object-locked the probe is head only: a probe object could
** not be deleted again, so nothing is written; the prefix is listed, and the
** newest manifest is looked at with a head. A capability report always comes with
** it.
*******************************************************************************/
public final class DestinationProbe
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private DestinationProbe()
{
}
/*******************************************************************************
** One thing that was checked.
*******************************************************************************/
public record Check(String name, boolean ok, String detail)
{
}
/*******************************************************************************
** The whole report.
*******************************************************************************/
public record Report(boolean headOnly, List<Check> checks, List<String> capabilities, List<String> warnings)
{
/*******************************************************************************
**
*******************************************************************************/
public boolean ok()
{
return (checks.stream().allMatch(Check::ok));
}
}
/*******************************************************************************
** Probe the destination of a plan.
*******************************************************************************/
public static Report probe(QRecord plan)
{
List<Check> checks = new ArrayList<>();
List<String> warnings = new ArrayList<>();
String alias = plan.getValueString("storageBucketAlias");
BackupStorage storage;
try
{
storage = BackupsRuntime.storageFor(alias);
}
catch(RuntimeException e)
{
checks.add(new Check("Open the backup bucket " + alias, false, e.getMessage()));
return (new Report(false, checks, List.of(), warnings));
}
checks.add(new Check("Open the backup bucket " + alias, true, "found"));
BackupStorage deleteSide = storage;
String pruneAlias = plan.getValueString("pruneBucketAlias");
if(pruneAlias == null || pruneAlias.isBlank())
{
warnings.add("No prune bucket is set, so one key both writes and deletes backups.");
}
else
{
try
{
deleteSide = BackupsRuntime.storageFor(pruneAlias);
checks.add(new Check("Open the prune bucket " + pruneAlias, true, "found"));
}
catch(RuntimeException e)
{
checks.add(new Check("Open the prune bucket " + pruneAlias, false, e.getMessage()));
}
}
List<String> capabilities = new ArrayList<>();
for(BackupStorage.StorageCapability capability : BackupStorage.StorageCapability.values())
{
capabilities.add(capability.name() + ": " + (storage.capabilities().contains(capability) ? "supported" : "not supported"));
}
String lockMode = plan.getValueString("objectLockMode");
boolean locked = lockMode != null && !"NONE".equals(lockMode);
if(locked && !storage.capabilities().contains(BackupStorage.StorageCapability.OBJECT_LOCK))
{
checks.add(new Check("Object lock " + lockMode, false, "the plan asks for object lock, but this bucket does not support it"));
}
String prefix = BackupPruner.planPrefix(plan);
if(locked)
{
headOnly(plan, storage, prefix, checks);
}
else
{
writeReadListDelete(storage, deleteSide, prefix + ".qqq-probe/", checks);
}
return (new Report(locked, checks, capabilities, warnings));
}
/*******************************************************************************
** The probe for a locked bucket: list the prefix, and head the newest manifest.
*******************************************************************************/
private static void headOnly(QRecord plan, BackupStorage storage, String prefix, List<Check> checks)
{
checks.add(attempt("Write and delete a probe object", () -> "skipped: object lock is on, a probe object could not be deleted again"));
checks.add(attempt("List the plan prefix", () ->
{
List<StoredObjectInfo> objects = storage.list(prefix);
return (objects.size() + " objects under " + prefix);
}));
Integer lastSuccess = plan.getValueInteger("lastSuccessRunId");
if(lastSuccess == null)
{
checks.add(new Check("Head the newest manifest", true, "there is no successful run yet"));
return;
}
checks.add(attempt("Head the newest manifest", () ->
{
QRecord run = new com.kingsrook.qbits.backups.run.BackupCatalog().get(com.kingsrook.qbits.backups.model.BackupsTables.RUN, lastSuccess).orElseThrow();
String key = run.getValueString("manifestKey");
if(key == null || storage.head(key).isEmpty())
{
throw (new IllegalStateException("the manifest of the newest successful run is not in the bucket"));
}
return ("found " + key);
}));
}
/*******************************************************************************
** Write, read back, list and delete a probe object.
*******************************************************************************/
private static void writeReadListDelete(BackupStorage storage, BackupStorage deleteSide, String probePrefix, List<Check> checks)
{
String key = probePrefix + UUID.randomUUID();
byte[] content = ("qqq backups probe " + UUID.randomUUID()).getBytes(StandardCharsets.UTF_8);
Check write = attempt("Write a probe object", () ->
{
try(OutputStream out = storage.openWrite(key, WriteOptions.create().withContentType("text/plain")))
{
out.write(content);
}
return ("wrote " + content.length + " bytes");
});
checks.add(write);
if(!write.ok())
{
return;
}
checks.add(attempt("Read it back", () ->
{
try(InputStream in = storage.openRead(key))
{
if(!java.util.Arrays.equals(content, in.readAllBytes()))
{
throw (new IllegalStateException("the bytes read back are not the bytes written"));
}
}
return ("the same bytes came back");
}));
checks.add(attempt("List it", () ->
{
if(storage.list(probePrefix).stream().noneMatch(o -> o.key().equals(key)))
{
throw (new IllegalStateException("the probe object is not in the listing"));
}
return ("found in the listing");
}));
checks.add(attempt("Delete it", () ->
{
BackupStorage.DeleteResult result = deleteSide.deletePermanent(key);
if(result != BackupStorage.DeleteResult.DELETED)
{
throw (new IllegalStateException("the delete returned " + result));
}
return ("deleted");
}));
}
/***************************************************************************
**
***************************************************************************/
@FunctionalInterface
private interface Attempt
{
String run() throws Exception;
}
/*******************************************************************************
**
*******************************************************************************/
private static Check attempt(String name, Attempt attempt)
{
try
{
return (new Check(name, true, attempt.run()));
}
catch(Exception e)
{
return (new Check(name, false, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
}
}
}
@@ -25,9 +25,8 @@ import java.util.Map;
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.BackupEngine;
import com.kingsrook.qbits.backups.run.BackupRunCoordinator;
import com.kingsrook.qbits.backups.run.BackupRunFactory;
import com.kingsrook.qbits.backups.run.BackupRunRequest;
import com.kingsrook.qbits.backups.run.BackupRunResult;
import com.kingsrook.qbits.backups.scope.ScopeResolver;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -76,6 +75,7 @@ public final class BackupsProcesses
processes.addAll(VerifyProcesses.processes());
processes.addAll(RestoreProcesses.processes());
processes.addAll(CatalogProcesses.processes());
processes.addAll(LifecycleProcesses.processes());
return (processes);
}
@@ -212,6 +212,7 @@ public final class BackupsProcesses
private final ProcessSummaryLine warnings = line(Status.WARNING, "backup finished with warnings", "backups finished with warnings",
"backup finished with warnings", "backups finished with warnings");
private final ProcessSummaryLine failed = line(Status.ERROR, "backup failed", "backups failed", "backup failed", "backups failed");
private final ProcessSummaryLine skipped = line(Status.WARNING, "backup did not start", "backups did not start", "backup did not start", "backups did not start");
private final ProcessSummaryLine rows = line(Status.INFO, "row was written", "rows were written", "row was written", "rows were written");
private final ProcessSummaryLine files = line(Status.INFO, "referenced file was copied or already stored", "referenced files were copied or already stored",
"referenced file was copied or already stored", "referenced files were copied or already stored");
@@ -227,13 +228,17 @@ public final class BackupsProcesses
{
for(QRecord plan : input.getRecords())
{
BackupRunRequest request = BackupRunFactory.forPlan(plan, "MANUAL");
BackupRunResult result = new BackupEngine().execute(request);
BackupRunResult result = new BackupRunCoordinator().runPlan(plan, "MANUAL");
results.add(result);
switch(result.getStatus())
{
case "SUCCEEDED" -> succeeded.incrementCountAndAddPrimaryKey(result.getRunId());
case "SUCCEEDED_WITH_WARNINGS" -> warnings.incrementCountAndAddPrimaryKey(result.getRunId());
case BackupRunCoordinator.SKIPPED_CONCURRENT, BackupRunCoordinator.QUEUED ->
{
skipped.incrementCountAndAddPrimaryKey(result.getRunId());
skipped.setMessageSuffix(result.getErrorMessage() == null ? null : ": " + result.getErrorMessage());
}
default ->
{
failed.incrementCountAndAddPrimaryKey(result.getRunId());
@@ -258,6 +263,7 @@ public final class BackupsProcesses
succeeded.addSelfToListIfAnyCount(lines);
warnings.addSelfToListIfAnyCount(lines);
failed.addSelfToListIfAnyCount(lines);
skipped.addSelfToListIfAnyCount(lines);
rows.addSelfToListIfAnyCount(lines);
files.addSelfToListIfAnyCount(lines);
return (lines);
@@ -0,0 +1,246 @@
/*
* 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.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.run.BackupRunCoordinator;
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.processes.QProcessMetaData;
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;
/*******************************************************************************
** Cancel (spec 9): a record action on a running backup run, and on a running
** restore run. The cancel is cooperative: it sets the run row to CANCELLING, and
** the run, which looks at its own row at every page, stops at the next page and
** ends CANCELLED. A queued run is cancelled at once, and a run that has lost its
** heartbeat (nothing is running it) is closed straight away.
*******************************************************************************/
public final class CancelRunProcess
{
public static final String NAME = "backupCancelRun";
public static final String NAME_RESTORE = "backupCancelRestoreRun";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CancelRunProcess()
{
}
/*******************************************************************************
** The two processes.
*******************************************************************************/
public static List<QProcessMetaData> processes()
{
return (List.of(
BackupsProcesses.permission(BackupsProcesses.etl(NAME, "Cancel", BackupsTables.RUN, Transform.class, Load.class,
"This asks the selected runs to stop. A run stops at its next page."), BackupsPermissions.RUN),
BackupsProcesses.permission(BackupsProcesses.etl(NAME_RESTORE, "Cancel", BackupsTables.RESTORE_RUN, Transform.class, Load.class,
"This asks the selected restores to stop. A restore stops at its next page."), BackupsPermissions.RESTORE)));
}
/*******************************************************************************
** What should happen to a run, by its row.
*******************************************************************************/
enum Action
{
ASK_TO_STOP, CANCEL_NOW, CLOSE_STALE, NOT_CANCELLABLE
}
/*******************************************************************************
**
*******************************************************************************/
static Action actionFor(QRecord run)
{
String status = run.getValueString("status");
if("QUEUED".equals(status))
{
return (Action.CANCEL_NOW);
}
if("RUNNING".equals(status))
{
Instant heartbeat = run.getValueInstant("heartbeatDate");
boolean live = heartbeat != null && heartbeat.isAfter(BackupsRuntime.getClock().instant().minus(BackupRunCoordinator.HEARTBEAT_STALE));
return (live ? Action.ASK_TO_STOP : Action.CLOSE_STALE);
}
return (Action.NOT_CANCELLABLE);
}
/*******************************************************************************
** Review.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine stop = line(Status.OK, "running run will be asked to stop at its next page", "running runs will be asked to stop at their next page",
"running run was asked to stop at its next page", "running runs were asked to stop at their next page");
private final ProcessSummaryLine queued = line(Status.OK, "queued run will be cancelled", "queued runs will be cancelled", "queued run was cancelled", "queued runs were cancelled");
private final ProcessSummaryLine stale = line(Status.WARNING, "run has lost its heartbeat (nothing is running it) and will be closed as cancelled",
"runs have lost their heartbeat (nothing is running them) and will be closed as cancelled", "run had lost its heartbeat and was closed as cancelled",
"runs had lost their heartbeat and were closed as cancelled");
private final ProcessSummaryLine no = line(Status.ERROR, "run cannot be cancelled: it is not running", "runs cannot be cancelled: they are not running",
"run could not be cancelled: it is not running", "runs could not be cancelled: they are not running");
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord run : input.getRecords())
{
switch(actionFor(run))
{
case ASK_TO_STOP -> stop.incrementCountAndAddPrimaryKey(run.getValue("id"));
case CANCEL_NOW -> queued.incrementCountAndAddPrimaryKey(run.getValue("id"));
case CLOSE_STALE -> stale.incrementCountAndAddPrimaryKey(run.getValue("id"));
default -> no.incrementCountAndAddPrimaryKey(run.getValue("id"));
}
output.getRecords().add(run);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
stop.addSelfToListIfAnyCount(lines);
queued.addSelfToListIfAnyCount(lines);
stale.addSelfToListIfAnyCount(lines);
no.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** Result.
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine stop = line(Status.OK, "running run was asked to stop at its next page", "running runs were asked to stop at their next page",
"running run was asked to stop at its next page", "running runs were asked to stop at their next page");
private final ProcessSummaryLine queued = line(Status.OK, "queued run was cancelled", "queued runs were cancelled", "queued run was cancelled", "queued runs were cancelled");
private final ProcessSummaryLine stale = line(Status.WARNING, "run that had lost its heartbeat was closed as cancelled", "runs that had lost their heartbeat were closed as cancelled",
"run that had lost its heartbeat was closed as cancelled", "runs that had lost their heartbeat were closed as cancelled");
private final ProcessSummaryLine no = line(Status.ERROR, "run was not cancelled: it is not running", "runs were not cancelled: they are not running",
"run was not cancelled: it is not running", "runs were not cancelled: they are not running");
/*******************************************************************************
** Cancel each run.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
BackupCatalog catalog = new BackupCatalog();
String table = input.getTableName();
for(QRecord selected : input.getRecords())
{
QRecord run = catalog.get(table, selected.getValueInteger("id")).orElse(selected);
switch(actionFor(run))
{
case ASK_TO_STOP ->
{
catalog.update(table, run.getValueInteger("id"), Map.of("status", "CANCELLING"));
stop.incrementCountAndAddPrimaryKey(run.getValue("id"));
}
case CANCEL_NOW ->
{
catalog.update(table, run.getValueInteger("id"), closed("Cancelled before it started."));
queued.incrementCountAndAddPrimaryKey(run.getValue("id"));
}
case CLOSE_STALE ->
{
catalog.update(table, run.getValueInteger("id"), closed("Cancelled: the run had stopped sending heartbeats."));
stale.incrementCountAndAddPrimaryKey(run.getValue("id"));
}
default -> no.incrementCountAndAddPrimaryKey(run.getValue("id"));
}
output.getRecords().add(run);
}
}
/*******************************************************************************
**
*******************************************************************************/
private static Map<String, Serializable> closed(String message)
{
Map<String, Serializable> values = new LinkedHashMap<>();
values.put("status", "CANCELLED");
values.put("endDate", BackupsRuntime.getClock().instant());
values.put("errorMessage", message);
return (values);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
stop.addSelfToListIfAnyCount(lines);
queued.addSelfToListIfAnyCount(lines);
stale.addSelfToListIfAnyCount(lines);
no.addSelfToListIfAnyCount(lines);
return (lines);
}
}
}
@@ -0,0 +1,407 @@
/*
* 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.io.Serializable;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.BackupsPermissionChecker;
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
import com.kingsrook.qbits.backups.run.BackupCatalog;
import com.kingsrook.qbits.backups.run.BackupRunCoordinator;
import com.kingsrook.qbits.backups.run.BackupRunRequest;
import com.kingsrook.qbits.backups.run.BackupRunResult;
import com.kingsrook.qbits.backups.run.TableReader;
import com.kingsrook.qbits.backups.scope.PlanSettings;
import com.kingsrook.qbits.backups.scope.ScopeResolver;
import com.kingsrook.qbits.backups.scope.TableRole;
import com.kingsrook.qbits.backups.scope.TableRoles;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
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.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.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;
/*******************************************************************************
** Back up these records (spec 9, 11.3): a table and bulk action on any table the
** user can read. It writes an ad-hoc backups_run (no plan) that restores like any
** other, with referenced files copied the same way.
**
** - The records are whatever the table action selected (the chosen records or the
** current filter), read as the user, so record locks apply and the manifest says
** securityScope USER.
** - Sensitive fields are left out unless the user has backups.admin.
** - The destination is the bucket given in the process input (storageBucketAlias),
** else the bucket of the newest active plan.
**
** The records are held in memory until the export runs (an export of selected
** records is bounded by what a person selected); a whole table is what a plan is for.
**
** The process is added to each table that can be backed up, named
** table.backupExportTable (see exportProcesses).
*******************************************************************************/
public final class ExportTableProcess
{
public static final String NAME_SUFFIX = "backupExportTable";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ExportTableProcess()
{
}
/*******************************************************************************
** The export process for one table.
*******************************************************************************/
public static QProcessMetaData processFor(String tableName)
{
return (BackupsProcesses.permission(BackupsProcesses.etl(tableName + "." + NAME_SUFFIX, "Back up these records", tableName, Transform.class, Load.class,
"This is a preview of what will be backed up. Nothing is written until you continue."), BackupsPermissions.RUN));
}
/*******************************************************************************
** The export process of every table of the instance that can be backed up (not the
** qbit own tables, and not secrets or sessions).
*******************************************************************************/
public static List<QProcessMetaData> exportProcesses(QInstance qInstance)
{
List<QProcessMetaData> processes = new ArrayList<>();
for(QTableMetaData table : qInstance.getTables().values())
{
if(table.getName().startsWith(BackupsQBitConfig.TABLE_NAME_PREFIX))
{
continue;
}
TableRole role = TableRoles.roleOf(qInstance, table);
if(role == TableRole.DATA || role == TableRole.AUDIT || role == TableRole.DERIVED)
{
processes.add(processFor(table.getName()));
}
}
return (processes);
}
/*******************************************************************************
** The sensitive field policy for the user.
*******************************************************************************/
static String sensitivePolicy()
{
return (BackupsPermissionChecker.has(BackupsPermissions.ADMIN) ? "INCLUDE" : "EXCLUDE");
}
/*******************************************************************************
** The bucket the export goes to, or null when there is none.
*******************************************************************************/
static String bucketAlias(RunBackendStepInput input) throws QException
{
String given = input.getValueString("storageBucketAlias");
if(given != null && !given.isBlank())
{
return (given);
}
List<QRecord> plans = new BackupCatalog().query(BackupsTables.PLAN, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter(
new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria("isActive", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, true))
.withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("id", false)));
return (plans.isEmpty() ? null : plans.get(0).getValueString("storageBucketAlias"));
}
/*******************************************************************************
** Review: what is selected and what will be left out.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine records = line(Status.OK, "record will be backed up", "records will be backed up", "record was backed up", "records were backed up");
private final ProcessSummaryLine sensitive = line(Status.INFO, "field is sensitive and will be left out of the backup (including it needs backups.admin)",
"fields are sensitive and will be left out of the backup (including them needs backups.admin)", "field was left out as sensitive", "fields were left out as sensitive");
private final ProcessSummaryLine included = line(Status.WARNING, "field is sensitive and will be written in the clear, because you have backups.admin",
"fields are sensitive and will be written in the clear, because you have backups.admin", "field was written in the clear", "fields were written in the clear");
private final ProcessSummaryLine noBucket = line(Status.ERROR, "export has no destination: there is no active backup plan to take its bucket from",
"exports have no destination: there is no active backup plan to take their bucket from", "export had no destination", "exports had no destination");
private final ProcessSummaryLine denied = line(Status.ERROR, "table cannot be read by you, so it cannot be backed up", "tables cannot be read by you, so they cannot be backed up",
"table could not be read by you", "tables could not be read by you");
private boolean described = false;
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String table = input.getTableName();
if(!described)
{
described = true;
if(!PermissionsHelper.hasTablePermission(input, table, TablePermissionSubType.READ))
{
denied.incrementCount();
}
if(bucketAlias(input) == null)
{
noBucket.incrementCount();
}
QTableMetaData meta = QContext.getQInstance().getTable(table);
int count = 0;
for(QFieldMetaData field : meta.getFields().values())
{
if(!field.getName().equals(meta.getPrimaryKeyField()) && (field.getIsSensitive() || field.getType() == QFieldType.PASSWORD))
{
count++;
}
}
(BackupsPermissionChecker.has(BackupsPermissions.ADMIN) ? included : sensitive).incrementCount(count);
}
if(denied.getCount() > 0)
{
return;
}
records.incrementCount(input.getRecords().size());
output.getRecords().addAll(input.getRecords());
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
records.addSelfToListIfAnyCount(lines);
sensitive.addSelfToListIfAnyCount(lines);
included.addSelfToListIfAnyCount(lines);
noBucket.addSelfToListIfAnyCount(lines);
denied.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** Result: collect the selected records, then run the export through the engine when
** the Result lines are asked for (after the last page, before post run).
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine succeeded = line(Status.OK, "export succeeded", "exports succeeded", "export succeeded", "exports succeeded");
private final ProcessSummaryLine warnings = line(Status.WARNING, "export finished with warnings", "exports finished with warnings", "export finished with warnings", "exports finished with warnings");
private final ProcessSummaryLine failed = line(Status.ERROR, "export failed", "exports failed", "export failed", "exports failed");
private final ProcessSummaryLine rows = line(Status.INFO, "record was written", "records were written", "record was written", "records were written");
private final ProcessSummaryLine files = line(Status.INFO, "referenced file was copied or already stored", "referenced files were copied or already stored",
"referenced file was copied or already stored", "referenced files were copied or already stored");
private final List<QRecord> buffered = new ArrayList<>();
private String tableName;
private String bucket;
private boolean exported = false;
/*******************************************************************************
** Remember what is being exported.
*******************************************************************************/
@Override
public void preRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
super.preRun(input, output);
tableName = input.getTableName();
bucket = bucketAlias(input);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
buffered.addAll(input.getRecords());
output.getRecords().addAll(input.getRecords());
}
/*******************************************************************************
** All records are in (the summary is asked for once the last page is loaded, and
** before post run): back them up, once.
*******************************************************************************/
private void export() throws QException
{
if(exported)
{
return;
}
exported = true;
if(bucket == null)
{
failed.incrementCount();
failed.setMessageSuffix(": there is no active backup plan to take a bucket from");
return;
}
BackupRunResult result = new BackupRunCoordinator().runAdHoc(request(tableName, bucket, buffered));
switch(result.getStatus())
{
case "SUCCEEDED" -> succeeded.incrementCountAndAddPrimaryKey(result.getRunId());
case "SUCCEEDED_WITH_WARNINGS" -> warnings.incrementCountAndAddPrimaryKey(result.getRunId());
default ->
{
failed.incrementCountAndAddPrimaryKey(result.getRunId());
failed.setMessageSuffix(result.getErrorMessage() == null ? null : ": " + result.getErrorMessage());
}
}
rows.incrementCount((int) Math.min(Integer.MAX_VALUE, result.getRows()));
files.incrementCount(result.getFilesCopied() + result.getFilesDeduplicated());
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
try
{
export();
}
catch(QException e)
{
failed.incrementCount();
failed.setMessageSuffix(": " + e.getMessage());
}
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
succeeded.addSelfToListIfAnyCount(lines);
warnings.addSelfToListIfAnyCount(lines);
failed.addSelfToListIfAnyCount(lines);
rows.addSelfToListIfAnyCount(lines);
files.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** The ad-hoc request for the selected records of a table.
*******************************************************************************/
public static BackupRunRequest request(String tableName, String bucketAlias, List<QRecord> selected)
{
QInstance qInstance = QContext.getQInstance();
QTableMetaData table = qInstance.getTable(tableName);
String pk = table.getPrimaryKeyField();
QSession session = QContext.getQSession();
String user = session == null || session.getUser() == null ? "system" : session.getUser().getIdReference();
BackupsQBitConfig config = BackupsRuntime.getConfig();
Map<String, QRecord> unique = new LinkedHashMap<>();
for(QRecord record : selected)
{
unique.put(String.valueOf(record.getValue(pk)), record);
}
List<QRecord> inPkOrder = new ArrayList<>(unique.values());
inPkOrder.sort(Comparator.comparing(r -> (Comparable) r.getValue(pk)));
PlanSettings settings = PlanSettings.adHoc().with("planKey", "adhoc").with("storageBucketAlias", bucketAlias).with("sensitiveFieldPolicy", sensitivePolicy());
return (new BackupRunRequest()
.withSettings(settings)
.withScope(new ScopeResolver.ResolvedScope(List.of(new ScopeResolver.ResolvedTable(tableName, TableRoles.roleOf(qInstance, table), null, List.of())), List.of(), List.of()))
.withTrigger("MANUAL")
.withRequestedBy(user)
.withSecurityScope("USER")
.withBucketAlias(bucketAlias)
.withStorage(BackupsRuntime.storageFor(bucketAlias))
.withSourceResolver(BackupsRuntime::storageFor)
.withInstanceName(config.getInstanceName() != null ? config.getInstanceName() : "qqq")
.withPlanName("Export of " + table.getLabel())
.withReader(new BufferedReader(inPkOrder))
.withClock(BackupsRuntime.getClock()));
}
/*******************************************************************************
** Serves the selected records to the engine, in primary key order.
*******************************************************************************/
static class BufferedReader implements TableReader
{
private final List<QRecord> records;
/*******************************************************************************
** Constructor.
*******************************************************************************/
BufferedReader(List<QRecord> records)
{
this.records = records;
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void read(QTableMetaData table, String filterJson, Serializable startAfterKey, int pageSize, QBackendTransaction transaction, java.util.function.Consumer<QRecord> sink)
{
records.forEach(sink);
}
}
}
@@ -0,0 +1,76 @@
/*
* 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.schedule.BackupPlanSyncToScheduledJobProcess;
import com.kingsrook.qbits.backups.schedule.BackupPlanVerifySyncToScheduledJobProcess;
import com.kingsrook.qbits.backups.schedule.BackupScheduledRunProcess;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** The processes of the run lifecycle (spec 9): scheduled runs, cancel, resume,
** test destination and prune, and the plan to scheduled job sync. Added to the
** qbit by BackupsProcesses.processes(). The per-table export process
** (exportProcesses) needs the instance tables, so the producer adds it.
*******************************************************************************/
public final class LifecycleProcesses
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private LifecycleProcesses()
{
}
/*******************************************************************************
** The processes.
*******************************************************************************/
public static List<QProcessMetaData> processes()
{
List<QProcessMetaData> processes = new ArrayList<>();
processes.add(BackupScheduledRunProcess.processMetaData());
processes.addAll(CancelRunProcess.processes());
processes.add(ResumeRunProcess.process());
processes.add(TestDestinationProcess.process());
processes.addAll(PruneProcess.processes());
BackupsQBitConfig config = BackupsRuntime.getConfig();
if(config != null && StringUtils.hasContent(config.getSchedulerName()))
{
try
{
processes.add(new BackupPlanSyncToScheduledJobProcess().produce(null));
processes.add(new BackupPlanVerifySyncToScheduledJobProcess().produce(null));
}
catch(QException e)
{
throw (new IllegalStateException("Could not define the plan schedule sync processes: " + e.getMessage(), e));
}
}
return (processes);
}
}
@@ -0,0 +1,336 @@
/*
* 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.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
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.prune.BackupPruner;
import com.kingsrook.qbits.backups.prune.PruneCalculator;
import com.kingsrook.qbits.backups.run.BackupCatalog;
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.processes.QProcessMetaData;
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;
/*******************************************************************************
** Prune per retention (spec 9, 10.3). A record action on backups_plan, and a
** table action on backups_run (which prunes the plans of the selected runs); the
** plan one also runs daily when the qbit has a scheduler.
**
** Review shows exactly what Result will do: the runs to delete, the runs kept by
** class, the runs kept because an object is still locked (with the date), the
** debris prefixes, the content-addressed objects to sweep, and the bytes freed.
** Review writes nothing.
*******************************************************************************/
public final class PruneProcess
{
public static final String NAME = "backupPrune";
public static final String NAME_RUNS = "backupPruneRuns";
private static final long MIB = 1024L * 1024L;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PruneProcess()
{
}
/*******************************************************************************
** The two processes.
*******************************************************************************/
public static List<QProcessMetaData> processes()
{
QProcessMetaData onPlans = BackupsProcesses.permission(BackupsProcesses.etl(NAME, "Prune per retention", BackupsTables.PLAN, PlanTransform.class, Load.class,
"This is a preview of what Prune will delete. Nothing is deleted until you continue."), BackupsPermissions.ADMIN);
BackupsQBitConfig config = BackupsRuntime.getConfig();
if(config != null && config.getSchedulerName() != null && !config.getSchedulerName().isBlank())
{
onPlans.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(24 * 3600).withInitialDelaySeconds(3600));
}
QProcessMetaData onRuns = BackupsProcesses.permission(BackupsProcesses.etl(NAME_RUNS, "Prune per retention", BackupsTables.RUN, RunTransform.class, Load.class,
"This is a preview of what Prune will delete for the plans of the selected runs. Nothing is deleted until you continue."), BackupsPermissions.ADMIN);
return (List.of(onPlans, onRuns));
}
/*******************************************************************************
** The review lines, shared by the two transforms.
*******************************************************************************/
abstract static class BaseTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine plans = line(Status.OK, "plan will be pruned", "plans will be pruned", "plan was pruned", "plans were pruned");
private final ProcessSummaryLine delete = line(Status.WARNING, "run will be deleted", "runs will be deleted", "run was deleted", "runs were deleted");
private final ProcessSummaryLine daily = line(Status.OK, "run is kept as a daily backup", "runs are kept as daily backups", "run was kept as a daily backup", "runs were kept as daily backups");
private final ProcessSummaryLine weekly = line(Status.OK, "run is kept as a weekly backup", "runs are kept as weekly backups", "run was kept as a weekly backup", "runs were kept as weekly backups");
private final ProcessSummaryLine monthly = line(Status.OK, "run is kept as a monthly backup", "runs are kept as monthly backups", "run was kept as a monthly backup", "runs were kept as monthly backups");
private final ProcessSummaryLine yearly = line(Status.OK, "run is kept as a yearly backup", "runs are kept as yearly backups", "run was kept as a yearly backup", "runs were kept as yearly backups");
private final ProcessSummaryLine guarded = line(Status.OK, "run is kept by a guard (the minimum, the newest verified run, or a recent restore)",
"runs are kept by a guard (the minimum, the newest verified run, or a recent restore)", "run was kept by a guard", "runs were kept by a guard");
private final ProcessSummaryLine locked = line(Status.WARNING, "run is kept: its objects are locked", "runs are kept: their objects are locked",
"run was kept: its objects are locked", "runs were kept: their objects are locked");
private final ProcessSummaryLine debris = line(Status.INFO, "unfinished backup (older than 7 days) will be deleted", "unfinished backups (older than 7 days) will be deleted",
"unfinished backup was deleted", "unfinished backups were deleted");
private final ProcessSummaryLine sweep = line(Status.INFO, "stored file that no retained backup references will be deleted", "stored files that no retained backup references will be deleted",
"stored file was deleted", "stored files were deleted");
private final ProcessSummaryLine sweepLocked = line(Status.WARNING, "stored file is kept: it is locked", "stored files are kept: they are locked", "stored file was kept: it is locked", "stored files were kept: they are locked");
private final ProcessSummaryLine deferred = line(Status.WARNING, "plan's file store sweep is skipped", "plans' file store sweeps are skipped", "plan's file store sweep was skipped", "plans' file store sweeps were skipped");
private final ProcessSummaryLine freed = line(Status.INFO, "MiB of storage will be freed", "MiB of storage will be freed", "MiB of storage was freed", "MiB of storage was freed");
private final ProcessSummaryLine failed = line(Status.ERROR, "plan cannot be pruned", "plans cannot be pruned", "plan could not be pruned", "plans could not be pruned");
private final Set<Integer> seenPlans = new HashSet<>();
private long bytes = 0;
private Instant latestLock;
/*******************************************************************************
** The plan records this step looks at.
*******************************************************************************/
abstract List<QRecord> plansFor(RunBackendStepInput input) throws QException;
/*******************************************************************************
** Plan each selected plan, writing nothing.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord plan : plansFor(input))
{
if(!seenPlans.add(plan.getValueInteger("id")))
{
continue;
}
try
{
BackupPruner.PrunePlan prunePlan = new BackupPruner().plan(plan);
plans.incrementCountAndAddPrimaryKey(plan.getValue("id"));
delete.incrementCount(prunePlan.toDelete().size());
var kept = prunePlan.keptByClass();
daily.incrementCount(kept.getOrDefault(PruneCalculator.RetentionClass.DAILY, 0));
weekly.incrementCount(kept.getOrDefault(PruneCalculator.RetentionClass.WEEKLY, 0));
monthly.incrementCount(kept.getOrDefault(PruneCalculator.RetentionClass.MONTHLY, 0));
yearly.incrementCount(kept.getOrDefault(PruneCalculator.RetentionClass.YEARLY, 0));
guarded.incrementCount(kept.getOrDefault(PruneCalculator.RetentionClass.NONE, 0));
locked.incrementCount(prunePlan.lockedNotKept().size());
for(BackupPruner.RunPlan run : prunePlan.lockedNotKept())
{
latestLock = latestLock == null || run.lockedUntil().isAfter(latestLock) ? run.lockedUntil() : latestLock;
}
debris.incrementCount(prunePlan.debris().stream().filter(d -> d.lockedUntil() == null).toList().size());
sweep.incrementCount(prunePlan.sweep().deletable().size());
sweepLocked.incrementCount(prunePlan.sweep().locked().size());
if(prunePlan.sweep().deferredReason() != null)
{
deferred.incrementCount();
deferred.setMessageSuffix(": " + prunePlan.sweep().deferredReason());
}
bytes += prunePlan.bytesToFree();
output.getRecords().add(plan);
}
catch(Exception e)
{
failed.incrementCountAndAddPrimaryKey(plan.getValue("id"));
failed.setMessageSuffix(": " + e.getMessage());
}
}
}
/*******************************************************************************
** The lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
if(latestLock != null)
{
locked.setMessageSuffix(" (the latest lock ends " + latestLock + ")");
}
freed.setCount((int) Math.min(Integer.MAX_VALUE, (bytes + MIB - 1) / MIB));
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
plans.addSelfToListIfAnyCount(lines);
delete.addSelfToListIfAnyCount(lines);
daily.addSelfToListIfAnyCount(lines);
weekly.addSelfToListIfAnyCount(lines);
monthly.addSelfToListIfAnyCount(lines);
yearly.addSelfToListIfAnyCount(lines);
guarded.addSelfToListIfAnyCount(lines);
locked.addSelfToListIfAnyCount(lines);
debris.addSelfToListIfAnyCount(lines);
sweep.addSelfToListIfAnyCount(lines);
sweepLocked.addSelfToListIfAnyCount(lines);
deferred.addSelfToListIfAnyCount(lines);
freed.addSelfToListIfAnyCount(lines);
failed.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** Review for plans.
*******************************************************************************/
public static class PlanTransform extends BaseTransform
{
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
List<QRecord> plansFor(RunBackendStepInput input)
{
return (input.getRecords());
}
}
/*******************************************************************************
** Review for runs: the plans of the selected runs.
*******************************************************************************/
public static class RunTransform extends BaseTransform
{
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
List<QRecord> plansFor(RunBackendStepInput input) throws QException
{
List<QRecord> plans = new ArrayList<>();
BackupCatalog catalog = new BackupCatalog();
for(QRecord run : input.getRecords())
{
if(run.getValueInteger("backupPlanId") != null)
{
catalog.get(BackupsTables.PLAN, run.getValueInteger("backupPlanId")).ifPresent(plans::add);
}
}
return (plans);
}
}
/*******************************************************************************
** Result: do it, and report what was done.
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine plans = line(Status.OK, "plan will be pruned", "plans will be pruned", "plan was pruned", "plans were pruned");
private final ProcessSummaryLine deleted = line(Status.OK, "run will be deleted", "runs will be deleted", "run was deleted", "runs were deleted");
private final ProcessSummaryLine locked = line(Status.WARNING, "run was kept: locked until a later date", "runs were kept: locked until a later date",
"run was kept: locked until a later date", "runs were kept: locked until a later date");
private final ProcessSummaryLine debris = line(Status.OK, "unfinished backup will be deleted", "unfinished backups will be deleted", "unfinished backup was deleted", "unfinished backups were deleted");
private final ProcessSummaryLine files = line(Status.OK, "stored file will be deleted", "stored files will be deleted", "stored file was deleted", "stored files were deleted");
private final ProcessSummaryLine filesKept = line(Status.WARNING, "stored file was kept: it is locked", "stored files were kept: they are locked", "stored file was kept: it is locked", "stored files were kept: they are locked");
private final ProcessSummaryLine notes = line(Status.INFO, "note", "notes", "note", "notes");
private final ProcessSummaryLine freed = line(Status.INFO, "MiB of storage was freed", "MiB of storage was freed", "MiB of storage was freed", "MiB of storage was freed");
private final ProcessSummaryLine failed = line(Status.ERROR, "plan could not be pruned", "plans could not be pruned", "plan could not be pruned", "plans could not be pruned");
private long bytes = 0;
private final List<String> messages = new ArrayList<>();
/*******************************************************************************
** Prune each plan.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
com.kingsrook.qqq.backend.core.model.session.QSession session = com.kingsrook.qqq.backend.core.context.QContext.getQSession();
String user = session == null || session.getUser() == null ? "system:scheduler" : session.getUser().getIdReference();
for(QRecord plan : input.getRecords())
{
try
{
BackupPruner.Outcome outcome = new BackupPruner().prune(plan, user);
plans.incrementCountAndAddPrimaryKey(plan.getValue("id"));
deleted.incrementCount(outcome.runsDeleted());
locked.incrementCount(outcome.runsLocked());
debris.incrementCount(outcome.debrisPrefixesDeleted());
files.incrementCount(outcome.filesSwept());
filesKept.incrementCount(outcome.filesLocked());
bytes += outcome.bytesFreed();
messages.addAll(outcome.messages());
output.getRecords().add(plan);
}
catch(Exception e)
{
failed.incrementCountAndAddPrimaryKey(plan.getValue("id"));
failed.setMessageSuffix(": " + e.getMessage());
}
}
}
/*******************************************************************************
** The Result lines: what was deleted, and what was kept because it is locked.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
freed.setCount((int) Math.min(Integer.MAX_VALUE, (bytes + MIB - 1) / MIB));
notes.setCount(messages.size());
if(!messages.isEmpty())
{
notes.setMessageSuffix(": " + String.join("; ", messages.stream().limit(5).toList()) + (messages.size() > 5 ? "; and " + (messages.size() - 5) + " more" : ""));
}
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
plans.addSelfToListIfAnyCount(lines);
deleted.addSelfToListIfAnyCount(lines);
locked.addSelfToListIfAnyCount(lines);
debris.addSelfToListIfAnyCount(lines);
files.addSelfToListIfAnyCount(lines);
filesKept.addSelfToListIfAnyCount(lines);
freed.addSelfToListIfAnyCount(lines);
notes.addSelfToListIfAnyCount(lines);
failed.addSelfToListIfAnyCount(lines);
return (lines);
}
}
}
@@ -0,0 +1,200 @@
/*
* 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 java.util.Map;
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.run.BackupResumePlanner;
import com.kingsrook.qbits.backups.run.BackupRunCoordinator;
import com.kingsrook.qbits.backups.run.BackupRunResult;
import com.kingsrook.qbits.backups.run.ResumeState;
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.processes.QProcessMetaData;
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;
/*******************************************************************************
** Resume (spec 9): a record action on a failed or timed out run that read each
** table on its own. The same run carries on: the tables that finished are kept,
** and the interrupted table continues from the last key of its last complete
** part. A run that held one snapshot cannot be resumed (a new read would not be
** the same instant), and says so; Review explains why and the way out is a fresh
** run of the plan.
*******************************************************************************/
public final class ResumeRunProcess
{
public static final String NAME = "backupResumeRun";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ResumeRunProcess()
{
}
/*******************************************************************************
** The process.
*******************************************************************************/
public static QProcessMetaData process()
{
return (BackupsProcesses.permission(BackupsProcesses.etl(NAME, "Resume", BackupsTables.RUN, Transform.class, Load.class,
"This is a preview of where each selected run will carry on. Nothing is written until you continue."), BackupsPermissions.RUN));
}
/*******************************************************************************
** Review: what is already done, and where the interrupted table resumes.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine runs = line(Status.OK, "run will be resumed", "runs will be resumed", "run was resumed", "runs were resumed");
private final ProcessSummaryLine done = line(Status.INFO, "table is complete and will be kept as it is", "tables are complete and will be kept as they are",
"table was complete and was kept", "tables were complete and were kept");
private final ProcessSummaryLine carryOn = line(Status.INFO, "table will carry on from its last complete part", "tables will carry on from their last complete part",
"table carried on from its last complete part", "tables carried on from their last complete part");
private final ProcessSummaryLine cannot = line(Status.ERROR, "run cannot be resumed", "runs cannot be resumed", "run could not be resumed", "runs could not be resumed");
private final List<String> resumeAt = new ArrayList<>();
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
BackupCatalog catalog = new BackupCatalog();
for(QRecord run : input.getRecords())
{
QRecord plan = run.getValueInteger("backupPlanId") == null ? null : catalog.get(BackupsTables.PLAN, run.getValueInteger("backupPlanId")).orElse(null);
String why = BackupResumePlanner.whyNotResumable(run, plan);
if(why != null)
{
cannot.incrementCountAndAddPrimaryKey(run.getValue("id"));
cannot.setMessageSuffix(": " + why);
continue;
}
ResumeState state = BackupResumePlanner.plan(run);
runs.incrementCountAndAddPrimaryKey(run.getValue("id"));
for(Map.Entry<String, ResumeState.TableState> entry : state.tables().entrySet())
{
if(entry.getValue().complete())
{
done.incrementCount();
}
else
{
carryOn.incrementCount();
resumeAt.add(entry.getKey() + " after key " + entry.getValue().lastKey());
}
}
output.getRecords().add(run);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
if(!resumeAt.isEmpty())
{
carryOn.setMessageSuffix(": " + String.join(", ", resumeAt.stream().limit(5).toList()));
}
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
runs.addSelfToListIfAnyCount(lines);
done.addSelfToListIfAnyCount(lines);
carryOn.addSelfToListIfAnyCount(lines);
cannot.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** Result: run the engine again on each run.
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine succeeded = line(Status.OK, "run will succeed", "runs succeeded", "run succeeded after being resumed", "runs succeeded after being resumed");
private final ProcessSummaryLine warnings = line(Status.WARNING, "run finished with warnings", "runs finished with warnings", "run finished with warnings", "runs finished with warnings");
private final ProcessSummaryLine failed = line(Status.ERROR, "run failed again", "runs failed again", "run failed again", "runs failed again");
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord run : input.getRecords())
{
BackupRunResult result = new BackupRunCoordinator().resume(run);
switch(result.getStatus())
{
case "SUCCEEDED" -> succeeded.incrementCountAndAddPrimaryKey(run.getValue("id"));
case "SUCCEEDED_WITH_WARNINGS" -> warnings.incrementCountAndAddPrimaryKey(run.getValue("id"));
default ->
{
failed.incrementCountAndAddPrimaryKey(run.getValue("id"));
failed.setMessageSuffix(result.getErrorMessage() == null ? null : ": " + result.getErrorMessage());
}
}
output.getRecords().add(run);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
succeeded.addSelfToListIfAnyCount(lines);
warnings.addSelfToListIfAnyCount(lines);
failed.addSelfToListIfAnyCount(lines);
return (lines);
}
}
}
@@ -0,0 +1,173 @@
/*
* 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.model.BackupsTables;
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
import com.kingsrook.qbits.backups.plan.DestinationProbe;
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.processes.QProcessMetaData;
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;
/*******************************************************************************
** Test destination (spec 9): a record action on backups_plan. Result shows the
** write, read, list and delete of a probe object (head only on a locked bucket),
** and the capability report of the bucket. See DestinationProbe.
*******************************************************************************/
public final class TestDestinationProcess
{
public static final String NAME = "backupTestDestination";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private TestDestinationProcess()
{
}
/*******************************************************************************
** The process.
*******************************************************************************/
public static QProcessMetaData process()
{
return (BackupsProcesses.permission(BackupsProcesses.etl(NAME, "Test destination", BackupsTables.PLAN, Transform.class, Load.class,
"This will write a small probe object to the destination, read it back, list it and delete it. On a locked bucket it only reads."), BackupsPermissions.ADMIN));
}
/*******************************************************************************
** Review: nothing is written yet.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine plans = line(Status.OK, "plan destination will be tested", "plan destinations will be tested", "plan destination was tested", "plan destinations were tested");
private final ProcessSummaryLine locked = line(Status.INFO, "destination is object locked: the test will only read (no probe object is written)",
"destinations are object locked: the tests will only read (no probe object is written)", "destination was object locked: the test only read", "destinations were object locked: the tests only read");
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord plan : input.getRecords())
{
plans.incrementCountAndAddPrimaryKey(plan.getValue("id"));
String mode = plan.getValueString("objectLockMode");
if(mode != null && !"NONE".equals(mode))
{
locked.incrementCount();
}
output.getRecords().add(plan);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
plans.addSelfToListIfAnyCount(lines);
locked.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** Result: the checks and the capability report.
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final List<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord plan : input.getRecords())
{
DestinationProbe.Report report = DestinationProbe.probe(plan);
String name = plan.getValueString("name");
for(DestinationProbe.Check check : report.checks())
{
ProcessSummaryLine line = line(check.ok() ? Status.OK : Status.ERROR, check.name() + " succeeded: " + check.detail(), check.name() + " succeeded: " + check.detail(),
check.name() + " succeeded: " + check.detail(), check.name() + " succeeded: " + check.detail());
if(!check.ok())
{
line = line(Status.ERROR, check.name() + " failed: " + check.detail(), check.name() + " failed: " + check.detail(),
check.name() + " failed: " + check.detail(), check.name() + " failed: " + check.detail());
}
line.setCount(1);
line.setMessageSuffix(" (" + name + ")");
lines.add(line);
}
ProcessSummaryLine capabilities = line(Status.INFO, "Capabilities: " + String.join(", ", report.capabilities()), "Capabilities: " + String.join(", ", report.capabilities()),
"Capabilities: " + String.join(", ", report.capabilities()), "Capabilities: " + String.join(", ", report.capabilities()));
capabilities.setCount(1);
lines.add(capabilities);
for(String warning : report.warnings())
{
ProcessSummaryLine w = line(Status.WARNING, warning, warning, warning, warning);
w.setCount(1);
lines.add(w);
}
output.getRecords().add(plan);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
return (new ArrayList<>(lines));
}
}
}
@@ -0,0 +1,771 @@
/*
* 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.prune;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.zip.GZIPInputStream;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.qbits.backups.run.BackupRunCoordinator;
import com.kingsrook.qbits.backups.storage.BackupStorage;
import com.kingsrook.qbits.backups.storage.StoredObjectInfo;
import com.kingsrook.qqq.backend.core.actions.audits.AuditAction;
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;
/*******************************************************************************
** Prune per retention (spec 10.3, 6.4, 8.5): plans what to delete without writing
** anything (the Review step), and carries it out (the Result step).
**
** - Runs are classified (backups_run.retentionClass) by the GFS PruneCalculator,
** and the runs that are not kept have every object deleted through
** BackupStorage.deletePermanent on the plan prune alias (pruneBucketAlias; the
** backup alias when none is set). The run row is kept as history, with
** prunedDate set.
** - A run with an object that is still locked is not touched: it is reported as
** "kept: locked until ..." and tried again at the next prune.
** - Prefixes of the plan with no manifest, whose newest object is older than 7
** days, are debris and are always deleted.
** - The content-addressed file store is mark-and-sweep: the file indexes of the
** retained runs give each backups_file_object its referenceCount, and objects
** nothing retained references (and that are not locked) are deleted, with
** deletedDate set. Nothing is swept while a backup is running or when any index
** cannot be read: no deletion on uncertainty.
*******************************************************************************/
public class BackupPruner
{
private static final QLogger LOG = QLogger.getLogger(BackupPruner.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private final BackupCatalog catalog = new BackupCatalog();
/*******************************************************************************
** One run, with what it holds in storage.
*******************************************************************************/
public record RunPlan(Integer runId, String runKey, String runPrefix, PruneCalculator.Decision decision, List<StoredObjectInfo> objects, long bytes, Instant lockedUntil)
{
/*******************************************************************************
** Whether the run is to be deleted (not kept, and nothing locked).
*******************************************************************************/
public boolean toDelete()
{
return (!decision.keep() && lockedUntil == null);
}
/*******************************************************************************
** Whether the run is not kept by retention but cannot be deleted yet.
*******************************************************************************/
public boolean lockedNotKept()
{
return (!decision.keep() && lockedUntil != null);
}
}
/*******************************************************************************
** A prefix with no manifest, older than 7 days.
*******************************************************************************/
public record DebrisPlan(String prefix, Integer runId, List<StoredObjectInfo> objects, long bytes, Instant lockedUntil)
{
}
/*******************************************************************************
** One content-addressed object that nothing retained references.
*******************************************************************************/
public record SweepCandidate(Integer fileObjectId, String sha256, String objectKey, long size, Instant lockedUntil)
{
}
/*******************************************************************************
** The mark-and-sweep of the file store.
**
** @param deferredReason why nothing will be swept, or null
** @param referenceCounts the new referenceCount of each live file object, by id
** @param candidates the unreferenced objects
*******************************************************************************/
public record SweepPlan(String deferredReason, Map<Integer, Integer> referenceCounts, List<SweepCandidate> candidates)
{
/*******************************************************************************
** Candidates that will be deleted (not locked).
*******************************************************************************/
public List<SweepCandidate> deletable()
{
return (candidates.stream().filter(c -> c.lockedUntil() == null).toList());
}
/*******************************************************************************
** Candidates still locked.
*******************************************************************************/
public List<SweepCandidate> locked()
{
return (candidates.stream().filter(c -> c.lockedUntil() != null).toList());
}
}
/*******************************************************************************
** The whole plan for one backup plan.
*******************************************************************************/
public record PrunePlan(Integer planId, String planKey, List<RunPlan> runs, List<DebrisPlan> debris, SweepPlan sweep)
{
/*******************************************************************************
** Runs to delete.
*******************************************************************************/
public List<RunPlan> toDelete()
{
return (runs.stream().filter(RunPlan::toDelete).toList());
}
/*******************************************************************************
** Runs not kept by retention but locked.
*******************************************************************************/
public List<RunPlan> lockedNotKept()
{
return (runs.stream().filter(RunPlan::lockedNotKept).toList());
}
/*******************************************************************************
** The kept runs by class.
*******************************************************************************/
public Map<PruneCalculator.RetentionClass, Integer> keptByClass()
{
return (PruneCalculator.keptByClass(runs.stream().map(RunPlan::decision).toList()));
}
/*******************************************************************************
** How many runs are kept only because a guard says so (minimum, verified, restore).
*******************************************************************************/
public long keptByGuard()
{
return (runs.stream().filter(r -> r.decision().keep() && !r.decision().guards().isEmpty()).count());
}
/*******************************************************************************
** Bytes that will be freed.
*******************************************************************************/
public long bytesToFree()
{
return (toDelete().stream().mapToLong(RunPlan::bytes).sum()
+ debris.stream().filter(d -> d.lockedUntil() == null).mapToLong(DebrisPlan::bytes).sum()
+ sweep.deletable().stream().mapToLong(SweepCandidate::size).sum());
}
}
/*******************************************************************************
** What a prune did.
*******************************************************************************/
public record Outcome(int runsDeleted, int runsLocked, int debrisPrefixesDeleted, int debrisLocked, int filesSwept, int filesLocked, long bytesFreed, List<String> messages)
{
}
/*******************************************************************************
** Work out what a prune would do, writing nothing.
*******************************************************************************/
public PrunePlan plan(QRecord plan) throws QException
{
Instant now = BackupsRuntime.getClock().instant();
Integer planId = plan.getValueInteger("id");
String planKey = plan.getValueString("planKey");
BackupStorage deleteSide = BackupsRuntime.storageFor(pruneAlias(plan));
List<QRecord> allRuns = catalog.where(BackupsTables.RUN, "backupPlanId", planId);
/////////////////
// GFS classes //
/////////////////
Set<Integer> protectedByRestore = restoreProtected(now);
List<PruneCalculator.RunInfo> infos = new ArrayList<>();
Map<Integer, QRecord> usable = new LinkedHashMap<>();
for(QRecord run : allRuns)
{
if(run.getValue("prunedDate") == null && isSuccessful(run))
{
usable.put(run.getValueInteger("id"), run);
infos.add(new PruneCalculator.RunInfo(run.getValueInteger("id"), startOf(run), isVerified(run), protectedByRestore.contains(run.getValueInteger("id"))));
}
}
ZoneId zone = zoneOf(plan);
List<PruneCalculator.Decision> decisions = PruneCalculator.calculate(infos, new PruneCalculator.Policy(intOf(plan, "retainDaily", 7),
intOf(plan, "retainWeekly", 4), intOf(plan, "retainMonthly", 12), intOf(plan, "retainYearly", 0), intOf(plan, "retainMinimum", 3)), zone);
List<RunPlan> runPlans = new ArrayList<>();
for(PruneCalculator.Decision decision : decisions)
{
QRecord run = usable.get(decision.id());
List<StoredObjectInfo> objects = decision.keep() ? List.of() : deleteSide.list(run.getValueString("runPrefix"));
runPlans.add(new RunPlan(decision.id(), run.getValueString("runKey"), run.getValueString("runPrefix"), decision, objects,
objects.stream().mapToLong(StoredObjectInfo::size).sum(), latestLock(objects, now)));
}
Set<Integer> deleting = new HashSet<>();
runPlans.stream().filter(RunPlan::toDelete).forEach(r -> deleting.add(r.runId()));
return (new PrunePlan(planId, planKey, runPlans, debris(plan, allRuns, deleteSide, now), sweepPlan(plan, deleting, deleteSide, now)));
}
/*******************************************************************************
** Carry out a prune.
*******************************************************************************/
public Outcome prune(QRecord plan, String prunedBy) throws QException
{
Instant now = BackupsRuntime.getClock().instant();
BackupStorage deleteSide = BackupsRuntime.storageFor(pruneAlias(plan));
PrunePlan prunePlan = plan(plan);
List<String> messages = new ArrayList<>();
int runsDeleted = 0;
int runsLocked = 0;
long bytesFreed = 0;
///////////////////////////////////////////////////////////////////////
// classify every run (the label tells why it is, or was, kept) //
///////////////////////////////////////////////////////////////////////
for(RunPlan run : prunePlan.runs())
{
Map<String, Serializable> values = new LinkedHashMap<>();
values.put("retentionClass", run.decision().keep() ? run.decision().retentionClass().name() : "NONE");
if(run.lockedUntil() != null)
{
values.put("lockedUntil", run.lockedUntil());
}
catalog.update(BackupsTables.RUN, run.runId(), values);
}
for(RunPlan run : prunePlan.runs())
{
if(run.decision().keep())
{
continue;
}
if(run.lockedUntil() != null)
{
runsLocked++;
messages.add("Run " + run.runKey() + " kept: locked until " + run.lockedUntil());
continue;
}
Instant lockedLate = deleteAll(deleteSide, run.objects(), now);
if(lockedLate != null)
{
runsLocked++;
messages.add("Run " + run.runKey() + " kept: locked until " + lockedLate);
continue;
}
Map<String, Serializable> done = new LinkedHashMap<>();
done.put("prunedDate", now);
done.put("prunedByUserId", prunedBy);
done.put("retentionClass", "NONE");
catalog.update(BackupsTables.RUN, run.runId(), done);
runsDeleted++;
bytesFreed += run.bytes();
}
//////////////////////////////////
// debris is always deleted //
//////////////////////////////////
int debrisDeleted = 0;
int debrisLocked = 0;
for(DebrisPlan debris : prunePlan.debris())
{
if(debris.lockedUntil() != null)
{
debrisLocked++;
messages.add("Debris " + debris.prefix() + " kept: locked until " + debris.lockedUntil());
continue;
}
Instant lockedLate = deleteAll(deleteSide, debris.objects(), now);
if(lockedLate != null)
{
debrisLocked++;
messages.add("Debris " + debris.prefix() + " kept: locked until " + lockedLate);
continue;
}
debrisDeleted++;
bytesFreed += debris.bytes();
if(debris.runId() != null)
{
catalog.update(BackupsTables.RUN, debris.runId(), Map.of("prunedDate", now, "prunedByUserId", prunedBy));
}
}
////////////////////////////////////////////////////////////////////////
// the file store: planned again, after the runs went, then swept //
////////////////////////////////////////////////////////////////////////
SweepPlan sweep = sweepPlan(plan, Set.of(), deleteSide, now);
int filesSwept = 0;
int filesLocked = 0;
for(Map.Entry<Integer, Integer> entry : sweep.referenceCounts().entrySet())
{
catalog.update(BackupsTables.FILE_OBJECT, entry.getKey(), Map.of("referenceCount", entry.getValue()));
}
if(sweep.deferredReason() != null)
{
messages.add("File store sweep skipped: " + sweep.deferredReason());
}
for(SweepCandidate candidate : sweep.candidates())
{
BackupStorage.DeleteResult result = candidate.lockedUntil() != null ? BackupStorage.DeleteResult.LOCKED : deleteSide.deletePermanent(candidate.objectKey());
if(result == BackupStorage.DeleteResult.LOCKED)
{
filesLocked++;
Instant until = candidate.lockedUntil() != null ? candidate.lockedUntil() : deleteSide.head(candidate.objectKey()).map(StoredObjectInfo::lockedUntil).orElse(null);
if(until != null)
{
catalog.update(BackupsTables.FILE_OBJECT, candidate.fileObjectId(), Map.of("lockedUntil", until));
}
messages.add("File " + candidate.sha256().substring(0, Math.min(12, candidate.sha256().length())) + " kept: locked until " + until);
}
else
{
filesSwept++;
bytesFreed += candidate.size();
catalog.update(BackupsTables.FILE_OBJECT, candidate.fileObjectId(), Map.of("deletedDate", now, "referenceCount", 0));
}
}
audit(plan, prunedBy, runsDeleted, debrisDeleted, filesSwept, bytesFreed);
return (new Outcome(runsDeleted, runsLocked, debrisDeleted, debrisLocked, filesSwept, filesLocked, bytesFreed, messages));
}
/*******************************************************************************
** Delete objects, the manifest last (so a half-deleted run is not mistaken for
** a usable one). Returns the latest lock date when something turned out to be
** locked after all, else null.
*******************************************************************************/
private Instant deleteAll(BackupStorage storage, List<StoredObjectInfo> objects, Instant now)
{
List<StoredObjectInfo> ordered = new ArrayList<>(objects);
ordered.sort(Comparator.comparing((StoredObjectInfo o) -> o.key().endsWith("/manifest.json")).thenComparing(StoredObjectInfo::key));
Instant locked = null;
for(StoredObjectInfo object : ordered)
{
BackupStorage.DeleteResult result = storage.deletePermanent(object.key());
if(result == BackupStorage.DeleteResult.LOCKED)
{
Instant until = storage.head(object.key()).map(StoredObjectInfo::lockedUntil).orElse(now);
if(locked == null || (until != null && until.isAfter(locked)))
{
locked = until == null ? now : until;
}
}
}
return (locked);
}
/*******************************************************************************
** Prefixes of the plan that never got a manifest and are old enough.
*******************************************************************************/
private List<DebrisPlan> debris(QRecord plan, List<QRecord> allRuns, BackupStorage deleteSide, Instant now) throws QException
{
String prefix = planPrefix(plan);
Map<String, List<StoredObjectInfo>> groups = new LinkedHashMap<>();
for(StoredObjectInfo object : deleteSide.list(prefix))
{
String[] parts = object.key().substring(prefix.length()).split("/", 5);
if(parts.length < 5)
{
continue;
}
groups.computeIfAbsent(prefix + parts[0] + "/" + parts[1] + "/" + parts[2] + "/" + parts[3] + "/", k -> new ArrayList<>()).add(object);
}
Map<String, QRecord> runsByPrefix = new LinkedHashMap<>();
for(QRecord run : allRuns)
{
if(run.getValueString("runPrefix") != null)
{
runsByPrefix.put(run.getValueString("runPrefix"), run);
}
}
List<DebrisPlan> debris = new ArrayList<>();
for(Map.Entry<String, List<StoredObjectInfo>> group : groups.entrySet())
{
List<StoredObjectInfo> objects = group.getValue();
if(objects.stream().anyMatch(o -> o.key().equals(group.getKey() + "manifest.json")))
{
continue;
}
QRecord run = runsByPrefix.get(group.getKey());
if(run != null && (List.of("RUNNING", "QUEUED", "CANCELLING").contains(run.getValueString("status")) && isLive(run, now)))
{
continue;
}
Instant newest = objects.stream().map(StoredObjectInfo::lastModified).max(Comparator.naturalOrder()).orElse(now);
if(newest.isAfter(now.minus(Duration.ofDays(PruneCalculator.DEBRIS_DAYS))))
{
continue;
}
debris.add(new DebrisPlan(group.getKey(), run == null ? null : run.getValueInteger("id"), objects, objects.stream().mapToLong(StoredObjectInfo::size).sum(), latestLock(objects, now)));
}
return (debris);
}
/*******************************************************************************
** Mark and sweep the content-addressed file store for the plan bucket.
**
** @param alsoDeleting runs about to be deleted: they do not count as retained
*******************************************************************************/
private SweepPlan sweepPlan(QRecord plan, Set<Integer> alsoDeleting, BackupStorage deleteSide, Instant now) throws QException
{
String alias = plan.getValueString("storageBucketAlias");
BackupStorage readSide = BackupsRuntime.storageFor(alias);
Map<String, Integer> counts = new LinkedHashMap<>();
///////////////////////////////////////////////////////////////////////////
// mark: every file the file indexes of retained runs list, counted once //
// per run //
///////////////////////////////////////////////////////////////////////////
List<QRecord> allRuns = catalog.query(BackupsTables.RUN, new QQueryFilter(new QFilterCriteria("storageBucketAlias", QCriteriaOperator.EQUALS, alias)));
Map<Integer, QRecord> byId = new LinkedHashMap<>();
for(QRecord run : allRuns)
{
byId.put(run.getValueInteger("id"), run);
}
String unreadable = null;
for(QRecord run : allRuns)
{
if(run.getValue("prunedDate") != null || !isSuccessful(run) || alsoDeleting.contains(run.getValueInteger("id")))
{
continue;
}
try
{
for(String sha : filesIndexed(run, readSide))
{
counts.merge(sha, 1, Integer::sum);
}
}
catch(Exception e)
{
unreadable = "the file index of run " + run.getValueString("runKey") + " could not be read (" + e.getMessage() + ")";
break;
}
}
List<QRecord> fileObjects = catalog.query(BackupsTables.FILE_OBJECT, new QQueryFilter(new QFilterCriteria("storageBucketAlias", QCriteriaOperator.EQUALS, alias)));
Map<Integer, Integer> newCounts = new LinkedHashMap<>();
for(QRecord file : fileObjects)
{
if(file.getValue("deletedDate") == null)
{
Integer count = counts.getOrDefault(file.getValueString("sha256"), 0);
if(!count.equals(file.getValueInteger("referenceCount")))
{
newCounts.put(file.getValueInteger("id"), count);
}
}
}
String deferred = unreadable;
if(deferred == null)
{
boolean running = catalog.liveRuns(now.minus(BackupRunCoordinator.HEARTBEAT_STALE)).stream().anyMatch(r -> alias.equals(r.getValueString("storageBucketAlias")))
|| !catalog.queuedRuns().isEmpty();
if(running)
{
deferred = "a backup is running or queued, and its files are not in any file index yet";
}
}
if(unreadable != null)
{
newCounts.clear();
}
if(deferred != null)
{
return (new SweepPlan(deferred, newCounts, List.of()));
}
/////////////////////////////////////////////////////////////////////////////////////////////
// sweep: live objects nothing retained references; not those a recently failed (and so //
// perhaps resumable) run registered, and not locked ones, which are reported //
/////////////////////////////////////////////////////////////////////////////////////////////
List<SweepCandidate> candidates = new ArrayList<>();
for(QRecord file : fileObjects)
{
if(file.getValue("deletedDate") != null || counts.containsKey(file.getValueString("sha256")))
{
continue;
}
QRecord registeredBy = byId.get(file.getValueInteger("lastReferencedRunId"));
if(registeredBy != null && registeredBy.getValue("prunedDate") == null && !isSuccessful(registeredBy) && startOf(registeredBy).isAfter(now.minus(Duration.ofDays(PruneCalculator.DEBRIS_DAYS))))
{
continue;
}
Instant lockedUntil = file.getValueInstant("lockedUntil");
if(lockedUntil == null || !lockedUntil.isAfter(now))
{
lockedUntil = deleteSide.head(file.getValueString("objectKey")).map(StoredObjectInfo::lockedUntil).filter(until -> until != null && until.isAfter(now)).orElse(null);
}
candidates.add(new SweepCandidate(file.getValueInteger("id"), file.getValueString("sha256"), file.getValueString("objectKey"),
file.getValueLong("sizeBytes") == null ? 0 : file.getValueLong("sizeBytes"), lockedUntil));
}
return (new SweepPlan(null, newCounts, candidates));
}
/*******************************************************************************
** The hashes a run file index lists as stored (copied or deduplicated).
*******************************************************************************/
private Set<String> filesIndexed(QRecord run, BackupStorage readSide) throws Exception
{
Set<String> hashes = new LinkedHashSet<>();
for(QRecord artifact : catalog.query(BackupsTables.ARTIFACT, new QQueryFilter(
new QFilterCriteria("backupRunId", QCriteriaOperator.EQUALS, run.getValueInteger("id")),
new QFilterCriteria("artifactType", QCriteriaOperator.EQUALS, "FILE_OBJECTS_INDEX"))))
{
String key = run.getValueString("runPrefix") + artifact.getValueString("objectKey");
try(InputStream raw = readSide.openRead(key);
InputStream in = "GZIP".equals(artifact.getValueString("compression")) ? new GZIPInputStream(raw) : raw;
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);
String status = node.path("status").asText("");
String sha = node.path("sha256").asText(null);
if(sha != null && ("COPIED".equals(status) || "DEDUPLICATED".equals(status)))
{
hashes.add(sha);
}
}
}
}
return (hashes);
}
/*******************************************************************************
** Runs a restore used in the last 30 days, as its source or its safety backup.
*******************************************************************************/
private Set<Integer> restoreProtected(Instant now) throws QException
{
Instant cutoff = now.minus(Duration.ofDays(PruneCalculator.RESTORE_PROTECTION_DAYS));
Set<Integer> ids = new HashSet<>();
for(QRecord restore : catalog.query(BackupsTables.RESTORE_RUN, new QQueryFilter()))
{
Instant when = restore.getValueInstant("startDate") != null ? restore.getValueInstant("startDate") : restore.getValueInstant("createDate");
if(when == null || when.isAfter(cutoff))
{
if(restore.getValueInteger("sourceBackupRunId") != null)
{
ids.add(restore.getValueInteger("sourceBackupRunId"));
}
if(restore.getValueInteger("preRestoreBackupRunId") != null)
{
ids.add(restore.getValueInteger("preRestoreBackupRunId"));
}
}
}
return (ids);
}
/*******************************************************************************
** One summary audit per prune. Auditing must never make a prune fail.
*******************************************************************************/
private void audit(QRecord plan, String prunedBy, int runs, int debris, int files, long bytes)
{
try
{
AuditAction.execute(BackupsTables.PLAN, plan.getValueInteger("id"), Map.of(), "Prune by " + prunedBy + ": " + runs + " runs, " + debris
+ " debris prefixes and " + files + " stored files deleted (" + bytes + " bytes)");
}
catch(Exception e)
{
LOG.debug("Could not audit a prune", e, logPair("plan", plan.getValueString("planKey")));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static Instant latestLock(List<StoredObjectInfo> objects, Instant now)
{
return (objects.stream().map(StoredObjectInfo::lockedUntil).filter(until -> until != null && until.isAfter(now)).max(Comparator.naturalOrder()).orElse(null));
}
/*******************************************************************************
**
*******************************************************************************/
private static boolean isSuccessful(QRecord run)
{
return (("SUCCEEDED".equals(run.getValueString("status")) || "SUCCEEDED_WITH_WARNINGS".equals(run.getValueString("status"))) && run.getValueString("manifestKey") != null);
}
/*******************************************************************************
**
*******************************************************************************/
private static boolean isVerified(QRecord run)
{
return (List.of("STORAGE_OK", "CONTENT_OK", "TEST_RESTORE_OK").contains(run.getValueString("verificationStatus")));
}
/*******************************************************************************
**
*******************************************************************************/
private static boolean isLive(QRecord run, Instant now)
{
Instant heartbeat = run.getValueInstant("heartbeatDate");
return (heartbeat != null && heartbeat.isAfter(now.minus(BackupRunCoordinator.HEARTBEAT_STALE)));
}
/*******************************************************************************
**
*******************************************************************************/
private static Instant startOf(QRecord run)
{
return (run.getValueInstant("startDate") != null ? run.getValueInstant("startDate") : run.getValueInstant("createDate"));
}
/*******************************************************************************
**
*******************************************************************************/
private static int intOf(QRecord plan, String field, int defaultValue)
{
return (plan.getValueInteger(field) == null ? defaultValue : plan.getValueInteger(field));
}
/*******************************************************************************
** The alias Prune deletes through: the prune alias, else the backup alias.
*******************************************************************************/
public static String pruneAlias(QRecord plan)
{
String alias = plan.getValueString("pruneBucketAlias");
return (alias == null || alias.isBlank() ? plan.getValueString("storageBucketAlias") : alias);
}
/*******************************************************************************
**
*******************************************************************************/
private static ZoneId zoneOf(QRecord plan)
{
try
{
String id = plan.getValueString("cronTimeZoneId");
return (id == null || id.isBlank() ? ZoneId.of("UTC") : ZoneId.of(id));
}
catch(Exception e)
{
return (ZoneId.of("UTC"));
}
}
/*******************************************************************************
** The prefix every run of the plan is under, ending in a slash.
*******************************************************************************/
public static String planPrefix(QRecord plan)
{
BackupsQBitConfig config = BackupsRuntime.getConfig();
String instance = config != null && config.getInstanceName() != null ? config.getInstanceName() : "qqq";
String prefix = plan.getValueString("prefix") == null || plan.getValueString("prefix").isBlank() ? "qqq-backups" : plan.getValueString("prefix");
String trimmed = prefix;
while(trimmed.startsWith("/"))
{
trimmed = trimmed.substring(1);
}
while(trimmed.endsWith("/"))
{
trimmed = trimmed.substring(0, trimmed.length() - 1);
}
return (trimmed + "/" + instance + "/" + plan.getValueString("planKey") + "/");
}
}
@@ -0,0 +1,221 @@
/*
* 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.prune;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.IsoFields;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/*******************************************************************************
** The grandfather-father-son keep-set (spec 10.3). Pure: it works on run
** summaries and a clock, so tests can use a synthetic one.
**
** The rules, over the successful, not yet pruned runs, newest first:
** - DAILY: the newest run of each of the last retainDaily calendar days that have
** a run
** - WEEKLY: the newest run of each of the last retainWeekly ISO weeks that have one
** - MONTHLY: the newest run of each of the last retainMonthly months that have one
** - YEARLY: the newest run of each of the last retainYearly years that have one
** ("last N" counts the periods that have a run, so a quiet week does not use up
** the allowance)
** - the newest retainMinimum runs are always kept
** - the newest run that passed any verification is always kept
** - a run a restore used in the last 30 days (as its source, or its pre-restore
** safety backup) is never deleted
**
** A run kept by several rules is labelled with the longest lived one (YEARLY, then
** MONTHLY, WEEKLY, DAILY); a run kept only by a guard (minimum, verified, restore)
** is labelled NONE and carries the reasons. A run that is not kept is labelled NONE.
*******************************************************************************/
public final class PruneCalculator
{
public static final int RESTORE_PROTECTION_DAYS = 30;
/** Prefixes with no manifest older than this are debris and are always deleted. */
public static final int DEBRIS_DAYS = 7;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PruneCalculator()
{
}
/*******************************************************************************
** The retention classes, longest lived first.
*******************************************************************************/
public enum RetentionClass
{
YEARLY, MONTHLY, WEEKLY, DAILY, NONE
}
/*******************************************************************************
** Why a run is kept, besides the class.
*******************************************************************************/
public enum Guard
{
MINIMUM, NEWEST_VERIFIED, RESTORE_REFERENCE
}
/*******************************************************************************
** What the calculator needs to know about one run.
**
** @param id the run id
** @param startDate when the run started
** @param verified whether any verification passed for the run
** @param usedByRecentRestore whether a restore in the last 30 days used the run
*******************************************************************************/
public record RunInfo(Integer id, Instant startDate, boolean verified, boolean usedByRecentRestore)
{
}
/*******************************************************************************
** The retention numbers of a plan.
*******************************************************************************/
public record Policy(int daily, int weekly, int monthly, int yearly, int minimum)
{
}
/*******************************************************************************
** The decision for one run.
*******************************************************************************/
public record Decision(Integer id, boolean keep, RetentionClass retentionClass, Set<Guard> guards)
{
}
/*******************************************************************************
** Decide every run. The result keeps the input order of runs newest first.
*******************************************************************************/
public static List<Decision> calculate(List<RunInfo> runs, Policy policy, ZoneId zone)
{
List<RunInfo> sorted = new ArrayList<>(runs);
sorted.sort(Comparator.comparing(RunInfo::startDate).reversed().thenComparing(RunInfo::id, Comparator.reverseOrder()));
Map<Integer, RetentionClass> best = new LinkedHashMap<>();
Map<Integer, Set<Guard>> guards = new LinkedHashMap<>();
for(RunInfo run : sorted)
{
best.put(run.id(), RetentionClass.NONE);
guards.put(run.id(), new LinkedHashSet<>());
}
mark(sorted, policy.daily(), RetentionClass.DAILY, run -> run.startDate().atZone(zone).toLocalDate().toString(), best);
mark(sorted, policy.weekly(), RetentionClass.WEEKLY, run -> isoWeek(run.startDate().atZone(zone).toLocalDate()), best);
mark(sorted, policy.monthly(), RetentionClass.MONTHLY, run -> run.startDate().atZone(zone).getYear() + "-" + run.startDate().atZone(zone).getMonthValue(), best);
mark(sorted, policy.yearly(), RetentionClass.YEARLY, run -> String.valueOf(run.startDate().atZone(zone).getYear()), best);
for(int i = 0; i < Math.min(policy.minimum(), sorted.size()); i++)
{
guards.get(sorted.get(i).id()).add(Guard.MINIMUM);
}
sorted.stream().filter(RunInfo::verified).findFirst().ifPresent(run -> guards.get(run.id()).add(Guard.NEWEST_VERIFIED));
sorted.stream().filter(RunInfo::usedByRecentRestore).forEach(run -> guards.get(run.id()).add(Guard.RESTORE_REFERENCE));
List<Decision> decisions = new ArrayList<>();
for(RunInfo run : sorted)
{
RetentionClass retentionClass = best.get(run.id());
boolean keep = retentionClass != RetentionClass.NONE || !guards.get(run.id()).isEmpty();
decisions.add(new Decision(run.id(), keep, retentionClass, guards.get(run.id())));
}
return (decisions);
}
/*******************************************************************************
** Count the kept runs by class (NONE counts the guard-kept ones).
*******************************************************************************/
public static Map<RetentionClass, Integer> keptByClass(List<Decision> decisions)
{
Map<RetentionClass, Integer> counts = new EnumMap<>(RetentionClass.class);
for(Decision decision : decisions)
{
if(decision.keep())
{
counts.merge(decision.retentionClass(), 1, Integer::sum);
}
}
return (counts);
}
/*******************************************************************************
** Keep the newest run of each of the first `limit` distinct periods.
*******************************************************************************/
private static void mark(List<RunInfo> newestFirst, int limit, RetentionClass retentionClass, java.util.function.Function<RunInfo, String> periodOf, Map<Integer, RetentionClass> best)
{
if(limit <= 0)
{
return;
}
Set<String> seen = new LinkedHashSet<>();
for(RunInfo run : newestFirst)
{
String period = periodOf.apply(run);
if(seen.contains(period))
{
continue;
}
if(seen.size() >= limit)
{
return;
}
seen.add(period);
if(retentionClass.ordinal() < best.get(run.id()).ordinal())
{
best.put(run.id(), retentionClass);
}
}
}
/*******************************************************************************
** The ISO week of a date, as week-based-year-week.
*******************************************************************************/
private static String isoWeek(LocalDate date)
{
return (date.get(IsoFields.WEEK_BASED_YEAR) + "-W" + date.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR));
}
}
@@ -26,10 +26,12 @@ import java.util.Map;
import java.util.Optional;
import com.kingsrook.qbits.backups.files.FileCopier;
import com.kingsrook.qbits.backups.model.BackupsTables;
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.actions.tables.UpdateAction;
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.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
@@ -235,6 +237,72 @@ public class BackupCatalog
/*******************************************************************************
** Every run (any plan, or an ad-hoc export) that is RUNNING with a heartbeat
** newer than the cutoff: the live leases.
*******************************************************************************/
public List<QRecord> liveRuns(Instant heartbeatCutoff) throws QException
{
List<QRecord> live = new ArrayList<>();
for(QRecord run : query(BackupsTables.RUN, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.IN, List.of("RUNNING", "CANCELLING")))))
{
Instant heartbeat = run.getValueInstant("heartbeatDate");
if(heartbeat != null && heartbeat.isAfter(heartbeatCutoff))
{
live.add(run);
}
}
return (live);
}
/*******************************************************************************
** The QUEUED runs, oldest first.
*******************************************************************************/
public List<QRecord> queuedRuns() throws QException
{
return (query(BackupsTables.RUN, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, "QUEUED")).withOrderBy(new QFilterOrderBy("id", true))));
}
/*******************************************************************************
** Whether somebody asked for a run to be cancelled (its row says CANCELLING).
** The engine checks this every page.
*******************************************************************************/
public boolean isCancelRequested(Integer runId)
{
try
{
return (get(BackupsTables.RUN, runId).map(r -> "CANCELLING".equals(r.getValueString("status"))).orElse(false));
}
catch(QException e)
{
return (false);
}
}
/*******************************************************************************
** Delete catalog rows by id (used only for artifact rows of a part that is
** being rewritten).
*******************************************************************************/
public void deleteByIds(String table, List<Integer> ids) throws QException
{
if(ids.isEmpty())
{
return;
}
DeleteInput input = new DeleteInput(table);
input.setPrimaryKeys(new ArrayList<>(ids));
input.withPermissionBypass("backups catalog");
new DeleteAction().execute(input);
}
/*******************************************************************************
** The runs that are RUNNING for a plan, with a heartbeat newer than the cutoff.
*******************************************************************************/
@@ -53,6 +53,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -90,10 +91,10 @@ public class BackupEngine
{
BackupRunResult result = new BackupRunResult();
Instant started = request.getClock().instant();
String runKey = RUN_KEY_FORMAT.format(started) + "-" + UUID.randomUUID().toString().substring(0, 4);
String runKey = request.getResume() != null ? request.getResume().runKey() : RUN_KEY_FORMAT.format(started) + "-" + UUID.randomUUID().toString().substring(0, 4);
String planPrefix = request.getSettings().string("prefix", "qqq-backups");
String instancePrefix = trimSlashes(planPrefix) + "/" + request.getInstanceName() + "/";
String runPrefix = instancePrefix + request.getSettings().planKey() + "/" + DATE_PATH.format(started) + "/" + runKey + "/";
String runPrefix = request.getResume() != null ? request.getResume().runPrefix() : instancePrefix + request.getSettings().planKey() + "/" + DATE_PATH.format(started) + "/" + runKey + "/";
result.setRunKey(runKey);
result.setRunPrefix(runPrefix);
@@ -116,7 +117,24 @@ public class BackupEngine
runValues.put("encryptionKeyRef", request.getEncryptionKeyRef());
runValues.put("encryptionKeyFingerprint", request.getEncryptionKeyFingerprint());
runValues.put("verificationStatus", "UNVERIFIED");
result.setRunId(catalog.insert(BackupsTables.RUN, runValues));
if(request.getResume() != null)
{
///////////////////////////////////////////////////////////////////////////////
// a resume carries on the original attempt: keep its start, clear its error //
///////////////////////////////////////////////////////////////////////////////
runValues.remove("startDate");
runValues.put("endDate", null);
runValues.put("errorMessage", null);
}
if(request.getExistingRunId() != null)
{
catalog.update(BackupsTables.RUN, request.getExistingRunId(), runValues);
result.setRunId(request.getExistingRunId());
}
else
{
result.setRunId(catalog.insert(BackupsTables.RUN, runValues));
}
}
catch(Exception e)
{
@@ -170,10 +188,32 @@ public class BackupEngine
throw (new IllegalStateException("The sensitive field policy INCLUDE_ENCRYPTED_ONLY requires client-side encryption."));
}
String consistency = consistencyFor(settings.string("consistency", "SNAPSHOT_PREFERRED"), result);
catalog.update(BackupsTables.RUN, result.getRunId(), Map.of("consistencyAchieved", result.getConsistencyAchieved()));
//////////////////////////////////////////////////////////////////////////////////////
// object lock is applied at write time, to every object of the run (spec 10.3) //
//////////////////////////////////////////////////////////////////////////////////////
String lockMode = settings.string("objectLockMode", "NONE");
Instant retainUntil = null;
if(!"NONE".equals(lockMode))
{
if(!storage.capabilities().contains(BackupStorage.StorageCapability.OBJECT_LOCK))
{
throw (new IllegalStateException("The plan asks for object lock " + lockMode + ", but the storage bucket does not support object lock."));
}
retainUntil = started.plus(java.time.Duration.ofDays(Math.max(1, settings.integer("objectLockDays", 1))));
}
else
{
lockMode = null;
}
final String fileLockMode = lockMode;
final Instant fileRetainUntil = retainUntil;
Function<String, BackupStorage> sourceResolver = request.getSourceResolver() != null ? request.getSourceResolver() : alias -> storage;
FileCopier copier = new FileCopier(sourceResolver, request.getBucketAlias(), storage, instancePrefix + "_files/sha256/", request.getTransform(),
catalog.fileStoreRegistry(request.getBucketAlias(), result.getRunId(), request.getEncryptionKeyFingerprint()), !copyFiles, result.getRunKey());
catalog.fileStoreRegistry(request.getBucketAlias(), result.getRunId(), request.getEncryptionKeyFingerprint()), !copyFiles, result.getRunKey())
.withObjectLock(lockMode, retainUntil);
List<BackupManifest.TableEntry> manifestTables = new ArrayList<>();
List<String> checksums = new ArrayList<>();
@@ -200,65 +240,131 @@ public class BackupEngine
List<BackupManifest.ExcludedField> excluded = excludedFields(table, resolved, sensitivePolicy, settings.string("heavyFieldPolicy", "INLINE"));
List<String> excludedNames = excluded.stream().map(BackupManifest.ExcludedField::name).toList();
ResumeState.TableState prior = request.getResume() == null ? null : request.getResume().table(table.getName());
List<TableArtifactWriter> writers = new ArrayList<>();
String tablePrefix = runPrefix + "tables/" + table.getName() + "/";
for(String f : "JSONL_AND_CSV".equals(format) ? List.of("JSONL", "CSV") : List.of("CSV".equals(format) ? "CSV" : "JSONL"))
{
writers.add(new TableArtifactWriter(storage, tablePrefix, table, excludedNames, f, compression, settings.targetPartBytes(), MAX_ROWS_PER_PART,
request.getTransform(), result.getRunKey()));
}
Map<TableArtifactWriter, Integer> lastArtifactIds = new LinkedHashMap<>();
List<FileReference> references = new ArrayList<>();
long[] counter = { 0 };
Instant deadline = started.plusMillis(maxMillis);
Throwable[] writeError = { null };
String rowsHash = null;
List<TableArtifactWriter.PartResult> allResults = new ArrayList<>();
boolean wantsReferences = copyFiles || "REFERENCE_ONLY".equals(settings.fileObjectPolicy());
request.getReader().read(table, resolved.filterJson(), null, settings.pageSize(), null, record ->
if(prior != null && prior.complete())
{
try
////////////////////////////////////////////////////////////////////////////////
// resume: this table finished before the failure. Keep its parts, and only //
// walk its rows again for the file references (the index is rebuilt) //
////////////////////////////////////////////////////////////////////////////////
if(wantsReferences)
{
for(TableArtifactWriter writer : writers)
collectReferences(request, table, resolved.filterJson(), null, references);
}
allResults.addAll(prior.parts());
rowsHash = prior.rowsHash();
counter[0] = prior.rows();
}
else
{
for(String f : "JSONL_AND_CSV".equals(format) ? List.of("JSONL", "CSV") : List.of("CSV".equals(format) ? "CSV" : "JSONL"))
{
TableArtifactWriter writer = new TableArtifactWriter(storage, tablePrefix, table, excludedNames, f, compression, settings.targetPartBytes(), MAX_ROWS_PER_PART,
request.getTransform(), result.getRunKey()).withObjectLock(fileLockMode, fileRetainUntil);
writer.withPartListener(part ->
{
writer.write(record);
}
try
{
lastArtifactIds.put(writer, catalog.insertArtifact(result.getRunId(), "TABLE_DATA", table.getName(), part.partNumber(), part.key().substring(runPrefix.length()),
part.format(), part.compression(), part.encrypted(), part.rows(), part.bytesRaw(), part.bytesStored(), part.sha256Stored(), part.sha256Raw(), null,
part.firstKey(), part.lastKey(), TableFingerprints.of(table, backendType)));
}
catch(Exception e)
{
throw (new IllegalStateException("Could not record a part of " + table.getName() + ": " + e.getMessage(), e));
}
});
writers.add(writer);
}
catch(IOException e)
{
throw (new IllegalStateException("Could not write a row of " + table.getName() + ": " + e.getMessage(), e));
}
if(copyFiles || "REFERENCE_ONLY".equals(settings.fileObjectPolicy()))
{
for(FileReferenceProvider provider : request.getFileProviders())
{
references.addAll(provider.referencesIn(table, record));
}
}
counter[0]++;
if(counter[0] % settings.pageSize() == 0)
{
heartbeat(request, result, deadline, table.getName(), counter[0]);
}
});
String rowsHash = null;
Serializable startAfter = null;
if(prior != null && !prior.parts().isEmpty())
{
boolean canContinue = writers.size() == 1 && "JSONL".equals(format) && !request.getTransform().encrypts();
if(canContinue)
{
TableArtifactWriter writer = writers.get(0);
writer.resumeFrom(prior.parts());
seedRowsHash(storage, runPrefix, prior.parts(), writer);
lastArtifactIds.put(writer, prior.artifactIds().get(prior.artifactIds().size() - 1));
startAfter = ValueUtils.getValueAsFieldType(table.getField(table.getPrimaryKeyField()).getType(), prior.lastKey());
counter[0] = prior.rows();
if(wantsReferences)
{
collectReferences(request, table, resolved.filterJson(), prior.lastKey(), references);
}
}
else
{
/////////////////////////////////////////////////////////////////////////
// CSV, a second format or encryption cannot rebuild the rows hash from //
// the parts: start this table over (its part objects are overwritten) //
/////////////////////////////////////////////////////////////////////////
catalog.deleteByIds(BackupsTables.ARTIFACT, prior.artifactIds());
}
}
request.getReader().read(table, resolved.filterJson(), startAfter, settings.pageSize(), null, record ->
{
try
{
for(TableArtifactWriter writer : writers)
{
writer.write(record);
}
}
catch(IOException e)
{
throw (new IllegalStateException("Could not write a row of " + table.getName() + ": " + e.getMessage(), e));
}
if(wantsReferences)
{
for(FileReferenceProvider provider : request.getFileProviders())
{
references.addAll(provider.referencesIn(table, record));
}
}
counter[0]++;
if(counter[0] % settings.pageSize() == 0)
{
heartbeat(request, result, deadline, table.getName(), counter[0]);
}
});
for(TableArtifactWriter writer : writers)
{
allResults.addAll(writer.finish());
rowsHash = writer.rowsHash();
Integer lastId = lastArtifactIds.get(writer);
if(lastId != null)
{
catalog.update(BackupsTables.ARTIFACT, lastId, Map.of("rowsHash", rowsHash));
}
}
}
List<BackupManifest.Part> parts = new ArrayList<>();
String fingerprint = TableFingerprints.of(table, backendType);
fingerprints.add(fingerprint);
for(TableArtifactWriter writer : writers)
for(TableArtifactWriter.PartResult part : allResults)
{
List<TableArtifactWriter.PartResult> results = writer.finish();
rowsHash = writer.rowsHash();
for(TableArtifactWriter.PartResult part : results)
{
String relative = part.key().substring(runPrefix.length());
parts.add(new BackupManifest.Part(relative, part.rows(), part.bytesRaw(), part.bytesStored(), part.sha256Stored(), part.sha256Raw(), part.format(), part.compression(), part.encrypted()));
checksums.add(part.sha256Stored() + " " + relative);
bytesStored += part.bytesStored();
bytesRaw += part.bytesRaw();
catalog.insertArtifact(result.getRunId(), "TABLE_DATA", table.getName(), part.partNumber(), relative, part.format(), part.compression(),
part.encrypted(), part.rows(), part.bytesRaw(), part.bytesStored(), part.sha256Stored(), part.sha256Raw(),
part.partNumber() == results.size() ? rowsHash : null, part.firstKey(), part.lastKey(), fingerprint);
}
String relative = part.key().substring(runPrefix.length());
parts.add(new BackupManifest.Part(relative, part.rows(), part.bytesRaw(), part.bytesStored(), part.sha256Stored(), part.sha256Raw(), part.format(), part.compression(), part.encrypted()));
checksums.add(part.sha256Stored() + " " + relative);
bytesStored += part.bytesStored();
bytesRaw += part.bytesRaw();
}
rows += counter[0];
@@ -266,7 +372,7 @@ public class BackupEngine
{
if(indexWriter == null)
{
indexWriter = new FileIndexWriter(storage, runPrefix + "files/index-00001.jsonl.gz", result.getRunKey());
indexWriter = new FileIndexWriter(storage, runPrefix + "files/index-00001.jsonl.gz", result.getRunKey(), fileLockMode, fileRetainUntil);
}
FileCopier.IndexEntry entry = copier.process(ref);
indexWriter.write(entry);
@@ -289,8 +395,8 @@ public class BackupEngine
}
Map<String, String> keyRange = new LinkedHashMap<>();
keyRange.put("min", writers.get(0).getFirstKey());
keyRange.put("max", writers.get(0).getLastKey());
keyRange.put("min", allResults.isEmpty() ? null : allResults.get(0).firstKey());
keyRange.put("max", allResults.isEmpty() ? null : allResults.get(allResults.size() - 1).lastKey());
manifestTables.add(new BackupManifest.TableEntry(table.getName(), resolved.role().name(), table.getBackendName(), table.getPrimaryKeyField(),
fingerprint, TableFingerprints.descriptor(table, backendType), resolved.filterJson(), excluded, counter[0], rowsHash, keyRange, parts, consistency));
done++;
@@ -332,7 +438,7 @@ public class BackupEngine
// checksums, then the manifest LAST (the commit mark) //
/////////////////////////////////////////////////////////
byte[] checksumBytes = (String.join("\n", checksums) + "\n").getBytes(StandardCharsets.UTF_8);
putBytes(storage, runPrefix + "checksums.sha256", checksumBytes, result.getRunKey());
putBytes(storage, runPrefix + "checksums.sha256", checksumBytes, result.getRunKey(), lockMode, retainUntil);
catalog.insertArtifact(result.getRunId(), "CHECKSUMS", null, 1, "checksums.sha256", "TEXT", "NONE", false, checksums.size(), checksumBytes.length,
checksumBytes.length, HashingOutputStream.sha256Hex(checksumBytes), HashingOutputStream.sha256Hex(checksumBytes), null, null, null, null);
@@ -352,7 +458,7 @@ public class BackupEngine
new BackupManifest.Totals(manifestTables.size(), rows, bytesStored));
byte[] manifestBytes = manifest.toJsonBytes();
String manifestKey = runPrefix + "manifest.json";
putBytes(storage, manifestKey, manifestBytes, result.getRunKey());
putBytes(storage, manifestKey, manifestBytes, result.getRunKey(), lockMode, retainUntil);
if(storage.head(manifestKey).isEmpty())
{
throw (new IllegalStateException("The manifest was written but is not visible in the bucket"));
@@ -384,6 +490,7 @@ public class BackupEngine
extra.put("filesMissing", missing);
extra.put("fileBytesCopied", fileBytes);
extra.put("fileBytesReferenced", fileBytesRef);
extra.put("lockedUntil", retainUntil);
extra.put("tablesDone", manifestTables.size());
extra.put("rowsTotal", rows);
extra.put("bytesRaw", bytesRaw);
@@ -601,9 +708,14 @@ public class BackupEngine
/*******************************************************************************
**
*******************************************************************************/
private static void putBytes(BackupStorage storage, String key, byte[] bytes, String runKey) throws IOException
private static void putBytes(BackupStorage storage, String key, byte[] bytes, String runKey, String lockMode, Instant retainUntil) throws IOException
{
try(OutputStream out = storage.openWrite(key, WriteOptions.create().withMetadata("qqq-backup-run", runKey)))
WriteOptions options = WriteOptions.create().withMetadata("qqq-backup-run", runKey);
if(lockMode != null)
{
options.withObjectLock(lockMode, retainUntil);
}
try(OutputStream out = storage.openWrite(key, options))
{
out.write(bytes);
}
@@ -641,6 +753,82 @@ public class BackupEngine
/*******************************************************************************
** Walk a table's rows only for the file references they hold (resume: rows that
** an earlier attempt already wrote to parts). Stops after stopAfterKey (inclusive)
** when given.
*******************************************************************************/
private void collectReferences(BackupRunRequest request, QTableMetaData table, String filterJson, String stopAfterKey, List<FileReference> references) throws Exception
{
String pk = table.getPrimaryKeyField();
try
{
request.getReader().read(table, filterJson, null, request.getSettings().pageSize(), null, record ->
{
if(stopAfterKey != null && !stopAfterKey.equals(String.valueOf(record.getValue(pk))) && keyAfter(record.getValue(pk), stopAfterKey))
{
throw (new StopReadingException());
}
for(FileReferenceProvider provider : request.getFileProviders())
{
references.addAll(provider.referencesIn(table, record));
}
});
}
catch(StopReadingException e)
{
// done: we are past the last key of the written parts
}
}
/*******************************************************************************
** Whether a primary key value sorts after a key given as text.
*******************************************************************************/
@SuppressWarnings({ "unchecked", "rawtypes" })
private static boolean keyAfter(Serializable value, String lastKeyText)
{
if(value instanceof Number number)
{
return (new java.math.BigDecimal(number.toString()).compareTo(new java.math.BigDecimal(lastKeyText)) > 0);
}
return (String.valueOf(value).compareTo(lastKeyText) > 0);
}
/*******************************************************************************
** Feed the rows hash of a resumed table from the parts already in storage (JSONL,
** gzip or none, not encrypted: the decoded bytes are the canonical lines).
*******************************************************************************/
private static void seedRowsHash(BackupStorage storage, String runPrefix, List<TableArtifactWriter.PartResult> parts, TableArtifactWriter writer) throws IOException
{
byte[] buffer = new byte[65536];
for(TableArtifactWriter.PartResult part : parts)
{
try(java.io.InputStream raw = storage.openRead(part.key()); java.io.InputStream in = "GZIP".equals(part.compression()) ? new java.util.zip.GZIPInputStream(raw) : raw)
{
int n;
while((n = in.read(buffer)) >= 0)
{
writer.seedRowsHash(buffer, 0, n);
}
}
}
}
/***************************************************************************
** Thrown from a row sink to stop a reference-only walk.
***************************************************************************/
static class StopReadingException extends RuntimeException
{
}
/***************************************************************************
**
***************************************************************************/
@@ -680,9 +868,14 @@ public class BackupEngine
/*******************************************************************************
** Constructor.
*******************************************************************************/
FileIndexWriter(BackupStorage storage, String key, String runKey) throws IOException
FileIndexWriter(BackupStorage storage, String key, String runKey, String lockMode, Instant retainUntil) throws IOException
{
stored = new HashingOutputStream(storage.openWrite(key, WriteOptions.create().withMetadata("qqq-backup-run", runKey)));
WriteOptions options = WriteOptions.create().withMetadata("qqq-backup-run", runKey);
if(lockMode != null)
{
options.withObjectLock(lockMode, retainUntil);
}
stored = new HashingOutputStream(storage.openWrite(key, options));
raw = new HashingOutputStream(new GZIPOutputStream(stored));
}
@@ -0,0 +1,127 @@
/*
* 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.run;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.backups.codec.TableArtifactWriter;
import com.kingsrook.qbits.backups.model.BackupsTables;
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.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Works out whether a failed run can be resumed, and from where (spec 9, Resume).
**
** Only a run that read each table on its own (PER_TABLE, or BEST_EFFORT while no
** backend gives a snapshot) can resume: a run that held one snapshot cannot carry
** on across a new one, and says so. A resume continues the SAME run (same run key
** and prefix): finished tables are kept, and the interrupted table carries on from
** the last key of its last complete part (the catalog records each part the moment
** it is complete).
*******************************************************************************/
public final class BackupResumePlanner
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private BackupResumePlanner()
{
}
/*******************************************************************************
** Why the run cannot be resumed, or null when it can.
*******************************************************************************/
public static String whyNotResumable(QRecord run, QRecord plan)
{
String status = run.getValueString("status");
if(!List.of("FAILED", "TIMED_OUT", "ABANDONED").contains(status))
{
return ("Only a failed, timed out or abandoned run can be resumed (this one is " + status + ").");
}
if(run.getValueInteger("backupPlanId") == null || plan == null)
{
return ("Only a run of a plan can be resumed. Start a new export instead.");
}
if(run.getValueString("manifestKey") != null)
{
return ("This run already finished: it has a manifest.");
}
String achieved = run.getValueString("consistencyAchieved");
String requested = plan.getValueString("consistency");
boolean snapshot = "INSTANCE_SNAPSHOT".equals(achieved) || "BACKEND_SNAPSHOT".equals(achieved) || (achieved == null && "SNAPSHOT_REQUIRED".equals(requested));
if(snapshot)
{
return ("This run read from one consistent snapshot, which cannot be carried on after it failed. Run the plan again to take a new backup.");
}
return (null);
}
/*******************************************************************************
** What the run already wrote.
*******************************************************************************/
public static ResumeState plan(QRecord run) throws QException
{
BackupCatalog catalog = new BackupCatalog();
List<QRecord> artifacts = catalog.query(BackupsTables.ARTIFACT, new QQueryFilter(
new QFilterCriteria("backupRunId", QCriteriaOperator.EQUALS, run.getValueInteger("id")),
new QFilterCriteria("artifactType", QCriteriaOperator.EQUALS, "TABLE_DATA"))
.withOrderBy(new QFilterOrderBy("tableName", true)).withOrderBy(new QFilterOrderBy("partNumber", true)).withOrderBy(new QFilterOrderBy("id", true)));
String runPrefix = run.getValueString("runPrefix");
Map<String, List<QRecord>> byTable = new LinkedHashMap<>();
for(QRecord artifact : artifacts)
{
byTable.computeIfAbsent(artifact.getValueString("tableName"), k -> new ArrayList<>()).add(artifact);
}
Map<String, ResumeState.TableState> tables = new LinkedHashMap<>();
for(Map.Entry<String, List<QRecord>> entry : byTable.entrySet())
{
List<TableArtifactWriter.PartResult> parts = new ArrayList<>();
List<Integer> ids = new ArrayList<>();
long rows = 0;
String hash = null;
for(QRecord a : entry.getValue())
{
parts.add(new TableArtifactWriter.PartResult(a.getValueInteger("partNumber"), runPrefix + a.getValueString("objectKey"), a.getValueLong("rowCount"),
a.getValueLong("bytesRaw"), a.getValueLong("bytesStored"), a.getValueString("sha256Raw"), a.getValueString("sha256Stored"),
a.getValueString("firstKey"), a.getValueString("lastKey"), Boolean.TRUE.equals(a.getValueBoolean("encrypted")), a.getValueString("format"),
a.getValueString("compression")));
ids.add(a.getValueInteger("id"));
if(a.getValueString("format") != null && a.getValueString("format").equals(entry.getValue().get(0).getValueString("format")))
{
rows += a.getValueLong("rowCount");
}
hash = a.getValueString("rowsHash");
}
tables.put(entry.getKey(), new ResumeState.TableState(hash != null, parts, hash, rows, ids));
}
return (new ResumeState(run.getValueInteger("id"), run.getValueString("runKey"), runPrefix, tables));
}
}
@@ -0,0 +1,417 @@
/*
* 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.run;
import java.io.Serializable;
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.UUID;
import com.kingsrook.qbits.backups.BackupsQBitConfig;
import com.kingsrook.qbits.backups.BackupsRuntime;
import com.kingsrook.qbits.backups.alerts.BackupAlertSink;
import com.kingsrook.qbits.backups.model.BackupsTables;
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 static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Decides whether a run may start, and starts it (spec 9.1): one run per plan at
** a time, and at most maxConcurrentRuns across the instance.
**
** The lease is the run row itself. A run is inserted QUEUED, and flipped to
** RUNNING only when no other run of the plan has a live heartbeat (newer than
** HEARTBEAT_STALE) and the instance has a free slot. The check and the flip happen
** under one lock in this JVM; because the lock cannot span pods, the flip is
** followed by a second look, and when two runs of the same plan are RUNNING the
** one with the higher id gives way. A second trigger while one is running is not
** silent: it leaves a CANCELLED run row saying SKIPPED_CONCURRENT and raises a
** warning. When the instance is full the run stays QUEUED and is started, oldest
** first, as slots free up (drainQueue runs after every managed run).
**
** The scheduled entry point and the Run backup now action both come through here.
*******************************************************************************/
public class BackupRunCoordinator
{
private static final QLogger LOG = QLogger.getLogger(BackupRunCoordinator.class);
/** A RUNNING run whose heartbeat is older than this is dead (spec 9.1). */
public static final Duration HEARTBEAT_STALE = Duration.ofMinutes(10);
public static final String SKIPPED_CONCURRENT = "SKIPPED_CONCURRENT";
public static final String QUEUED = "QUEUED";
private static final Object LEASE_LOCK = new Object();
private final BackupCatalog catalog = new BackupCatalog();
/*******************************************************************************
** Run a plan. The result status is the run status, or SKIPPED_CONCURRENT (a
** run of the plan is already live) or QUEUED (the instance is full; the run
** starts when a slot frees up).
*******************************************************************************/
public BackupRunResult runPlan(QRecord plan, String trigger) throws QException
{
return (runPlan(plan, trigger, null));
}
/*******************************************************************************
** Run a plan, letting the caller adjust the request first (swap the row reader,
** add a file reference provider, ...).
*******************************************************************************/
public BackupRunResult runPlan(QRecord plan, String trigger, java.util.function.Consumer<BackupRunRequest> customizer) throws QException
{
BackupRunRequest request = BackupRunFactory.forPlan(plan, trigger);
if(customizer != null)
{
customizer.accept(request);
}
String refusal = encryptionRefusal(plan, request);
if(refusal != null)
{
BackupRunResult result = new BackupRunResult();
result.setStatus("FAILED");
result.setErrorMessage(refusal);
return (result);
}
Claim claim = claim(plan.getValueInteger("id"), null, trigger, request.getRequestedBy(), true);
try
{
if(claim.outcome != Outcome.STARTED)
{
return (claim.toResult());
}
return (execute(request, claim.runId));
}
finally
{
if(claim.outcome == Outcome.STARTED)
{
drainQueue();
}
}
}
/*******************************************************************************
** Run an ad-hoc export (no plan). It counts as a live run for the instance
** limit but, being built from records in hand, it is never queued.
*******************************************************************************/
public BackupRunResult runAdHoc(BackupRunRequest request) throws QException
{
Claim claim = claim(null, null, request.getTrigger(), request.getRequestedBy(), false);
try
{
return (execute(request, claim.runId));
}
finally
{
drainQueue();
}
}
/*******************************************************************************
** Resume a failed run of a plan: the same run carries on. Refused (with a
** message) when it cannot be resumed, or when a run of the plan is live.
*******************************************************************************/
public BackupRunResult resume(QRecord run) throws QException
{
QRecord plan = run.getValueInteger("backupPlanId") == null ? null : catalog.get(BackupsTables.PLAN, run.getValueInteger("backupPlanId")).orElse(null);
String why = BackupResumePlanner.whyNotResumable(run, plan);
if(why != null)
{
BackupRunResult refused = new BackupRunResult();
refused.setRunId(run.getValueInteger("id"));
refused.setStatus("FAILED");
refused.setErrorMessage(why);
return (refused);
}
BackupRunRequest request = BackupRunFactory.forPlan(plan, "MANUAL");
Claim claim = claim(plan.getValueInteger("id"), run.getValueInteger("id"), run.getValueString("trigger"), request.getRequestedBy(), false);
if(claim.outcome != Outcome.STARTED)
{
BackupRunResult refused = claim.toResult();
refused.setRunId(run.getValueInteger("id"));
refused.setStatus("FAILED");
refused.setErrorMessage("Cannot resume now: " + claim.message);
return (refused);
}
try
{
request.withResume(BackupResumePlanner.plan(run));
return (execute(request, claim.runId));
}
finally
{
drainQueue();
}
}
/*******************************************************************************
** Start every queued run that now has a slot, oldest first.
*******************************************************************************/
public List<BackupRunResult> drainQueue() throws QException
{
List<BackupRunResult> results = new ArrayList<>();
while(true)
{
QRecord next = nextStartable();
if(next == null)
{
return (results);
}
Integer planId = next.getValueInteger("backupPlanId");
QRecord plan = planId == null ? null : catalog.get(BackupsTables.PLAN, planId).orElse(null);
if(plan == null)
{
catalog.update(BackupsTables.RUN, next.getValueInteger("id"), Map.of("status", "CANCELLED", "endDate", now(), "errorMessage", "The plan of this queued run no longer exists."));
continue;
}
BackupRunRequest request = BackupRunFactory.forPlan(plan, next.getValueString("trigger") == null ? "MANUAL" : next.getValueString("trigger"));
results.add(execute(request, next.getValueInteger("id")));
}
}
/*******************************************************************************
** The oldest QUEUED run that may start now (the plan has no live run and the
** instance has a free slot); it is claimed (RUNNING) before it is returned.
*******************************************************************************/
private QRecord nextStartable() throws QException
{
synchronized(LEASE_LOCK)
{
List<QRecord> live = catalog.liveRuns(cutoff());
for(QRecord queued : catalog.queuedRuns())
{
if(live.size() >= maxConcurrentRuns())
{
return (null);
}
Integer planId = queued.getValueInteger("backupPlanId");
if(planId != null && live.stream().anyMatch(r -> planId.equals(r.getValueInteger("backupPlanId"))))
{
continue;
}
catalog.update(BackupsTables.RUN, queued.getValueInteger("id"), Map.of("status", "RUNNING", "heartbeatDate", now()));
return (queued);
}
return (null);
}
}
/*******************************************************************************
** Run the engine on a claimed run row, then return its result.
*******************************************************************************/
private BackupRunResult execute(BackupRunRequest request, Integer runId)
{
request.withExistingRunId(runId).withCancelRequested(() -> catalog.isCancelRequested(runId));
return (new BackupEngine().execute(request));
}
/*******************************************************************************
** The lease: insert (or reuse) the run row and decide.
*******************************************************************************/
private Claim claim(Integer planId, Integer existingRunId, String trigger, String requestedBy, boolean queueIfFull) throws QException
{
synchronized(LEASE_LOCK)
{
Instant now = now();
List<QRecord> live = catalog.liveRuns(cutoff());
Integer runId = existingRunId;
if(runId == null)
{
Map<String, Serializable> values = new LinkedHashMap<>();
values.put("backupPlanId", planId);
values.put("runKey", "queued-" + UUID.randomUUID().toString().substring(0, 8));
values.put("trigger", trigger);
values.put("requestedByUserId", requestedBy);
values.put("status", QUEUED);
values.put("startDate", now);
runId = catalog.insert(BackupsTables.RUN, values);
}
final Integer claimedId = runId;
if(planId != null && live.stream().anyMatch(r -> planId.equals(r.getValueInteger("backupPlanId")) && !claimedId.equals(r.getValueInteger("id"))))
{
return (skip(planId, runId, existingRunId == null, "a run of this plan is already running"));
}
if(planId != null && live.size() >= maxConcurrentRuns() && queueIfFull)
{
LOG.info("A backup run is queued because the instance is at its concurrent run limit", logPair("runId", runId));
return (new Claim(Outcome.QUEUED, runId, "the instance is running its maximum of " + maxConcurrentRuns() + " backups; this one is queued"));
}
if(planId != null && live.size() >= maxConcurrentRuns() && !queueIfFull)
{
return (new Claim(Outcome.QUEUED, runId, "the instance is running its maximum of " + maxConcurrentRuns() + " backups"));
}
catalog.update(BackupsTables.RUN, runId, Map.of("status", "RUNNING", "heartbeatDate", now));
/////////////////////////////////////////////////////////////////////////////
// a second look: another pod may have claimed the same plan in the meantime //
/////////////////////////////////////////////////////////////////////////////
if(planId != null)
{
for(QRecord other : catalog.liveRuns(cutoff()))
{
if(planId.equals(other.getValueInteger("backupPlanId")) && other.getValueInteger("id") < runId && !other.getValueInteger("id").equals(existingRunId))
{
return (skip(planId, runId, existingRunId == null, "a run of this plan started at the same moment"));
}
}
}
return (new Claim(Outcome.STARTED, runId, null));
}
}
/*******************************************************************************
** Record that a trigger was skipped because the plan is already running: a
** visible run row, and a warning.
*******************************************************************************/
private Claim skip(Integer planId, Integer runId, boolean ownRow, String why) throws QException
{
String message = SKIPPED_CONCURRENT + ": " + why;
if(ownRow)
{
Map<String, Serializable> values = new LinkedHashMap<>();
values.put("status", "CANCELLED");
values.put("endDate", now());
values.put("errorMessage", message);
values.put("warningCount", 1);
catalog.update(BackupsTables.RUN, runId, values);
}
String planKey = String.valueOf(planId);
try
{
planKey = catalog.get(BackupsTables.PLAN, planId).map(p -> p.getValueString("planKey")).orElse(planKey);
}
catch(QException e)
{
LOG.debug("Could not look up a plan key", e);
}
LOG.warn("A backup trigger was skipped: " + why, logPair("planId", planId));
BackupsRuntime.getAlertSink().raise("backups.run.skipped_concurrent", "backups.run.skipped_concurrent:" + planKey, BackupAlertSink.Severity.WARNING,
"A backup was skipped because the plan is already running", Map.of("plan", planKey));
return (new Claim(Outcome.SKIPPED, runId, message));
}
/*******************************************************************************
** A plan that asks for client-side encryption must not silently produce plain
** files: refuse unless the request carries a cipher.
*******************************************************************************/
private static String encryptionRefusal(QRecord plan, BackupRunRequest request)
{
String mode = plan.getValueString("encryptionMode");
if(mode != null && !"NONE".equals(mode) && !request.getTransform().encrypts())
{
return ("The plan asks for " + mode + " encryption, but no cipher is available in this version. Nothing was written.");
}
return (null);
}
/*******************************************************************************
**
*******************************************************************************/
private static int maxConcurrentRuns()
{
BackupsQBitConfig config = BackupsRuntime.getConfig();
Integer max = config == null ? null : config.getMaxConcurrentRuns();
return (max == null || max < 1 ? 1 : max);
}
/*******************************************************************************
**
*******************************************************************************/
private static Instant now()
{
return (BackupsRuntime.getClock().instant());
}
/*******************************************************************************
**
*******************************************************************************/
private static Instant cutoff()
{
return (now().minus(HEARTBEAT_STALE));
}
/***************************************************************************
**
***************************************************************************/
private enum Outcome
{
STARTED, SKIPPED, QUEUED
}
/***************************************************************************
**
***************************************************************************/
private record Claim(Outcome outcome, Integer runId, String message)
{
/*******************************************************************************
**
*******************************************************************************/
BackupRunResult toResult()
{
BackupRunResult result = new BackupRunResult();
result.setRunId(runId);
result.setStatus(outcome == Outcome.SKIPPED ? SKIPPED_CONCURRENT : QUEUED);
result.setErrorMessage(message);
return (result);
}
}
}
@@ -52,6 +52,8 @@ public class BackupRunRequest
private Clock clock = Clock.systemUTC();
private BooleanSupplier cancelRequested = () -> false;
private java.util.function.Function<String, BackupStorage> sourceResolver;
private Integer existingRunId;
private ResumeState resume;
@@ -410,4 +412,47 @@ public class BackupRunRequest
this.sourceResolver = sourceResolver;
return (this);
}
/*******************************************************************************
** Getter for existingRunId: a run row the coordinator already created (QUEUED,
** then claimed RUNNING); the engine then updates it instead of inserting one.
*******************************************************************************/
public Integer getExistingRunId()
{
return (existingRunId);
}
/*******************************************************************************
** Fluent setter for existingRunId
*******************************************************************************/
public BackupRunRequest withExistingRunId(Integer existingRunId)
{
this.existingRunId = existingRunId;
return (this);
}
/*******************************************************************************
** Getter for resume: what an earlier, failed attempt of this run already wrote.
*******************************************************************************/
public ResumeState getResume()
{
return (resume);
}
/*******************************************************************************
** Fluent setter for resume
*******************************************************************************/
public BackupRunRequest withResume(ResumeState resume)
{
this.resume = resume;
return (this);
}
}
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more