backup: add configurable keep-last retention

This commit is contained in:
Brad Stein 2026-04-13 13:55:17 -03:00
parent 9a26274242
commit 4b5e4f9e31
5 changed files with 197 additions and 10 deletions

View File

@ -7,6 +7,7 @@ type BackupRequest struct {
Snapshot bool `json:"snapshot"`
DryRun bool `json:"dry_run"`
Dedupe *bool `json:"dedupe,omitempty"`
KeepLast *int `json:"keep_last,omitempty"`
}
type BackupResponse struct {
@ -19,6 +20,7 @@ type BackupResponse struct {
RequestedBy string `json:"requested_by,omitempty"`
DryRun bool `json:"dry_run"`
Dedupe bool `json:"dedupe"`
KeepLast int `json:"keep_last"`
}
type RestoreTestRequest struct {
@ -112,6 +114,7 @@ type BackupPolicy struct {
IntervalHours float64 `json:"interval_hours"`
Enabled bool `json:"enabled"`
Dedupe bool `json:"dedupe"`
KeepLast int `json:"keep_last"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
@ -122,6 +125,7 @@ type BackupPolicyUpsertRequest struct {
IntervalHours float64 `json:"interval_hours"`
Enabled *bool `json:"enabled,omitempty"`
Dedupe *bool `json:"dedupe,omitempty"`
KeepLast *int `json:"keep_last,omitempty"`
}
type BackupPolicyListResponse struct {
@ -132,6 +136,7 @@ type NamespaceBackupRequest struct {
Namespace string `json:"namespace"`
DryRun bool `json:"dry_run"`
Dedupe *bool `json:"dedupe,omitempty"`
KeepLast *int `json:"keep_last,omitempty"`
}
type NamespaceBackupResult struct {
@ -149,6 +154,7 @@ type NamespaceBackupResponse struct {
Driver string `json:"driver"`
DryRun bool `json:"dry_run"`
Dedupe bool `json:"dedupe"`
KeepLast int `json:"keep_last"`
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`

View File

@ -24,6 +24,7 @@ const (
labelPVC = "soteria.bstein.dev/pvc"
annotationResticRepository = "soteria.bstein.dev/restic-repository"
annotationDedupeEnabled = "soteria.bstein.dev/dedupe-enabled"
annotationKeepLast = "soteria.bstein.dev/keep-last"
)
type BackupJobSummary struct {
@ -32,6 +33,7 @@ type BackupJobSummary struct {
PVC string
Repository string
DedupeEnabled bool
KeepLast int
CreatedAt time.Time
CompletionTime time.Time
State string
@ -116,6 +118,7 @@ func summarizeBackupJob(job batchv1.Job, pvc string) BackupJobSummary {
Namespace: job.Namespace,
PVC: pvc,
DedupeEnabled: true,
KeepLast: 0,
CreatedAt: job.CreationTimestamp.Time,
State: "Pending",
}
@ -123,6 +126,7 @@ func summarizeBackupJob(job batchv1.Job, pvc string) BackupJobSummary {
summary.Repository = raw
}
summary.DedupeEnabled = parseBoolWithDefault(job.Annotations[annotationDedupeEnabled], true)
summary.KeepLast = parseIntWithDefault(job.Annotations[annotationKeepLast], 0)
if job.Status.CompletionTime != nil {
summary.CompletionTime = job.Status.CompletionTime.Time
}
@ -168,6 +172,7 @@ func (c *Client) CreateBackupJob(ctx context.Context, cfg *config.Config, req ap
jobName := jobName("backup", req.PVC)
secretName := fmt.Sprintf("soteria-%s-restic", jobName)
dedupeEnabled := dedupeEnabled(req.Dedupe)
keepLast := keepLastWithDefault(req.KeepLast)
repository := resticRepositoryForBackup(cfg.ResticRepository, req.Namespace, req.PVC, dedupeEnabled)
if req.DryRun {
@ -184,7 +189,7 @@ func (c *Client) CreateBackupJob(ctx context.Context, cfg *config.Config, req ap
return "", "", err
}
job := buildBackupJob(cfg, req, jobName, secretName, repository, dedupeEnabled)
job := buildBackupJob(cfg, req, jobName, secretName, repository, dedupeEnabled, keepLast)
if nodeName, err := c.resolvePVCMountedNode(ctx, req.Namespace, req.PVC); err == nil && nodeName != "" {
job.Spec.Template.Spec.NodeName = nodeName
}
@ -272,7 +277,7 @@ func (c *Client) CreateRestoreJob(ctx context.Context, cfg *config.Config, req a
return jobName, secretName, nil
}
func buildBackupJob(cfg *config.Config, req api.BackupRequest, jobName, secretName, repository string, dedupeEnabled bool) *batchv1.Job {
func buildBackupJob(cfg *config.Config, req api.BackupRequest, jobName, secretName, repository string, dedupeEnabled bool, keepLast int) *batchv1.Job {
labels := map[string]string{
labelAppName: "soteria",
labelComponent: "backup",
@ -282,6 +287,7 @@ func buildBackupJob(cfg *config.Config, req api.BackupRequest, jobName, secretNa
annotations := map[string]string{
annotationResticRepository: repository,
annotationDedupeEnabled: strconv.FormatBool(dedupeEnabled),
annotationKeepLast: strconv.Itoa(keepLast),
}
command := backupCommand(cfg, req)
@ -431,8 +437,10 @@ func backupCommand(cfg *config.Config, req api.BackupRequest) string {
if !dedupeEnabled(req.Dedupe) {
mode = "off"
}
keepLast := keepLastWithDefault(req.KeepLast)
args := []string{
"restic", "backup", "/data",
"--host", "soteria",
"--tag", "soteria",
"--tag", fmt.Sprintf("pvc=%s", req.PVC),
"--tag", fmt.Sprintf("dedupe=%s", mode),
@ -447,7 +455,17 @@ func backupCommand(cfg *config.Config, req api.BackupRequest) string {
args = append(args, cfg.ResticBackupArgs...)
cmd := strings.Join(args, " ")
if len(cfg.ResticForgetArgs) > 0 {
if keepLast > 0 {
forget := strings.Join([]string{
"restic", "forget",
"--host", "soteria",
"--group-by", "host,tags",
"--tag", fmt.Sprintf("pvc=%s", req.PVC),
"--keep-last", strconv.Itoa(keepLast),
"--prune",
}, " ")
cmd = fmt.Sprintf("%s && %s", cmd, forget)
} else if len(cfg.ResticForgetArgs) > 0 {
forget := strings.Join(append([]string{"restic", "forget"}, cfg.ResticForgetArgs...), " ")
cmd = fmt.Sprintf("%s && %s", cmd, forget)
}
@ -573,6 +591,16 @@ func dedupeEnabled(raw *bool) bool {
return *raw
}
func keepLastWithDefault(raw *int) int {
if raw == nil {
return 0
}
if *raw < 0 {
return 0
}
return *raw
}
func resticRepositoryForBackup(base, namespace, pvc string, dedupe bool) string {
if dedupe {
return strings.TrimSpace(base)
@ -626,6 +654,17 @@ func parseBoolWithDefault(raw string, fallback bool) bool {
}
}
func parseIntWithDefault(raw string, fallback int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return fallback
}
if parsed < 0 {
return fallback
}
return parsed
}
func int32Ptr(val int32) *int32 {
return &val
}

View File

@ -82,6 +82,7 @@ const (
policySecretKey = "policies.json"
defaultPolicyHours = 24.0
maxPolicyIntervalHrs = 24 * 365
maxPolicyKeepLast = 1000
maxUsageSampleJobs = 20
resticSelectorPrefix = "restic-latest:"
)
@ -331,6 +332,11 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "namespace and pvc are required")
return
}
if err := validateKeepLast(req.KeepLast); err != nil {
s.metrics.RecordBackupRequest(s.cfg.BackupDriver, "validation_error")
writeError(w, http.StatusBadRequest, err.Error())
return
}
req.Namespace = strings.TrimSpace(req.Namespace)
req.PVC = strings.TrimSpace(req.PVC)
requester := currentRequester(r.Context())
@ -435,6 +441,11 @@ func (s *Server) handleNamespaceBackup(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "namespace is required")
return
}
if err := validateKeepLast(req.KeepLast); err != nil {
s.metrics.RecordNamespaceBackupRequest(s.cfg.BackupDriver, "validation_error")
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := validateKubernetesName("namespace", req.Namespace); err != nil {
s.metrics.RecordNamespaceBackupRequest(s.cfg.BackupDriver, "validation_error")
writeError(w, http.StatusBadRequest, err.Error())
@ -450,12 +461,14 @@ func (s *Server) handleNamespaceBackup(w http.ResponseWriter, r *http.Request) {
requester := currentRequester(r.Context())
resolvedDedupe := dedupeDefault(req.Dedupe)
resolvedKeepLast := keepLastDefault(req.KeepLast)
response := api.NamespaceBackupResponse{
Namespace: req.Namespace,
RequestedBy: requester,
Driver: s.cfg.BackupDriver,
DryRun: req.DryRun,
Dedupe: resolvedDedupe,
KeepLast: resolvedKeepLast,
Results: make([]api.NamespaceBackupResult, 0, len(pvcs)),
}
@ -465,6 +478,7 @@ func (s *Server) handleNamespaceBackup(w http.ResponseWriter, r *http.Request) {
PVC: pvc.Name,
DryRun: req.DryRun,
Dedupe: boolPtr(resolvedDedupe),
KeepLast: intPtr(resolvedKeepLast),
}
result, status, execErr := s.executeBackup(r.Context(), backupReq, requester)
s.metrics.RecordBackupRequest(s.cfg.BackupDriver, status)
@ -637,7 +651,9 @@ func (s *Server) executeBackup(ctx context.Context, req api.BackupRequest, reque
return api.BackupResponse{}, "validation_error", fmt.Errorf("namespace and pvc are required")
}
resolvedDedupe := dedupeDefault(req.Dedupe)
resolvedKeepLast := keepLastDefault(req.KeepLast)
req.Dedupe = boolPtr(resolvedDedupe)
req.KeepLast = intPtr(resolvedKeepLast)
switch s.cfg.BackupDriver {
case "longhorn":
@ -655,6 +671,7 @@ func (s *Server) executeBackup(ctx context.Context, req api.BackupRequest, reque
RequestedBy: requester,
DryRun: req.DryRun,
Dedupe: resolvedDedupe,
KeepLast: resolvedKeepLast,
}
if req.DryRun {
return response, "dry_run", nil
@ -689,6 +706,7 @@ func (s *Server) executeBackup(ctx context.Context, req api.BackupRequest, reque
RequestedBy: requester,
DryRun: req.DryRun,
Dedupe: resolvedDedupe,
KeepLast: resolvedKeepLast,
}, result, nil
default:
return api.BackupResponse{}, "unsupported_driver", fmt.Errorf("unsupported backup driver")
@ -1250,6 +1268,7 @@ func (s *Server) runPolicyCycle(ctx context.Context) {
type effectivePolicy struct {
IntervalHours float64
Dedupe bool
KeepLast int
}
effectivePolicies := map[string]effectivePolicy{}
for _, policy := range policies {
@ -1265,10 +1284,11 @@ func (s *Server) runPolicyCycle(ctx context.Context) {
for _, pvc := range matches {
key := pvc.Namespace + "/" + pvc.PVC
current, exists := effectivePolicies[key]
if !exists || policy.IntervalHours < current.IntervalHours {
if !exists || policy.IntervalHours < current.IntervalHours || (policy.IntervalHours == current.IntervalHours && keepLastStricter(policy.KeepLast, current.KeepLast)) {
effectivePolicies[key] = effectivePolicy{
IntervalHours: policy.IntervalHours,
Dedupe: policy.Dedupe,
KeepLast: policy.KeepLast,
}
}
}
@ -1289,6 +1309,7 @@ func (s *Server) runPolicyCycle(ctx context.Context) {
PVC: pvc.PVC,
DryRun: false,
Dedupe: boolPtr(effective.Dedupe),
KeepLast: intPtr(effective.KeepLast),
}, "policy-scheduler")
s.metrics.RecordBackupRequest(s.cfg.BackupDriver, result)
if err != nil {
@ -1333,6 +1354,7 @@ func (s *Server) loadPolicies(ctx context.Context) error {
IntervalHours float64 `json:"interval_hours"`
Enabled bool `json:"enabled"`
Dedupe *bool `json:"dedupe,omitempty"`
KeepLast *int `json:"keep_last,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
} `json:"policies"`
@ -1357,6 +1379,7 @@ func (s *Server) loadPolicies(ctx context.Context) error {
if policy.Dedupe != nil {
dedupe = *policy.Dedupe
}
keepLast := keepLastDefault(policy.KeepLast)
id := policyKey(namespace, pvc)
createdAt := policy.CreatedAt
if createdAt == "" {
@ -1373,6 +1396,7 @@ func (s *Server) loadPolicies(ctx context.Context) error {
IntervalHours: interval,
Enabled: policy.Enabled,
Dedupe: dedupe,
KeepLast: keepLast,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
@ -1457,6 +1481,10 @@ func (s *Server) upsertPolicy(ctx context.Context, req api.BackupPolicyUpsertReq
enabled = *req.Enabled
}
dedupe := dedupeDefault(req.Dedupe)
if err := validateKeepLast(req.KeepLast); err != nil {
return api.BackupPolicy{}, err
}
keepLast := keepLastDefault(req.KeepLast)
id := policyKey(namespace, pvc)
now := time.Now().UTC().Format(time.RFC3339)
@ -1474,6 +1502,7 @@ func (s *Server) upsertPolicy(ctx context.Context, req api.BackupPolicyUpsertReq
IntervalHours: interval,
Enabled: enabled,
Dedupe: dedupe,
KeepLast: keepLast,
CreatedAt: createdAt,
UpdatedAt: now,
}
@ -1917,3 +1946,44 @@ func boolPtr(value bool) *bool {
ptr := value
return &ptr
}
func keepLastDefault(value *int) int {
if value == nil {
return 0
}
if *value < 0 {
return 0
}
return *value
}
func intPtr(value int) *int {
ptr := value
return &ptr
}
func validateKeepLast(value *int) error {
if value == nil {
return nil
}
if *value < 0 {
return fmt.Errorf("keep_last must be >= 0")
}
if *value > maxPolicyKeepLast {
return fmt.Errorf("keep_last must be <= %d", maxPolicyKeepLast)
}
return nil
}
func keepLastStricter(candidate, current int) bool {
switch {
case candidate > 0 && current == 0:
return true
case candidate == 0:
return false
case current == 0:
return true
default:
return candidate < current
}
}

View File

@ -337,6 +337,9 @@ func TestResticBackupDefaultsDedupeEnabled(t *testing.T) {
if kube.lastBackupReq.Dedupe == nil || !*kube.lastBackupReq.Dedupe {
t.Fatalf("expected dedupe default true, got %#v", kube.lastBackupReq.Dedupe)
}
if kube.lastBackupReq.KeepLast == nil || *kube.lastBackupReq.KeepLast != 0 {
t.Fatalf("expected keep_last default 0, got %#v", kube.lastBackupReq.KeepLast)
}
var payload api.BackupResponse
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
@ -345,6 +348,9 @@ func TestResticBackupDefaultsDedupeEnabled(t *testing.T) {
if !payload.Dedupe {
t.Fatalf("expected response dedupe=true, got %#v", payload)
}
if payload.KeepLast != 0 {
t.Fatalf("expected response keep_last=0, got %#v", payload)
}
}
func TestResticInventoryUsesCompletedBackupJobs(t *testing.T) {
@ -672,6 +678,9 @@ func TestPoliciesCRUD(t *testing.T) {
if !created.Dedupe {
t.Fatalf("expected policy dedupe default true, got %#v", created)
}
if created.KeepLast != 0 {
t.Fatalf("expected policy keep_last default 0, got %#v", created)
}
listReq := httptest.NewRequest(http.MethodGet, "/v1/policies", nil)
listRes := httptest.NewRecorder()
@ -723,6 +732,9 @@ func TestLoadPoliciesDefaultsDedupeEnabledWhenMissing(t *testing.T) {
if !policies[0].Dedupe {
t.Fatalf("expected dedupe to default true for legacy policy, got %#v", policies[0])
}
if policies[0].KeepLast != 0 {
t.Fatalf("expected keep_last to default 0 for legacy policy, got %#v", policies[0])
}
}
func TestNamespaceBackupDryRun(t *testing.T) {
@ -760,6 +772,9 @@ func TestNamespaceBackupDryRun(t *testing.T) {
if payload.Total != 2 || payload.Succeeded != 2 || payload.Failed != 0 {
t.Fatalf("unexpected namespace backup payload: %#v", payload)
}
if payload.KeepLast != 0 {
t.Fatalf("expected namespace backup keep_last default 0, got %#v", payload)
}
}
func TestNamespaceBackupUsesDedupeFlag(t *testing.T) {
@ -780,7 +795,7 @@ func TestNamespaceBackupUsesDedupeFlag(t *testing.T) {
}
srv.handler = http.HandlerFunc(srv.route)
body := `{"namespace":"apps","dry_run":false,"dedupe":false}`
body := `{"namespace":"apps","dry_run":false,"dedupe":false,"keep_last":1}`
req := httptest.NewRequest(http.MethodPost, "/v1/backup/namespace", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
@ -797,9 +812,41 @@ func TestNamespaceBackupUsesDedupeFlag(t *testing.T) {
if payload.Dedupe {
t.Fatalf("expected response dedupe false, got %#v", payload)
}
if payload.KeepLast != 1 {
t.Fatalf("expected keep_last=1, got %#v", payload)
}
if kube.lastBackupReq.Dedupe == nil || *kube.lastBackupReq.Dedupe {
t.Fatalf("expected backup request dedupe false, got %#v", kube.lastBackupReq.Dedupe)
}
if kube.lastBackupReq.KeepLast == nil || *kube.lastBackupReq.KeepLast != 1 {
t.Fatalf("expected backup request keep_last=1, got %#v", kube.lastBackupReq.KeepLast)
}
}
func TestBackupRejectsNegativeKeepLast(t *testing.T) {
srv := &Server{
cfg: &config.Config{
AuthRequired: false,
BackupDriver: "restic",
},
client: &fakeKubeClient{},
longhorn: &fakeLonghornClient{},
metrics: newTelemetry(),
}
srv.handler = http.HandlerFunc(srv.route)
body := `{"namespace":"apps","pvc":"data","keep_last":-1}`
req := httptest.NewRequest(http.MethodPost, "/v1/backup", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
srv.Handler().ServeHTTP(res, req)
if res.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid keep_last, got %d: %s", res.Code, res.Body.String())
}
if !strings.Contains(res.Body.String(), "keep_last must be") {
t.Fatalf("expected keep_last validation message, got %s", res.Body.String())
}
}
func TestNamespaceRestoreDryRun(t *testing.T) {

View File

@ -68,6 +68,7 @@ interface BackupPolicy {
interval_hours: number;
enabled: boolean;
dedupe?: boolean;
keep_last?: number;
created_at?: string;
updated_at?: string;
}
@ -270,7 +271,9 @@ function App() {
const [policyIntervalHours, setPolicyIntervalHours] = useState<number>(24);
const [policyEnabled, setPolicyEnabled] = useState<boolean>(true);
const [policyDedupe, setPolicyDedupe] = useState<boolean>(true);
const [policyKeepLast, setPolicyKeepLast] = useState<number>(0);
const [manualDedupe, setManualDedupe] = useState<boolean>(true);
const [manualKeepLast, setManualKeepLast] = useState<number>(0);
const [lastAction, setLastAction] = useState<string>('No action yet.');
const [busy, setBusy] = useState<boolean>(false);
@ -404,7 +407,7 @@ function App() {
const payload = await fetchJSON<unknown>('/v1/backup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ namespace, pvc, dry_run: false, dedupe: manualDedupe })
body: JSON.stringify({ namespace, pvc, dry_run: false, dedupe: manualDedupe, keep_last: manualKeepLast })
});
writeAction(payload);
await Promise.all([loadInventory(), loadB2Usage()]);
@ -421,7 +424,7 @@ function App() {
const payload = await fetchJSON<unknown>('/v1/backup/namespace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ namespace, dry_run: false, dedupe: manualDedupe })
body: JSON.stringify({ namespace, dry_run: false, dedupe: manualDedupe, keep_last: manualKeepLast })
});
writeAction(payload);
await Promise.all([loadInventory(), loadB2Usage()]);
@ -534,7 +537,8 @@ function App() {
pvc: policyPVC,
interval_hours: policyIntervalHours,
enabled: policyEnabled,
dedupe: policyDedupe
dedupe: policyDedupe,
keep_last: policyKeepLast
})
});
writeAction(payload);
@ -599,6 +603,16 @@ function App() {
<input type="checkbox" checked={manualDedupe} onChange={(event) => setManualDedupe(event.target.checked)} />
Dedupe unchanged blocks (default)
</label>
<label>
Keep last snapshots per PVC (0 = keep all)
<input
type="number"
min={0}
max={1000}
value={manualKeepLast}
onChange={(event) => setManualKeepLast(Math.max(0, Math.min(1000, Number(event.target.value || 0))))}
/>
</label>
<p className="subtle tiny">This setting applies to both `Backup now` and `Backup namespace` actions.</p>
{inventoryError && <p className="error">{inventoryError}</p>}
{!inventory && !inventoryError && <p className="subtle">Loading inventory...</p>}
@ -820,7 +834,7 @@ function App() {
<section className="panel scroll-panel">
<h2>Backup Policies</h2>
<p className="subtle tiny">Policy backups create new restic snapshots. With dedupe on, unchanged blocks are reused in the shared repository. With dedupe off, Soteria isolates each PVC to its own repository path.</p>
<p className="subtle tiny">Policy backups create new restic snapshots. `Keep last` controls version retention per PVC: 1 means only newest copy remains after each run. With dedupe on, unchanged blocks are reused in the shared repository. With dedupe off, Soteria isolates each PVC to its own repository path.</p>
<div className="stack">
<label>
Namespace
@ -849,6 +863,16 @@ function App() {
<input type="checkbox" checked={policyDedupe} onChange={(event) => setPolicyDedupe(event.target.checked)} />
Dedupe unchanged blocks
</label>
<label>
Keep last snapshots per PVC (0 = keep all)
<input
type="number"
min={0}
max={1000}
value={policyKeepLast}
onChange={(event) => setPolicyKeepLast(Math.max(0, Math.min(1000, Number(event.target.value || 0))))}
/>
</label>
<button type="button" onClick={() => void savePolicy()} disabled={busy || !policyNamespace}>Save policy</button>
</div>
@ -861,7 +885,7 @@ function App() {
<strong>{policy.namespace}/{policy.pvc || '*'}</strong>
<span className={`chip ${policy.enabled ? 'good' : 'bad'}`}>{policy.enabled ? 'Enabled' : 'Disabled'}</span>
</div>
<p className="subtle tiny">Every {policy.interval_hours}h | Dedupe: {policy.dedupe === false ? 'off' : 'on'} | Updated {formatTimestamp(policy.updated_at || policy.created_at)}</p>
<p className="subtle tiny">Every {policy.interval_hours}h | Dedupe: {policy.dedupe === false ? 'off' : 'on'} | Keep last: {policy.keep_last ?? 0} | Updated {formatTimestamp(policy.updated_at || policy.created_at)}</p>
<div className="actions">
<button
type="button"
@ -872,6 +896,7 @@ function App() {
setPolicyIntervalHours(policy.interval_hours);
setPolicyEnabled(policy.enabled);
setPolicyDedupe(policy.dedupe !== false);
setPolicyKeepLast(policy.keep_last ?? 0);
}}
>
Load