Skip to content

Commit

Permalink
Define schedules using rotations
Browse files Browse the repository at this point in the history
This is a fundamental and incompatible change to how schedules are defined.
Now, a schedule consists of a list of rotations that's ordered by priority.
Each rotation contains multiple members where each is either a contact or a
contact group. Each member is linked to some timeperiod entries which defines
when this member is active in the rotation.

This commit already includes code for a feature that was planned but is
possible using the web interface at the moment: multiple versions of the same
rotation where the handoff time defines when a given version becomes active.

With this change, for the time being, the TimePeriod type itself fulfills no
real purpose and the timeperiod entries are directly loaded as part of the
schedule, bypassing the timeperiod loading code. However, there still is the
plan to add standalone timeperiods in the future, thus the timeperiod code is
kept.

More context for these changes:
- Icinga/icinga-notifications-web#177
- #193
  • Loading branch information
julianbrost committed May 28, 2024
1 parent 92bf559 commit 3e03da5
Show file tree
Hide file tree
Showing 7 changed files with 446 additions and 100 deletions.
138 changes: 93 additions & 45 deletions internal/config/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"context"
"github.com/icinga/icinga-notifications/internal/recipient"
"github.com/icinga/icinga-notifications/internal/timeperiod"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
)
Expand All @@ -27,28 +28,93 @@ func (r *RuntimeConfig) fetchSchedules(ctx context.Context, tx *sqlx.Tx) error {
zap.String("name", g.Name))
}

var memberPtr *recipient.ScheduleMemberRow
stmt = r.db.BuildSelectStmt(memberPtr, memberPtr)
var rotationPtr *recipient.Rotation
stmt = r.db.BuildSelectStmt(rotationPtr, rotationPtr)
r.logger.Debugf("Executing query %q", stmt)

var members []*recipient.ScheduleMemberRow
var rotations []*recipient.Rotation
if err := tx.SelectContext(ctx, &rotations, stmt); err != nil {
r.logger.Errorln(err)
return err
}

rotationsById := make(map[int64]*recipient.Rotation)
for _, rotation := range rotations {
rotationLogger := r.logger.SugaredLogger.With(zap.Object("rotation", rotation))

if schedule := schedulesById[rotation.ScheduleID]; schedule == nil {
rotationLogger.Warnw("ignoring schedule rotation for unknown schedule_id")
} else {
rotationsById[rotation.ID] = rotation
schedule.Rotations = append(schedule.Rotations, rotation)

rotationLogger.Debugw("loaded schedule rotation")
}
}

var rotationMemberPtr *recipient.RotationMember
stmt = r.db.BuildSelectStmt(rotationMemberPtr, rotationMemberPtr)
r.logger.Debugf("Executing query %q", stmt)

var members []*recipient.RotationMember
if err := tx.SelectContext(ctx, &members, stmt); err != nil {
r.logger.Errorln(err)
return err
}

rotationMembersById := make(map[int64]*recipient.RotationMember)
for _, member := range members {
memberLogger := makeScheduleMemberLogger(r.logger.SugaredLogger, member)
memberLogger := r.logger.SugaredLogger.With(zap.Object("rotation_member", member))

if s := schedulesById[member.ScheduleID]; s == nil {
memberLogger.Warnw("ignoring schedule member for unknown schedule_id")
if rotation := rotationsById[member.RotationID]; rotation == nil {
memberLogger.Warnw("ignoring rotation member for unknown rotation_member_id")
} else {
s.MemberRows = append(s.MemberRows, member)
member.TimePeriodEntries = make(map[int64]*timeperiod.Entry)
rotation.Members = append(rotation.Members, member)
rotationMembersById[member.ID] = member

memberLogger.Debugw("member")
memberLogger.Debugw("loaded schedule rotation member")
}
}

var entryPtr *timeperiod.Entry
stmt = r.db.BuildSelectStmt(entryPtr, entryPtr) + " WHERE rotation_member_id IS NOT NULL"
r.logger.Debugf("Executing query %q", stmt)

var entries []*timeperiod.Entry
if err := tx.SelectContext(ctx, &entries, stmt); err != nil {
r.logger.Errorln(err)
return err
}

for _, entry := range entries {
var member *recipient.RotationMember
if entry.RotationMemberID.Valid {
member = rotationMembersById[entry.RotationMemberID.Int64]
}

if member == nil {
r.logger.Warnw("ignoring entry for unknown rotation_member_id",
zap.Int64("timeperiod_entry_id", entry.ID),
zap.Int64("timeperiod_id", entry.TimePeriodID))
continue
}

err := entry.Init()
if err != nil {
r.logger.Warnw("ignoring time period entry",
zap.Object("entry", entry),
zap.Error(err))
continue
}

member.TimePeriodEntries[entry.ID] = entry
}

for _, schedule := range schedulesById {
schedule.RefreshRotations()
}

if r.Schedules != nil {
// mark no longer existing schedules for deletion
for id := range r.Schedules {
Expand All @@ -72,38 +138,29 @@ func (r *RuntimeConfig) applyPendingSchedules() {
if pendingSchedule == nil {
delete(r.Schedules, id)
} else {
for _, memberRow := range pendingSchedule.MemberRows {
memberLogger := makeScheduleMemberLogger(r.logger.SugaredLogger, memberRow)

period := r.TimePeriods[memberRow.TimePeriodID]
if period == nil {
memberLogger.Warnw("ignoring schedule member for unknown timeperiod_id")
continue
}

var contact *recipient.Contact
if memberRow.ContactID.Valid {
contact = r.Contacts[memberRow.ContactID.Int64]
if contact == nil {
memberLogger.Warnw("ignoring schedule member for unknown contact_id")
continue
for _, rotation := range pendingSchedule.Rotations {

for _, member := range rotation.Members {
memberLogger := r.logger.With(
zap.Object("rotation", rotation),
zap.Object("rotation_member", member))

if member.ContactID.Valid {
member.Contact = r.Contacts[member.ContactID.Int64]
if member.Contact == nil {
memberLogger.Warnw("ignoring rotation member for unknown contact_id")
continue
}
}
}

var group *recipient.Group
if memberRow.GroupID.Valid {
group = r.Groups[memberRow.GroupID.Int64]
if group == nil {
memberLogger.Warnw("ignoring schedule member for unknown contactgroup_id")
continue
if member.ContactGroupID.Valid {
member.ContactGroup = r.Groups[member.ContactGroupID.Int64]
if member.ContactGroup == nil {
memberLogger.Warnw("ignoring rotation member for unknown contactgroup_id")
continue
}
}
}

pendingSchedule.Members = append(pendingSchedule.Members, &recipient.Member{
TimePeriod: period,
Contact: contact,
ContactGroup: group,
})
}

if currentSchedule := r.Schedules[id]; currentSchedule != nil {
Expand All @@ -116,12 +173,3 @@ func (r *RuntimeConfig) applyPendingSchedules() {

r.pending.Schedules = nil
}

func makeScheduleMemberLogger(logger *zap.SugaredLogger, member *recipient.ScheduleMemberRow) *zap.SugaredLogger {
return logger.With(
zap.Int64("schedule_id", member.ScheduleID),
zap.Int64("timeperiod_id", member.TimePeriodID),
zap.Int64("contact_id", member.ContactID.Int64),
zap.Int64("contactgroup_id", member.GroupID.Int64),
)
}
3 changes: 0 additions & 3 deletions internal/config/timeperiod.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,6 @@ func (r *RuntimeConfig) fetchTimePeriods(ctx context.Context, tx *sqlx.Tx) error

if p.Name == "" {
p.Name = fmt.Sprintf("Time Period #%d", entry.TimePeriodID)
if entry.Description.Valid {
p.Name += fmt.Sprintf(" (%s)", entry.Description.String)
}
}

err := entry.Init()
Expand Down
44 changes: 23 additions & 21 deletions internal/config/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,34 +199,36 @@ func (r *RuntimeConfig) debugVerifySchedule(id int64, schedule *recipient.Schedu
return fmt.Errorf("schedule %p is inconsistent with RuntimeConfig.Schedules[%d] = %p", schedule, id, other)
}

for i, member := range schedule.Members {
if member == nil {
return fmt.Errorf("Members[%d] is nil", i)
for i, rotation := range schedule.Rotations {
if rotation == nil {
return fmt.Errorf("Rotations[%d] is nil", i)
}

if member.TimePeriod == nil {
return fmt.Errorf("Members[%d].TimePeriod is nil", i)
}
for j, member := range rotation.Members {
if member == nil {
return fmt.Errorf("Rotations[%d].Members[%d] is nil", i, j)
}

if member.Contact == nil && member.ContactGroup == nil {
return fmt.Errorf("Members[%d] has neither Contact nor ContactGroup set", i)
}
if member.Contact == nil && member.ContactGroup == nil {
return fmt.Errorf("Rotations[%d].Members[%d] has neither Contact nor ContactGroup set", i, j)
}

if member.Contact != nil && member.ContactGroup != nil {
return fmt.Errorf("Members[%d] has both Contact and ContactGroup set", i)
}
if member.Contact != nil && member.ContactGroup != nil {
return fmt.Errorf("Rotations[%d].Members[%d] has both Contact and ContactGroup set", i, j)
}

if member.Contact != nil {
err := r.debugVerifyContact(member.Contact.ID, member.Contact)
if err != nil {
return fmt.Errorf("Contact: %w", err)
if member.Contact != nil {
err := r.debugVerifyContact(member.ContactID.Int64, member.Contact)
if err != nil {
return fmt.Errorf("Contact: %w", err)
}
}
}

if member.ContactGroup != nil {
err := r.debugVerifyGroup(member.ContactGroup.ID, member.ContactGroup)
if err != nil {
return fmt.Errorf("ContactGroup: %w", err)
if member.ContactGroup != nil {
err := r.debugVerifyGroup(member.ContactGroupID.Int64, member.ContactGroup)
if err != nil {
return fmt.Errorf("ContactGroup: %w", err)
}
}
}
}
Expand Down
112 changes: 112 additions & 0 deletions internal/recipient/rotations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package recipient

import (
"cmp"
"slices"
"time"
)

// rotationResolver stores all the rotations from a scheduled in a structured way that's suitable for evaluating them.
type rotationResolver struct {
// sortedByPriority is ordered so that the elements at a smaller index have higher precedence.
sortedByPriority []*rotationsWithPriority
}

// rotationsWithPriority stores the different versions of the rotations with the same priority within a single schedule.
type rotationsWithPriority struct {
priority int32

// sortedByHandoff contains the different version of a specific rotation sorted by their ActualHandoff time.
// This allows using binary search to find the active version.
sortedByHandoff []*Rotation
}

// update initializes the rotationResolver with the given rotations, resetting any previously existing state.
func (r *rotationResolver) update(rotations []*Rotation) {
// Group sortedByHandoff by priority using a temporary map with the priority as key.
prioMap := make(map[int32]*rotationsWithPriority)
for _, rotation := range rotations {
p := prioMap[rotation.Priority]
if p == nil {
p = &rotationsWithPriority{
priority: rotation.Priority,
}
prioMap[rotation.Priority] = p
}

p.sortedByHandoff = append(p.sortedByHandoff, rotation)
}

// Copy it to a slice and sort it by priority so that these can easily be iterated by priority.
rs := make([]*rotationsWithPriority, 0, len(prioMap))
for _, rotation := range prioMap {
rs = append(rs, rotation)
}
slices.SortFunc(rs, func(a, b *rotationsWithPriority) int {
return cmp.Compare(a.priority, b.priority)
})

// Sort the different versions of the same rotation (i.e. same schedule and priority, differing in their handoff
// time) by the handoff time so that the currently active version can be found with binary search.
for _, rotation := range rs {
slices.SortFunc(rotation.sortedByHandoff, func(a, b *Rotation) int {
return a.ActualHandoff.Time().Compare(b.ActualHandoff.Time())
})
}

r.sortedByPriority = rs
}

// getRotationsAt returns a slice of active rotations at the given time.
//
// For priority, there may be at most one active rotation version. This function return all rotation versions that
// are active at the given time t, ordered by priority (lower index has higher precedence).
func (r *rotationResolver) getRotationsAt(t time.Time) []*Rotation {
rotations := make([]*Rotation, 0, len(r.sortedByPriority))

for _, w := range r.sortedByPriority {
i, found := slices.BinarySearchFunc(w.sortedByHandoff, t, func(rotation *Rotation, t time.Time) int {
return rotation.ActualHandoff.Time().Compare(t)
})

// If a rotation version with sortedByHandoff[i].ActualHandoff == t is found, it just became valid and should be
// used. Otherwise, the smallest i so that sortedByHandoff[i].ActualHandoff > t is returned, the currently
// active rotation version thus is the preceding one.
if !found {
i--
}

// If all rotation versions have ActualHandoff > t, there is none that's currently active and i is negative.
if i >= 0 {
rotations = append(rotations, w.sortedByHandoff[i])
}
}

return rotations
}

// getContactsAt evaluates the rotations by priority and returns all contacts active at the given time.
func (r *rotationResolver) getContactsAt(t time.Time) []*Contact {
rotations := r.getRotationsAt(t)
for _, rotation := range rotations {
for _, member := range rotation.Members {
for _, entry := range member.TimePeriodEntries {
if entry.Contains(t) {
var contacts []*Contact

if member.Contact != nil {
contacts = append(contacts, member.Contact)
}

if member.ContactGroup != nil {
contacts = append(contacts, member.ContactGroup.Members...)
}

return contacts
}
}
}
}

return nil
}
Loading

0 comments on commit 3e03da5

Please sign in to comment.