Add Restic read concurrency setting

This commit is contained in:
Mikei386
2026-06-15 20:49:51 +02:00
parent bb6b3211e9
commit 84df215ad0
12 changed files with 79 additions and 28 deletions
-1
View File
@@ -1 +0,0 @@
ad334b4da853736a6583a0a96e11d3d196a4b22275b95dc0eb0e624a66f77bdb urbm-2026.06.15.r022-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
e7a53aae8a052d41025771b79811176c9570f6ac767734e8ef77fa24fcea0bca urbm-2026.06.15.r023-x86_64-1.txz
+6 -2
View File
@@ -2,13 +2,17 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.15.r022"> <!ENTITY version "2026.06.15.r023">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz"> <!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "ad334b4da853736a6583a0a96e11d3d196a4b22275b95dc0eb0e624a66f77bdb"> <!ENTITY packageSHA256 "e7a53aae8a052d41025771b79811176c9570f6ac767734e8ef77fa24fcea0bca">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
<CHANGES> <CHANGES>
### 2026.06.15.r023
- Add a per-job Restic file read concurrency setting for faster backups from SSD and NVMe sources.
- Keep zero as the backward-compatible Restic default and validate custom values from 1 through 64.
### 2026.06.15.r022 ### 2026.06.15.r022
- Replace the source folder table with a lazy-loaded expandable tree for Restic and Rsync jobs. - Replace the source folder table with a lazy-loaded expandable tree for Restic and Rsync jobs.
- Add rotating disclosure triangles, nested indentation, and per-folder loading, empty, retry, and error states. - Add rotating disclosure triangles, nested indentation, and per-folder loading, empty, retry, and error states.
+1
View File
@@ -58,6 +58,7 @@ type Job struct {
Consistency Consistency `json:"consistency"` Consistency Consistency `json:"consistency"`
Compression string `json:"compression"` Compression string `json:"compression"`
CPUCores int `json:"cpuCores,omitempty"` CPUCores int `json:"cpuCores,omitempty"`
ReadConcurrency int `json:"readConcurrency,omitempty"`
Excludes []string `json:"excludes,omitempty"` Excludes []string `json:"excludes,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
FlashImage bool `json:"flashImage,omitempty"` FlashImage bool `json:"flashImage,omitempty"`
+3
View File
@@ -138,6 +138,9 @@ func ValidateJob(j Job) error {
if j.CPUCores < 0 || j.CPUCores > 256 { if j.CPUCores < 0 || j.CPUCores > 256 {
return errors.New("cpuCores must be between 0 and 256") return errors.New("cpuCores must be between 0 and 256")
} }
if j.ReadConcurrency < 0 || j.ReadConcurrency > 64 {
return errors.New("readConcurrency must be between 0 and 64")
}
if j.Consistency.Mode != "live" && j.Consistency.Mode != "stop" { if j.Consistency.Mode != "live" && j.Consistency.Mode != "stop" {
return errors.New("consistency mode must be live or stop") return errors.New("consistency mode must be live or stop")
} }
+12
View File
@@ -92,6 +92,18 @@ func TestBackupCPUCoresRange(t *testing.T) {
} }
} }
func TestBackupReadConcurrencyRange(t *testing.T) {
job := Job{SchemaVersion: 1, ID: "job", Name: "Share", Type: JobShare, RepositoryID: "repo", Sources: []Source{{Path: "/mnt/user/data"}}, Consistency: Consistency{Mode: "live"}, Compression: "auto", Retention: Retention{KeepWithinDays: 30}}
job.ReadConcurrency = 8
if err := ValidateJob(job); err != nil {
t.Fatalf("valid read concurrency rejected: %v", err)
}
job.ReadConcurrency = 65
if err := ValidateJob(job); err == nil {
t.Fatal("excessive read concurrency accepted")
}
}
func TestRsyncJobDoesNotRequireRepository(t *testing.T) { func TestRsyncJobDoesNotRequireRepository(t *testing.T) {
c := DefaultConfig() c := DefaultConfig()
c.Jobs = []Job{{SchemaVersion: 1, ID: "rsync-job", Name: "Mirror", Type: JobRsync, Enabled: true, Sources: []Source{{Path: "/mnt/user/data"}}, Rsync: RsyncOptions{Target: "/mnt/remotes/backup", Overwrite: true}}} c.Jobs = []Job{{SchemaVersion: 1, ID: "rsync-job", Name: "Mirror", Type: JobRsync, Enabled: true, Sources: []Source{{Path: "/mnt/user/data"}}, Rsync: RsyncOptions{Target: "/mnt/remotes/backup", Overwrite: true}}}
+3
View File
@@ -121,6 +121,9 @@ func (r *Runner) TestPassword(ctx context.Context, repo model.Repository, passwo
func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Job, sources []string, progress ProgressCallback) (Summary, error) { func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Job, sources []string, progress ProgressCallback) (Summary, error) {
args := []string{"backup", "--json", "--compression", job.Compression} args := []string{"backup", "--json", "--compression", job.Compression}
if job.ReadConcurrency > 0 {
args = append(args, "--read-concurrency", fmt.Sprint(job.ReadConcurrency))
}
for _, tag := range append([]string{"urbm", "job:" + job.ID}, job.Tags...) { for _, tag := range append([]string{"urbm", "job:" + job.ID}, job.Tags...) {
args = append(args, "--tag", tag) args = append(args, "--tag", tag)
} }
+23
View File
@@ -110,6 +110,29 @@ func TestBackupLimitsCPUForThisJob(t *testing.T) {
} }
} }
func TestBackupSetsReadConcurrencyForThisJob(t *testing.T) {
dir := t.TempDir()
argsPath := filepath.Join(dir, "args")
script := filepath.Join(dir, "restic")
body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"read123\"}'\n", argsPath)
if err := os.WriteFile(script, []byte(body), 0700); err != nil {
t.Fatal(err)
}
runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}}
repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"}
job := model.Job{ID: "job", Compression: "auto", ReadConcurrency: 8}
if _, err := runner.Backup(context.Background(), repo, job, []string{"/source"}, nil); err != nil {
t.Fatal(err)
}
args, err := os.ReadFile(argsPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(args), "--read-concurrency\n8\n") {
t.Fatalf("read concurrency missing from arguments: %s", args)
}
}
func TestBackupImageStreamsRawDeviceWithStableFilename(t *testing.T) { func TestBackupImageStreamsRawDeviceWithStableFilename(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
argsPath := filepath.Join(dir, "args") argsPath := filepath.Join(dir, "args")
+5 -1
View File
@@ -2,13 +2,17 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.15.r022"> <!ENTITY version "2026.06.15.r023">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz"> <!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE"> <!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
<CHANGES> <CHANGES>
### 2026.06.15.r023
- Add a per-job Restic file read concurrency setting for faster backups from SSD and NVMe sources.
- Keep zero as the backward-compatible Restic default and validate custom values from 1 through 64.
### 2026.06.15.r022 ### 2026.06.15.r022
- Replace the source folder table with a lazy-loaded expandable tree for Restic and Rsync jobs. - Replace the source folder table with a lazy-loaded expandable tree for Restic and Rsync jobs.
- Add rotating disclosure triangles, nested indentation, and per-folder loading, empty, retry, and error states. - Add rotating disclosure triangles, nested indentation, and per-folder loading, empty, retry, and error states.
+4 -4
View File
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
<?php <?php
$pluginRoot = '/plugins/urbm'; $pluginRoot = '/plugins/urbm';
?> ?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r022"> <link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r023">
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php"> <div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
<header class="bu-header"> <header class="bu-header">
<div class="bu-brand"> <div class="bu-brand">
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r022" alt="URBM-Logo"> <img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r023" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.06.15.r022</span></h1><p>Restic Backup Manager für Unraid</p></div> <div><h1>URBM <span class="bu-version">2026.06.15.r023</span></h1><p>Restic Backup Manager für Unraid</p></div>
</div> </div>
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div> <div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div>
</header> </header>
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div> <div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
</div> </div>
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script> <script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r022.js"></script> <script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r023.js"></script>
+3 -2
View File
@@ -20,6 +20,7 @@
consistency: 'Live sichert ohne Unterbrechung, kann aber nur crash-konsistent sein. Controlled stop beendet Container oder VM vor dem Backup und startet sie danach wieder.', consistency: 'Live sichert ohne Unterbrechung, kann aber nur crash-konsistent sein. Controlled stop beendet Container oder VM vor dem Backup und startet sie danach wieder.',
compression: 'Restic-Kompression. auto ist empfohlen; off spart CPU; max spart eher Speicher, benötigt aber mehr CPU-Zeit.', compression: 'Restic-Kompression. auto ist empfohlen; off spart CPU; max spart eher Speicher, benötigt aber mehr CPU-Zeit.',
cpuCores: 'Begrenzt diesen Backup-Lauf über GOMAXPROCS auf die angegebene Anzahl CPU-Kerne. 0 verwendet wie bisher alle verfügbaren Kerne.', cpuCores: 'Begrenzt diesen Backup-Lauf über GOMAXPROCS auf die angegebene Anzahl CPU-Kerne. 0 verwendet wie bisher alle verfügbaren Kerne.',
readConcurrency: 'Anzahl der Dateien, die Restic gleichzeitig liest. Höhere Werte können SSD- und NVMe-Quellen mit vielen kleinen Dateien beschleunigen, bei HDDs aber langsamer sein. 0 verwendet den Restic-Standard.',
sources: 'Optionale manuelle Quellen, eine pro Zeile. Ordner wählst du einfacher über die Checkbox-Liste. Docker- oder VM-ID mit @ voranstellen, z. B. @HomeAssistant.', sources: 'Optionale manuelle Quellen, eine pro Zeile. Ordner wählst du einfacher über die Checkbox-Liste. Docker- oder VM-ID mit @ voranstellen, z. B. @HomeAssistant.',
excludes: 'Muster für Dateien, die nicht gesichert werden. Ein Muster pro Zeile. Beispiele: *.tmp, cache/** oder /mnt/user/appdata/plex/Cache/**.', excludes: 'Muster für Dateien, die nicht gesichert werden. Ein Muster pro Zeile. Beispiele: *.tmp, cache/** oder /mnt/user/appdata/plex/Cache/**.',
shutdownSecs: 'Maximale Wartezeit für einen kontrollierten Stopp. Beispiel: 120 wartet bis zu zwei Minuten; danach wird das Backup ohne Force-Stop abgebrochen.', shutdownSecs: 'Maximale Wartezeit für einen kontrollierten Stopp. Beispiel: 120 wartet bis zu zwei Minuten; danach wird das Backup ohne Force-Stop abgebrochen.',
@@ -388,7 +389,7 @@
content.innerHTML = `<section class="bu-card"><h2>Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}</h2><form id="bu-job-form" class="bu-form"> content.innerHTML = `<section class="bu-card"><h2>Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}</h2><form id="bu-job-form" class="bu-form">
<input type="hidden" name="id" value="${esc(job.id || '')}"><label>Name<input name="name" required value="${esc(job.name || '')}"></label> <input type="hidden" name="id" value="${esc(job.id || '')}"><label>Name<input name="name" required value="${esc(job.name || '')}"></label>
<label>Typ<select name="type">${['share','appdata','docker','vm','flash','rsync'].map(v=>`<option value="${v}" ${v===job.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label> <label>Typ<select name="type">${['share','appdata','docker','vm','flash','rsync'].map(v=>`<option value="${v}" ${v===job.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label>
<fieldset id="bu-restic-job-settings" class="wide bu-fieldset"><legend>Restic-Einstellungen</legend><div class="bu-form-grid"><label>Repository<select name="repositoryId">${options}</select></label><label>Kompression<select name="compression"><option value="auto" ${(job.compression||'auto')==='auto'?'selected':''}>Automatisch</option><option value="off" ${job.compression==='off'?'selected':''}>Aus</option><option value="max" ${job.compression==='max'?'selected':''}>Maximal</option></select></label><label>CPU-Kerne für dieses Backup<input name="cpuCores" type="number" min="0" max="256" value="${Number(job.cpuCores||0)}"><span class="bu-muted">0 = alle verfügbaren Kerne</span></label></div></fieldset> <fieldset id="bu-restic-job-settings" class="wide bu-fieldset"><legend>Restic-Einstellungen</legend><div class="bu-form-grid"><label>Repository<select name="repositoryId">${options}</select></label><label>Kompression<select name="compression"><option value="auto" ${(job.compression||'auto')==='auto'?'selected':''}>Automatisch</option><option value="off" ${job.compression==='off'?'selected':''}>Aus</option><option value="max" ${job.compression==='max'?'selected':''}>Maximal</option></select></label><label>CPU-Kerne für dieses Backup<input name="cpuCores" type="number" min="0" max="256" value="${Number(job.cpuCores||0)}"><span class="bu-muted">0 = alle verfügbaren Kerne</span></label><label>Gleichzeitig gelesene Dateien<input name="readConcurrency" type="number" min="0" max="64" value="${Number(job.readConcurrency||0)}"><span class="bu-muted">0 = Restic-Standard</span></label></div></fieldset>
${scheduleFields(job)} ${scheduleFields(job)}
<div id="bu-job-specific" class="wide"></div> <div id="bu-job-specific" class="wide"></div>
<label class="wide">Ausschlussmuster, eines pro Zeile<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label> <label class="wide">Ausschlussmuster, eines pro Zeile<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label>
@@ -735,7 +736,7 @@
if(type==='rsync'&&!rsync.target) throw new Error('Wähle einen Zielordner für die Rsync-Kopie.'); if(type==='rsync'&&!rsync.target) throw new Error('Wähle einen Zielordner für die Rsync-Kopie.');
if(type==='rsync'&&rsync.delete&&!confirm('Rsync darf Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind. Diese Option kann Daten am Ziel entfernen. Wirklich speichern?')) return; if(type==='rsync'&&rsync.delete&&!confirm('Rsync darf Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind. Diese Option kann Daten am Ziel entfernen. Wirklich speichern?')) return;
if(type==='rsync'&&rsync.skipSpaceCheck&&!confirm('Die Kapazitätsprüfung wirklich abschalten? Der Rsync-Lauf kann dann das Ziel vollständig füllen und mit einem Schreibfehler abbrechen.')) return; if(type==='rsync'&&rsync.skipSpaceCheck&&!confirm('Die Kapazitätsprüfung wirklich abschalten? Der Rsync-Lauf kann dann das Ziel vollständig füllen und mit einem Schreibfehler abbrechen.')) return;
const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:type==='rsync'?'':f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:type==='rsync'?'':f.get('compression'),cpuCores:type==='rsync'?0:Number(f.get('cpuCores')||0),excludes:lines(f.get('excludes')),tags:existing?.tags||[],flashImage:type==='flash'&&f.get('flashImage')==='on',retention:type==='rsync'?{}:retention,rsync,notifyOn:existing?.notifyOn||['success','warning','failed'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)}; const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:type==='rsync'?'':f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:type==='rsync'?'':f.get('compression'),cpuCores:type==='rsync'?0:Number(f.get('cpuCores')||0),readConcurrency:type==='rsync'?0:Number(f.get('readConcurrency')||0),excludes:lines(f.get('excludes')),tags:existing?.tags||[],flashImage:type==='flash'&&f.get('flashImage')==='on',retention:type==='rsync'?{}:retention,rsync,notifyOn:existing?.notifyOn||['success','warning','failed'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)};
state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render(); state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render();
} }
if (kind === 'submit-repo') { if (kind === 'submit-repo') {