board: cache layer

Michael Muré created

Change summary

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(-)

Detailed changes

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()
+}

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)
+}

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

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

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

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

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
 }

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
 }

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
 	}

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
 	}

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
 	}

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() {}

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)