Skip to content

Commit 8366201

Browse files
committed
Define schedules using rotations
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
1 parent 6fb24b8 commit 8366201

File tree

8 files changed

+442
-105
lines changed

8 files changed

+442
-105
lines changed

internal/config/schedule.go

Lines changed: 90 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package config
33
import (
44
"context"
55
"github.com/icinga/icinga-notifications/internal/recipient"
6+
"github.com/icinga/icinga-notifications/internal/timeperiod"
67
"github.com/jmoiron/sqlx"
78
"go.uber.org/zap"
89
)
@@ -27,28 +28,93 @@ func (r *RuntimeConfig) fetchSchedules(ctx context.Context, tx *sqlx.Tx) error {
2728
zap.String("name", g.Name))
2829
}
2930

30-
var memberPtr *recipient.ScheduleMemberRow
31-
stmt = r.db.BuildSelectStmt(memberPtr, memberPtr)
31+
var rotationPtr *recipient.Rotation
32+
stmt = r.db.BuildSelectStmt(rotationPtr, rotationPtr)
3233
r.logger.Debugf("Executing query %q", stmt)
3334

34-
var members []*recipient.ScheduleMemberRow
35+
var rotations []*recipient.Rotation
36+
if err := tx.SelectContext(ctx, &rotations, stmt); err != nil {
37+
r.logger.Errorln(err)
38+
return err
39+
}
40+
41+
rotationsById := make(map[int64]*recipient.Rotation)
42+
for _, rotation := range rotations {
43+
rotationLogger := r.logger.With(zap.Object("rotation", rotation))
44+
45+
if schedule := schedulesById[rotation.ScheduleID]; schedule == nil {
46+
rotationLogger.Warnw("ignoring schedule rotation for unknown schedule_id")
47+
} else {
48+
rotationsById[rotation.ID] = rotation
49+
schedule.Rotations = append(schedule.Rotations, rotation)
50+
51+
rotationLogger.Debugw("loaded schedule rotation")
52+
}
53+
}
54+
55+
var rotationMemberPtr *recipient.RotationMember
56+
stmt = r.db.BuildSelectStmt(rotationMemberPtr, rotationMemberPtr)
57+
r.logger.Debugf("Executing query %q", stmt)
58+
59+
var members []*recipient.RotationMember
3560
if err := tx.SelectContext(ctx, &members, stmt); err != nil {
3661
r.logger.Errorln(err)
3762
return err
3863
}
3964

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

43-
if s := schedulesById[member.ScheduleID]; s == nil {
44-
memberLogger.Warnw("ignoring schedule member for unknown schedule_id")
69+
if rotation := rotationsById[member.RotationID]; rotation == nil {
70+
memberLogger.Warnw("ignoring rotation member for unknown rotation_member_id")
4571
} else {
46-
s.MemberRows = append(s.MemberRows, member)
72+
member.TimePeriodEntries = make(map[int64]*timeperiod.Entry)
73+
rotation.Members = append(rotation.Members, member)
74+
rotationMembersById[member.ID] = member
4775

48-
memberLogger.Debugw("member")
76+
memberLogger.Debugw("loaded schedule rotation member")
4977
}
5078
}
5179

80+
var entryPtr *timeperiod.Entry
81+
stmt = r.db.BuildSelectStmt(entryPtr, entryPtr) + " WHERE rotation_member_id IS NOT NULL"
82+
r.logger.Debugf("Executing query %q", stmt)
83+
84+
var entries []*timeperiod.Entry
85+
if err := tx.SelectContext(ctx, &entries, stmt); err != nil {
86+
r.logger.Errorln(err)
87+
return err
88+
}
89+
90+
for _, entry := range entries {
91+
var member *recipient.RotationMember
92+
if entry.RotationMemberID.Valid {
93+
member = rotationMembersById[entry.RotationMemberID.Int64]
94+
}
95+
96+
if member == nil {
97+
r.logger.Warnw("ignoring entry for unknown rotation_member_id",
98+
zap.Int64("timeperiod_entry_id", entry.ID),
99+
zap.Int64("timeperiod_id", entry.TimePeriodID))
100+
continue
101+
}
102+
103+
err := entry.Init()
104+
if err != nil {
105+
r.logger.Warnw("ignoring time period entry",
106+
zap.Object("entry", entry),
107+
zap.Error(err))
108+
continue
109+
}
110+
111+
member.TimePeriodEntries[entry.ID] = entry
112+
}
113+
114+
for _, schedule := range schedulesById {
115+
schedule.RefreshRotations()
116+
}
117+
52118
if r.Schedules != nil {
53119
// mark no longer existing schedules for deletion
54120
for id := range r.Schedules {
@@ -72,38 +138,26 @@ func (r *RuntimeConfig) applyPendingSchedules() {
72138
if pendingSchedule == nil {
73139
delete(r.Schedules, id)
74140
} else {
75-
for _, memberRow := range pendingSchedule.MemberRows {
76-
memberLogger := makeScheduleMemberLogger(r.logger.SugaredLogger, memberRow)
77-
78-
period := r.TimePeriods[memberRow.TimePeriodID]
79-
if period == nil {
80-
memberLogger.Warnw("ignoring schedule member for unknown timeperiod_id")
81-
continue
82-
}
83-
84-
var contact *recipient.Contact
85-
if memberRow.ContactID.Valid {
86-
contact = r.Contacts[memberRow.ContactID.Int64]
87-
if contact == nil {
88-
memberLogger.Warnw("ignoring schedule member for unknown contact_id")
89-
continue
141+
for _, rotation := range pendingSchedule.Rotations {
142+
for _, member := range rotation.Members {
143+
memberLogger := r.logger.With(
144+
zap.Object("rotation", rotation),
145+
zap.Object("rotation_member", member))
146+
147+
if member.ContactID.Valid {
148+
member.Contact = r.Contacts[member.ContactID.Int64]
149+
if member.Contact == nil {
150+
memberLogger.Warnw("rotation member has an unknown contact_id")
151+
}
90152
}
91-
}
92153

93-
var group *recipient.Group
94-
if memberRow.GroupID.Valid {
95-
group = r.Groups[memberRow.GroupID.Int64]
96-
if group == nil {
97-
memberLogger.Warnw("ignoring schedule member for unknown contactgroup_id")
98-
continue
154+
if member.ContactGroupID.Valid {
155+
member.ContactGroup = r.Groups[member.ContactGroupID.Int64]
156+
if member.ContactGroup == nil {
157+
memberLogger.Warnw("rotation member has an unknown contactgroup_id")
158+
}
99159
}
100160
}
101-
102-
pendingSchedule.Members = append(pendingSchedule.Members, &recipient.Member{
103-
TimePeriod: period,
104-
Contact: contact,
105-
ContactGroup: group,
106-
})
107161
}
108162

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

117171
r.pending.Schedules = nil
118172
}
119-
120-
func makeScheduleMemberLogger(logger *zap.SugaredLogger, member *recipient.ScheduleMemberRow) *zap.SugaredLogger {
121-
return logger.With(
122-
zap.Int64("schedule_id", member.ScheduleID),
123-
zap.Int64("timeperiod_id", member.TimePeriodID),
124-
zap.Int64("contact_id", member.ContactID.Int64),
125-
zap.Int64("contactgroup_id", member.GroupID.Int64),
126-
)
127-
}

internal/config/timeperiod.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package config
33
import (
44
"context"
55
"fmt"
6-
"github.com/icinga/icinga-go-library/types"
76
"github.com/icinga/icinga-notifications/internal/timeperiod"
87
"github.com/jmoiron/sqlx"
98
"go.uber.org/zap"
@@ -45,9 +44,6 @@ func (r *RuntimeConfig) fetchTimePeriods(ctx context.Context, tx *sqlx.Tx) error
4544

4645
if p.Name == "" {
4746
p.Name = fmt.Sprintf("Time Period #%d", entry.TimePeriodID)
48-
if entry.Description.Valid {
49-
p.Name += fmt.Sprintf(" (%s)", entry.Description.String)
50-
}
5147
}
5248

5349
err := entry.Init()

internal/config/verify.go

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -199,34 +199,28 @@ func (r *RuntimeConfig) debugVerifySchedule(id int64, schedule *recipient.Schedu
199199
return fmt.Errorf("schedule %p is inconsistent with RuntimeConfig.Schedules[%d] = %p", schedule, id, other)
200200
}
201201

202-
for i, member := range schedule.Members {
203-
if member == nil {
204-
return fmt.Errorf("Members[%d] is nil", i)
205-
}
206-
207-
if member.TimePeriod == nil {
208-
return fmt.Errorf("Members[%d].TimePeriod is nil", i)
202+
for i, rotation := range schedule.Rotations {
203+
if rotation == nil {
204+
return fmt.Errorf("Rotations[%d] is nil", i)
209205
}
210206

211-
if member.Contact == nil && member.ContactGroup == nil {
212-
return fmt.Errorf("Members[%d] has neither Contact nor ContactGroup set", i)
213-
}
214-
215-
if member.Contact != nil && member.ContactGroup != nil {
216-
return fmt.Errorf("Members[%d] has both Contact and ContactGroup set", i)
217-
}
207+
for j, member := range rotation.Members {
208+
if member == nil {
209+
return fmt.Errorf("Rotations[%d].Members[%d] is nil", i, j)
210+
}
218211

219-
if member.Contact != nil {
220-
err := r.debugVerifyContact(member.Contact.ID, member.Contact)
221-
if err != nil {
222-
return fmt.Errorf("Contact: %w", err)
212+
if member.Contact != nil {
213+
err := r.debugVerifyContact(member.ContactID.Int64, member.Contact)
214+
if err != nil {
215+
return fmt.Errorf("Contact: %w", err)
216+
}
223217
}
224-
}
225218

226-
if member.ContactGroup != nil {
227-
err := r.debugVerifyGroup(member.ContactGroup.ID, member.ContactGroup)
228-
if err != nil {
229-
return fmt.Errorf("ContactGroup: %w", err)
219+
if member.ContactGroup != nil {
220+
err := r.debugVerifyGroup(member.ContactGroupID.Int64, member.ContactGroup)
221+
if err != nil {
222+
return fmt.Errorf("ContactGroup: %w", err)
223+
}
230224
}
231225
}
232226
}

internal/recipient/rotations.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package recipient
2+
3+
import (
4+
"cmp"
5+
"slices"
6+
"time"
7+
)
8+
9+
// rotationResolver stores all the rotations from a scheduled in a structured way that's suitable for evaluating them.
10+
type rotationResolver struct {
11+
// sortedByPriority is ordered so that the elements at a smaller index have higher precedence.
12+
sortedByPriority []*rotationsWithPriority
13+
}
14+
15+
// rotationsWithPriority stores the different versions of the rotations with the same priority within a single schedule.
16+
type rotationsWithPriority struct {
17+
priority int32
18+
19+
// sortedByHandoff contains the different version of a specific rotation sorted by their ActualHandoff time.
20+
// This allows using binary search to find the active version.
21+
sortedByHandoff []*Rotation
22+
}
23+
24+
// update initializes the rotationResolver with the given rotations, resetting any previously existing state.
25+
func (r *rotationResolver) update(rotations []*Rotation) {
26+
// Group sortedByHandoff by priority using a temporary map with the priority as key.
27+
prioMap := make(map[int32]*rotationsWithPriority)
28+
for _, rotation := range rotations {
29+
p := prioMap[rotation.Priority]
30+
if p == nil {
31+
p = &rotationsWithPriority{
32+
priority: rotation.Priority,
33+
}
34+
prioMap[rotation.Priority] = p
35+
}
36+
37+
p.sortedByHandoff = append(p.sortedByHandoff, rotation)
38+
}
39+
40+
// Copy it to a slice and sort it by priority so that these can easily be iterated by priority.
41+
rs := make([]*rotationsWithPriority, 0, len(prioMap))
42+
for _, rotation := range prioMap {
43+
rs = append(rs, rotation)
44+
}
45+
slices.SortFunc(rs, func(a, b *rotationsWithPriority) int {
46+
return cmp.Compare(a.priority, b.priority)
47+
})
48+
49+
// Sort the different versions of the same rotation (i.e. same schedule and priority, differing in their handoff
50+
// time) by the handoff time so that the currently active version can be found with binary search.
51+
for _, rotation := range rs {
52+
slices.SortFunc(rotation.sortedByHandoff, func(a, b *Rotation) int {
53+
return a.ActualHandoff.Time().Compare(b.ActualHandoff.Time())
54+
})
55+
}
56+
57+
r.sortedByPriority = rs
58+
}
59+
60+
// getRotationsAt returns a slice of active rotations at the given time.
61+
//
62+
// For priority, there may be at most one active rotation version. This function return all rotation versions that
63+
// are active at the given time t, ordered by priority (lower index has higher precedence).
64+
func (r *rotationResolver) getRotationsAt(t time.Time) []*Rotation {
65+
rotations := make([]*Rotation, 0, len(r.sortedByPriority))
66+
67+
for _, w := range r.sortedByPriority {
68+
i, found := slices.BinarySearchFunc(w.sortedByHandoff, t, func(rotation *Rotation, t time.Time) int {
69+
return rotation.ActualHandoff.Time().Compare(t)
70+
})
71+
72+
// If a rotation version with sortedByHandoff[i].ActualHandoff == t is found, it just became valid and should be
73+
// used. Otherwise, BinarySearchFunc returns the first index i after t so that:
74+
//
75+
// sortedByHandoff[i-1].ActualHandoff < t < sortedByHandoff[i].ActualHandoff
76+
//
77+
// Thus, the version at index i becomes active after t and the preceding one is still active.
78+
if !found {
79+
i--
80+
}
81+
82+
// If all rotation versions have ActualHandoff > t, there is none that's currently active and i is negative.
83+
if i >= 0 {
84+
rotations = append(rotations, w.sortedByHandoff[i])
85+
}
86+
}
87+
88+
return rotations
89+
}
90+
91+
// getContactsAt evaluates the rotations by priority and returns all contacts active at the given time.
92+
func (r *rotationResolver) getContactsAt(t time.Time) []*Contact {
93+
rotations := r.getRotationsAt(t)
94+
for _, rotation := range rotations {
95+
for _, member := range rotation.Members {
96+
for _, entry := range member.TimePeriodEntries {
97+
if entry.Contains(t) {
98+
var contacts []*Contact
99+
100+
if member.Contact != nil {
101+
contacts = append(contacts, member.Contact)
102+
}
103+
104+
if member.ContactGroup != nil {
105+
contacts = append(contacts, member.ContactGroup.Members...)
106+
}
107+
108+
return contacts
109+
}
110+
}
111+
}
112+
}
113+
114+
return nil
115+
}

0 commit comments

Comments
 (0)