refactor: move more background jobs to using job queue

This commit is contained in:
Ferdinand Mütsch 2022-11-20 10:10:24 +01:00
parent e2ef54152d
commit fcca881cfc
9 changed files with 179 additions and 156 deletions

View File

@ -2,25 +2,33 @@ package config
import ( import (
"fmt" "fmt"
"github.com/emvi/logbuch"
"github.com/muety/artifex" "github.com/muety/artifex"
"math"
"runtime"
) )
var jobqueues map[string]*artifex.Dispatcher var jobqueues map[string]*artifex.Dispatcher
const ( const (
QueueDefault = "" QueueDefault = "wakapi.default"
QueueProcessing = "processing" QueueProcessing = "wakapi.processing"
QueueMails = "mails" QueueReports = "wakapi.reports"
) )
func init() { func init() {
jobqueues = make(map[string]*artifex.Dispatcher) jobqueues = make(map[string]*artifex.Dispatcher)
InitQueue(QueueDefault, 1)
InitQueue(QueueProcessing, int(math.Ceil(float64(runtime.NumCPU())/2.0)))
InitQueue(QueueReports, 1)
} }
func InitQueue(name string, workers int) error { func InitQueue(name string, workers int) error {
if _, ok := jobqueues[name]; ok { if _, ok := jobqueues[name]; ok {
return fmt.Errorf("queue '%s' already existing", name) return fmt.Errorf("queue '%s' already existing", name)
} }
logbuch.Info("creating job queue '%s' (%d workers)", name, workers)
jobqueues[name] = artifex.NewDispatcher(workers, 4096) jobqueues[name] = artifex.NewDispatcher(workers, 4096)
jobqueues[name].Start() jobqueues[name].Start()
return nil return nil

View File

@ -186,9 +186,9 @@ func main() {
// Schedule background tasks // Schedule background tasks
go aggregationService.Schedule() go aggregationService.Schedule()
go leaderboardService.ScheduleDefault() go leaderboardService.Schedule()
go miscService.ScheduleCountTotalTime()
go reportService.Schedule() go reportService.Schedule()
go miscService.ScheduleCountTotalTime()
routes.Init() routes.Init()

View File

@ -661,7 +661,7 @@ func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
return err return err
} }
if err := h.aggregationSrvc.Run(datastructure.NewSet(user.ID)); err != nil { if err := h.aggregationSrvc.AggregateSummaries(datastructure.NewSet(user.ID)); err != nil {
logbuch.Error("failed to regenerate summaries: %v", err) logbuch.Error("failed to regenerate summaries: %v", err)
return err return err
} }

View File

@ -4,12 +4,11 @@ import (
"errors" "errors"
datastructure "github.com/duke-git/lancet/v2/datastructure/set" datastructure "github.com/duke-git/lancet/v2/datastructure/set"
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/muety/artifex"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"runtime"
"sync" "sync"
"time" "time"
"github.com/go-co-op/gocron"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
) )
@ -25,6 +24,8 @@ type AggregationService struct {
summaryService ISummaryService summaryService ISummaryService
heartbeatService IHeartbeatService heartbeatService IHeartbeatService
inProgress datastructure.Set[string] inProgress datastructure.Set[string]
queueDefault *artifex.Dispatcher
queueWorkers *artifex.Dispatcher
} }
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService { func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
@ -34,6 +35,8 @@ func NewAggregationService(userService IUserService, summaryService ISummaryServ
summaryService: summaryService, summaryService: summaryService,
heartbeatService: heartbeatService, heartbeatService: heartbeatService,
inProgress: datastructure.NewSet[string](), inProgress: datastructure.NewSet[string](),
queueDefault: config.GetDefaultQueue(),
queueWorkers: config.GetQueue(config.QueueProcessing),
} }
} }
@ -45,58 +48,23 @@ type AggregationJob struct {
// Schedule a job to (re-)generate summaries every day shortly after midnight // Schedule a job to (re-)generate summaries every day shortly after midnight
func (srv *AggregationService) Schedule() { func (srv *AggregationService) Schedule() {
s := gocron.NewScheduler(time.Local) logbuch.Info("scheduling summary aggregation")
s.Every(1).Day().At(srv.config.App.AggregationTime).WaitForSchedule().Do(srv.Run, datastructure.NewSet[string]())
s.StartBlocking() if _, err := srv.queueDefault.DispatchCron(func() {
if err := srv.AggregateSummaries(datastructure.NewSet[string]()); err != nil {
config.Log().Error("failed to generate summaries, %v", err)
}
}, srv.config.App.GetAggregationTimeCron()); err != nil {
config.Log().Error("failed to schedule summary generation, %v", err)
}
} }
func (srv *AggregationService) Run(userIds datastructure.Set[string]) error { func (srv *AggregationService) AggregateSummaries(userIds datastructure.Set[string]) error {
if err := srv.lockUsers(userIds); err != nil { if err := srv.lockUsers(userIds); err != nil {
return err return err
} }
defer srv.unlockUsers(userIds) defer srv.unlockUsers(userIds)
jobs := make(chan *AggregationJob)
summaries := make(chan *models.Summary)
for i := 0; i < runtime.NumCPU(); i++ {
go srv.summaryWorker(jobs, summaries)
}
for i := 0; i < int(srv.config.Db.MaxConn); i++ {
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)
}
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
for job := range jobs {
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}, nil); err != nil {
config.Log().Error("failed to generate summary (%v, %v, %s) - %v", job.From, job.To, job.UserID, err)
} else {
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
summaries <- summary
}
}
}
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
for summary := range summaries {
if err := srv.summaryService.Insert(summary); err != nil {
config.Log().Error("failed to save summary (%v, %v, %s) - %v", summary.UserID, summary.FromTime, summary.ToTime, err)
}
}
}
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds datastructure.Set[string]) error {
logbuch.Info("generating summaries") logbuch.Info("generating summaries")
// Get a map from user ids to the time of their latest summary or nil if none exists yet // Get a map from user ids to the time of their latest summary or nil if none exists yet
@ -119,6 +87,19 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds data
firstUserHeartbeatLookup[e.User] = e.Time firstUserHeartbeatLookup[e.User] = e.Time
} }
// Dispatch summary generation jobs
jobs := make(chan *AggregationJob)
defer close(jobs)
go func() {
for job := range jobs {
if err := srv.queueWorkers.Dispatch(func() {
srv.process(job)
}); err != nil {
config.Log().Error("failed to dispatch summary generation job for user '%s'", job.UserID)
}
}
}()
// Generate summary aggregation jobs // Generate summary aggregation jobs
for _, e := range lastUserSummaryTimes { for _, e := range lastUserSummaryTimes {
if userIds != nil && !userIds.IsEmpty() && !userIds.Contain(e.User) { if userIds != nil && !userIds.IsEmpty() && !userIds.Contain(e.User) {
@ -141,24 +122,15 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds data
return nil return nil
} }
func (srv *AggregationService) lockUsers(userIds datastructure.Set[string]) error { func (srv *AggregationService) process(job *AggregationJob) {
aggregationLock.Lock() if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}, nil); err != nil {
defer aggregationLock.Unlock() config.Log().Error("failed to generate summary (%v, %v, %s) - %v", job.From, job.To, job.UserID, err)
for uid := range userIds { } else {
if srv.inProgress.Contain(uid) { logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
return errors.New("aggregation already in progress for at least of the request users") if err := srv.summaryService.Insert(summary); err != nil {
config.Log().Error("failed to save summary (%v, %v, %s) - %v", summary.UserID, summary.FromTime, summary.ToTime, err)
} }
} }
srv.inProgress = srv.inProgress.Union(userIds)
return nil
}
func (srv *AggregationService) unlockUsers(userIds datastructure.Set[string]) {
aggregationLock.Lock()
defer aggregationLock.Unlock()
for uid := range userIds {
srv.inProgress.Delete(uid)
}
} }
func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) { func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) {
@ -189,6 +161,26 @@ func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob
} }
} }
func (srv *AggregationService) lockUsers(userIds datastructure.Set[string]) error {
aggregationLock.Lock()
defer aggregationLock.Unlock()
for uid := range userIds {
if srv.inProgress.Contain(uid) {
return errors.New("aggregation already in progress for at least of the request users")
}
}
srv.inProgress = srv.inProgress.Union(userIds)
return nil
}
func (srv *AggregationService) unlockUsers(userIds datastructure.Set[string]) {
aggregationLock.Lock()
defer aggregationLock.Unlock()
for uid := range userIds {
srv.inProgress.Delete(uid)
}
}
func getStartOfToday() time.Time { func getStartOfToday() time.Time {
now := time.Now() now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 1, now.Location()) return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 1, now.Location())

View File

@ -2,8 +2,8 @@ package services
import ( import (
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/go-co-op/gocron"
"github.com/leandro-lugaresi/hub" "github.com/leandro-lugaresi/hub"
"github.com/muety/artifex"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories" "github.com/muety/wakapi/repositories"
@ -22,6 +22,8 @@ type LeaderboardService struct {
repository repositories.ILeaderboardRepository repository repositories.ILeaderboardRepository
summaryService ISummaryService summaryService ISummaryService
userService IUserService userService IUserService
queueDefault *artifex.Dispatcher
queueWorkers *artifex.Dispatcher
} }
func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository, summaryService ISummaryService, userService IUserService) *LeaderboardService { func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository, summaryService ISummaryService, userService IUserService) *LeaderboardService {
@ -32,6 +34,8 @@ func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository,
repository: leaderboardRepo, repository: leaderboardRepo,
summaryService: summaryService, summaryService: summaryService,
userService: userService, userService: userService,
queueDefault: config.GetDefaultQueue(),
queueWorkers: config.GetQueue(config.QueueProcessing),
} }
onUserUpdate := srv.eventBus.Subscribe(0, config.EventUserUpdate) onUserUpdate := srv.eventBus.Subscribe(0, config.EventUserUpdate)
@ -48,7 +52,7 @@ func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository,
if user.PublicLeaderboard && !exists { if user.PublicLeaderboard && !exists {
logbuch.Info("generating leaderboard for '%s' after settings update", user.ID) logbuch.Info("generating leaderboard for '%s' after settings update", user.ID)
srv.Run([]*models.User{user}, models.IntervalPast7Days, []uint8{models.SummaryLanguage}) srv.ComputeLeaderboard([]*models.User{user}, models.IntervalPast7Days, []uint8{models.SummaryLanguage})
} else if !user.PublicLeaderboard && exists { } else if !user.PublicLeaderboard && exists {
logbuch.Info("clearing leaderboard for '%s' after settings update", user.ID) logbuch.Info("clearing leaderboard for '%s' after settings update", user.ID)
if err := srv.repository.DeleteByUser(user.ID); err != nil { if err := srv.repository.DeleteByUser(user.ID); err != nil {
@ -62,23 +66,26 @@ func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository,
return srv return srv
} }
func (srv *LeaderboardService) ScheduleDefault() { func (srv *LeaderboardService) Schedule() {
runAllUsers := func(interval *models.IntervalKey, by []uint8) { logbuch.Info("scheduling leaderboard generation")
generate := func() {
users, err := srv.userService.GetAllByLeaderboard(true) users, err := srv.userService.GetAllByLeaderboard(true)
if err != nil { if err != nil {
config.Log().Error("failed to get users for leaderboard generation - %v", err) config.Log().Error("failed to get users for leaderboard generation - %v", err)
return return
} }
srv.ComputeLeaderboard(users, models.IntervalPast7Days, []uint8{models.SummaryLanguage})
srv.Run(users, interval, by)
} }
s := gocron.NewScheduler(time.Local) for _, cronExp := range srv.config.App.GetLeaderboardGenerationTimeCron() {
s.Every(1).Day().At(srv.config.App.LeaderboardGenerationTime).Do(runAllUsers, models.IntervalPast7Days, []uint8{models.SummaryLanguage}) if _, err := srv.queueDefault.DispatchCron(generate, cronExp); err != nil {
s.StartBlocking() config.Log().Error("failed to schedule leaderboard generation (%s), %v", cronExp, err)
}
}
} }
func (srv *LeaderboardService) Run(users []*models.User, interval *models.IntervalKey, by []uint8) error { func (srv *LeaderboardService) ComputeLeaderboard(users []*models.User, interval *models.IntervalKey, by []uint8) error {
logbuch.Info("generating leaderboard (%s) for %d users (%d aggregations)", (*interval)[0], len(users), len(by)) logbuch.Info("generating leaderboard (%s) for %d users (%d aggregations)", (*interval)[0], len(users), len(by))
for _, user := range users { for _, user := range users {

View File

@ -2,12 +2,13 @@ package services
import ( import (
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/muety/artifex"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"runtime" "github.com/muety/wakapi/utils"
"strconv" "strconv"
"sync"
"time" "time"
"github.com/go-co-op/gocron"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
) )
@ -16,6 +17,8 @@ type MiscService struct {
userService IUserService userService IUserService
summaryService ISummaryService summaryService ISummaryService
keyValueService IKeyValueService keyValueService IKeyValueService
queueDefault *artifex.Dispatcher
queueWorkers *artifex.Dispatcher
} }
func NewMiscService(userService IUserService, summaryService ISummaryService, keyValueService IKeyValueService) *MiscService { func NewMiscService(userService IUserService, summaryService ISummaryService, keyValueService IKeyValueService) *MiscService {
@ -24,81 +27,64 @@ func NewMiscService(userService IUserService, summaryService ISummaryService, ke
userService: userService, userService: userService,
summaryService: summaryService, summaryService: summaryService,
keyValueService: keyValueService, keyValueService: keyValueService,
queueDefault: config.GetDefaultQueue(),
queueWorkers: config.GetQueue(config.QueueProcessing),
} }
} }
type CountTotalTimeJob struct {
UserID string
NumJobs int
}
type CountTotalTimeResult struct {
UserId string
Total time.Duration
}
func (srv *MiscService) ScheduleCountTotalTime() { func (srv *MiscService) ScheduleCountTotalTime() {
s := gocron.NewScheduler(time.Local) if _, err := srv.queueDefault.DispatchEvery(srv.CountTotalTime, 1*time.Hour); err != nil {
s.Every(1).Hour().WaitForSchedule().Do(srv.runCountTotalTime) config.Log().Error("failed to schedule user counting jobs, %v", err)
s.StartBlocking() }
} }
func (srv *MiscService) runCountTotalTime() error { func (srv *MiscService) CountTotalTime() {
users, err := srv.userService.GetAll() users, err := srv.userService.GetAll()
if err != nil { if err != nil {
return err logbuch.Error("failed to fetch users for time counting, %v", err)
} }
jobs := make(chan *CountTotalTimeJob, len(users)) var totalTime time.Duration = 0
results := make(chan *CountTotalTimeResult, len(users)) var pendingJobs sync.WaitGroup
pendingJobs.Add(len(users))
for _, u := range users { for _, u := range users {
jobs <- &CountTotalTimeJob{ if err := srv.queueWorkers.Dispatch(func() {
UserID: u.ID, defer pendingJobs.Done()
NumJobs: len(users), totalTime += srv.countUserTotalTime(u.ID)
}); err != nil {
config.Log().Error("failed to enqueue counting job for user '%s'", u.ID)
pendingJobs.Done()
} }
} }
close(jobs)
for i := 0; i < runtime.NumCPU(); i++ {
go srv.countTotalTimeWorker(jobs, results)
}
// persist // persist
var i int go func(wg *sync.WaitGroup) {
var total time.Duration if utils.WaitTimeout(&pendingJobs, 10*time.Minute) {
for i = 0; i < len(users); i++ { if err := srv.keyValueService.PutString(&models.KeyStringValue{
result := <-results Key: config.KeyLatestTotalTime,
total += result.Total Value: totalTime.String(),
} }); err != nil {
close(results) logbuch.Error("failed to save total time count: %v", err)
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalTime,
Value: total.String(),
}); err != nil {
logbuch.Error("failed to save total time count: %v", err)
}
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalUsers,
Value: strconv.Itoa(i),
}); err != nil {
logbuch.Error("failed to save total users count: %v", err)
}
return nil
}
func (srv *MiscService) countTotalTimeWorker(jobs <-chan *CountTotalTimeJob, results chan<- *CountTotalTimeResult) {
for job := range jobs {
if result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: job.UserID}, srv.summaryService.Retrieve, nil, false); err != nil {
config.Log().Error("failed to count total for user %s: %v", job.UserID, err)
} else {
results <- &CountTotalTimeResult{
UserId: job.UserID,
Total: result.TotalTime(),
} }
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalUsers,
Value: strconv.Itoa(len(users)),
}); err != nil {
logbuch.Error("failed to save total users count: %v", err)
}
} else {
config.Log().Error("waiting for user counting jobs timed out")
} }
} }(&pendingJobs)
}
func (srv *MiscService) countUserTotalTime(userId string) time.Duration {
result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: userId}, srv.summaryService.Retrieve, nil, false)
if err != nil {
config.Log().Error("failed to count total for user %s: %v", userId, err)
return 0
}
return result.TotalTime()
} }

View File

@ -4,6 +4,7 @@ import (
"github.com/duke-git/lancet/v2/slice" "github.com/duke-git/lancet/v2/slice"
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/leandro-lugaresi/hub" "github.com/leandro-lugaresi/hub"
"github.com/muety/artifex"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"math/rand" "math/rand"
@ -11,7 +12,7 @@ import (
) )
// delay between evey report generation task (to throttle email sending frequency) // delay between evey report generation task (to throttle email sending frequency)
const reportDelay = 15 * time.Second const reportDelay = 5 * time.Second
// past time range to cover in the report // past time range to cover in the report
const reportRange = 7 * 24 * time.Hour const reportRange = 7 * 24 * time.Hour
@ -23,6 +24,8 @@ type ReportService struct {
userService IUserService userService IUserService
mailService IMailService mailService IMailService
rand *rand.Rand rand *rand.Rand
queueDefault *artifex.Dispatcher
queueWorkers *artifex.Dispatcher
} }
func NewReportService(summaryService ISummaryService, userService IUserService, mailService IMailService) *ReportService { func NewReportService(summaryService ISummaryService, userService IUserService, mailService IMailService) *ReportService {
@ -33,15 +36,27 @@ func NewReportService(summaryService ISummaryService, userService IUserService,
userService: userService, userService: userService,
mailService: mailService, mailService: mailService,
rand: rand.New(rand.NewSource(time.Now().Unix())), rand: rand.New(rand.NewSource(time.Now().Unix())),
queueDefault: config.GetDefaultQueue(),
queueWorkers: config.GetQueue(config.QueueReports),
} }
return srv return srv
} }
func (srv *ReportService) Schedule() { func (srv *ReportService) Schedule() {
logbuch.Info("initializing report service") logbuch.Info("scheduling report generation")
_, err := config.GetDefaultQueue().DispatchCron(func() { scheduleUserReport := func(u *models.User, index int) {
if err := srv.queueWorkers.DispatchIn(func() {
if err := srv.SendReport(u, reportRange); err != nil {
config.Log().Error("failed to generate report for '%s', %v", u.ID, err)
}
}, time.Duration(index)*reportDelay); err != nil {
config.Log().Error("failed to dispatch report generation job for user '%s', %v", u.ID, err)
}
}
_, err := srv.queueDefault.DispatchCron(func() {
// fetch all users with reports enabled // fetch all users with reports enabled
users, err := srv.userService.GetAllByReports(true) users, err := srv.userService.GetAllByReports(true)
if err != nil { if err != nil {
@ -54,18 +69,10 @@ func (srv *ReportService) Schedule() {
return u.Email != "" return u.Email != ""
}) })
// schedule jobs, throttled by one job per 15 seconds // schedule jobs, throttled by one job per x seconds
logbuch.Info("scheduling report generation for %d users", len(users)) logbuch.Info("scheduling report generation for %d users", len(users))
for i, u := range users { for i, u := range users {
err := config.GetQueue(config.QueueMails).DispatchIn(func() { scheduleUserReport(u, i)
if err := srv.SendReport(u, reportRange); err != nil {
config.Log().Error("failed to generate report for '%s', %v", u.ID, err)
}
}, time.Duration(i)*reportDelay)
if err != nil {
config.Log().Error("failed to dispatch report generation job for user '%s', %v", u.ID, err)
}
} }
}, srv.config.App.GetWeeklyReportCron()) }, srv.config.App.GetWeeklyReportCron())

View File

@ -8,7 +8,7 @@ import (
type IAggregationService interface { type IAggregationService interface {
Schedule() Schedule()
Run(set datastructure.Set[string]) error AggregateSummaries(set datastructure.Set[string]) error
} }
type IMiscService interface { type IMiscService interface {
@ -97,8 +97,8 @@ type IReportService interface {
} }
type ILeaderboardService interface { type ILeaderboardService interface {
ScheduleDefault() Schedule()
Run([]*models.User, *models.IntervalKey, []uint8) error ComputeLeaderboard([]*models.User, *models.IntervalKey, []uint8) error
ExistsAnyByUser(string) (bool, error) ExistsAnyByUser(string) (bool, error)
CountUsers() (int64, error) CountUsers() (int64, error)
GetByInterval(*models.IntervalKey, *models.PageParams, bool) (models.Leaderboard, error) GetByInterval(*models.IntervalKey, *models.PageParams, bool) (models.Leaderboard, error)

23
utils/sync.go Normal file
View File

@ -0,0 +1,23 @@
package utils
import (
"sync"
"time"
)
// WaitTimeout waits for the waitgroup for the specified max timeout.
// Returns true if waiting timed out.
// See // https://stackoverflow.com/a/32843750/3112139.
func WaitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool {
c := make(chan struct{})
go func() {
defer close(c)
wg.Wait()
}()
select {
case <-c:
return false // completed normally
case <-time.After(timeout):
return true // timed out
}
}