Skip to content

Commit 6e8edac

Browse files
[Backport 7.82.x] fix(dataobs): reuse stored base config across DO_QUERY_ACTIONS updates (#54292)
Backport 83b0223 from #54212. ___ <!--Please give us some feedback on your experience writing this PR ! https://app.datadoghq.com/forms/43db4c02-6837-400c-8083-692e141b1b88 !--> ### What does this PR do? Follow-up to #54200 (merged). Fixes a **second, distinct** duplication bug in `comp/dataobs/queryactions/impl/handler.go`, surfaced while verifying #54200: after the first `DO_QUERY_ACTIONS` update correctly retires a base config in favor of a DO check, a **second** RC update for the same `config_id` (e.g. editing the monitor's queries) could resurrect the already-retired base config alongside the new DO check — reintroducing the "parallel checks" duplication that #54200 fixed for the first-update case. Root cause: `onRCUpdate` always re-derived the "base config" via `findMatchingConfig`, which searches only currently-*active* (scheduled) configs. Once the true original is unscheduled after the first update, the only thing left matching that host is the DO component's own previously-scheduled check — which also satisfies `matchesIdentifier` and `instanceHasDOEnabled`, so it looks like a legitimate base config from the outside. Adopting it as the new "base" gives it a different digest than the true original, so `reconcileBases`'s digest-based tracking loses track of the true original and wrongly restores it. Fix: added `resolveBaseConfig`, which reuses the previously-stored `baseCfg` for an already-active `config_id` instead of re-deriving it every update. Falls back to a fresh `findMatchingConfig` search if the stored base no longer has a matching instance (e.g. a genuine host change). Extracted `findMatchingInstance` so both the fresh-search path and the reuse path share identical matching logic. Rebased onto latest `main` (now including #54215, an orthogonal fix for a same-host/different-port remainder-matching bug — no overlap with this change; both coexist cleanly). ### Motivation Confirmed live against a real SAP HANA instance, with #54200 already applied: editing a monitor tied to an already-active `DO_QUERY_ACTIONS` config (growing its query count) reliably triggered `reconcileBases | Restored original postgres config` and a second concurrent `schema-collection`/`data_observability` cycle, exactly reproducing the duplication bug via a different trigger than #54200 covers. ### Describe how you validated your changes - Added `TestOnRCUpdate_SecondUpdateReusesStoredBase`, a regression test using a mutable mock `GetUnresolvedConfigs()` that reflects what autodiscovery would actually look like after the first update (existing tests' static fixtures couldn't exercise this — they never reflect a prior update's `changes.Schedule`/`Unschedule`). Verified this test fails with the pre-fix behavior (temporarily reverted the fix locally, confirmed the test fails with the same "Restored original postgres config" signature seen live, then restored the fix). - `dda inv test --targets=./comp/dataobs/queryactions/impl` — all 39 tests pass (41 counting table-driven subtests). - Live-verified end-to-end against a running SAP HANA Express instance: first RC delivery settles cleanly (base retired, DO check active), then a real monitor edit triggers a second delivery for the same `config_id` (query count 4 → 5) — before this fix, that reproduced the duplication; after, the base config is never rescheduled and exactly one check instance remains active throughout, confirmed via schema-collection logs showing only a single running instance. - Re-verified after rebasing onto latest `main` (with #54215 merged): build succeeds, all tests still pass, agent runs cleanly against the live SAP HANA instance. ### Additional Notes None outstanding — the two known duplication triggers (first update in #54200, second update here) are both covered now. Co-authored-by: axel.vonengel <axel.vonengel@datadoghq.com>
1 parent c9150d5 commit 6e8edac

3 files changed

Lines changed: 136 additions & 15 deletions

File tree

comp/dataobs/queryactions/impl/handler.go

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func (c *component) onRCUpdate(updates map[string]state.RawConfig, applyStatus f
113113
continue
114114
}
115115

116-
baseCfg, instance, err := c.findMatchingConfig(&payload.DBIdentifier)
116+
baseCfg, instance, err := c.resolveBaseConfig(configID, &payload.DBIdentifier)
117117
if err != nil {
118118
c.log.Warnf("No matching postgres config for %s: %v", configID, err)
119119
applyStatus(path, state.ApplyStatus{State: state.ApplyStateError, Error: err.Error()})
@@ -320,6 +320,37 @@ func sameConfig(a, b *integration.Config) bool {
320320
return a.Digest() == b.Digest()
321321
}
322322

323+
// resolveBaseConfig returns the base config a DO check for configID should be derived from.
324+
//
325+
// If configID is already active, its previously-resolved base is reused as-is rather than
326+
// re-derived from the current active set. Once reconcileBases unschedules a base config's
327+
// targeted instance in favor of a DO check, that instance is no longer present in
328+
// GetUnresolvedConfigs() (autodiscovery only reports currently-scheduled configs) — so a fresh
329+
// findMatchingConfig search would instead match the DO component's own previously-scheduled check
330+
// output, which also satisfies matchesIdentifier and instanceHasDOEnabled. Adopting that as the
331+
// new "base" corrupts the digest reconcileBases uses to track the true original, causing it to be
332+
// wrongly restored on this update. Reusing the stored base avoids re-deriving it from a set that
333+
// may no longer contain it.
334+
//
335+
// Falls back to a fresh search if the stored base no longer has an instance matching dbID (e.g.
336+
// its host genuinely changed between updates).
337+
func (c *component) resolveBaseConfig(configID string, dbID *DBIdentifier) (*integration.Config, map[string]any, error) {
338+
c.activeConfigsMu.Lock()
339+
existing, alreadyActive := c.activeConfigs[configID]
340+
c.activeConfigsMu.Unlock()
341+
342+
if alreadyActive {
343+
instance, err := c.findMatchingInstance(existing.baseCfg, dbID)
344+
if instance != nil {
345+
return existing.baseCfg, instance, nil
346+
}
347+
if err != nil {
348+
c.log.Warnf("Stored base config for %s no longer parses cleanly, re-resolving: %v", configID, err)
349+
}
350+
}
351+
return c.findMatchingConfig(dbID)
352+
}
353+
323354
// findMatchingConfig finds a supported DB integration config that matches the given identifier
324355
// and has data_observability.enabled: true. Returns the matching config and the already-parsed
325356
// instance map to avoid re-parsing YAML in callers.
@@ -329,21 +360,12 @@ func (c *component) findMatchingConfig(dbID *DBIdentifier) (*integration.Config,
329360
var lastParseErr error
330361
for cfgIdx := range cfgs {
331362
cfg := cfgs[cfgIdx]
332-
if cfg.Name != "postgres" && cfg.Name != "sap_hana" {
333-
c.log.Warnf("DO query action: config %s is not a known DO-supported integration", cfg.Name)
363+
instance, err := c.findMatchingInstance(&cfg, dbID)
364+
if err != nil {
365+
lastParseErr = err
334366
}
335-
336-
for _, instanceData := range cfg.Instances {
337-
var instance map[string]any
338-
if err := yaml.Unmarshal(instanceData, &instance); err != nil {
339-
c.log.Warnf("Failed to unmarshal %s instance data for config %s, skipping: %v", cfg.Name, cfg.Name, err)
340-
lastParseErr = err
341-
continue
342-
}
343-
344-
if matchesIdentifier(instance, dbID) && instanceHasDOEnabled(instance) {
345-
return &cfg, instance, nil
346-
}
367+
if instance != nil {
368+
return &cfg, instance, nil
347369
}
348370
}
349371

@@ -355,6 +377,30 @@ func (c *component) findMatchingConfig(dbID *DBIdentifier) (*integration.Config,
355377
dbID.Type, dbID.Host)
356378
}
357379

380+
// findMatchingInstance searches cfg's instances for one matching dbID with data_observability
381+
// enabled, returning the first match. Instances whose YAML fails to parse are skipped (logged and
382+
// recorded as lastErr) rather than aborting the search — a later instance may still match.
383+
func (c *component) findMatchingInstance(cfg *integration.Config, dbID *DBIdentifier) (map[string]any, error) {
384+
if cfg.Name != "postgres" && cfg.Name != "sap_hana" {
385+
c.log.Warnf("DO query action: config %s is not a known DO-supported integration", cfg.Name)
386+
}
387+
388+
var lastErr error
389+
for _, instanceData := range cfg.Instances {
390+
var instance map[string]any
391+
if err := yaml.Unmarshal(instanceData, &instance); err != nil {
392+
c.log.Warnf("Failed to unmarshal %s instance data for config %s, skipping: %v", cfg.Name, cfg.Name, err)
393+
lastErr = err
394+
continue
395+
}
396+
397+
if matchesIdentifier(instance, dbID) && instanceHasDOEnabled(instance) {
398+
return instance, nil
399+
}
400+
}
401+
return nil, lastErr
402+
}
403+
358404
// matchesIdentifier checks if an instance matches the given DB identifier.
359405
// Matching is by host — per-query dbname fields handle database routing.
360406
func matchesIdentifier(instance map[string]any, dbID *DBIdentifier) bool {

comp/dataobs/queryactions/impl/handler_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,3 +1232,70 @@ func TestValidateQuerySpec_ValidIntervalOnly(t *testing.T) {
12321232
_, hasSchedule := q["schedule"]
12331233
assert.False(t, hasSchedule, "schedule field must be absent when not set on the query")
12341234
}
1235+
1236+
// TestOnRCUpdate_SecondUpdateReusesStoredBase is a regression test for a bug where a SECOND RC
1237+
// update for an already-active config_id could resurrect the true original base config alongside
1238+
// a new DO check. After the first update, the base config's targeted instance is no longer
1239+
// present in GetUnresolvedConfigs() — autodiscovery only reports currently-scheduled configs, and
1240+
// reconcileBases has by then unscheduled it in favor of the DO check. Without reusing the stored
1241+
// base, a second onRCUpdate call would instead match the DO component's own previously-scheduled
1242+
// check as the "base" (it also satisfies matchesIdentifier + instanceHasDOEnabled), corrupting the
1243+
// digest reconcileBases tracks and causing it to wrongly restore the true original — exactly the
1244+
// "three parallel checks" duplication bug, triggered by a second update instead of the first.
1245+
func TestOnRCUpdate_SecondUpdateReusesStoredBase(t *testing.T) {
1246+
const server = "172.17.128.2"
1247+
const port = 39041
1248+
sapHanaCfg := integration.Config{
1249+
Name: "sap_hana",
1250+
Provider: "file",
1251+
Instances: []integration.Data{
1252+
integration.Data(fmt.Sprintf("server: %s\nport: %d\ndata_observability:\n enabled: true\n", server, port)),
1253+
},
1254+
}
1255+
1256+
mockAC := &mockAutodiscovery{
1257+
Component: fxutil.Test[autodiscovery.Component](t, noopautoconfig.Module()),
1258+
configs: []integration.Config{sapHanaCfg},
1259+
}
1260+
c := &component{
1261+
log: logmock.New(t),
1262+
ac: mockAC,
1263+
activeConfigs: make(map[string]activeConfigEntry),
1264+
managedBases: make(map[string]*managedBaseEntry),
1265+
}
1266+
1267+
dbID := DBIdentifier{Type: "self-hosted", Host: fmt.Sprintf("%s:%d", server, port)}
1268+
makePayload := func(n int) []byte {
1269+
queries := make([]QuerySpec, n)
1270+
for i := range queries {
1271+
queries[i] = QuerySpec{Type: "run_query", Query: fmt.Sprintf("SELECT %d", i), IntervalSeconds: 60, TimeoutSeconds: 10}
1272+
}
1273+
data, err := json.Marshal(DOQueryPayload{ConfigID: "cfg-saphana", DBIdentifier: dbID, Queries: queries})
1274+
require.NoError(t, err)
1275+
return data
1276+
}
1277+
1278+
// Update 1: 2 queries. The true original base has no other instances, so it's fully
1279+
// unscheduled (no remainder) in favor of the DO check.
1280+
_, changes1 := collectStatuses(c, map[string]state.RawConfig{"path/cfg-saphana": {Config: makePayload(2)}})
1281+
require.Len(t, changes1.Unschedule, 1, "the true original base should be unscheduled")
1282+
require.Len(t, changes1.Schedule, 1, "only the DO check should be scheduled")
1283+
1284+
// Simulate autodiscovery applying changes1: the true original is gone from the active set;
1285+
// only the DO check (which itself satisfies matchesIdentifier + instanceHasDOEnabled) remains.
1286+
mockAC.configs = []integration.Config{changes1.Schedule[0]}
1287+
1288+
// Update 2: same config_id, now 3 queries — e.g. a monitor edit changing the query count.
1289+
_, changes2 := collectStatuses(c, map[string]state.RawConfig{"path/cfg-saphana": {Config: makePayload(3)}})
1290+
1291+
// Regression check: the true original (0-query) base must never be rescheduled. Every config
1292+
// scheduled by update 2 must carry DO queries.
1293+
for _, cfg := range changes2.Schedule {
1294+
var instance map[string]any
1295+
require.NoError(t, yaml.Unmarshal(cfg.Instances[0], &instance))
1296+
doSection, ok := instance["data_observability"].(map[string]any)
1297+
require.True(t, ok, "scheduled config missing data_observability section — looks like the wrongly-restored original")
1298+
queries, _ := doSection["queries"].([]any)
1299+
assert.NotEmpty(t, queries, "scheduled config has no DO queries — looks like the wrongly-restored original")
1300+
}
1301+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
fixes:
3+
- |
4+
Fix a bug where editing an active Data Observability query action's
5+
monitor (e.g. changing its query count) could cause the previously
6+
excluded database instance to run as a duplicate check instance again,
7+
alongside its Data Observability check, causing duplicate database
8+
monitoring collection.

0 commit comments

Comments
 (0)