From ee79b42efac9d4c28a19afd6898b2e258d536600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Tue, 27 Dec 2022 15:08:56 +0100 Subject: [PATCH] board: cache layer --- cache/board_cache.go | 106 +++++++++++++++++++++++++++ cache/board_excerpt.go | 72 ++++++++++++++++++ cache/board_subcache.go | 78 ++++++++++++++++++++ cache/repo_cache.go | 11 +++ entities/board/board.go | 27 +++++-- entities/board/board_actions.go | 23 +----- entities/board/op_add_item_draft.go | 6 +- entities/board/op_add_item_entity.go | 6 +- entities/board/op_create.go | 9 ++- entities/board/op_set_description.go | 5 +- entities/board/op_set_title.go | 5 +- entities/board/snapshot.go | 43 ++++++++++- misc/random_bugs/cmd/main.go | 2 + 13 files changed, 350 insertions(+), 43 deletions(-) create mode 100644 cache/board_cache.go create mode 100644 cache/board_excerpt.go create mode 100644 cache/board_subcache.go diff --git a/cache/board_cache.go b/cache/board_cache.go new file mode 100644 index 0000000000000000000000000000000000000000..fbae731756b3446f375ad8b2afbe2762a03e183e --- /dev/null +++ b/cache/board_cache.go @@ -0,0 +1,106 @@ +package cache + +import ( + "time" + + "github.com/MichaelMure/git-bug/entities/board" + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/repository" +) + +// BoardCache is a wrapper around a Board. It provides multiple functions: +// +// 1. Provide a higher level API to use than the raw API from Board. +// 2. Maintain an up-to-date Snapshot available. +// 3. Deal with concurrency. +type BoardCache struct { + CachedEntityBase[*board.Snapshot, board.Operation] +} + +func NewBoardCache(b *board.Board, repo repository.ClockedRepo, getUserIdentity getUserIdentityFunc, entityUpdated func(id entity.Id) error) *BoardCache { + return &BoardCache{ + CachedEntityBase: CachedEntityBase[*board.Snapshot, board.Operation]{ + repo: repo, + entityUpdated: entityUpdated, + getUserIdentity: getUserIdentity, + entity: &withSnapshot[*board.Snapshot, board.Operation]{Interface: b}, + }, + } +} + +func (c *BoardCache) AddItemDraft(columnId entity.Id, title, message string, files []repository.Hash) (entity.CombinedId, *board.AddItemDraftOperation, error) { + author, err := c.getUserIdentity() + if err != nil { + return entity.UnsetCombinedId, nil, err + } + + return c.AddItemDraftRaw(author, time.Now().Unix(), columnId, title, message, files, nil) +} + +func (c *BoardCache) AddItemDraftRaw(author identity.Interface, unixTime int64, columnId entity.Id, title, message string, files []repository.Hash, metadata map[string]string) (entity.CombinedId, *board.AddItemDraftOperation, error) { + c.mu.Lock() + itemId, op, err := board.AddItemDraft(c.entity, author, unixTime, columnId, title, message, files, metadata) + c.mu.Unlock() + if err != nil { + return entity.UnsetCombinedId, nil, err + } + return itemId, op, c.notifyUpdated() +} + +func (c *BoardCache) AddItemEntity(columnId entity.Id, e entity.Interface) (entity.CombinedId, *board.AddItemEntityOperation, error) { + author, err := c.getUserIdentity() + if err != nil { + return entity.UnsetCombinedId, nil, err + } + + return c.AddItemEntityRaw(author, time.Now().Unix(), columnId, e, nil) +} + +func (c *BoardCache) AddItemEntityRaw(author identity.Interface, unixTime int64, columnId entity.Id, e entity.Interface, metadata map[string]string) (entity.CombinedId, *board.AddItemEntityOperation, error) { + c.mu.Lock() + itemId, op, err := board.AddItemEntity(c.entity, author, unixTime, columnId, e, metadata) + c.mu.Unlock() + if err != nil { + return entity.UnsetCombinedId, nil, err + } + return itemId, op, c.notifyUpdated() +} + +func (c *BoardCache) SetDescription(description string) (*board.SetDescriptionOperation, error) { + author, err := c.getUserIdentity() + if err != nil { + return nil, err + } + + return c.SetDescriptionRaw(author, time.Now().Unix(), description, nil) +} + +func (c *BoardCache) SetDescriptionRaw(author identity.Interface, unixTime int64, description string, metadata map[string]string) (*board.SetDescriptionOperation, error) { + c.mu.Lock() + op, err := board.SetDescription(c.entity, author, unixTime, description, metadata) + c.mu.Unlock() + if err != nil { + return nil, err + } + return op, c.notifyUpdated() +} + +func (c *BoardCache) SetTitle(title string) (*board.SetTitleOperation, error) { + author, err := c.getUserIdentity() + if err != nil { + return nil, err + } + + return c.SetTitleRaw(author, time.Now().Unix(), title, nil) +} + +func (c *BoardCache) SetTitleRaw(author identity.Interface, unixTime int64, title string, metadata map[string]string) (*board.SetTitleOperation, error) { + c.mu.Lock() + op, err := board.SetTitle(c.entity, author, unixTime, title, metadata) + c.mu.Unlock() + if err != nil { + return nil, err + } + return op, c.notifyUpdated() +} diff --git a/cache/board_excerpt.go b/cache/board_excerpt.go new file mode 100644 index 0000000000000000000000000000000000000000..bb7981d989cc11ab53f31f4ad1ae7f7df74a5a3c --- /dev/null +++ b/cache/board_excerpt.go @@ -0,0 +1,72 @@ +package cache + +import ( + "encoding/gob" + "time" + + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/util/lamport" +) + +// Package initialisation used to register the type for (de)serialization +func init() { + gob.Register(BoardExcerpt{}) +} + +var _ Excerpt = &BoardExcerpt{} + +// BoardExcerpt hold a subset of the board values to be able to sort and filter boards +// efficiently without having to read and compile each raw boards. +type BoardExcerpt struct { + id entity.Id + + CreateLamportTime lamport.Time + EditLamportTime lamport.Time + CreateUnixTime int64 + EditUnixTime int64 + + Title string + Description string + ItemCount int + Actors []entity.Id + + CreateMetadata map[string]string +} + +func NewBoardExcerpt(b *BoardCache) *BoardExcerpt { + snap := b.Snapshot() + + actorsIds := make([]entity.Id, 0, len(snap.Actors)) + for _, actor := range snap.Actors { + actorsIds = append(actorsIds, actor.Id()) + } + + return &BoardExcerpt{ + id: b.Id(), + CreateLamportTime: b.CreateLamportTime(), + EditLamportTime: b.EditLamportTime(), + CreateUnixTime: b.FirstOp().Time().Unix(), + EditUnixTime: snap.EditTime().Unix(), + Title: snap.Title, + Description: snap.Description, + ItemCount: snap.ItemCount(), + Actors: actorsIds, + CreateMetadata: b.FirstOp().AllMetadata(), + } +} + +func (b *BoardExcerpt) Id() entity.Id { + return b.id +} + +func (b *BoardExcerpt) setId(id entity.Id) { + b.id = id +} + +func (b *BoardExcerpt) CreateTime() time.Time { + return time.Unix(b.CreateUnixTime, 0) +} + +func (b *BoardExcerpt) EditTime() time.Time { + return time.Unix(b.EditUnixTime, 0) +} diff --git a/cache/board_subcache.go b/cache/board_subcache.go new file mode 100644 index 0000000000000000000000000000000000000000..db1aff261c0a8019d6c91d48125c9ee8940c4713 --- /dev/null +++ b/cache/board_subcache.go @@ -0,0 +1,78 @@ +package cache + +import ( + "time" + + "github.com/MichaelMure/git-bug/entities/board" + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/repository" +) + +type RepoCacheBoard struct { + *SubCache[*board.Board, *BoardExcerpt, *BoardCache] +} + +func NewRepoCacheBoard(repo repository.ClockedRepo, + resolvers func() entity.Resolvers, + getUserIdentity getUserIdentityFunc) *RepoCacheBoard { + + makeCached := func(b *board.Board, entityUpdated func(id entity.Id) error) *BoardCache { + return NewBoardCache(b, repo, getUserIdentity, entityUpdated) + } + + makeIndexData := func(b *BoardCache) []string { + // no indexing + return nil + } + + actions := Actions[*board.Board]{ + ReadWithResolver: board.ReadWithResolver, + ReadAllWithResolver: board.ReadAllWithResolver, + Remove: board.Remove, + MergeAll: board.MergeAll, + } + + sc := NewSubCache[*board.Board, *BoardExcerpt, *BoardCache]( + repo, resolvers, getUserIdentity, + makeCached, NewBoardExcerpt, makeIndexData, actions, + board.Typename, board.Namespace, + formatVersion, defaultMaxLoadedBugs, + ) + + return &RepoCacheBoard{SubCache: sc} +} + +func (c *RepoCacheBoard) New(title, description string, columns []string) (*BoardCache, *board.CreateOperation, error) { + author, err := c.getUserIdentity() + if err != nil { + return nil, nil, err + } + + return c.NewRaw(author, time.Now().Unix(), title, description, columns, nil) +} + +func (c *RepoCacheBoard) NewDefaultColumns(title, description string) (*BoardCache, *board.CreateOperation, error) { + return c.New(title, description, board.DefaultColumns) +} + +// NewRaw create a new board with the given title, description and columns. +// The new board is written in the repository (commit). +func (c *RepoCacheBoard) NewRaw(author identity.Interface, unixTime int64, title, description string, columns []string, metadata map[string]string) (*BoardCache, *board.CreateOperation, error) { + b, op, err := board.Create(author, unixTime, title, description, columns, metadata) + if err != nil { + return nil, nil, err + } + + err = b.Commit(c.repo) + if err != nil { + return nil, nil, err + } + + cached, err := c.add(b) + if err != nil { + return nil, nil, err + } + + return cached, op, nil +} diff --git a/cache/repo_cache.go b/cache/repo_cache.go index eb441d9e6acc2d2843030aa19034584787235004..acecabf3f279b4208c93627d114df90fa84db46a 100644 --- a/cache/repo_cache.go +++ b/cache/repo_cache.go @@ -62,6 +62,7 @@ type RepoCache struct { // resolvers for all known entities and excerpts resolvers entity.Resolvers + boards *RepoCacheBoard bugs *RepoCacheBug identities *RepoCacheIdentity @@ -94,11 +95,16 @@ func NewNamedRepoCache(r repository.ClockedRepo, name string) (*RepoCache, chan c.bugs = NewRepoCacheBug(r, c.getResolvers, c.GetUserIdentity) c.subcaches = append(c.subcaches, c.bugs) + c.boards = NewRepoCacheBoard(r, c.getResolvers, c.GetUserIdentity) + c.subcaches = append(c.subcaches, c.boards) + c.resolvers = entity.Resolvers{ &IdentityCache{}: entity.ResolverFunc[*IdentityCache](c.identities.Resolve), &IdentityExcerpt{}: entity.ResolverFunc[*IdentityExcerpt](c.identities.ResolveExcerpt), &BugCache{}: entity.ResolverFunc[*BugCache](c.bugs.Resolve), &BugExcerpt{}: entity.ResolverFunc[*BugExcerpt](c.bugs.ResolveExcerpt), + &BoardCache{}: entity.ResolverFunc[*BoardCache](c.boards.Resolve), + &BoardExcerpt{}: entity.ResolverFunc[*BoardExcerpt](c.boards.ResolveExcerpt), } // small buffer so that below functions can emit an event without blocking @@ -137,6 +143,11 @@ func NewRepoCacheNoEvents(r repository.ClockedRepo) (*RepoCache, error) { return cache, nil } +// Boards gives access to the Board entities +func (c *RepoCache) Boards() *RepoCacheBoard { + return c.boards +} + // Bugs gives access to the Bug entities func (c *RepoCache) Bugs() *RepoCacheBug { return c.bugs diff --git a/entities/board/board.go b/entities/board/board.go index 1b35c6b5f65e5b8adf2cf28115ba04d71f8a5fd4..2b523fb7f629bcb0ede9ac1074d86d08d0e9131e 100644 --- a/entities/board/board.go +++ b/entities/board/board.go @@ -16,9 +16,12 @@ var _ Interface = &Board{} // 1: original format const formatVersion = 1 +const Typename = "board" +const Namespace = "boards" + var def = dag.Definition{ - Typename: "board", - Namespace: "boards", + Typename: Typename, + Namespace: Namespace, OperationUnmarshaler: operationUnmarshaler, FormatVersion: formatVersion, } @@ -41,6 +44,10 @@ func NewBoard() *Board { } } +func wrapper(e *dag.Entity) *Board { + return &Board{Entity: e} +} + func simpleResolvers(repo repository.ClockedRepo) entity.Resolvers { return entity.Resolvers{ &identity.Identity{}: identity.NewSimpleResolver(repo), @@ -55,11 +62,17 @@ func Read(repo repository.ClockedRepo, id entity.Id) (*Board, error) { // ReadWithResolver will read a board from its Id, with a custom identity.Resolver func ReadWithResolver(repo repository.ClockedRepo, resolvers entity.Resolvers, id entity.Id) (*Board, error) { - e, err := dag.Read(def, repo, resolvers, id) - if err != nil { - return nil, err - } - return &Board{Entity: e}, nil + return dag.Read(def, wrapper, repo, resolvers, id) +} + +// ReadAll read and parse all local boards +func ReadAll(repo repository.ClockedRepo) <-chan entity.StreamedEntity[*Board] { + return dag.ReadAll(def, wrapper, repo, simpleResolvers(repo)) +} + +// ReadAllWithResolver read and parse all local boards +func ReadAllWithResolver(repo repository.ClockedRepo, resolvers entity.Resolvers) <-chan entity.StreamedEntity[*Board] { + return dag.ReadAll(def, wrapper, repo, resolvers) } // Validate check if the Board data is valid diff --git a/entities/board/board_actions.go b/entities/board/board_actions.go index b19a8218b0b82cb80a73ada894207ba1d5b873fa..e1d9d37485f2fc805031ad2587c7daeacf5137bd 100644 --- a/entities/board/board_actions.go +++ b/entities/board/board_actions.go @@ -23,33 +23,14 @@ func Push(repo repository.Repo, remote string) (string, error) { // Note: an author is necessary for the case where a merge commit is created, as this commit will // have an author and may be signed if a signing key is available. func Pull(repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, mergeAuthor identity.Interface) error { - return dag.Pull(def, repo, resolvers, remote, mergeAuthor) + return dag.Pull(def, wrapper, repo, resolvers, remote, mergeAuthor) } // MergeAll will merge all the available remote board // Note: an author is necessary for the case where a merge commit is created, as this commit will // have an author and may be signed if a signing key is available. func MergeAll(repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, mergeAuthor identity.Interface) <-chan entity.MergeResult { - out := make(chan entity.MergeResult) - - go func() { - defer close(out) - - results := dag.MergeAll(def, repo, resolvers, remote, mergeAuthor) - - // wrap the dag.Entity into a complete Bug - for result := range results { - result := result - if result.Entity != nil { - result.Entity = &Board{ - Entity: result.Entity.(*dag.Entity), - } - } - out <- result - } - }() - - return out + return dag.MergeAll(def, wrapper, repo, resolvers, remote, mergeAuthor) } // Remove will remove a local bug from its entity.Id diff --git a/entities/board/op_add_item_draft.go b/entities/board/op_add_item_draft.go index 6ce58ad36c749807725a51dc0a4e8eb0875f00b1..051144084de3d731bfd3f0fb22685339e327ef73 100644 --- a/entities/board/op_add_item_draft.go +++ b/entities/board/op_add_item_draft.go @@ -88,14 +88,14 @@ func NewAddItemDraftOp(author identity.Interface, unixTime int64, columnId entit } // AddItemDraft is a convenience function to add a draft item to a Board -func AddItemDraft(b *Board, author identity.Interface, unixTime int64, columnId entity.Id, title, message string, files []repository.Hash, metadata map[string]string) (*AddItemDraftOperation, error) { +func AddItemDraft(b Interface, author identity.Interface, unixTime int64, columnId entity.Id, title, message string, files []repository.Hash, metadata map[string]string) (entity.CombinedId, *AddItemDraftOperation, error) { op := NewAddItemDraftOp(author, unixTime, columnId, title, message, files) for key, val := range metadata { op.SetMetadata(key, val) } if err := op.Validate(); err != nil { - return nil, err + return entity.UnsetCombinedId, nil, err } b.Append(op) - return op, nil + return entity.CombineIds(b.Id(), op.Id()), op, nil } diff --git a/entities/board/op_add_item_entity.go b/entities/board/op_add_item_entity.go index 4628c6a08f23c22d8dea30d2e6ed24006bb6360e..5161483d8540a6b9589a99476857882268427b09 100644 --- a/entities/board/op_add_item_entity.go +++ b/entities/board/op_add_item_entity.go @@ -89,14 +89,14 @@ func NewAddItemEntityOp(author identity.Interface, unixTime int64, columnId enti } // AddItemEntity is a convenience function to add an entity item to a Board -func AddItemEntity(b *Board, author identity.Interface, unixTime int64, columnId entity.Id, e entity.Interface, metadata map[string]string) (*AddItemEntityOperation, error) { +func AddItemEntity(b Interface, author identity.Interface, unixTime int64, columnId entity.Id, e entity.Interface, metadata map[string]string) (entity.CombinedId, *AddItemEntityOperation, error) { op := NewAddItemEntityOp(author, unixTime, columnId, e) for key, val := range metadata { op.SetMetadata(key, val) } if err := op.Validate(); err != nil { - return nil, err + return entity.UnsetCombinedId, nil, err } b.Append(op) - return op, nil + return entity.CombineIds(b.Id(), op.Id()), op, nil } diff --git a/entities/board/op_create.go b/entities/board/op_create.go index a5befbf02a48c6afd8317dc7bc76add87237b5f7..fe7a11541d6745060cf0c660c15ba9dfe61d6936 100644 --- a/entities/board/op_create.go +++ b/entities/board/op_create.go @@ -100,14 +100,17 @@ func (op *CreateOperation) Apply(snap *Snapshot) { } // CreateDefaultColumns is a convenience function to create a board with the default columns -func CreateDefaultColumns(author identity.Interface, unixTime int64, title, description string) (*Board, *CreateOperation, error) { - return Create(author, unixTime, title, description, DefaultColumns) +func CreateDefaultColumns(author identity.Interface, unixTime int64, title, description string, metadata map[string]string) (*Board, *CreateOperation, error) { + return Create(author, unixTime, title, description, DefaultColumns, metadata) } // Create is a convenience function to create a board -func Create(author identity.Interface, unixTime int64, title, description string, columns []string) (*Board, *CreateOperation, error) { +func Create(author identity.Interface, unixTime int64, title, description string, columns []string, metadata map[string]string) (*Board, *CreateOperation, error) { b := NewBoard() op := NewCreateOp(author, unixTime, title, description, columns) + for key, val := range metadata { + op.SetMetadata(key, val) + } if err := op.Validate(); err != nil { return nil, op, err } diff --git a/entities/board/op_set_description.go b/entities/board/op_set_description.go index c82e0c7252cf767deeaeb687e004e6c8c8b03291..fa48e3c929586a5076900dd21aa0a79b07848798 100644 --- a/entities/board/op_set_description.go +++ b/entities/board/op_set_description.go @@ -56,7 +56,7 @@ func NewSetDescriptionOp(author identity.Interface, unixTime int64, description } // SetDescription is a convenience function to change a board description -func SetDescription(b *Board, author identity.Interface, unixTime int64, description string) (*SetDescriptionOperation, error) { +func SetDescription(b Interface, author identity.Interface, unixTime int64, description string, metadata map[string]string) (*SetDescriptionOperation, error) { var lastDescriptionOp *SetDescriptionOperation for _, op := range b.Operations() { switch op := op.(type) { @@ -73,6 +73,9 @@ func SetDescription(b *Board, author identity.Interface, unixTime int64, descrip } op := NewSetDescriptionOp(author, unixTime, description, was) + for key, val := range metadata { + op.SetMetadata(key, val) + } if err := op.Validate(); err != nil { return nil, err } diff --git a/entities/board/op_set_title.go b/entities/board/op_set_title.go index e009ddb6593f21267aae031328d018dc1954763a..c5fb5f4d8a73a23b83ad2492de2b9a4133c096ff 100644 --- a/entities/board/op_set_title.go +++ b/entities/board/op_set_title.go @@ -56,7 +56,7 @@ func NewSetTitleOp(author identity.Interface, unixTime int64, title string, was } // SetTitle is a convenience function to change a board title -func SetTitle(b *Board, author identity.Interface, unixTime int64, title string) (*SetTitleOperation, error) { +func SetTitle(b Interface, author identity.Interface, unixTime int64, title string, metadata map[string]string) (*SetTitleOperation, error) { var lastTitleOp *SetTitleOperation for _, op := range b.Operations() { switch op := op.(type) { @@ -73,6 +73,9 @@ func SetTitle(b *Board, author identity.Interface, unixTime int64, title string) } op := NewSetTitleOp(author, unixTime, title, was) + for key, val := range metadata { + op.SetMetadata(key, val) + } if err := op.Validate(); err != nil { return nil, err } diff --git a/entities/board/snapshot.go b/entities/board/snapshot.go index ec14843abdaa30639b4f15c94e2a6c5721285e37..99c61887d09ecd965ff4ba9872ecc4955ab85161 100644 --- a/entities/board/snapshot.go +++ b/entities/board/snapshot.go @@ -33,10 +33,6 @@ type Snapshot struct { Operations []dag.Operation } -func (snap *Snapshot) AllOperations() []dag.Operation { - return snap.Operations -} - // Id returns the Board identifier func (snap *Snapshot) Id() entity.Id { if snap.id == "" { @@ -46,6 +42,14 @@ func (snap *Snapshot) Id() entity.Id { return snap.id } +func (snap *Snapshot) AllOperations() []dag.Operation { + return snap.Operations +} + +func (snap *Snapshot) AppendOperation(op dag.Operation) { + snap.Operations = append(snap.Operations, op) +} + // EditTime returns the last time the board was modified func (snap *Snapshot) EditTime() time.Time { if len(snap.Operations) == 0 { @@ -65,3 +69,34 @@ func (snap *Snapshot) addActor(actor identity.Interface) { snap.Actors = append(snap.Actors, actor) } + +// HasActor return true if the id is a actor +func (snap *Snapshot) HasActor(id entity.Id) bool { + for _, p := range snap.Actors { + if p.Id() == id { + return true + } + } + return false +} + +// HasAnyActor return true if one of the ids is a actor +func (snap *Snapshot) HasAnyActor(ids ...entity.Id) bool { + for _, id := range ids { + if snap.HasActor(id) { + return true + } + } + return false +} + +func (snap *Snapshot) ItemCount() int { + var count int + for _, column := range snap.Columns { + count += len(column.Items) + } + return count +} + +// IsAuthored is a sign post method for gqlgen +func (snap *Snapshot) IsAuthored() {} diff --git a/misc/random_bugs/cmd/main.go b/misc/random_bugs/cmd/main.go index 9a24bbb498f986f38c49229a946893cbbca0b260..d57678c347a99e80348b40352d494ceab6d56aa0 100644 --- a/misc/random_bugs/cmd/main.go +++ b/misc/random_bugs/cmd/main.go @@ -3,6 +3,7 @@ package main import ( "os" + "github.com/git-bug/git-bug/entities/board" "github.com/git-bug/git-bug/entities/bug" rb "github.com/git-bug/git-bug/misc/random_bugs" "github.com/git-bug/git-bug/repository" @@ -20,6 +21,7 @@ func main() { loaders := []repository.ClockLoader{ bug.ClockLoader, + board.ClockLoader, } repo, err := repository.OpenGoGitRepo(dir, gitBugNamespace, loaders)