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:
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
}