Skip to content

Commit 860ea0c

Browse files
oxzijulianbrost
authored andcommitted
Incremental Configuration Updates
Previously, the entire configuration stored in the database was synchronized every second. With the growth of configurations in live environments on the horizon, this would simply not scale well. This brings us to incremental updates. By introducing two new columns - "changed_at" as a Unix millisecond timestamp and "deleted" as a boolean - for all tables referenced in the ConfigSet structure, SQL queries can be modified to retrieve only those rows with a more recent timestamp. The "deleted" column became necessary to detect disappearances, since the synchronization now only takes newer items into account. Some additional fields needed to be added to the ConfigSet to track relationships. Even though the codebase served well at the time, there was some code that did almost the same thing as other code, just in different ways. So a huge refactoring was done. This resulted in an internal generic function that handles all synchronization with custom callbacks. The web counterpart is being developed in <Icinga/icinga-notifications-web#187>. Closes #5.
1 parent f369a11 commit 860ea0c

File tree

25 files changed

+1121
-931
lines changed

25 files changed

+1121
-931
lines changed

internal/channel/channel.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,19 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"github.com/icinga/icinga-notifications/internal/config/baseconf"
78
"github.com/icinga/icinga-notifications/internal/contracts"
89
"github.com/icinga/icinga-notifications/internal/event"
910
"github.com/icinga/icinga-notifications/internal/recipient"
1011
"github.com/icinga/icinga-notifications/pkg/plugin"
1112
"go.uber.org/zap"
13+
"go.uber.org/zap/zapcore"
1214
"net/url"
1315
)
1416

1517
type Channel struct {
16-
ID int64 `db:"id"`
18+
baseconf.IncrementalPkDbEntry[int64] `db:",inline"`
19+
1720
Name string `db:"name"`
1821
Type string `db:"type"`
1922
Config string `db:"config" json:"-"` // excluded from JSON config dump as this may contain sensitive information
@@ -27,6 +30,19 @@ type Channel struct {
2730
pluginCtxCancel func()
2831
}
2932

33+
// MarshalLogObject implements the zapcore.ObjectMarshaler interface.
34+
func (c *Channel) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
35+
encoder.AddInt64("id", c.ID)
36+
encoder.AddString("name", c.Name)
37+
encoder.AddString("type", c.Type)
38+
return nil
39+
}
40+
41+
// IncrementalInitAndValidate implements the config.IncrementalConfigurableInitAndValidatable interface.
42+
func (c *Channel) IncrementalInitAndValidate() error {
43+
return ValidateType(c.Type)
44+
}
45+
3046
// newConfig helps to store the channel's updated properties
3147
type newConfig struct {
3248
ctype string
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package baseconf
2+
3+
import (
4+
"github.com/icinga/icinga-go-library/types"
5+
)
6+
7+
// IncrementalDbEntry contains the changed_at and deleted columns as struct fields.
8+
//
9+
// This type partially implements config.IncrementalConfigurable with GetChangedAt and IsDeleted. Thus, it can be
10+
// embedded in other types with the _`db:",inline"`_ struct tag. However, most structs might want to embed the
11+
// IncrementalPkDbEntry struct instead.
12+
type IncrementalDbEntry struct {
13+
ChangedAt types.UnixMilli `db:"changed_at"`
14+
Deleted types.Bool `db:"deleted"`
15+
}
16+
17+
// GetChangedAt returns the changed_at value of this entry from the database.
18+
//
19+
// It is required by the config.IncrementalConfigurable interface.
20+
func (i IncrementalDbEntry) GetChangedAt() types.UnixMilli {
21+
return i.ChangedAt
22+
}
23+
24+
// IsDeleted indicates if this entry is marked as deleted in the database.
25+
//
26+
// It is required by the config.IncrementalConfigurable interface.
27+
func (i IncrementalDbEntry) IsDeleted() bool {
28+
return i.Deleted.Valid && i.Deleted.Bool
29+
}
30+
31+
// IncrementalPkDbEntry implements a single primary key named id of a generic type next to IncrementalDbEntry.
32+
//
33+
// This type embeds IncrementalDbEntry and adds a single column/value id field, getting one step closer to implementing
34+
// the config.IncrementalConfigurable interface. Thus, it needs to be embedded with the _`db:",inline"`_ struct tag.
35+
type IncrementalPkDbEntry[PK comparable] struct {
36+
IncrementalDbEntry `db:",inline"`
37+
ID PK `db:"id"`
38+
}
39+
40+
// GetPrimaryKey returns the id of this entry from the database.
41+
//
42+
// It is required by the config.IncrementalConfigurable interface.
43+
func (i IncrementalPkDbEntry[PK]) GetPrimaryKey() PK {
44+
return i.ID
45+
}

internal/config/channel.go

Lines changed: 22 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -3,84 +3,30 @@ package config
33
import (
44
"context"
55
"github.com/icinga/icinga-notifications/internal/channel"
6-
"github.com/jmoiron/sqlx"
76
"go.uber.org/zap"
87
)
98

10-
func (r *RuntimeConfig) fetchChannels(ctx context.Context, tx *sqlx.Tx) error {
11-
var channelPtr *channel.Channel
12-
stmt := r.db.BuildSelectStmt(channelPtr, channelPtr)
13-
r.logger.Debugf("Executing query %q", stmt)
14-
15-
var channels []*channel.Channel
16-
if err := tx.SelectContext(ctx, &channels, stmt); err != nil {
17-
r.logger.Errorln(err)
18-
return err
19-
}
20-
21-
channelsById := make(map[int64]*channel.Channel)
22-
for _, c := range channels {
23-
channelLogger := r.logger.With(
24-
zap.Int64("id", c.ID),
25-
zap.String("name", c.Name),
26-
zap.String("type", c.Type),
27-
)
28-
if channelsById[c.ID] != nil {
29-
channelLogger.Warnw("ignoring duplicate config for channel type")
30-
} else if err := channel.ValidateType(c.Type); err != nil {
31-
channelLogger.Errorw("Cannot load channel config", zap.Error(err))
32-
} else {
33-
channelsById[c.ID] = c
34-
35-
channelLogger.Debugw("loaded channel config")
36-
}
37-
}
38-
39-
if r.Channels != nil {
40-
// mark no longer existing channels for deletion
41-
for id := range r.Channels {
42-
if _, ok := channelsById[id]; !ok {
43-
channelsById[id] = nil
44-
}
45-
}
46-
}
47-
48-
r.pending.Channels = channelsById
49-
50-
return nil
51-
}
52-
9+
// applyPendingChannels synchronizes changed channels.
5310
func (r *RuntimeConfig) applyPendingChannels() {
54-
if r.Channels == nil {
55-
r.Channels = make(map[int64]*channel.Channel)
56-
}
57-
58-
for id, pendingChannel := range r.pending.Channels {
59-
if pendingChannel == nil {
60-
r.Channels[id].Logger.Info("Channel has been removed")
61-
r.Channels[id].Stop()
62-
63-
delete(r.Channels, id)
64-
} else if currentChannel := r.Channels[id]; currentChannel != nil {
65-
// Currently, the whole config is reloaded from the database frequently, replacing everything.
66-
// Prevent restarting the plugin processes every time by explicitly checking for config changes.
67-
// The if condition should no longer be necessary when https://github.com/Icinga/icinga-notifications/issues/5
68-
// is solved properly.
69-
if currentChannel.Type != pendingChannel.Type || currentChannel.Name != pendingChannel.Name || currentChannel.Config != pendingChannel.Config {
70-
currentChannel.Type = pendingChannel.Type
71-
currentChannel.Name = pendingChannel.Name
72-
currentChannel.Config = pendingChannel.Config
73-
74-
currentChannel.Restart()
75-
}
76-
} else {
77-
pendingChannel.Start(context.TODO(), r.logs.GetChildLogger("channel").With(
78-
zap.Int64("id", pendingChannel.ID),
79-
zap.String("name", pendingChannel.Name)))
80-
81-
r.Channels[id] = pendingChannel
82-
}
83-
}
84-
85-
r.pending.Channels = nil
11+
incrementalApplyPending(
12+
r,
13+
&r.Channels, &r.configChange.Channels,
14+
func(newElement *channel.Channel) error {
15+
newElement.Start(context.TODO(), r.logs.GetChildLogger("channel").With(
16+
zap.Int64("id", newElement.ID),
17+
zap.String("name", newElement.Name)))
18+
return nil
19+
},
20+
func(curElement, update *channel.Channel) error {
21+
curElement.ChangedAt = update.ChangedAt
22+
curElement.Name = update.Name
23+
curElement.Type = update.Type
24+
curElement.Config = update.Config
25+
curElement.Restart()
26+
return nil
27+
},
28+
func(delElement *channel.Channel) error {
29+
delElement.Stop()
30+
return nil
31+
})
8632
}

internal/config/contact.go

Lines changed: 46 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,57 @@
11
package config
22

33
import (
4-
"context"
4+
"fmt"
55
"github.com/icinga/icinga-notifications/internal/recipient"
6-
"github.com/jmoiron/sqlx"
7-
"go.uber.org/zap"
6+
"slices"
87
)
98

10-
func (r *RuntimeConfig) fetchContacts(ctx context.Context, tx *sqlx.Tx) error {
11-
var contactPtr *recipient.Contact
12-
stmt := r.db.BuildSelectStmt(contactPtr, contactPtr)
13-
r.logger.Debugf("Executing query %q", stmt)
14-
15-
var contacts []*recipient.Contact
16-
if err := tx.SelectContext(ctx, &contacts, stmt); err != nil {
17-
r.logger.Errorln(err)
18-
return err
19-
}
20-
21-
contactsByID := make(map[int64]*recipient.Contact)
22-
for _, c := range contacts {
23-
contactsByID[c.ID] = c
24-
25-
r.logger.Debugw("loaded contact config",
26-
zap.Int64("id", c.ID),
27-
zap.String("name", c.FullName))
28-
}
29-
30-
if r.Contacts != nil {
31-
// mark no longer existing contacts for deletion
32-
for id := range r.Contacts {
33-
if _, ok := contactsByID[id]; !ok {
34-
contactsByID[id] = nil
9+
// applyPendingContacts synchronizes changed contacts
10+
func (r *RuntimeConfig) applyPendingContacts() {
11+
incrementalApplyPending(
12+
r,
13+
&r.Contacts, &r.configChange.Contacts,
14+
nil,
15+
func(curElement, update *recipient.Contact) error {
16+
curElement.ChangedAt = update.ChangedAt
17+
curElement.FullName = update.FullName
18+
curElement.Username = update.Username
19+
curElement.DefaultChannelID = update.DefaultChannelID
20+
return nil
21+
},
22+
nil)
23+
24+
incrementalApplyPending(
25+
r,
26+
&r.ContactAddresses, &r.configChange.ContactAddresses,
27+
func(newElement *recipient.Address) error {
28+
contact, ok := r.Contacts[newElement.ContactID]
29+
if !ok {
30+
return fmt.Errorf("contact address refers unknown contact %d", newElement.ContactID)
3531
}
36-
}
37-
}
38-
39-
r.pending.Contacts = contactsByID
4032

41-
return nil
42-
}
43-
44-
func (r *RuntimeConfig) applyPendingContacts() {
45-
if r.Contacts == nil {
46-
r.Contacts = make(map[int64]*recipient.Contact)
47-
}
33+
contact.Addresses = append(contact.Addresses, newElement)
34+
return nil
35+
},
36+
func(curElement, update *recipient.Address) error {
37+
if curElement.ContactID != update.ContactID {
38+
return errRemoveAndAddInstead
39+
}
4840

49-
for id, pendingContact := range r.pending.Contacts {
50-
if pendingContact == nil {
51-
delete(r.Contacts, id)
52-
} else if currentContact := r.Contacts[id]; currentContact != nil {
53-
currentContact.FullName = pendingContact.FullName
54-
currentContact.Username = pendingContact.Username
55-
currentContact.DefaultChannelID = pendingContact.DefaultChannelID
56-
} else {
57-
r.Contacts[id] = pendingContact
58-
}
59-
}
41+
curElement.ChangedAt = update.ChangedAt
42+
curElement.Type = update.Type
43+
curElement.Address = update.Address
44+
return nil
45+
},
46+
func(delElement *recipient.Address) error {
47+
contact, ok := r.Contacts[delElement.ContactID]
48+
if !ok {
49+
return nil
50+
}
6051

61-
r.pending.Contacts = nil
52+
contact.Addresses = slices.DeleteFunc(contact.Addresses, func(address *recipient.Address) bool {
53+
return address.ID == delElement.ID
54+
})
55+
return nil
56+
})
6257
}

0 commit comments

Comments
 (0)