Skip to content

Commit 7dec004

Browse files
oxziyhabteab
andcommitted
icingadb: Manually close deleted Comments and Downtimes
It is possible for objects to vanish in Icinga 2, e.g., when a new config pack is deployed as the Director does. Icinga DB already handles this within its synchronization logic, explicitly deleting removed objects. However, these deleted objects might have open logical dependencies, such as entries in the history tables. For example, only deleting a Downtime object is not enough, as both its downtime_history and sla_history_downtime entries needs to be updated as well. In this example, these updates are required to not show the Downtimes anymore, have a valid SLA calculation, and ensure a working downtime history retention. As Icinga DB does not receive the change from Icinga 2 over the usual "icinga:history:stream:downtime" Redis stream, it must be reproduced or mocked by Icinga DB itself. Now, Icinga DB is able to close the history for both vanished Comment and Downtime objects itself. Co-Authored-By: Yonas Habteab <yonas.habteab@icinga.com>
1 parent 0bbd81b commit 7dec004

2 files changed

Lines changed: 326 additions & 5 deletions

File tree

pkg/icingadb/sync.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package icingadb
33
import (
44
"context"
55
"fmt"
6+
"runtime"
7+
"time"
8+
69
"github.com/icinga/icinga-go-library/com"
710
"github.com/icinga/icinga-go-library/database"
811
"github.com/icinga/icinga-go-library/logging"
@@ -16,8 +19,6 @@ import (
1619
"github.com/pkg/errors"
1720
"go.uber.org/zap"
1821
"golang.org/x/sync/errgroup"
19-
"runtime"
20-
"time"
2122
)
2223

2324
// Sync implements a rendezvous point for Icinga DB and Redis to synchronize their entities.
@@ -171,9 +172,35 @@ func (s Sync) ApplyDelta(ctx context.Context, delta *Delta) error {
171172
// Delete
172173
if len(delta.Delete) > 0 {
173174
s.logger.Infof("Deleting %d items of type %s", len(delta.Delete), strcase.Delimited(types.Name(delta.Subject.Entity()), ' '))
174-
g.Go(func() error {
175-
return s.db.Delete(ctx, delta.Subject.Entity(), delta.Delete.IDs(), database.OnSuccessIncrement[any](stat))
176-
})
175+
176+
ids := delta.Delete.IDs()
177+
178+
// Those objects are probably removed from the configuration files, i.e., Icinga 2 won't send the
179+
// corresponding end/removed events for them. So try to mark them as cancelled manually, so that
180+
// they don't show up as if they were still active in the UI.
181+
//
182+
// This is not performed in a separate goroutine to ensure that the relevant Comment/Downtime
183+
// object is not deleted before closing the history.
184+
185+
var err error
186+
switch delta.Subject.Entity().(type) {
187+
case *v1.Comment:
188+
err = s.mockCommentEnd(ctx, ids)
189+
case *v1.Downtime:
190+
err = s.mockDowntimeEnd(ctx, ids)
191+
}
192+
193+
if ctx.Err() == nil {
194+
g.Go(func() error { return err })
195+
}
196+
197+
// Start the deletion process only if we haven't aborted the config sync due to an error in the
198+
// downtime history update above.
199+
if ctx.Err() == nil {
200+
g.Go(func() error {
201+
return s.db.Delete(ctx, delta.Subject.Entity(), ids, database.OnSuccessIncrement[any](stat))
202+
})
203+
}
177204
}
178205

179206
return g.Wait()

pkg/icingadb/sync_history_mock.go

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
package icingadb
2+
3+
import (
4+
"context"
5+
"crypto/sha1" // #nosec G505 -- required by Icinga 2's HashValue function
6+
"database/sql"
7+
"fmt"
8+
"time"
9+
10+
"github.com/icinga/icinga-go-library/backoff"
11+
"github.com/icinga/icinga-go-library/objectpacker"
12+
"github.com/icinga/icinga-go-library/retry"
13+
"github.com/icinga/icinga-go-library/types"
14+
"github.com/icinga/icingadb/pkg/icingadb/v1/history"
15+
"github.com/jmoiron/sqlx"
16+
"github.com/pkg/errors"
17+
"go.uber.org/zap"
18+
)
19+
20+
// mkGenericHistoryMockStmts creates SQL queries to acquire data and to create a new history entry.
21+
//
22+
// This helper function is used in the methods implementing closeObjectAndMockHistory below.
23+
func mkGenericHistoryMockStmts(tableName string) (stmtPopulate, stmtHistoryInsert string) {
24+
stmtPopulate = fmt.Sprintf(`
25+
SELECT
26+
history.environment_id AS environment_id,
27+
history.endpoint_id AS endpoint_id,
28+
history.object_type AS object_type,
29+
history.host_id AS host_id,
30+
history.service_id AS service_id,
31+
%[1]s.name AS name
32+
FROM %[1]s
33+
JOIN history ON
34+
%[1]s.id = history.%[1]s_history_id
35+
WHERE %[1]s.id = :%[1]s_id`, tableName)
36+
37+
stmtHistoryInsert = fmt.Sprintf(`
38+
INSERT INTO history (
39+
id, environment_id, endpoint_id, object_type, host_id, service_id, %[1]s_history_id, event_type, event_time
40+
) VALUES (
41+
:id, :environment_id, :endpoint_id, :object_type, :host_id, :service_id, :%[1]s_id, :event_type, :event_time
42+
)`, tableName)
43+
44+
return
45+
}
46+
47+
// calcEventId mocks Icinga 2's IcingaDB::CalcEventID method for missing history entries.
48+
//
49+
// This is a helper function used below to simulate a new history.id primary key.
50+
func calcEventId(environmentId, eventType, objectName string) (types.Binary, error) {
51+
h := sha1.New() // #nosec G401 -- required by Icinga 2's HashValue function
52+
53+
err := objectpacker.PackAny([]string{environmentId, eventType, objectName}, h)
54+
if err != nil {
55+
return nil, err
56+
}
57+
58+
return types.Binary(h.Sum(nil)), nil
59+
}
60+
61+
// closeObjectAndMockHistory manually closes vanished Icinga 2 objects, e.g., Downtimes of vanished Hosts.
62+
//
63+
// This method provides a common stub for each type-specific implementation below.
64+
func (s Sync) closeObjectAndMockHistory(
65+
ctx context.Context,
66+
ids []any,
67+
objectType string,
68+
action func(ctx context.Context, tx *sqlx.Tx, id types.Binary) error,
69+
) error {
70+
s.logger.Debugw("Start manually closing vanished objects",
71+
zap.String("type", objectType),
72+
zap.Int("amount", len(ids)))
73+
74+
binaryIds := make([]types.Binary, 0, len(ids))
75+
for _, id := range ids {
76+
binaryId, ok := id.(types.Binary)
77+
if !ok {
78+
return errors.Errorf("ID is not types.Binary, but %T", id)
79+
}
80+
binaryIds = append(binaryIds, binaryId)
81+
}
82+
83+
// Process action callback in bulks of the given step size instead of creating a transaction for
84+
// each action anew. The current step size is quite arbitrary, tweaked during testing.
85+
const steps = 256
86+
for infimum := 0; infimum < len(binaryIds); infimum += steps {
87+
startTime := time.Now()
88+
89+
err := retry.WithBackoff(
90+
ctx,
91+
func(ctx context.Context) error {
92+
return s.db.ExecTx(ctx, func(ctx context.Context, tx *sqlx.Tx) error {
93+
for i := infimum; i < min(len(binaryIds), infimum+steps); i++ {
94+
err := action(ctx, tx, binaryIds[i])
95+
if err != nil {
96+
return err
97+
}
98+
}
99+
100+
return nil
101+
})
102+
},
103+
retry.Retryable,
104+
backoff.DefaultBackoff,
105+
s.db.GetDefaultRetrySettings())
106+
if err != nil {
107+
return errors.Wrap(err, "can't mock object end")
108+
}
109+
110+
s.logger.Debugw("Manually closed vanished objects",
111+
zap.String("type", objectType),
112+
zap.Int("amount", min(len(binaryIds), infimum+steps)-infimum),
113+
zap.Duration("duration", time.Since(startTime)))
114+
}
115+
116+
return nil
117+
}
118+
119+
// mockCommentEnd manually closes comments by altering the related history tables.
120+
//
121+
// This affects history retention by setting comment_history.remove_time.
122+
func (s Sync) mockCommentEnd(ctx context.Context, ids []any) error {
123+
type Comment struct {
124+
history.CommentHistory
125+
history.HistoryMeta
126+
127+
Name types.String
128+
EventTime types.UnixMilli
129+
}
130+
131+
stmtPopulate, stmtHistoryInsert := mkGenericHistoryMockStmts("comment")
132+
133+
// Marks the existing comment_history as removed.
134+
stmtCommentHistoryUpdate := `
135+
UPDATE comment_history
136+
SET
137+
removed_by = :removed_by,
138+
remove_time = :remove_time,
139+
has_been_removed = :has_been_removed
140+
WHERE comment_id = :comment_id`
141+
142+
return s.closeObjectAndMockHistory(
143+
ctx,
144+
ids,
145+
"comment",
146+
func(ctx context.Context, tx *sqlx.Tx, id types.Binary) error {
147+
eventTime := time.Now()
148+
149+
comment := &Comment{
150+
CommentHistory: history.CommentHistory{
151+
CommentHistoryEntity: history.CommentHistoryEntity{
152+
CommentId: id,
153+
},
154+
},
155+
}
156+
157+
// Start by populating information based on the previous data.
158+
prepStmtCommentPopulate, err := tx.PrepareNamedContext(ctx, stmtPopulate)
159+
if err != nil {
160+
return errors.Wrap(err, "can't prepare statement to populate entry")
161+
}
162+
defer func() { _ = prepStmtCommentPopulate.Close() }()
163+
164+
err = prepStmtCommentPopulate.GetContext(ctx, comment, comment)
165+
if errors.Is(err, sql.ErrNoRows) {
166+
s.logger.Infow("Can't fetch vanished Comment from database for cleanup",
167+
zap.String("comment_id", id.String()),
168+
zap.Error(err))
169+
return nil
170+
} else if err != nil {
171+
return errors.Wrap(err, "can't fetch information to populate entry")
172+
}
173+
174+
// Fill fields to update comment_history
175+
comment.RemovedBy = types.MakeString("Comment Config Removed")
176+
comment.RemoveTime = types.UnixMilli(eventTime)
177+
comment.HasBeenRemoved = types.MakeBool(true)
178+
179+
_, err = tx.NamedExecContext(ctx, stmtCommentHistoryUpdate, comment)
180+
if err != nil {
181+
return errors.Wrap(err, "can't update comment_history")
182+
}
183+
184+
// Update fields for a new history entry.
185+
comment.Id, err = calcEventId(comment.EndpointId.String(), "comment_remove", comment.Name.String)
186+
if err != nil {
187+
return errors.Wrap(err, "can't calculate event_id")
188+
}
189+
190+
comment.EventType = "comment_remove"
191+
comment.EventTime = types.UnixMilli(eventTime)
192+
193+
_, err = tx.NamedExecContext(ctx, stmtHistoryInsert, comment)
194+
if err != nil {
195+
return errors.Wrap(err, "can't insert new history entry")
196+
}
197+
198+
return nil
199+
})
200+
}
201+
202+
// mockDowntimeEnd manually closes downtimes by altering the related history tables.
203+
//
204+
// This affects SLA calculations (sla_history_donwtime table).
205+
func (s Sync) mockDowntimeEnd(ctx context.Context, ids []any) error {
206+
type Downtime struct {
207+
history.DowntimeHistory
208+
history.HistoryMeta
209+
210+
Name types.String
211+
EventTime types.UnixMilli
212+
}
213+
214+
stmtPopulate, stmtHistoryInsert := mkGenericHistoryMockStmts("downtime")
215+
216+
// Marks the existing downtime_history as cancelled.
217+
stmtDowntimeHistoryUpdate := `
218+
UPDATE downtime_history
219+
SET
220+
cancelled_by = :cancelled_by,
221+
has_been_cancelled = :has_been_cancelled,
222+
cancel_time = :cancel_time
223+
WHERE downtime_id = :downtime_id`
224+
225+
// Mark the existing sla_history_downtime as ended.
226+
stmtSlaHistoryDowntimeUpdate := `
227+
UPDATE sla_history_downtime
228+
SET downtime_end = :cancel_time
229+
WHERE downtime_id = :downtime_id`
230+
231+
return s.closeObjectAndMockHistory(
232+
ctx,
233+
ids,
234+
"downtime",
235+
func(ctx context.Context, tx *sqlx.Tx, id types.Binary) error {
236+
eventTime := time.Now()
237+
238+
downtime := &Downtime{
239+
DowntimeHistory: history.DowntimeHistory{
240+
DowntimeHistoryEntity: history.DowntimeHistoryEntity{
241+
DowntimeId: id,
242+
},
243+
},
244+
}
245+
246+
// Start by populating information based on the previous data.
247+
prepStmtHistorySelect, err := tx.PrepareNamedContext(ctx, stmtPopulate)
248+
if err != nil {
249+
return errors.Wrap(err, "can't prepare statement to populate entry")
250+
}
251+
defer func() { _ = prepStmtHistorySelect.Close() }()
252+
253+
err = prepStmtHistorySelect.GetContext(ctx, downtime, downtime)
254+
if errors.Is(err, sql.ErrNoRows) {
255+
s.logger.Infow("Can't fetch vanished Downtime from database for cleanup",
256+
zap.String("downtime_id", id.String()),
257+
zap.Error(err))
258+
return nil
259+
} else if err != nil {
260+
return errors.Wrap(err, "can't fetch information to populate entry")
261+
}
262+
263+
// Fill fields to update downtime_history and sla_history_downtime.
264+
downtime.CancelledBy = types.MakeString("Downtime Config Removed")
265+
downtime.HasBeenCancelled = types.MakeBool(true)
266+
downtime.CancelTime = types.UnixMilli(eventTime)
267+
268+
_, err = tx.NamedExecContext(ctx, stmtDowntimeHistoryUpdate, downtime)
269+
if err != nil {
270+
return errors.Wrap(err, "can't update downtime_history")
271+
}
272+
273+
_, err = tx.NamedExecContext(ctx, stmtSlaHistoryDowntimeUpdate, downtime)
274+
if err != nil {
275+
return errors.Wrap(err, "can't update sla_history_downtime")
276+
}
277+
278+
// Update fields for a new history entry.
279+
downtime.Id, err = calcEventId(downtime.EnvironmentId.String(), "downtime_end", downtime.Name.String)
280+
if err != nil {
281+
return errors.Wrap(err, "can't calculate event_id")
282+
}
283+
284+
downtime.EventType = "downtime_end"
285+
downtime.EventTime = types.UnixMilli(eventTime)
286+
287+
_, err = tx.NamedExecContext(ctx, stmtHistoryInsert, downtime)
288+
if err != nil {
289+
return errors.Wrap(err, "can't insert new history entry")
290+
}
291+
292+
return nil
293+
})
294+
}

0 commit comments

Comments
 (0)