From 55befa3a53ab56bac31026b1b6099b2d31fd6d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20Herg=C3=A8s?= Date: Sat, 17 Jan 2026 14:37:09 +0100 Subject: feat(state): base --- user/state.go | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 user/state.go (limited to 'user/state.go') diff --git a/user/state.go b/user/state.go new file mode 100644 index 0000000..bef2f53 --- /dev/null +++ b/user/state.go @@ -0,0 +1,80 @@ +package user + +import ( + "sync" + + "github.com/nyttikord/gokord/state" +) + +type CopaingCached struct { + ID uint `gorm:"primarykey"` + DiscordID string `gorm:"not null"` + GuildID string `gorm:"not null"` + XPs uint + XPToAdd uint +} + +const KeyCopaingCachedPrefix = "cc:" + +func KeyCopaingCached(c *Copaing) state.Key { + return KeyCopaingCachedRaw(c.GuildID, c.DiscordID) +} + +func KeyCopaingCachedRaw(guildID, copaingID string) state.Key { + return KeyCopaingCachedPrefix + state.Key(guildID+":"+copaingID) +} + +type State struct { + mu sync.RWMutex + storage state.MapStorage[CopaingCached] +} + +func (s *State) Copaing(guildID, copaingID string) (*CopaingCached, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + c, err := s.storage.Get(KeyCopaingCachedRaw(guildID, copaingID)) + if err != nil { + return nil, err + } + mC := c.(CopaingCached) + return &mC, nil +} + +// CopaingAdd does not call Copaing.Load! +func (s *State) CopaingAdd(c *Copaing, xpToAdd uint) error { + s.mu.Lock() + defer s.mu.Unlock() + + sum := calcXP(c) + var err error + var cc *CopaingCached + if cc, err = s.Copaing(c.GuildID, c.DiscordID); err != nil { + cc.XPs = sum + cc.XPToAdd = xpToAdd + } else { + cc = &CopaingCached{ + ID: c.ID, + DiscordID: c.DiscordID, + GuildID: c.GuildID, + XPs: sum, + XPToAdd: xpToAdd, + } + } + return s.storage.Write(KeyCopaingCached(c), *cc) +} + +func (s *State) CopaingRemove(c *Copaing) error { + s.mu.Lock() + defer s.mu.Unlock() + + return s.storage.Delete(KeyCopaingCached(c)) +} + +func calcXP(c *Copaing) uint { + var sum uint + for _, entry := range c.CopaingXPs { + sum += entry.XP + } + return sum +} -- cgit v1.2.3 From febb77607e81fbb182dd456733ea5adafda44ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20Herg=C3=A8s?= Date: Sat, 17 Jan 2026 16:31:25 +0100 Subject: perf(member): use stat for load --- user/state.go | 69 ++++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 54 insertions(+), 15 deletions(-) (limited to 'user/state.go') diff --git a/user/state.go b/user/state.go index bef2f53..07096db 100644 --- a/user/state.go +++ b/user/state.go @@ -1,6 +1,7 @@ package user import ( + "context" "sync" "github.com/nyttikord/gokord/state" @@ -14,6 +15,35 @@ type CopaingCached struct { XPToAdd uint } +// Copaing turns a CopaingCached into a Copaing. +// This operation is heavy. +func (cc *CopaingCached) Copaing(ctx context.Context) *Copaing { + c := Copaing{DiscordID: cc.DiscordID, GuildID: cc.GuildID} + if err := c.Load(ctx); err != nil { + panic(err) + } + return &c +} + +func (cc *CopaingCached) Save(ctx context.Context) error { + state := GetState(ctx) + + state.mu.Lock() + defer state.mu.Unlock() + + return state.storage.Write(KeyCopaingCachedRaw(cc.GuildID, cc.DiscordID), *cc) +} + +func FromCopaing(c *Copaing) *CopaingCached { + return &CopaingCached{ + ID: c.ID, + DiscordID: c.DiscordID, + GuildID: c.GuildID, + XPs: calcXP(c), + XPToAdd: 0, + } +} + const KeyCopaingCachedPrefix = "cc:" func KeyCopaingCached(c *Copaing) state.Key { @@ -29,6 +59,22 @@ type State struct { storage state.MapStorage[CopaingCached] } +func NewState() *State { + return &State{ + storage: state.MapStorage[CopaingCached]{}, + } +} + +const ContextKeyState = "state" + +func GetState(ctx context.Context) *State { + return ctx.Value(ContextKeyState).(*State) +} + +func SetState(ctx context.Context, state *State) context.Context { + return context.WithValue(ctx, ContextKeyState, state) +} + func (s *State) Copaing(guildID, copaingID string) (*CopaingCached, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -42,26 +88,19 @@ func (s *State) Copaing(guildID, copaingID string) (*CopaingCached, error) { } // CopaingAdd does not call Copaing.Load! -func (s *State) CopaingAdd(c *Copaing, xpToAdd uint) error { - s.mu.Lock() - defer s.mu.Unlock() - - sum := calcXP(c) +func (s *State) CopaingAdd(c *Copaing, xpToAdd uint) (*CopaingCached, error) { var err error var cc *CopaingCached - if cc, err = s.Copaing(c.GuildID, c.DiscordID); err != nil { - cc.XPs = sum + if cc, err = s.Copaing(c.GuildID, c.DiscordID); err == nil { + cc.XPs = calcXP(c) cc.XPToAdd = xpToAdd } else { - cc = &CopaingCached{ - ID: c.ID, - DiscordID: c.DiscordID, - GuildID: c.GuildID, - XPs: sum, - XPToAdd: xpToAdd, - } + cc = FromCopaing(c) } - return s.storage.Write(KeyCopaingCached(c), *cc) + s.mu.Lock() + defer s.mu.Unlock() + + return cc, s.storage.Write(KeyCopaingCached(c), *cc) } func (s *State) CopaingRemove(c *Copaing) error { -- cgit v1.2.3 From 64dfe4ed79022c6a7a00991db7ba679f2dcb3495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20Herg=C3=A8s?= Date: Sat, 17 Jan 2026 17:06:38 +0100 Subject: refactor(member): better distinction between cached and from database --- user/state.go | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) (limited to 'user/state.go') diff --git a/user/state.go b/user/state.go index 07096db..84f2852 100644 --- a/user/state.go +++ b/user/state.go @@ -15,9 +15,9 @@ type CopaingCached struct { XPToAdd uint } -// Copaing turns a CopaingCached into a Copaing. +// copaing turns a CopaingCached into a Copaing. // This operation is heavy. -func (cc *CopaingCached) Copaing(ctx context.Context) *Copaing { +func (cc *CopaingCached) copaing(ctx context.Context) *Copaing { c := Copaing{DiscordID: cc.DiscordID, GuildID: cc.GuildID} if err := c.Load(ctx); err != nil { panic(err) @@ -25,6 +25,15 @@ func (cc *CopaingCached) Copaing(ctx context.Context) *Copaing { return &c } +func (cc *CopaingCached) Sync(ctx context.Context) error { + synced, err := GetState(ctx).CopaingAdd(cc.copaing(ctx), cc.XPToAdd) + if err != nil { + return err + } + *cc = *synced + return nil +} + func (cc *CopaingCached) Save(ctx context.Context) error { state := GetState(ctx) @@ -34,6 +43,26 @@ func (cc *CopaingCached) Save(ctx context.Context) error { return state.storage.Write(KeyCopaingCachedRaw(cc.GuildID, cc.DiscordID), *cc) } +func (cc *CopaingCached) SaveInDB(ctx context.Context) error { + c := cc.copaing(ctx) + c.CopaingXPs = append(c.CopaingXPs, CopaingXP{CopaingID: c.ID, XP: cc.XPToAdd, GuildID: c.GuildID}) + err := c.Save() + if err != nil { + return err + } + cc.XPToAdd = 0 + return cc.Save(ctx) +} + +func (cc *CopaingCached) Delete(ctx context.Context) error { + c := cc.copaing(ctx) + err := c.Delete() + if err != nil { + return err + } + return GetState(ctx).CopaingRemove(c) +} + func FromCopaing(c *Copaing) *CopaingCached { return &CopaingCached{ ID: c.ID, @@ -92,11 +121,12 @@ func (s *State) CopaingAdd(c *Copaing, xpToAdd uint) (*CopaingCached, error) { var err error var cc *CopaingCached if cc, err = s.Copaing(c.GuildID, c.DiscordID); err == nil { - cc.XPs = calcXP(c) - cc.XPToAdd = xpToAdd + cc.XPs = calcXP(c) + xpToAdd } else { cc = FromCopaing(c) } + cc.XPToAdd = xpToAdd + s.mu.Lock() defer s.mu.Unlock() -- cgit v1.2.3 From c9129c2e7edcf6e588cac674dfdb240f1714083d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20Herg=C3=A8s?= Date: Sat, 17 Jan 2026 19:15:12 +0100 Subject: feat(member): save cache in db every 30 minutes --- user/state.go | 91 +++++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 58 insertions(+), 33 deletions(-) (limited to 'user/state.go') diff --git a/user/state.go b/user/state.go index 84f2852..540d496 100644 --- a/user/state.go +++ b/user/state.go @@ -2,31 +2,46 @@ package user import ( "context" + "errors" "sync" + "time" "github.com/nyttikord/gokord/state" ) +var ErrSyncingUnsavedData = errors.New("trying to sync unsaved data") + type CopaingCached struct { - ID uint `gorm:"primarykey"` - DiscordID string `gorm:"not null"` - GuildID string `gorm:"not null"` + ID uint + DiscordID string + GuildID string XPs uint XPToAdd uint + lastSync time.Time // time.Time of the lastSync } // copaing turns a CopaingCached into a Copaing. -// This operation is heavy. -func (cc *CopaingCached) copaing(ctx context.Context) *Copaing { - c := Copaing{DiscordID: cc.DiscordID, GuildID: cc.GuildID} - if err := c.Load(ctx); err != nil { +// This operation get the copaing synced with the database. +// It doesn't: +// - save the copaing in the database, use CopaingCached.SaveInDB for that; +// - sync the copaing cached, use CopaingCached.Sync for that. +// TL;DR: don't use this method, unless you know what are you doing. +func (cc *CopaingCached) copaing() *Copaing { + c := &Copaing{DiscordID: cc.DiscordID, GuildID: cc.GuildID} + if err := c.load(); err != nil { panic(err) } - return &c + return c } func (cc *CopaingCached) Sync(ctx context.Context) error { - synced, err := GetState(ctx).CopaingAdd(cc.copaing(ctx), cc.XPToAdd) + if cc.mustSave() { + return ErrSyncingUnsavedData + } + synced := FromCopaing(cc.copaing()) + synced.XPs += cc.XPToAdd + synced.XPToAdd = cc.XPToAdd + err := synced.Save(ctx) if err != nil { return err } @@ -44,7 +59,7 @@ func (cc *CopaingCached) Save(ctx context.Context) error { } func (cc *CopaingCached) SaveInDB(ctx context.Context) error { - c := cc.copaing(ctx) + c := cc.copaing() c.CopaingXPs = append(c.CopaingXPs, CopaingXP{CopaingID: c.ID, XP: cc.XPToAdd, GuildID: c.GuildID}) err := c.Save() if err != nil { @@ -55,12 +70,33 @@ func (cc *CopaingCached) SaveInDB(ctx context.Context) error { } func (cc *CopaingCached) Delete(ctx context.Context) error { - c := cc.copaing(ctx) + c := cc.copaing() err := c.Delete() if err != nil { return err } - return GetState(ctx).CopaingRemove(c) + state := GetState(ctx) + + state.mu.Lock() + defer state.mu.Unlock() + + return state.storage.Delete(KeyCopaingCached(c)) +} + +func (cc *CopaingCached) mustSave() bool { + return cc.XPToAdd > 0 +} + +func saveStateInDB(ctx context.Context) error { + for _, v := range GetState(ctx).storage { + if v.mustSave() { + err := v.SaveInDB(ctx) + if err != nil { + return err + } + } + } + return nil } func FromCopaing(c *Copaing) *CopaingCached { @@ -116,28 +152,17 @@ func (s *State) Copaing(guildID, copaingID string) (*CopaingCached, error) { return &mC, nil } -// CopaingAdd does not call Copaing.Load! -func (s *State) CopaingAdd(c *Copaing, xpToAdd uint) (*CopaingCached, error) { - var err error - var cc *CopaingCached - if cc, err = s.Copaing(c.GuildID, c.DiscordID); err == nil { - cc.XPs = calcXP(c) + xpToAdd - } else { - cc = FromCopaing(c) - } - cc.XPToAdd = xpToAdd - - s.mu.Lock() - defer s.mu.Unlock() - - return cc, s.storage.Write(KeyCopaingCached(c), *cc) -} - -func (s *State) CopaingRemove(c *Copaing) error { - s.mu.Lock() - defer s.mu.Unlock() +func (s *State) Copaings(guild string) []CopaingCached { + s.mu.RLock() + defer s.mu.RUnlock() - return s.storage.Delete(KeyCopaingCached(c)) + var ccs []CopaingCached + for _, cc := range s.storage { + if cc.GuildID == guild { + ccs = append(ccs, cc) + } + } + return ccs } func calcXP(c *Copaing) uint { -- cgit v1.2.3 From ec5cfa632eeb607351f67bad6686ec872291bd61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20Herg=C3=A8s?= Date: Sat, 17 Jan 2026 19:57:28 +0100 Subject: perf(command): top now partially uses state --- user/state.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'user/state.go') diff --git a/user/state.go b/user/state.go index 540d496..b977fb6 100644 --- a/user/state.go +++ b/user/state.go @@ -41,6 +41,7 @@ func (cc *CopaingCached) Sync(ctx context.Context) error { synced := FromCopaing(cc.copaing()) synced.XPs += cc.XPToAdd synced.XPToAdd = cc.XPToAdd + synced.lastSync = time.Now() err := synced.Save(ctx) if err != nil { return err @@ -144,12 +145,12 @@ func (s *State) Copaing(guildID, copaingID string) (*CopaingCached, error) { s.mu.RLock() defer s.mu.RUnlock() - c, err := s.storage.Get(KeyCopaingCachedRaw(guildID, copaingID)) + raw, err := s.storage.Get(KeyCopaingCachedRaw(guildID, copaingID)) if err != nil { return nil, err } - mC := c.(CopaingCached) - return &mC, nil + c := raw.(CopaingCached) + return &c, nil } func (s *State) Copaings(guild string) []CopaingCached { -- cgit v1.2.3 From c661541e45dddd6a082af66fcf7df7ba7dfdc6a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20Herg=C3=A8s?= Date: Sat, 17 Jan 2026 21:50:54 +0100 Subject: perf(command): store data used by top in state --- user/state.go | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 9 deletions(-) (limited to 'user/state.go') diff --git a/user/state.go b/user/state.go index b977fb6..effdc80 100644 --- a/user/state.go +++ b/user/state.go @@ -6,18 +6,24 @@ import ( "sync" "time" + "github.com/anhgelus/gokord" "github.com/nyttikord/gokord/state" ) var ErrSyncingUnsavedData = errors.New("trying to sync unsaved data") +type XPCached struct { + XP uint + Time time.Duration +} + type CopaingCached struct { ID uint DiscordID string GuildID string - XPs uint + XP uint + XPs []XPCached XPToAdd uint - lastSync time.Time // time.Time of the lastSync } // copaing turns a CopaingCached into a Copaing. @@ -39,9 +45,8 @@ func (cc *CopaingCached) Sync(ctx context.Context) error { return ErrSyncingUnsavedData } synced := FromCopaing(cc.copaing()) - synced.XPs += cc.XPToAdd + synced.XP += cc.XPToAdd synced.XPToAdd = cc.XPToAdd - synced.lastSync = time.Now() err := synced.Save(ctx) if err != nil { return err @@ -89,7 +94,12 @@ func (cc *CopaingCached) mustSave() bool { } func saveStateInDB(ctx context.Context) error { - for _, v := range GetState(ctx).storage { + state := GetState(ctx) + + state.saveInDB.Lock() + defer state.saveInDB.Unlock() + + for _, v := range state.storage { if v.mustSave() { err := v.SaveInDB(ctx) if err != nil { @@ -105,7 +115,8 @@ func FromCopaing(c *Copaing) *CopaingCached { ID: c.ID, DiscordID: c.DiscordID, GuildID: c.GuildID, - XPs: calcXP(c), + XP: calcXP(c), + XPs: generateXPs(c), XPToAdd: 0, } } @@ -121,14 +132,24 @@ func KeyCopaingCachedRaw(guildID, copaingID string) state.Key { } type State struct { - mu sync.RWMutex - storage state.MapStorage[CopaingCached] + mu sync.RWMutex + saveInDB sync.Mutex + storage state.MapStorage[CopaingCached] } func NewState() *State { - return &State{ + state := &State{ storage: state.MapStorage[CopaingCached]{}, } + var cs []*Copaing + err := gokord.DB.Find(&cs).Error + if err != nil { + panic(err) + } + for _, v := range cs { + FromCopaing(v).Save(SetState(context.Background(), state)) + } + return state } const ContextKeyState = "state" @@ -173,3 +194,27 @@ func calcXP(c *Copaing) uint { } return sum } + +func generateXPs(c *Copaing) []XPCached { + data := map[time.Duration]XPCached{} + sixH := 6 * time.Hour + for _, xp := range c.CopaingXPs { + // we add sixH at the end because we want it to be rounded to ceil + since := time.Since(xp.CreatedAt)/sixH + sixH + if v, ok := data[since]; ok { + v.XP += xp.XP + } else { + data[since] = XPCached{ + Time: since, + XP: xp.XP, + } + } + } + ccs := make([]XPCached, len(data)) + i := 0 + for _, v := range data { + ccs[i] = v + i++ + } + return ccs +} -- cgit v1.2.3