ZFS: dedup toggle per pool and dedup option for new datasets
build / build (push) Successful in 1m4s

Pool cards show the dedup ratio and can set dedup=on|off on the pool root
dataset (inherited by children); the new-dataset form gets a dedup option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 09:30:38 +03:00
co-authored by Claude Opus 5.5
parent b965d2c9db
commit 0cdee29359
9 changed files with 209 additions and 15 deletions
+43 -6
View File
@@ -124,7 +124,9 @@ type zfsVM struct {
S sched.Status
Th config.Thresholds
Datasets []datasetRow
Form createForm // last submitted values (re-rendered after an error)
// PoolDedup maps a pool to the dedup property of its root dataset.
PoolDedup map[string]string
Form createForm // last submitted values (re-rendered after an error)
}
type datasetRow struct {
@@ -134,15 +136,18 @@ type datasetRow struct {
}
type createForm struct {
Pool, Name, Owner, Compression, Quota string
Pool, Name, Owner, Compression, Dedup, Quota string
}
func (s *Server) zfsView(st sched.Status, form createForm) zfsVM {
vm := zfsVM{S: st, Th: s.cfg.Get().Thresholds, Form: form}
vm := zfsVM{S: st, Th: s.cfg.Get().Thresholds, Form: form, PoolDedup: map[string]string{}}
if vm.Form.Owner == "" {
vm.Form.Owner = defaultOwner
}
for _, d := range st.Datasets {
if !strings.Contains(d.Name, "/") {
vm.PoolDedup[d.Name] = d.Dedup
}
row := datasetRow{Dataset: d, DomID: strings.NewReplacer("/", "-", ".", "_", ":", "_").Replace(d.Name)}
row.CanDestroy = strings.Contains(d.Name, "/") && d.Refer < zfsadm.EmptyRefer
for _, o := range st.Datasets {
@@ -170,6 +175,7 @@ func (s *Server) zfsCreate(w http.ResponseWriter, r *http.Request) {
Name: strings.Trim(strings.TrimSpace(r.FormValue("name")), "/"),
Owner: strings.TrimSpace(r.FormValue("owner")),
Compression: r.FormValue("compression"),
Dedup: r.FormValue("dedup"),
Quota: strings.ToUpper(strings.TrimSpace(r.FormValue("quota"))),
}
st := s.sch.Status()
@@ -191,20 +197,20 @@ func (s *Server) zfsCreate(w http.ResponseWriter, r *http.Request) {
return
}
}
for _, err := range []error{zfsadm.ValidateOwner(f.Owner), zfsadm.ValidateCompression(f.Compression), zfsadm.ValidateSize(f.Quota)} {
for _, err := range []error{zfsadm.ValidateOwner(f.Owner), zfsadm.ValidateCompression(f.Compression), zfsadm.ValidateDedup(f.Dedup), zfsadm.ValidateSize(f.Quota)} {
if err != nil {
fail(err.Error())
return
}
}
err := s.admin.Create(full, zfsadm.CreateOpts{Compression: f.Compression, Quota: f.Quota, Owner: f.Owner})
err := s.admin.Create(full, zfsadm.CreateOpts{Compression: f.Compression, Dedup: f.Dedup, Quota: f.Quota, Owner: f.Owner})
s.sch.RefreshStorage()
if err != nil {
log.Printf("zfs create %s: %v", full, err)
fail(err.Error())
return
}
log.Printf("dataset %s created (owner %s, compression %q, quota %q)", full, f.Owner, f.Compression, f.Quota)
log.Printf("dataset %s created (owner %s, compression %q, dedup %q, quota %q)", full, f.Owner, f.Compression, f.Dedup, f.Quota)
http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+full+" created")+"#"+url.PathEscape(f.Pool), http.StatusSeeOther)
}
@@ -243,6 +249,37 @@ func (s *Server) zfsDestroy(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+name+" destroyed"), http.StatusSeeOther)
}
// zfsDedup turns dedup on/off on a pool's root dataset (inherited by children).
func (s *Server) zfsDedup(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pool := strings.TrimSpace(r.FormValue("pool"))
value := r.FormValue("dedup")
st := s.sch.Status()
fail := func(msg string) {
s.render(w, r, "zfs", pageData{Title: "ZFS", Data: s.zfsView(st, createForm{}), Alerts: len(st.Alerts), Error: "Dedup not changed: " + msg})
}
if !hasPool(st, pool) {
fail("unknown pool " + pool)
return
}
if value != "on" && value != "off" {
fail("dedup must be on or off")
return
}
err := s.admin.SetDedup(pool, value)
s.sch.RefreshStorage()
if err != nil {
log.Printf("zfs set dedup=%s %s: %v", value, pool, err)
fail(err.Error())
return
}
log.Printf("dedup=%s set on pool %s", value, pool)
http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dedup "+value+" for "+pool)+"#"+url.PathEscape(pool), http.StatusSeeOther)
}
func hasPool(st sched.Status, name string) bool {
for _, p := range st.Pools {
if p.Name == name {
+1
View File
@@ -73,6 +73,7 @@ func New(cfg *config.Store, st *store.Store, ring *history.Ring, sch *sched.Sche
s.mux.HandleFunc("GET /zfs", s.zfs)
s.mux.HandleFunc("POST /zfs/create", s.zfsCreate)
s.mux.HandleFunc("POST /zfs/destroy", s.zfsDestroy)
s.mux.HandleFunc("POST /zfs/dedup", s.zfsDedup)
s.mux.HandleFunc("GET /alerts", s.alerts)
s.mux.HandleFunc("POST /alerts/{id}/{action}", s.alertAction)
s.mux.HandleFunc("GET /shares", s.sharesPage)
+23
View File
@@ -13,6 +13,7 @@
<div><span class="muted">fragmentation</span> {{.FragPct}}%</div>
<div><span class="muted">last scrub</span> {{if .LastScrub}}{{dtp .LastScrub}} ({{agop .LastScrub}}){{else}}never{{end}}</div>
<div><span class="muted">errors</span> {{if .Errors}}{{.Errors}}{{else}}—{{end}}</div>
<div><span class="muted">dedup</span> {{with index $.Data.PoolDedup .Name}}{{.}}{{else}}—{{end}}{{if .DedupRatio}} (ratio {{printf "%.2f" .DedupRatio}}x){{end}}</div>
</div>
<div class="bar"><span class="fill {{capClass .CapPct $.Data.Th.PoolCapacityPct 95}}" style="width:{{.CapPct}}%"></span></div>
{{if .Scan}}<p class="small"><span class="muted">scan:</span> {{.Scan}}</p>{{end}}
@@ -31,6 +32,21 @@
{{end}}
</tbody>
</table></div>
{{$dedup := index $.Data.PoolDedup .Name}}
<details class="dedup dedup-{{.Name}}">
<summary>Deduplication</summary>
<form method="post" action="/zfs/dedup" class="inline">
<input type="hidden" name="pool" value="{{.Name}}">
<label>Dedup for {{.Name}}
<select name="dedup">
<option value="off" {{if or (eq $dedup "off") (eq $dedup "")}}selected{{end}}>off</option>
<option value="on" {{if and (ne $dedup "off") (ne $dedup "")}}selected{{end}}>on</option>
</select>
</label>
<button type="submit">Apply</button>
</form>
<p class="small muted">Sets dedup on the pool root dataset; child datasets inherit it unless they override it. Only newly written data is deduplicated, and turning it off keeps the existing dedup table. The dedup table needs a lot of RAM (up to ~5 GB per 1 TB of unique data) &mdash; enable it only for data with many duplicate blocks.</p>
</details>
<details class="newds newds-{{.Name}}" {{if eq $form.Pool .Name}}open{{end}}>
<summary>New dataset in {{.Name}}</summary>
<form method="post" action="/zfs/create" class="ds-form">
@@ -46,6 +62,13 @@
<option value="off" {{if eq $form.Compression "off"}}selected{{end}}>off</option>
</select>
</label>
<label>Dedup
<select name="dedup">
<option value="" {{if eq $form.Dedup ""}}selected{{end}}>inherit</option>
<option value="on" {{if eq $form.Dedup "on"}}selected{{end}}>on</option>
<option value="off" {{if eq $form.Dedup "off"}}selected{{end}}>off</option>
</select>
</label>
<label>Quota <input name="quota" placeholder="none, or 100G / 1.5T" value="{{if eq $form.Pool .Name}}{{$form.Quota}}{{end}}"></label>
</div>
<p class="small muted">Creates {{.Name}}/&lt;name&gt; (parents are created too), mounts it at /{{.Name}}/&lt;name&gt; and chowns the mountpoint to the owner.</p>
+44 -5
View File
@@ -68,12 +68,12 @@ func newTestServer(t *testing.T) (*Server, *sched.Scheduler, *store.Store) {
},
Pools: []collect.Pool{
{Name: "tank", Health: "DEGRADED", Size: 8 << 40, Alloc: 3 << 40, Free: 5 << 40, CapPct: 37, FragPct: 5, Scan: "scrub repaired 0B", LastScrub: &old,
Status: "One or more devices could not be opened.", Action: "Attach the missing device", Errors: "No known data errors",
Status: "One or more devices could not be opened.", Action: "Attach the missing device", Errors: "No known data errors", DedupRatio: 1.42,
Vdevs: []collect.Vdev{{Name: "tank", State: "DEGRADED"}, {Name: "mirror-0", Depth: 1, State: "DEGRADED"}, {Name: "sda", Depth: 2, State: "ONLINE", Cksum: 2}, {Name: "123", Depth: 2, State: "UNAVAIL", Note: "was /dev/sdb1"}}},
{Name: "tmp_pool", Health: "ONLINE", Size: 1 << 40, Alloc: 900 << 30, Free: 124 << 30, CapPct: 88, Scrubbing: true, Scan: "scrub in progress"},
},
Datasets: []collect.Dataset{
{Name: "tank", Used: 3 << 40, Avail: 5 << 40, Refer: 1 << 20, Mountpoint: "/tank"},
{Name: "tank", Used: 3 << 40, Avail: 5 << 40, Refer: 1 << 20, Mountpoint: "/tank", Dedup: "on"},
{Name: "tank/empty", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/empty"},
{Name: "tank/data", Used: 3 << 40, Avail: 5 << 40, Refer: 3 << 40, Mountpoint: "/tank/data"},
{Name: "tank/parent", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/parent"},
@@ -245,7 +245,7 @@ func TestZfsPageControls(t *testing.T) {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/zfs?msg=hello", nil))
body := rec.Body.String()
for _, want := range []string{"New dataset in tank", "New dataset in tmp_pool", "hello", `value="1000:1000"`, "type tank/empty to confirm"} {
for _, want := range []string{"New dataset in tank", "New dataset in tmp_pool", "hello", `value="1000:1000"`, "type tank/empty to confirm", `action="/zfs/dedup"`, "Dedup for tmp_pool", "ratio 1.42x"} {
if !strings.Contains(body, want) {
t.Errorf("zfs page missing %q", want)
}
@@ -274,6 +274,7 @@ func TestZfsCreate(t *testing.T) {
{"pool": {"tank"}, "name": {"x"}, "quota": {"lots"}}, // bad quota
{"pool": {"tank"}, "name": {"x"}, "owner": {"ansible"}}, // bad owner
{"pool": {"tank"}, "name": {"x"}, "compression": {"gzip"}},
{"pool": {"tank"}, "name": {"x"}, "dedup": {"verify"}},
}
for _, form := range bad {
f.calls = nil
@@ -289,11 +290,11 @@ func TestZfsCreate(t *testing.T) {
}
}
rec := postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"/media/video/"}, "owner": {"1000:1000"}, "compression": {"lz4"}, "quota": {"100g"}})
rec := postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"/media/video/"}, "owner": {"1000:1000"}, "compression": {"lz4"}, "dedup": {"on"}, "quota": {"100g"}})
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "created") {
t.Fatalf("status %d location %q body %s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
want := "zfs create -p -o compression=lz4 -o quota=100G tank/media/video"
want := "zfs create -p -o compression=lz4 -o dedup=on -o quota=100G tank/media/video"
if len(f.calls) != 3 || f.calls[0] != want || f.calls[2] != "chown 1000:1000 /tank/media/video" {
t.Errorf("calls = %v", f.calls)
}
@@ -307,6 +308,44 @@ func TestZfsCreate(t *testing.T) {
}
}
func TestZfsDedup(t *testing.T) {
srv, _, _ := newTestServer(t)
f := &fakeRunner{}
srv.admin = zfsadm.New(f.run)
bad := []url.Values{
{"pool": {"nope"}, "dedup": {"on"}}, // unknown pool
{"pool": {"tank/data"}, "dedup": {"on"}}, // not a pool
{"pool": {"tank"}, "dedup": {"verify"}}, // unsupported value
{"pool": {"tank"}}, // missing value
}
for _, form := range bad {
f.calls = nil
rec := postForm(srv, "/zfs/dedup", form)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Dedup not changed") {
t.Errorf("%v: status %d, no error shown", form, rec.Code)
}
if len(f.calls) != 0 {
t.Errorf("%v: commands ran: %v", form, f.calls)
}
}
rec := postForm(srv, "/zfs/dedup", url.Values{"pool": {"tmp_pool"}, "dedup": {"on"}})
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "#tmp_pool") {
t.Fatalf("status %d location %q", rec.Code, rec.Header().Get("Location"))
}
if len(f.calls) != 1 || f.calls[0] != "zfs set dedup=on tmp_pool" {
t.Errorf("calls = %v", f.calls)
}
f = &fakeRunner{fail: "zfs set"}
srv.admin = zfsadm.New(f.run)
rec = postForm(srv, "/zfs/dedup", url.Values{"pool": {"tank"}, "dedup": {"off"}})
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "cannot do that") {
t.Errorf("failure not reported: %d", rec.Code)
}
}
func TestZfsDestroy(t *testing.T) {
srv, _, _ := newTestServer(t)
f := &fakeRunner{}