mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
refactor: simplify summary generation (resolve #68)
This commit is contained in:
parent
8ddd9904a0
commit
2f12d8efde
@ -41,3 +41,24 @@ func (h *Heartbeat) Augment(languageMappings map[string]string) {
|
|||||||
}
|
}
|
||||||
h.Language, _ = languageMappings[ending]
|
h.Language, _ = languageMappings[ending]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Heartbeat) GetKey(t uint8) (key string) {
|
||||||
|
switch t {
|
||||||
|
case SummaryProject:
|
||||||
|
key = h.Project
|
||||||
|
case SummaryEditor:
|
||||||
|
key = h.Editor
|
||||||
|
case SummaryLanguage:
|
||||||
|
key = h.Language
|
||||||
|
case SummaryOS:
|
||||||
|
key = h.OperatingSystem
|
||||||
|
case SummaryMachine:
|
||||||
|
key = h.Machine
|
||||||
|
}
|
||||||
|
|
||||||
|
if key == "" {
|
||||||
|
key = UnknownSummaryKey
|
||||||
|
}
|
||||||
|
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
38
models/heartbeats.go
Normal file
38
models/heartbeats.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
type Heartbeats []*Heartbeat
|
||||||
|
|
||||||
|
func (h Heartbeats) Len() int {
|
||||||
|
return len(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Heartbeats) Less(i, j int) bool {
|
||||||
|
return h[i].Time.T().Before(h[j].Time.T())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Heartbeats) Swap(i, j int) {
|
||||||
|
h[i], h[j] = h[j], h[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Heartbeats) Sorted() *Heartbeats {
|
||||||
|
sort.Sort(h)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Heartbeats) First() *Heartbeat {
|
||||||
|
// assumes slice to be sorted
|
||||||
|
if h.Len() == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return (*h)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Heartbeats) Last() *Heartbeat {
|
||||||
|
// assumes slice to be sorted
|
||||||
|
if h.Len() == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return (*h)[h.Len()-1]
|
||||||
|
}
|
@ -24,6 +24,11 @@ type KeyStringValue struct {
|
|||||||
Value string `gorm:"type:text"`
|
Value string `gorm:"type:text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Interval struct {
|
||||||
|
Start time.Time
|
||||||
|
End time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type CustomTime time.Time
|
type CustomTime time.Time
|
||||||
|
|
||||||
func (j *CustomTime) UnmarshalJSON(b []byte) error {
|
func (j *CustomTime) UnmarshalJSON(b []byte) error {
|
||||||
@ -79,3 +84,7 @@ func (j CustomTime) String() string {
|
|||||||
func (j CustomTime) T() time.Time {
|
func (j CustomTime) T() time.Time {
|
||||||
return time.Time(j)
|
return time.Time(j)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (j CustomTime) Valid() bool {
|
||||||
|
return j.T().Unix() >= 0
|
||||||
|
}
|
||||||
|
@ -75,6 +75,8 @@ type SummaryParams struct {
|
|||||||
Recompute bool
|
Recompute bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AliasResolver func(t uint8, k string) string
|
||||||
|
|
||||||
func SummaryTypes() []uint8 {
|
func SummaryTypes() []uint8 {
|
||||||
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
||||||
}
|
}
|
||||||
@ -178,3 +180,53 @@ func (s *Summary) TotalTimeByFilters(filter *Filters) (timeSum time.Duration) {
|
|||||||
}
|
}
|
||||||
return timeSum
|
return timeSum
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
|
||||||
|
processAliases := func(origin []*SummaryItem) []*SummaryItem {
|
||||||
|
target := make([]*SummaryItem, 0)
|
||||||
|
|
||||||
|
findItem := func(key string) *SummaryItem {
|
||||||
|
for _, item := range target {
|
||||||
|
if item.Key == key {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range origin {
|
||||||
|
// Add all "top-level" items, i.e. such without aliases
|
||||||
|
if key := resolve(item.Type, item.Key); key == item.Key {
|
||||||
|
target = append(target, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range origin {
|
||||||
|
// Add all remaining projects and merge with their alias
|
||||||
|
if key := resolve(item.Type, item.Key); key != item.Key {
|
||||||
|
if targetItem := findItem(key); targetItem != nil {
|
||||||
|
targetItem.Total += item.Total
|
||||||
|
} else {
|
||||||
|
target = append(target, &SummaryItem{
|
||||||
|
ID: item.ID,
|
||||||
|
SummaryID: item.SummaryID,
|
||||||
|
Type: item.Type,
|
||||||
|
Key: key,
|
||||||
|
Total: item.Total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve aliases
|
||||||
|
s.Projects = processAliases(s.Projects)
|
||||||
|
s.Editors = processAliases(s.Editors)
|
||||||
|
s.Languages = processAliases(s.Languages)
|
||||||
|
s.OperatingSystems = processAliases(s.OperatingSystems)
|
||||||
|
s.Machines = processAliases(s.Machines)
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
@ -26,6 +26,11 @@ type CredentialsReset struct {
|
|||||||
PasswordRepeat string `schema:"password_repeat"`
|
PasswordRepeat string `schema:"password_repeat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TimeByUser struct {
|
||||||
|
User string
|
||||||
|
Time CustomTime
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CredentialsReset) IsValid() bool {
|
func (c *CredentialsReset) IsValid() bool {
|
||||||
return validatePassword(c.PasswordNew) &&
|
return validatePassword(c.PasswordNew) &&
|
||||||
c.PasswordNew == c.PasswordRepeat
|
c.PasswordNew == c.PasswordRepeat
|
||||||
|
@ -34,18 +34,14 @@ func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User
|
|||||||
return heartbeats, nil
|
return heartbeats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Will return *models.Heartbeat object with only user_id and time fields filled
|
func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||||
func (r *HeartbeatRepository) GetFirstByUsers(userIds []string) ([]*models.Heartbeat, error) {
|
var result []*models.TimeByUser
|
||||||
var heartbeats []*models.Heartbeat
|
r.db.Model(&models.User{}).
|
||||||
if err := r.db.
|
Select("users.id as user, min(time) as time").
|
||||||
Table("heartbeats").
|
Joins("left join heartbeats on users.id = heartbeats.user_id").
|
||||||
Select("user_id, min(time) as time").
|
Group("user").
|
||||||
Where("user_id IN (?)", userIds).
|
Scan(&result)
|
||||||
Group("user_id").
|
return result, nil
|
||||||
Scan(&heartbeats).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return heartbeats, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
||||||
|
@ -39,17 +39,14 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
|
|||||||
return summaries, nil
|
return summaries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Will return *models.Index objects with only user_id and to_time filled
|
func (r *SummaryRepository) GetLastByUser() ([]*models.TimeByUser, error) {
|
||||||
func (r *SummaryRepository) GetLatestByUser() ([]*models.Summary, error) {
|
var result []*models.TimeByUser
|
||||||
var summaries []*models.Summary
|
r.db.Model(&models.User{}).
|
||||||
if err := r.db.
|
Select("users.id as user, max(to_time) as time").
|
||||||
Table("summaries").
|
Joins("left join summaries on users.id = summaries.user_id").
|
||||||
Select("user_id, max(to_time) as to_time").
|
Group("user").
|
||||||
Group("user_id").
|
Scan(&result)
|
||||||
Scan(&summaries).Error; err != nil {
|
return result, nil
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return summaries, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *SummaryRepository) DeleteByUser(userId string) error {
|
func (r *SummaryRepository) DeleteByUser(userId string) error {
|
||||||
|
@ -97,9 +97,12 @@ func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*mod
|
|||||||
User: user,
|
User: user,
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, err := h.summarySrvc.PostProcessWrapped(
|
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
|
||||||
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute),
|
if summaryParams.Recompute {
|
||||||
)
|
retrieveSummary = h.summarySrvc.Summarize
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err, http.StatusInternalServerError
|
return nil, err, http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
@ -55,9 +55,12 @@ func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, er
|
|||||||
Recompute: false,
|
Recompute: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, err := h.summarySrvc.PostProcessWrapped(
|
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
|
||||||
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute), // 'to' is always constant
|
if summaryParams.Recompute {
|
||||||
)
|
retrieveSummary = h.summarySrvc.Summarize
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err, http.StatusInternalServerError
|
return nil, err, http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
@ -86,9 +86,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
|
|||||||
summaries := make([]*models.Summary, len(intervals))
|
summaries := make([]*models.Summary, len(intervals))
|
||||||
|
|
||||||
for i, interval := range intervals {
|
for i, interval := range intervals {
|
||||||
summary, err := h.summarySrvc.PostProcessWrapped(
|
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve)
|
||||||
h.summarySrvc.Construct(interval[0], interval[1], user, false), // 'to' is always constant
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err, http.StatusInternalServerError
|
return nil, err, http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
@ -72,9 +72,12 @@ func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, erro
|
|||||||
return nil, err, http.StatusBadRequest
|
return nil, err, http.StatusBadRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, err := h.summarySrvc.PostProcessWrapped(
|
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
|
||||||
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute), // 'to' is always constant
|
if summaryParams.Recompute {
|
||||||
)
|
retrieveSummary = h.summarySrvc.Summarize
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err, http.StatusInternalServerError
|
return nil, err, http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
@ -59,12 +59,19 @@ func (srv *AggregationService) Run(userIds map[string]bool) error {
|
|||||||
go srv.persistWorker(summaries)
|
go srv.persistWorker(summaries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// don't leak open channels
|
||||||
|
go func(c1 chan *AggregationJob, c2 chan *models.Summary) {
|
||||||
|
defer close(c1)
|
||||||
|
defer close(c2)
|
||||||
|
time.Sleep(1 * time.Hour)
|
||||||
|
}(jobs, summaries)
|
||||||
|
|
||||||
return srv.trigger(jobs, userIds)
|
return srv.trigger(jobs, userIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
|
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
|
||||||
for job := range jobs {
|
for job := range jobs {
|
||||||
if summary, err := srv.summaryService.Construct(job.From, job.To, &models.User{ID: job.UserID}, true); err != nil {
|
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}); err != nil {
|
||||||
log.Printf("Failed to generate summary (%v, %v, %s) – %v.\n", job.From, job.To, job.UserID, err)
|
log.Printf("Failed to generate summary (%v, %v, %s) – %v.\n", job.From, job.To, job.UserID, err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Successfully generated summary (%v, %v, %s).\n", job.From, job.To, job.UserID)
|
log.Printf("Successfully generated summary (%v, %v, %s).\n", job.From, job.To, job.UserID)
|
||||||
@ -99,57 +106,59 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
|
|||||||
users = allUsers
|
users = allUsers
|
||||||
}
|
}
|
||||||
|
|
||||||
latestSummaries, err := srv.summaryService.GetLatestByUser()
|
// Get a map from user ids to the time of their latest summary or nil if none exists yet
|
||||||
|
lastUserSummaryTimes, err := srv.summaryService.GetLatestByUser()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
userSummaryTimes := make(map[string]time.Time)
|
// Get a map from user ids to the time of their earliest heartbeats or nil if none exists yet
|
||||||
for _, s := range latestSummaries {
|
firstUserHeartbeatTimes, err := srv.heartbeatService.GetFirstByUsers()
|
||||||
userSummaryTimes[s.UserID] = s.ToTime.T()
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
missingUserIDs := make([]string, 0)
|
// Build actual lookup table from it
|
||||||
for _, u := range users {
|
firstUserHeartbeatLookup := make(map[string]models.CustomTime)
|
||||||
if _, ok := userSummaryTimes[u.ID]; !ok {
|
for _, e := range firstUserHeartbeatTimes {
|
||||||
missingUserIDs = append(missingUserIDs, u.ID)
|
firstUserHeartbeatLookup[e.User] = e.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate summary aggregation jobs
|
||||||
|
for _, e := range lastUserSummaryTimes {
|
||||||
|
if e.Time.Valid() {
|
||||||
|
// Case 1: User has aggregated summaries already
|
||||||
|
// -> Spawn jobs to create summaries from their latest aggregation to now
|
||||||
|
generateUserJobs(e.User, e.Time.T(), jobs)
|
||||||
|
} else if t := firstUserHeartbeatLookup[e.User]; t.Valid() {
|
||||||
|
// Case 2: User has no aggregated summaries, yet, but has heartbeats
|
||||||
|
// -> Spawn jobs to create summaries from their first heartbeat to now
|
||||||
|
generateUserJobs(e.User, t.T(), jobs)
|
||||||
}
|
}
|
||||||
}
|
// Case 3: User doesn't have heartbeats at all
|
||||||
|
// -> Nothing to do
|
||||||
firstHeartbeats, err := srv.heartbeatService.GetFirstUserHeartbeats(missingUserIDs)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for id, t := range userSummaryTimes {
|
|
||||||
generateUserJobs(id, t, jobs)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, h := range firstHeartbeats {
|
|
||||||
generateUserJobs(h.UserID, time.Time(h.Time), jobs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateUserJobs(userId string, lastAggregation time.Time, jobs chan<- *AggregationJob) {
|
func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) {
|
||||||
var from, to time.Time
|
var to time.Time
|
||||||
|
|
||||||
|
// Go to next day of either user's first heartbeat or latest aggregation
|
||||||
|
from.Add(-1 * time.Second)
|
||||||
|
from = time.Date(
|
||||||
|
from.Year(),
|
||||||
|
from.Month(),
|
||||||
|
from.Day()+aggregateIntervalDays,
|
||||||
|
0, 0, 0, 0,
|
||||||
|
from.Location(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Iteratively aggregate per-day summaries until end of yesterday is reached
|
||||||
end := getStartOfToday().Add(-1 * time.Second)
|
end := getStartOfToday().Add(-1 * time.Second)
|
||||||
|
|
||||||
if lastAggregation.Hour() == 0 {
|
|
||||||
from = lastAggregation
|
|
||||||
} else {
|
|
||||||
from = time.Date(
|
|
||||||
lastAggregation.Year(),
|
|
||||||
lastAggregation.Month(),
|
|
||||||
lastAggregation.Day()+aggregateIntervalDays,
|
|
||||||
0, 0, 0, 0,
|
|
||||||
lastAggregation.Location(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for from.Before(end) && to.Before(end) {
|
for from.Before(end) && to.Before(end) {
|
||||||
to = time.Date(
|
to = time.Date(
|
||||||
from.Year(),
|
from.Year(),
|
||||||
|
@ -38,8 +38,8 @@ func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User)
|
|||||||
return srv.augmented(heartbeats, user.ID)
|
return srv.augmented(heartbeats, user.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *HeartbeatService) GetFirstUserHeartbeats(userIds []string) ([]*models.Heartbeat, error) {
|
func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||||
return srv.repository.GetFirstByUsers(userIds)
|
return srv.repository.GetFirstByUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
|
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
|
||||||
|
@ -4,14 +4,12 @@ import (
|
|||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/repositories"
|
"github.com/muety/wakapi/repositories"
|
||||||
"github.com/patrickmn/go-cache"
|
"github.com/patrickmn/go-cache"
|
||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/muety/wakapi/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const HeartbeatDiffThreshold = 2 * time.Minute
|
const HeartbeatDiffThreshold = 2 * time.Minute
|
||||||
@ -24,6 +22,8 @@ type SummaryService struct {
|
|||||||
aliasService *AliasService
|
aliasService *AliasService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
|
||||||
|
|
||||||
func NewSummaryService(summaryRepo *repositories.SummaryRepository, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService {
|
func NewSummaryService(summaryRepo *repositories.SummaryRepository, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService {
|
||||||
return &SummaryService{
|
return &SummaryService{
|
||||||
config: config.Get(),
|
config: config.Get(),
|
||||||
@ -34,60 +34,98 @@ func NewSummaryService(summaryRepo *repositories.SummaryRepository, heartbeatSer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Interval struct {
|
// Public summary generation methods
|
||||||
Start time.Time
|
|
||||||
End time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: simplify!
|
func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f SummaryRetriever) (*models.Summary, error) {
|
||||||
func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) {
|
// Check cache
|
||||||
var existingSummaries []*models.Summary
|
cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased")
|
||||||
var cacheKey string
|
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
|
||||||
|
return cacheResult.(*models.Summary), nil
|
||||||
if recompute {
|
|
||||||
existingSummaries = make([]*models.Summary, 0)
|
|
||||||
} else {
|
|
||||||
cacheKey = getHash([]time.Time{from, to}, user)
|
|
||||||
if result, ok := srv.cache.Get(cacheKey); ok {
|
|
||||||
return result.(*models.Summary), nil
|
|
||||||
}
|
|
||||||
summaries, err := srv.GetByUserWithin(user, from, to)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
existingSummaries = summaries
|
|
||||||
}
|
}
|
||||||
|
|
||||||
missingIntervals := getMissingIntervals(from, to, existingSummaries)
|
// Wrap alias resolution
|
||||||
|
resolve := func(t uint8, k string) string {
|
||||||
|
s, _ := srv.aliasService.GetAliasOrDefault(user.ID, t, k)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
heartbeats := make([]*models.Heartbeat, 0)
|
// Initialize alias resolver service
|
||||||
|
if err := srv.aliasService.LoadUserAliases(user.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get actual summary
|
||||||
|
s, err := f(from, to, user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-process summary and cache it
|
||||||
|
summary := s.WithResolvedAliases(resolve)
|
||||||
|
srv.cache.SetDefault(cacheKey, summary)
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) {
|
||||||
|
// Check cache
|
||||||
|
cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased")
|
||||||
|
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
|
||||||
|
return cacheResult.(*models.Summary), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all already existing, pre-generated summaries that fall into the requested interval
|
||||||
|
summaries, err := srv.repository.GetByUserWithin(user, from, to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate missing slots (especially before and after existing summaries) from raw heartbeats
|
||||||
|
missingIntervals := srv.getMissingIntervals(from, to, summaries)
|
||||||
for _, interval := range missingIntervals {
|
for _, interval := range missingIntervals {
|
||||||
hb, err := srv.heartbeatService.GetAllWithin(interval.Start, interval.End, user)
|
if s, err := srv.Summarize(interval.Start, interval.End, user); err == nil {
|
||||||
if err != nil {
|
summaries = append(summaries, s)
|
||||||
|
} else {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
heartbeats = append(heartbeats, hb...)
|
}
|
||||||
|
|
||||||
|
// Merge existing and newly generated summary snippets
|
||||||
|
summary, err := srv.mergeSummaries(summaries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache 'em
|
||||||
|
srv.cache.SetDefault(cacheKey, summary)
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*models.Summary, error) {
|
||||||
|
// Initialize and fetch data
|
||||||
|
var heartbeats models.Heartbeats
|
||||||
|
if rawHeartbeats, err := srv.heartbeatService.GetAllWithin(from, to, user); err == nil {
|
||||||
|
heartbeats = rawHeartbeats
|
||||||
|
} else {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
types := models.SummaryTypes()
|
types := models.SummaryTypes()
|
||||||
|
|
||||||
|
typedAggregations := make(chan models.SummaryItemContainer)
|
||||||
|
defer close(typedAggregations)
|
||||||
|
for _, t := range types {
|
||||||
|
go srv.aggregateBy(heartbeats, t, typedAggregations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate raw heartbeats by types in parallel and collect them
|
||||||
var projectItems []*models.SummaryItem
|
var projectItems []*models.SummaryItem
|
||||||
var languageItems []*models.SummaryItem
|
var languageItems []*models.SummaryItem
|
||||||
var editorItems []*models.SummaryItem
|
var editorItems []*models.SummaryItem
|
||||||
var osItems []*models.SummaryItem
|
var osItems []*models.SummaryItem
|
||||||
var machineItems []*models.SummaryItem
|
var machineItems []*models.SummaryItem
|
||||||
|
|
||||||
if err := srv.aliasService.LoadUserAliases(user.ID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
c := make(chan models.SummaryItemContainer)
|
|
||||||
for _, t := range types {
|
|
||||||
go srv.aggregateBy(heartbeats, t, user, c)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < len(types); i++ {
|
for i := 0; i < len(types); i++ {
|
||||||
item := <-c
|
item := <-typedAggregations
|
||||||
switch item.Type {
|
switch item.Type {
|
||||||
case models.SummaryProject:
|
case models.SummaryProject:
|
||||||
projectItems = item.Items
|
projectItems = item.Items
|
||||||
@ -101,31 +139,16 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
|||||||
machineItems = item.Items
|
machineItems = item.Items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
close(c)
|
|
||||||
|
|
||||||
realFrom, realTo := from, to
|
if heartbeats.Len() > 0 {
|
||||||
if len(existingSummaries) > 0 {
|
from = time.Time(heartbeats.First().Time)
|
||||||
realFrom = existingSummaries[0].FromTime.T()
|
to = time.Time(heartbeats.Last().Time)
|
||||||
realTo = existingSummaries[len(existingSummaries)-1].ToTime.T()
|
|
||||||
|
|
||||||
for _, summary := range existingSummaries {
|
|
||||||
summary.FillUnknown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(heartbeats) > 0 {
|
|
||||||
t1, t2 := time.Time(heartbeats[0].Time), time.Time(heartbeats[len(heartbeats)-1].Time)
|
|
||||||
if t1.After(realFrom) && t1.Before(time.Date(realFrom.Year(), realFrom.Month(), realFrom.Day()+1, 0, 0, 0, 0, realFrom.Location())) {
|
|
||||||
realFrom = t1
|
|
||||||
}
|
|
||||||
if t2.Before(realTo) && t2.After(time.Date(realTo.Year(), realTo.Month(), realTo.Day()-1, 0, 0, 0, 0, realTo.Location())) {
|
|
||||||
realTo = t2
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
aggregatedSummary := &models.Summary{
|
summary := &models.Summary{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
FromTime: models.CustomTime(realFrom),
|
FromTime: models.CustomTime(from),
|
||||||
ToTime: models.CustomTime(realTo),
|
ToTime: models.CustomTime(to),
|
||||||
Projects: projectItems,
|
Projects: projectItems,
|
||||||
Languages: languageItems,
|
Languages: languageItems,
|
||||||
Editors: editorItems,
|
Editors: editorItems,
|
||||||
@ -133,123 +156,32 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
|||||||
Machines: machineItems,
|
Machines: machineItems,
|
||||||
}
|
}
|
||||||
|
|
||||||
allSummaries := []*models.Summary{aggregatedSummary}
|
summary.FillUnknown()
|
||||||
allSummaries = append(allSummaries, existingSummaries...)
|
|
||||||
|
|
||||||
summary, err := mergeSummaries(allSummaries)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if cacheKey != "" {
|
|
||||||
srv.cache.SetDefault(cacheKey, summary)
|
|
||||||
}
|
|
||||||
|
|
||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *SummaryService) PostProcessWrapped(summary *models.Summary, err error) (*models.Summary, error) {
|
// CRUD methods
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return srv.PostProcess(summary), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *SummaryService) PostProcess(summary *models.Summary) *models.Summary {
|
func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
|
||||||
updatedSummary := &models.Summary{
|
return srv.repository.GetLastByUser()
|
||||||
ID: summary.ID,
|
|
||||||
UserID: summary.UserID,
|
|
||||||
FromTime: summary.FromTime,
|
|
||||||
ToTime: summary.ToTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
processAliases := func(origin []*models.SummaryItem) []*models.SummaryItem {
|
|
||||||
target := make([]*models.SummaryItem, 0)
|
|
||||||
|
|
||||||
findItem := func(key string) *models.SummaryItem {
|
|
||||||
for _, item := range target {
|
|
||||||
if item.Key == key {
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range origin {
|
|
||||||
// Add all "top-level" items, i.e. such without aliases
|
|
||||||
if key, _ := srv.aliasService.GetAliasOrDefault(summary.UserID, item.Type, item.Key); key == item.Key {
|
|
||||||
target = append(target, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range origin {
|
|
||||||
// Add all remaining projects and merge with their alias
|
|
||||||
if key, _ := srv.aliasService.GetAliasOrDefault(summary.UserID, item.Type, item.Key); key != item.Key {
|
|
||||||
if targetItem := findItem(key); targetItem != nil {
|
|
||||||
targetItem.Total += item.Total
|
|
||||||
} else {
|
|
||||||
target = append(target, &models.SummaryItem{
|
|
||||||
ID: item.ID,
|
|
||||||
SummaryID: item.SummaryID,
|
|
||||||
Type: item.Type,
|
|
||||||
Key: key,
|
|
||||||
Total: item.Total,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return target
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve aliases
|
|
||||||
updatedSummary.Projects = processAliases(summary.Projects)
|
|
||||||
updatedSummary.Editors = processAliases(summary.Editors)
|
|
||||||
updatedSummary.Languages = processAliases(summary.Languages)
|
|
||||||
updatedSummary.OperatingSystems = processAliases(summary.OperatingSystems)
|
|
||||||
updatedSummary.Machines = processAliases(summary.Machines)
|
|
||||||
|
|
||||||
return updatedSummary
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *SummaryService) Insert(summary *models.Summary) error {
|
|
||||||
return srv.repository.Insert(summary)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
|
|
||||||
return srv.repository.GetByUserWithin(user, from, to)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Will return *models.Index objects with only user_id and to_time filled
|
|
||||||
func (srv *SummaryService) GetLatestByUser() ([]*models.Summary, error) {
|
|
||||||
return srv.repository.GetLatestByUser()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *SummaryService) DeleteByUser(userId string) error {
|
func (srv *SummaryService) DeleteByUser(userId string) error {
|
||||||
return srv.repository.DeleteByUser(userId)
|
return srv.repository.DeleteByUser(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, user *models.User, c chan models.SummaryItemContainer) {
|
func (srv *SummaryService) Insert(summary *models.Summary) error {
|
||||||
|
return srv.repository.Insert(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private summary generation and utility methods
|
||||||
|
|
||||||
|
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, c chan models.SummaryItemContainer) {
|
||||||
durations := make(map[string]time.Duration)
|
durations := make(map[string]time.Duration)
|
||||||
|
|
||||||
for i, h := range heartbeats {
|
for i, h := range heartbeats {
|
||||||
var key string
|
key := h.GetKey(summaryType)
|
||||||
switch summaryType {
|
|
||||||
case models.SummaryProject:
|
|
||||||
key = h.Project
|
|
||||||
case models.SummaryEditor:
|
|
||||||
key = h.Editor
|
|
||||||
case models.SummaryLanguage:
|
|
||||||
key = h.Language
|
|
||||||
case models.SummaryOS:
|
|
||||||
key = h.OperatingSystem
|
|
||||||
case models.SummaryMachine:
|
|
||||||
key = h.Machine
|
|
||||||
}
|
|
||||||
|
|
||||||
if key == "" {
|
|
||||||
key = models.UnknownSummaryKey
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := durations[key]; !ok {
|
if _, ok := durations[key]; !ok {
|
||||||
durations[key] = time.Duration(0)
|
durations[key] = time.Duration(0)
|
||||||
@ -287,43 +219,7 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
|
|||||||
c <- models.SummaryItemContainer{Type: summaryType, Items: items}
|
c <- models.SummaryItemContainer{Type: summaryType, Items: items}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getMissingIntervals(from, to time.Time, existingSummaries []*models.Summary) []*Interval {
|
func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||||
if len(existingSummaries) == 0 {
|
|
||||||
return []*Interval{{from, to}}
|
|
||||||
}
|
|
||||||
|
|
||||||
intervals := make([]*Interval, 0)
|
|
||||||
|
|
||||||
// Pre
|
|
||||||
if from.Before(existingSummaries[0].FromTime.T()) {
|
|
||||||
intervals = append(intervals, &Interval{from, existingSummaries[0].FromTime.T()})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Between
|
|
||||||
for i := 0; i < len(existingSummaries)-1; i++ {
|
|
||||||
t1, t2 := existingSummaries[i].ToTime.T(), existingSummaries[i+1].FromTime.T()
|
|
||||||
if t1.Equal(t2) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
|
|
||||||
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
|
|
||||||
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
|
|
||||||
// one or more day missing in between?
|
|
||||||
if td1.Before(td2) {
|
|
||||||
intervals = append(intervals, &Interval{existingSummaries[i].ToTime.T(), existingSummaries[i+1].FromTime.T()})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Post
|
|
||||||
if to.After(existingSummaries[len(existingSummaries)-1].ToTime.T()) {
|
|
||||||
intervals = append(intervals, &Interval{existingSummaries[len(existingSummaries)-1].ToTime.T(), to})
|
|
||||||
}
|
|
||||||
|
|
||||||
return intervals
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
|
||||||
if len(summaries) < 1 {
|
if len(summaries) < 1 {
|
||||||
return nil, errors.New("no summaries given")
|
return nil, errors.New("no summaries given")
|
||||||
}
|
}
|
||||||
@ -353,11 +249,11 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
|||||||
maxTime = s.ToTime.T()
|
maxTime = s.ToTime.T()
|
||||||
}
|
}
|
||||||
|
|
||||||
finalSummary.Projects = mergeSummaryItems(finalSummary.Projects, s.Projects)
|
finalSummary.Projects = srv.mergeSummaryItems(finalSummary.Projects, s.Projects)
|
||||||
finalSummary.Languages = mergeSummaryItems(finalSummary.Languages, s.Languages)
|
finalSummary.Languages = srv.mergeSummaryItems(finalSummary.Languages, s.Languages)
|
||||||
finalSummary.Editors = mergeSummaryItems(finalSummary.Editors, s.Editors)
|
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
|
||||||
finalSummary.OperatingSystems = mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
||||||
finalSummary.Machines = mergeSummaryItems(finalSummary.Machines, s.Machines)
|
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
|
||||||
}
|
}
|
||||||
|
|
||||||
finalSummary.FromTime = models.CustomTime(minTime)
|
finalSummary.FromTime = models.CustomTime(minTime)
|
||||||
@ -366,7 +262,7 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
|||||||
return finalSummary, nil
|
return finalSummary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem) []*models.SummaryItem {
|
func (srv *SummaryService) mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem) []*models.SummaryItem {
|
||||||
items := make(map[string]*models.SummaryItem)
|
items := make(map[string]*models.SummaryItem)
|
||||||
|
|
||||||
// Build map from existing
|
// Build map from existing
|
||||||
@ -396,11 +292,46 @@ func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem
|
|||||||
return itemList
|
return itemList
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHash(times []time.Time, user *models.User) string {
|
func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*models.Summary) []*models.Interval {
|
||||||
digest := md5.New()
|
if len(summaries) == 0 {
|
||||||
for _, t := range times {
|
return []*models.Interval{{from, to}}
|
||||||
digest.Write([]byte(strconv.Itoa(int(t.Unix()))))
|
}
|
||||||
|
|
||||||
|
intervals := make([]*models.Interval, 0)
|
||||||
|
|
||||||
|
// Pre
|
||||||
|
if from.Before(summaries[0].FromTime.T()) {
|
||||||
|
intervals = append(intervals, &models.Interval{from, summaries[0].FromTime.T()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Between
|
||||||
|
for i := 0; i < len(summaries)-1; i++ {
|
||||||
|
t1, t2 := summaries[i].ToTime.T(), summaries[i+1].FromTime.T()
|
||||||
|
if t1.Equal(t2) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
|
||||||
|
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
|
||||||
|
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
|
||||||
|
// one or more day missing in between?
|
||||||
|
if td1.Before(td2) {
|
||||||
|
intervals = append(intervals, &models.Interval{summaries[i].ToTime.T(), summaries[i+1].FromTime.T()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post
|
||||||
|
if to.After(summaries[len(summaries)-1].ToTime.T()) {
|
||||||
|
intervals = append(intervals, &models.Interval{summaries[len(summaries)-1].ToTime.T(), to})
|
||||||
|
}
|
||||||
|
|
||||||
|
return intervals
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *SummaryService) getHash(args ...string) string {
|
||||||
|
digest := md5.New()
|
||||||
|
for _, a := range args {
|
||||||
|
digest.Write([]byte(a))
|
||||||
}
|
}
|
||||||
digest.Write([]byte(user.ID))
|
|
||||||
return string(digest.Sum(nil))
|
return string(digest.Sum(nil))
|
||||||
}
|
}
|
||||||
|
@ -1 +1 @@
|
|||||||
1.15.1
|
1.16.0
|
||||||
|
Loading…
Reference in New Issue
Block a user