Longhorn 14272: Snapshot Group recurring job, two options for Retain

UI for longhorn 14272 (recurring group snapshots). Backend: longhorn-manager PR 5332, still open, built at e6eb37c and running on the dev cluster. Two local longhorn-ui branches, not pushed: 14272-solution-1 (6b11866) and 14272-solution-2 (cddcd57, Solution 1 plus one commit). 2026-10-26.

Previews: main http://13.60.211.184:30081 (0f560ca), Solution 1 http://13.60.211.184:30082, Solution 2 http://13.60.211.184:30083. Same backend for all three; jobs created on one show up on the others.

The decision

Why Retain is the question

The LEP says both things:

Non-goals: A group-level retention task: which lets the job keep the newest N groups it created and deletes older whole groups, so group snapshots do not pile up on member volumes.
Implementation overview: Generate a group name create one SnapshotGroup. Apply retention.

The code follows the non-goal.

Side by side

mainSolution 1: Retain editableSolution 2: no retention
Task list"Snapshot Group" missing; a job made with kubectl or the API still shows in the list"Snapshot Group" added"Snapshot Group" added
Retain fieldn/aEditable, like Snapshot (default 1)Disabled at 0, like Snapshot Cleanup
Extra textn/aNoneWarning: groups are not deleted automatically; delete them from Snapshot Groups before the volumes hit their snapshot limit
Retain column in the job listsShows the stored value (1)Shows the stored value (1, 3, …)"-" for Snapshot Group jobs
What the backend must don/aAdd group retention: keep the newest N groups the job created and delete older ones. That is new backend work, listed as a non-goal in the LEP today.Small: add snapshot-group to the no-retention tasks (like Snapshot Cleanup) so it stops forcing retain ≥ 1 and stops accepting age-based retention. Works with today's backend as is: the UI sends 0, the backend stores 1, the UI shows "-".
If the backend never adds retentionn/aMisleading: users set Retain 3 and still get unlimited groups (see the test below)Accurate
If the backend adds retention latern/aAlready rightA small follow-up to re-enable Retain
Code (vs main)n/a1 file, +14/-2 (the shared part only)3 files, +33/-7 (shared part + retention changes)

Videos

main An API-created job is listed with Retain 1; the form has no Snapshot Group task (12 s).
Solution 1 Pick Snapshot Group, Concurrency hidden, Retain set to 3, Groups help, create; the list shows 3 (26 s).
Solution 2 Pick Snapshot Group, Retain disabled with the warning, create; the list shows "-"; then the 5 groups that piled up (34 s).

Screenshots

Task list in Create Recurring Job

No Snapshot Group.
main No Snapshot Group.
Snapshot Group after Snapshot (same in both solutions).
Solution 1 Snapshot Group after Snapshot (same in both solutions).

The form with Snapshot Group selected

Retain editable (3 here). No Concurrency.
Solution 1 Retain editable (3 here). No Concurrency.
Retain disabled at 0, with the warning. No Concurrency.
Solution 2 Retain disabled at 0, with the warning. No Concurrency.

Recurring Jobs list (same three jobs on each build)

Stored values: 1, 3, 1. Solution 1 shows exactly the same.
main Stored values: 1, 3, 1. Solution 1 shows exactly the same.
"-" for every Snapshot Group job. Concurrency 10 is the backend default (see Open points).
Solution 2 "-" for every Snapshot Group job. Concurrency 10 is the backend default (see Open points).

Open points (either way)

PointDetail
Concurrency columnBoth solutions hide Concurrency in the form (one group per run). The backend then stores its default, 10, and the list shows 10. Could show "-" there too; not done in either solution yet.
Backend gaps to raise on PR 5332Retain/age-based handling above; and the author's pending fix so a failed group fails the Kubernetes Job (agreed on 2026-10-21, not pushed yet).
Per-volume recurring job formSnapshot Group is offered only in the global Recurring Jobs form, like System Backup, in both solutions.

The code

Solution 1 changes only src/routes/recurringJob/CreateRecurringJob.js. Solution 2 adds one commit on top: retain handling in the form, and the "-" in RecurringJobList.js and volume/detail/RecurringJob.js.

Solution 1 vs main (1 file, +14/-2)
diff --git a/src/routes/recurringJob/CreateRecurringJob.js b/src/routes/recurringJob/CreateRecurringJob.js
index ab97231..ed9975e 100644
--- a/src/routes/recurringJob/CreateRecurringJob.js
+++ b/src/routes/recurringJob/CreateRecurringJob.js
@@ -66,6 +66,7 @@ const modal = ({
   },
 }) => {
   const isSystemBackup = getFieldValue('task') === 'system-backup'
+  const isSnapshotGroup = getFieldValue('task') === 'snapshot-group'
 
   function handleOk() {
     validateFields((errors) => {
@@ -233,7 +234,16 @@ const modal = ({
   const formGroups = keys.map((k, index) => (
     <Form.Item
       {...(index === 0 ? formItemLayout : formItemLayoutWithOutLabel)}
-      label={index === 0 ? 'Groups' : ''}
+      label={index === 0 ? (
+        <span>
+          Groups
+          {isSnapshotGroup && (
+            <Tooltip title="On each run, the volumes in these groups are snapshotted together as one Snapshot Group.">
+              <Icon type="question-circle-o" style={{ marginLeft: 4 }} />
+            </Tooltip>
+          )}
+        </span>
+      ) : ''}
       key={k.index}
     >
       {getFieldDecorator(`groups[${k.index}]`, {
@@ -322,7 +332,8 @@ const modal = ({
   const disableAddDefaultGroup = getFieldValue('keys').some((k) => getFieldValue('groups')[k.index] === 'default')
   const isParametersValueRequired = !!getFieldValue('parametersKey')
   const showParametersField = getFieldValue('task') === 'backup' || getFieldValue('task') === 'backup-force-create' || isSystemBackup
-  const showConcurrency = !isSystemBackup
+  // A snapshot-group job creates one SnapshotGroup per run, so concurrency doesn't apply.
+  const showConcurrency = !isSystemBackup && !isSnapshotGroup
   const showGroup = !isSystemBackup
   const showLabels = !isSystemBackup
   const systemBackupParameterOptions = ['if-not-present', 'always', 'disabled']
@@ -379,6 +390,7 @@ const modal = ({
                 ] : [
                   <Option key="backup" value="backup">Backup</Option>,
                   <Option key="snapshot" value="snapshot">Snapshot</Option>,
+                  <Option key="snapshot-group" value="snapshot-group">Snapshot Group</Option>,
                   <Option key="snapshot-delete" value="snapshot-delete">Snapshot Delete</Option>,
                   <Option key="snapshot-cleanup" value="snapshot-cleanup">Snapshot Cleanup</Option>,
                   <Option key="system-backup" value="system-backup">System Backup</Option>,
Solution 2 vs Solution 1 (3 files)
diff --git a/src/routes/recurringJob/CreateRecurringJob.js b/src/routes/recurringJob/CreateRecurringJob.js
index ed9975e..326af8f 100644
--- a/src/routes/recurringJob/CreateRecurringJob.js
+++ b/src/routes/recurringJob/CreateRecurringJob.js
@@ -1,6 +1,6 @@
 import React from 'react'
 import PropTypes from 'prop-types'
-import { Form, Input, Icon, Button, Select, InputNumber, Checkbox, Tooltip, Radio } from 'antd'
+import { Form, Input, Icon, Button, Select, InputNumber, Checkbox, Tooltip, Radio, Alert } from 'antd'
 import { ModalBlur, ReactCron } from '../../components'
 import { parseRetainAge } from '../../utils/recurringJob'
 
@@ -41,8 +41,9 @@ const formItemLayoutWithOutForAddLabels = {
   },
 }
 
+// snapshot-group jobs never delete the groups they create, so retain has no effect.
 const noRetain = (val) => {
-  return val === 'snapshot-cleanup' || val === 'filesystem-trim'
+  return val === 'snapshot-cleanup' || val === 'filesystem-trim' || val === 'snapshot-group'
 }
 
 // Tasks that support the age-based retention policy.
@@ -342,6 +343,10 @@ const modal = ({
   // Retention policy is only editable when the task is backup / snapshot / system-backup.
   const canEditRetentionPolicy = AGE_BASED_TASKS.includes((getFieldValue('task') || '').replace('-force-create', ''))
   const initialRetainAge = parseRetainAge(isEdit ? item.retainAge : '')
+  let initialRetain = 1
+  if (isEdit) {
+    initialRetain = noRetain(item.task) ? 0 : item.retain
+  }
 
   return (
     <ModalBlur {...modalOpts}>
@@ -411,7 +416,7 @@ const modal = ({
         </div>
         <FormItem label="Retain" hasFeedback {...formItemLayout} style={{ display: isAgeBased ? 'none' : undefined }}>
           {getFieldDecorator('retain', {
-            initialValue: isEdit ? item.retain : 1,
+            initialValue: initialRetain,
             rules: [
               {
                 required: !isAgeBased,
@@ -419,6 +424,15 @@ const modal = ({
             ],
           })(<InputNumber disabled={noRetain(getFieldValue('task'))} style={{ width: '80%' }} min={0} />)}
         </FormItem>
+        {isSnapshotGroup && (
+          <FormItem {...formItemLayoutWithOutLabel}>
+            <Alert
+              type="warning"
+              showIcon
+              message="Snapshot groups created by this job are not deleted automatically. Delete old groups from the Snapshot Groups page before the volumes reach their snapshot limit."
+            />
+          </FormItem>
+        )}
         {isAgeBased && (
           <FormItem label="Retain Age" required {...formItemLayout}>
             <div style={{ display: 'flex', alignItems: 'center' }}>
diff --git a/src/routes/recurringJob/RecurringJobList.js b/src/routes/recurringJob/RecurringJobList.js
index d830769..b6cf6b6 100644
--- a/src/routes/recurringJob/RecurringJobList.js
+++ b/src/routes/recurringJob/RecurringJobList.js
@@ -60,7 +60,7 @@ function list({ loading, dataSource, rowSelection, height, deleteRecurringJob, e
       width: 120,
       render: (record) => {
         return (
-          <div>{record.retentionPolicy === 'age-based' ? '-' : record.retain}</div>
+          <div>{record.retentionPolicy === 'age-based' || record.task === 'snapshot-group' ? '-' : record.retain}</div>
         )
       },
     }, {
diff --git a/src/routes/volume/detail/RecurringJob.js b/src/routes/volume/detail/RecurringJob.js
index 0f60885..f8e2f27 100644
--- a/src/routes/volume/detail/RecurringJob.js
+++ b/src/routes/volume/detail/RecurringJob.js
@@ -466,7 +466,7 @@ class RecurringJob extends React.Component {
         width: 120,
         render: (record) => {
           return (
-            <div>{record.retentionPolicy === 'age-based' ? '-' : record.retain}</div>
+            <div>{record.retentionPolicy === 'age-based' || record.task === 'snapshot-group' ? '-' : record.retain}</div>
           )
         },
       }, {