diff --git a/tests/config_changes_test.go b/tests/config_changes_test.go index 1f9bcca5..9da9c8a5 100644 --- a/tests/config_changes_test.go +++ b/tests/config_changes_test.go @@ -3,8 +3,10 @@ package tests import ( "time" + "github.com/flanksource/duty/db" "github.com/flanksource/duty/models" "github.com/flanksource/duty/query" + "github.com/flanksource/duty/tests/fixtures/dummy" "github.com/flanksource/duty/types" "github.com/google/uuid" ginkgo "github.com/onsi/ginkgo/v2" @@ -366,3 +368,66 @@ var _ = ginkgo.Describe("Config changes recursive", ginkgo.Ordered, func() { }) }) }) + +var _ = ginkgo.Describe("handle external id conflict on config change inserts", ginkgo.Ordered, func() { + // An arbitrarily chosen time of the event we will be inserting multiple times + var referenceTime = time.Date(2020, 01, 15, 12, 00, 00, 00, time.UTC) + + dummyChanges := []models.ConfigChange{ + {ConfigID: dummy.LogisticsAPIDeployment.ID.String(), ExternalChangeID: lo.ToPtr("conflict_test_1"), Count: 1, CreatedAt: lo.ToPtr(referenceTime.Add(-time.Minute * 5)), Details: []byte(`{"replicas": "1"}`)}, + {ConfigID: dummy.LogisticsAPIDeployment.ID.String(), ExternalChangeID: lo.ToPtr("conflict_test_2"), Count: 1, CreatedAt: lo.ToPtr(referenceTime.Add(-time.Minute * 4)), Details: []byte(`{"replicas": "2"}`)}, + } + + ginkgo.BeforeAll(func() { + err := DefaultContext.DB().Create(dummyChanges).Error + Expect(err).To(BeNil()) + }) + + ginkgo.AfterAll(func() { + err := DefaultContext.DB().Delete(dummyChanges).Error + Expect(err).To(BeNil()) + }) + + ginkgo.It("should increase count when the details is changed", func() { + c := models.ConfigChange{ConfigID: dummy.LogisticsAPIDeployment.ID.String(), ExternalChangeID: lo.ToPtr("conflict_test_1"), Count: 1, CreatedAt: lo.ToPtr(referenceTime), Details: []byte(`{"replicas": "3"}`)} + err := DefaultContext.DB().Create(&c).Error + Expect(err).To(BeNil()) + + { + var inserted models.ConfigChange + err := DefaultContext.DB().Where("external_change_id = ? AND config_id = ?", c.ExternalChangeID, c.ConfigID).First(&inserted).Error + Expect(db.ErrorDetails(err)).NotTo(HaveOccurred()) + Expect(inserted.Count).To(Equal(2)) + } + }) + + ginkgo.It("should NOT increase count", func() { + // insert the same change with the same details and external change id + // and ensure the count doesn't change. + for i := 0; i < 10; i++ { + c := models.ConfigChange{ConfigID: dummy.LogisticsAPIDeployment.ID.String(), ExternalChangeID: lo.ToPtr("conflict_test_1"), CreatedAt: lo.ToPtr(referenceTime), Count: 1, Details: []byte(`{"replicas": "3"}`)} + err := DefaultContext.DB().Create(&c).Error + Expect(err).To(BeNil()) + + { + var inserted models.ConfigChange + err := DefaultContext.DB().Where("external_change_id = ? AND config_id = ?", c.ExternalChangeID, c.ConfigID).First(&inserted).Error + Expect(db.ErrorDetails(err)).NotTo(HaveOccurred()) + Expect(inserted.Count).To(Equal(2)) + } + } + }) + + ginkgo.It("should increase count when the details is the same but the created_at is changed", func() { + c := models.ConfigChange{ConfigID: dummy.LogisticsAPIDeployment.ID.String(), ExternalChangeID: lo.ToPtr("conflict_test_1"), Count: 1, CreatedAt: lo.ToPtr(referenceTime.Add(time.Minute)), Details: []byte(`{"replicas": "3"}`)} + err := DefaultContext.DB().Create(&c).Error + Expect(err).To(BeNil()) + + { + var inserted models.ConfigChange + err := DefaultContext.DB().Where("external_change_id = ? AND config_id = ?", c.ExternalChangeID, c.ConfigID).First(&inserted).Error + Expect(db.ErrorDetails(err)).NotTo(HaveOccurred()) + Expect(inserted.Count).To(Equal(3)) + } + }) +}) diff --git a/views/030_config_changes.sql b/views/030_config_changes.sql new file mode 100644 index 00000000..95139079 --- /dev/null +++ b/views/030_config_changes.sql @@ -0,0 +1,136 @@ +CREATE OR REPLACE FUNCTION config_changes_update_trigger() +RETURNS TRIGGER AS $$ +DECLARE + count_increment INT; +BEGIN + count_increment := NEW.count - OLD.count; + + UPDATE + config_changes + SET + change_type = NEW.change_type, + count = CASE + WHEN NEW.details IS DISTINCT FROM OLD.details THEN NEW.count + ELSE count + END, + created_at = NOW(), + created_by = NEW.created_by, + details = NEW.details, + is_pushed = NEW.is_pushed, + diff = NEW.diff, + external_created_by = NEW.external_created_by, + external_change_id = NEW.external_change_id, + first_observed = LEAST(first_observed, created_at), + patches = NEW.patches, + severity = NEW.severity, + source = NEW.source, + summary = NEW.summary + WHERE + id = NEW.id; + + -- Prevent the original update by returning NULL + RETURN NULL; +EXCEPTION + WHEN unique_violation THEN + IF sqlerrm LIKE '%config_changes_config_id_external_change_id_key%' THEN + UPDATE config_changes + SET + change_type = NEW.change_type, + count = CASE + WHEN NEW.details IS DISTINCT FROM OLD.details THEN config_changes.count + count_increment + ELSE count + END, + created_at = NOW(), + created_by = NEW.created_by, + details = NEW.details, + diff = NEW.diff, + external_created_by = NEW.external_created_by, + first_observed = LEAST(first_observed, created_at), + patches = NEW.patches, + severity = NEW.severity, + source = NEW.source, + summary = NEW.summary + WHERE + external_change_id = NEW.external_change_id AND config_id = NEW.config_id; + + RETURN NULL; + ELSE + RAISE; + END IF; + WHEN OTHERS THEN + RAISE; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE TRIGGER config_changes_update_trigger +BEFORE UPDATE +ON config_changes FOR EACH ROW +WHEN (pg_trigger_depth() = 0) EXECUTE FUNCTION config_changes_update_trigger(); + +--- +CREATE OR REPLACE FUNCTION config_changes_insert_trigger() +RETURNS TRIGGER AS $$ +DECLARE + existing_details JSONB; + existing_created_at TIMESTAMP WITH TIME ZONE; +BEGIN + -- run the original insert manually. + INSERT INTO config_changes SELECT NEW.* + ON CONFLICT (id) + DO UPDATE + SET + details = excluded.details, + created_by = excluded.created_by, + diff = excluded.diff, + external_created_by = excluded.external_created_by, + patches = excluded.patches, + severity = excluded.severity, + fingerprint = excluded.fingerprint, + count = excluded.count, + source = excluded.source, + created_at = excluded.created_at, + summary = excluded.summary; + + -- Prevent the original insert by returning NULL + RETURN NULL; +EXCEPTION + WHEN unique_violation THEN + IF sqlerrm LIKE '%config_changes_config_id_external_change_id_key%' THEN + SELECT details, created_at FROM config_changes + WHERE external_change_id = NEW.external_change_id AND config_id = NEW.config_id + INTO existing_details, existing_created_at; + + UPDATE config_changes + SET + change_type = NEW.change_type, + count = CASE + WHEN (NEW.details IS DISTINCT FROM existing_details OR NEW.created_at IS DISTINCT FROM existing_created_at) THEN config_changes.count + 1 + ELSE count + END, + created_at = NEW.created_at, + created_by = NEW.created_by, + details = NEW.details, + diff = NEW.diff, + external_created_by = NEW.external_created_by, + patches = NEW.patches, + severity = NEW.severity, + source = NEW.source, + summary = NEW.summary + WHERE + external_change_id = NEW.external_change_id + AND config_id = NEW.config_id; + + RETURN NULL; + ELSE + RAISE; + END IF; + WHEN OTHERS THEN + RAISE; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE TRIGGER config_changes_insert_trigger +BEFORE INSERT +ON config_changes FOR EACH ROW +WHEN (pg_trigger_depth() = 0) EXECUTE FUNCTION config_changes_insert_trigger(); +--- \ No newline at end of file