kanban

Kanban client in Gio
Log | Files | Refs | README | LICENSE

commit f5d0cbf64a6cc63579130ab6099e131b00fcdea6
parent ceeb4b1100656117d3827dd80520d2c626e96dcb
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date:   Sun, 11 Apr 2021 17:44:04 +0800

fix: correctly define equality between projects

reflect.DeepEqual was not testing projects appropriately, resulting in excessive disk writes.

Diffstat:
Mcmd/kanban/main.go | 4++--
Mcmd/kanban/ui.go | 4+++-
Mgo.mod | 12++++++------
Mgo.sum | 28++++++++++++++--------------
Mkanban.go | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mstorage/lazy/lazy.go | 111++++++++++++++++++++++---------------------------------------------------------
Mstorage/mem/mem.go | 96++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
7 files changed, 165 insertions(+), 140 deletions(-)

diff --git a/cmd/kanban/main.go b/cmd/kanban/main.go @@ -14,7 +14,7 @@ import ( "gioui.org/unit" "gioui.org/widget/material" "git.sr.ht/~jackmordaunt/kanban/storage" - "git.sr.ht/~jackmordaunt/kanban/storage/bolt" + "git.sr.ht/~jackmordaunt/kanban/storage/lazy" "gioui.org/app" ) @@ -41,7 +41,7 @@ func main() { } db := filepath.Join(data, "kanban.db") fmt.Printf("%s\n", db) - return bolt.Open(db) + return lazy.Open(db) }() if err != nil { log.Fatalf("storage driver: %v\n", err) diff --git a/cmd/kanban/ui.go b/cmd/kanban/ui.go @@ -572,7 +572,9 @@ func (ui *UI) Save() { // Remove any zeroed out projects because they don't exist anymore. for ii, p := range ui.Projects { if p.ID == uuid.Nil { - ui.Projects = append(ui.Projects[:ii], ui.Projects[ii+1:]...) + if len(ui.Projects) > 1 { + ui.Projects = append(ui.Projects[:ii], ui.Projects[ii+1:]...) + } } } } diff --git a/go.mod b/go.mod @@ -3,14 +3,14 @@ module git.sr.ht/~jackmordaunt/kanban go 1.15 require ( - gioui.org v0.0.0-20210210205757-7286b075e2fb - gioui.org/x v0.0.0-20210120222453-b55819bc712b + gioui.org v0.0.0-20210410094005-495c69018772 + gioui.org/x v0.0.0-20210405014033-ab05db36ed5b github.com/boltdb/bolt v1.3.1 github.com/google/uuid v1.2.0 github.com/pkg/profile v1.5.0 github.com/spf13/pflag v1.0.5 - golang.org/x/exp v0.0.0-20210212053707-62dc52270d37 - golang.org/x/image v0.0.0-20201208152932-35266b937fa6 // indirect - golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect - golang.org/x/text v0.3.5 // indirect + golang.org/x/exp v0.0.0-20210405174845-4513512abef3 + golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb // indirect + golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57 // indirect + golang.org/x/text v0.3.6 // indirect ) diff --git a/go.sum b/go.sum @@ -1,10 +1,10 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -gioui.org v0.0.0-20210116085804-99bfa6a33cdf/go.mod h1:Y+uS7hHMvku1Q+ooaoq6fYD5B2LGoT8JtFgvmYmRzTw= -gioui.org v0.0.0-20210210205757-7286b075e2fb h1:UKF3b0qSQED+HO1weY+H4GpYJALVn3suJxkavMgNDVQ= -gioui.org v0.0.0-20210210205757-7286b075e2fb/go.mod h1:Y+uS7hHMvku1Q+ooaoq6fYD5B2LGoT8JtFgvmYmRzTw= -gioui.org/x v0.0.0-20210120222453-b55819bc712b h1:C7/1fHOBkcypkgWopz8Mf+eDJggTWQWlL315KjhDVDQ= -gioui.org/x v0.0.0-20210120222453-b55819bc712b/go.mod h1:+CDNGkslHDIe1EK2Q4E0G98qRllCaQrL6qtjBhBjSAo= +gioui.org v0.0.0-20210402191542-ce7f0da06ee3/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8= +gioui.org v0.0.0-20210410094005-495c69018772 h1:QpGMsubuP4JVlZj5VvvFfRikbLDN0QJj71oX9ABeaqM= +gioui.org v0.0.0-20210410094005-495c69018772/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8= +gioui.org/x v0.0.0-20210405014033-ab05db36ed5b h1:F5y0TDqXReud0BBl+SPv9c5wMlTM4rr1+gwxDtmuiO8= +gioui.org/x v0.0.0-20210405014033-ab05db36ed5b/go.mod h1:qsAS5EBzGhn3sJ98FJdA+ae+E5/DgmxjySERjjR1Zvg= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= @@ -23,14 +23,14 @@ golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE= golang.org/x/exp v0.0.0-20201229011636-eab1b5eb1a03/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4= -golang.org/x/exp v0.0.0-20210212053707-62dc52270d37 h1:8LMx3JFCswBZrnLWtxzpogDG5g1Hb7KWy/16Msz0hQk= -golang.org/x/exp v0.0.0-20210212053707-62dc52270d37/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4= +golang.org/x/exp v0.0.0-20210405174845-4513512abef3 h1:ZsldXBaaFMK70l0+CbgvsHOcjhgd9LzPhePQIsm5aS4= +golang.org/x/exp v0.0.0-20210405174845-4513512abef3/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20201208152932-35266b937fa6 h1:nfeHNc1nAqecKCy2FCy4HY+soOOe5sDLJ/gZLbx6GYI= -golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb h1:fqpd0EBDzlHRCjiphRR5Zo/RSWWQlWv34418dnEixWk= +golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= @@ -46,13 +46,13 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210304124612-50617c2ba197/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57 h1:F5Gozwx4I1xtr/sr/8CFbb57iKi3297KFs0QDbGN60A= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= diff --git a/kanban.go b/kanban.go @@ -284,3 +284,53 @@ func (p *Project) String() string { } return fmt.Sprintf("%v", *p) } + +// Clone a project ensuring all data is copied. +func (p Project) Clone() Project { + var ( + stages = make([]Stage, len(p.Stages)) + finalized = make([]Ticket, len(p.Finalized)) + ) + copy(finalized, p.Finalized) + for ii, s := range p.Stages { + tickets := make([]Ticket, len(s.Tickets)) + copy(tickets, s.Tickets) + stages[ii] = Stage{ + Name: s.Name, + Tickets: tickets, + } + } + return Project{ + ID: p.ID, + Name: p.Name, + Stages: stages, + Finalized: finalized, + } +} + +func (p *Project) Eq(other *Project) bool { + return p.ID == other.ID && + p.Name == other.Name && + p.Stages.Eq(other.Stages) +} + +func (s Stages) Eq(other Stages) bool { + for ii := range s { + if !s[ii].Eq(other[ii]) { + return false + } + } + return true +} + +func (s Stage) Eq(other Stage) bool { + if len(s.Tickets) != len(other.Tickets) { + return false + } + for ii, t := range s.Tickets { + if t != other.Tickets[ii] { + return false + } + } + return s.Name == other.Name +} diff --git a/storage/lazy/lazy.go b/storage/lazy/lazy.go @@ -4,7 +4,6 @@ package lazy import ( "fmt" - "reflect" "git.sr.ht/~jackmordaunt/kanban" "git.sr.ht/~jackmordaunt/kanban/storage" @@ -16,9 +15,15 @@ import ( var _ storage.Storer = (*Storer)(nil) // Storer writes to disk when a change has been detected. +// +// Reads come directly from disk because it is memory mapped. +// Cache simply holds old values so that we can detect for changes +// to know when to write data. +// +// Writes get flushed to disk, hence that is the work being minimized. type Storer struct { Cache *mem.Storer - Disk *bolt.Storer + *bolt.Storer } // Open a lazy storer, initializing the underlying database at the path @@ -29,123 +34,69 @@ func Open(path string) (*Storer, error) { return nil, err } s := Storer{ - Cache: mem.New(), - Disk: disk, + Cache: mem.New(), + Storer: disk, } - return &s, s.Populate() -} - -// Create a project. Saves to disk. -func (s *Storer) Create(p kanban.Project) error { - if err := s.Cache.Create(p); err != nil { - return fmt.Errorf("creating on disk: %w", err) - } - if err := s.Disk.Create(p); err != nil { - return fmt.Errorf("creating on disk: %w", err) + if err := s.Populate(); err != nil { + return nil, err } - return nil + return &s, nil } // Save a project. Only saves to disk if changed. func (s *Storer) Save(projects ...kanban.Project) error { + var save []kanban.Project for _, p := range projects { old, ok, err := s.Cache.Find(p.ID) if err != nil { return err } if !ok { - return fmt.Errorf("project does not exist: %q", p.Name) + s.Cache.Active.Add(p) + continue + } + if !p.Eq(&old) { + save = append(save, p) } - if !reflect.DeepEqual(p, old) { - if err := s.Disk.Save(p); err != nil { - return fmt.Errorf("saving to disk: %w", err) - } - return s.Refresh(p.ID) + } + if len(save) > 0 { + if err := s.Storer.Save(save...); err != nil { + return fmt.Errorf("saving to disk: %w", err) } + return s.Populate() } return nil } -// Load a project by ID. -// Bool indicates whether a project exists for that ID. -func (s *Storer) Find(id uuid.UUID) (kanban.Project, bool, error) { - return s.Cache.Find(id) -} - -// List projects. -func (s *Storer) List() ([]kanban.Project, error) { - return s.Cache.List() -} - // Refresh a project entity by loading from disk. func (s *Storer) Refresh(id uuid.UUID) error { - p, ok, err := s.Disk.Find(id) + p, ok, err := s.Storer.Find(id) if err != nil { return fmt.Errorf("loading from disk: %w", err) } if !ok { return fmt.Errorf("project does not exist: %v", id) } - return s.Cache.Save(p) + s.Cache.Active.Add(p) + return nil } // Populate cache from disk. func (s *Storer) Populate() error { - projects, err := s.Disk.List() + s.Cache.Clear() + projects, err := s.Storer.List() if err != nil { return fmt.Errorf("loading projects from disk: %w", err) } for _, p := range projects { - if err := s.Cache.Create(p); err != nil { - return fmt.Errorf("saving project to cache: %w", err) - } + s.Cache.Active.Add(p) } - archived, err := s.Disk.ListArchived() + archived, err := s.Storer.ListArchived() if err != nil { return fmt.Errorf("loading archived projects from disk: %w", err) } for _, p := range archived { - if err := func() error { - if err := s.Cache.Create(p); err != nil { - return err - } - if err := s.Cache.Archive(p.ID); err != nil { - return err - } - return nil - }(); err != nil { - return fmt.Errorf("saving archived projects to cache: %w", err) - } + s.Cache.Archived.Add(p) } return nil } - -func (s *Storer) Load(projects []kanban.Project) error { - return s.Cache.Load(projects) -} - -func (s *Storer) Close() error { - return s.Disk.DB.Close() -} - -func (s *Storer) Count() (int, error) { - return s.Cache.Count() -} - -func (s *Storer) Archive(id uuid.UUID) error { - if err := s.Disk.Archive(id); err != nil { - return err - } - return s.Populate() -} - -func (s *Storer) Restore(id uuid.UUID) error { - if err := s.Disk.Restore(id); err != nil { - return err - } - return s.Populate() -} - -func (s *Storer) ListArchived() ([]kanban.Project, error) { - return s.Cache.ListArchived() -} diff --git a/storage/mem/mem.go b/storage/mem/mem.go @@ -12,32 +12,38 @@ import ( // Storer implements in-memory storage for Projects. type Storer struct { - Data map[uuid.UUID]kanban.Project - Order []uuid.UUID - Archived map[uuid.UUID]kanban.Project - ArchivedOrder []uuid.UUID + Active Bucket + Archived Bucket +} + +type Bucket struct { + Data map[uuid.UUID]kanban.Project + Order []uuid.UUID } func New() *Storer { return &Storer{ - Data: make(map[uuid.UUID]kanban.Project), - Archived: make(map[uuid.UUID]kanban.Project), + Active: Bucket{ + Data: make(map[uuid.UUID]kanban.Project), + }, + Archived: Bucket{ + Data: make(map[uuid.UUID]kanban.Project), + }, } } func (s *Storer) Create(p kanban.Project) error { - if _, ok := s.Data[p.ID]; ok { + if _, ok := s.Active.Data[p.ID]; ok { return fmt.Errorf("project %q exists", p.Name) } - s.Data[p.ID] = p - s.Order = append(s.Order, p.ID) + s.Active.Add(p) return nil } func (s *Storer) Save(projects ...kanban.Project) error { for _, p := range projects { - if _, ok := s.Data[p.ID]; ok { - s.Data[p.ID] = p + if _, ok := s.Active.Data[p.ID]; ok { + s.Active.Data[p.ID] = p.Clone() } else { return fmt.Errorf("project %q does not exist", p.Name) } @@ -45,61 +51,77 @@ func (s *Storer) Save(projects ...kanban.Project) error { return nil } -func (s *Storer) Find(id uuid.UUID) (kanban.Project, bool, error) { - for _, p := range s.Data { - if p.ID == id { - return s.Data[p.ID], true, nil - } - } - return kanban.Project{}, false, nil +func (s *Storer) Find(id uuid.UUID) (p kanban.Project, ok bool, err error) { + p, ok = s.Active.Data[id] + return p.Clone(), ok, nil } func (s *Storer) Count() (int, error) { - return len(s.Data), nil + return len(s.Active.Data), nil } func (s *Storer) List() (list []kanban.Project, err error) { - for _, id := range s.Order { - if p, ok := s.Data[id]; ok { - list = append(list, p) - } - } - return list, nil + return s.Active.List(), nil } func (s *Storer) Load(projects []kanban.Project) error { for ii := range projects { - p := s.Data[projects[ii].ID] - projects[ii] = p + projects[ii] = s.Active.Data[projects[ii].ID].Clone() } return nil } func (s *Storer) Archive(id uuid.UUID) error { - p, ok := s.Data[id] + p, ok := s.Active.Data[id] if !ok { return nil } - delete(s.Data, id) - s.Archived[id] = p + s.Active.Delete(id) + s.Archived.Add(p) return nil } func (s *Storer) Restore(id uuid.UUID) error { - p, ok := s.Archived[id] + p, ok := s.Archived.Data[id] if !ok { return nil } - delete(s.Archived, id) - s.Data[id] = p + s.Archived.Delete(id) + s.Active.Add(p) return nil } func (s *Storer) ListArchived() (list []kanban.Project, err error) { - for _, id := range s.ArchivedOrder { - if p, ok := s.Archived[id]; ok { - list = append(list, p) + return s.Archived.List(), nil +} + +func (s *Storer) Clear() { + s.Active = Bucket{ + Data: make(map[uuid.UUID]kanban.Project), + } + s.Archived = Bucket{ + Data: make(map[uuid.UUID]kanban.Project), + } +} + +func (b *Bucket) Add(p kanban.Project) { + b.Data[p.ID] = p + b.Order = append(b.Order, p.ID) +} + +func (b *Bucket) Delete(id uuid.UUID) { + delete(b.Data, id) + for ii := range b.Order { + if b.Order[ii] == id { + b.Order = append(b.Order[:ii], b.Order[ii+1:]...) + break } } - return list, nil +} + +func (b *Bucket) List() (list []kanban.Project) { + for _, id := range b.Order { + list = append(list, b.Data[id]) + } + return list }