|
| 1 | +package integration |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "fmt" |
| 6 | + "testing" |
| 7 | + |
| 8 | + "github.com/google/uuid" |
| 9 | + "github.com/jackc/pgx/v5" |
| 10 | + "github.com/segmentio/analytics-go/v3" |
| 11 | + "github.com/stretchr/testify/assert" |
| 12 | + "github.com/stretchr/testify/require" |
| 13 | + |
| 14 | + "github.com/checkmarble/marble-backend/models" |
| 15 | + "github.com/checkmarble/marble-backend/repositories" |
| 16 | + "github.com/checkmarble/marble-backend/usecases" |
| 17 | + "github.com/checkmarble/marble-backend/usecases/worker_jobs" |
| 18 | + "github.com/checkmarble/marble-backend/utils" |
| 19 | +) |
| 20 | + |
| 21 | +// TestGraphIncrementalMatchesAFullBuild is the test that justifies maintaining the adjacency table |
| 22 | +// from the ingestion path at all. |
| 23 | +// |
| 24 | +// Two records are related when their stored projections are byte-equal, so a writer that renders a |
| 25 | +// value even slightly differently from the periodic build does not fail — it silently stops matching, |
| 26 | +// and the graph quietly loses edges. Asserting the two writers produce the same rows for the same |
| 27 | +// data is the only check that catches that, and it needs a real database: the projections are SQL |
| 28 | +// casts, and whether `t."opened_at"::text` and `(t."opened_at" at time zone 'utc')::text` differ is a |
| 29 | +// question only Postgres can answer. |
| 30 | +func TestGraphIncrementalMatchesAFullBuild(t *testing.T) { |
| 31 | + ctx := utils.StoreLoggerInContext(context.Background(), utils.NewLogger("text")) |
| 32 | + ctx = utils.StoreSegmentClientInContext(ctx, analytics.New("dummy key")) |
| 33 | + |
| 34 | + creds, _, _ := setupOrgAndCreds(ctx, t, "test org for graph maintenance") |
| 35 | + orgId := creds.OrganizationId |
| 36 | + uc := generateUsecaseWithCreds(testUsecases, creds) |
| 37 | + |
| 38 | + builder := newTestGraphBuilder() |
| 39 | + |
| 40 | + // The organization has no adjacency table until the first build runs, so the incremental write has |
| 41 | + // nothing to write to. It must not fail the ingestion over that. |
| 42 | + ingestGraphObject(ctx, t, uc, orgId, "companies", |
| 43 | + `{"object_id": "comp-0", "updated_at": "2026-01-01T00:00:00Z", "name": "Before Any Build"}`) |
| 44 | + |
| 45 | + require.NoError(t, builder.Build(ctx, orgId), "first build") |
| 46 | + |
| 47 | + // Ingested before the build, so the build itself is what put it in. |
| 48 | + assert.Contains(t, readGraphRows(ctx, t, orgId), |
| 49 | + graphTestRow{"companies", "comp-0", "object_id", "comp-0"}) |
| 50 | + |
| 51 | + // A company, an account belonging to it, and a transaction on that account: the data model links |
| 52 | + // transactions.account_id → accounts.object_id and accounts.company_id → companies.object_id, so |
| 53 | + // all three participate. |
| 54 | + ingestGraphObject(ctx, t, uc, orgId, "companies", |
| 55 | + `{"object_id": "comp-1", "updated_at": "2026-01-01T00:00:00Z", "name": "Acme"}`) |
| 56 | + ingestGraphObject(ctx, t, uc, orgId, "accounts", |
| 57 | + `{"object_id": "acc-1", "updated_at": "2026-01-01T00:00:00Z", "company_id": "comp-1", "name": "Acme Main", "balance": 12.5}`) |
| 58 | + ingestGraphObject(ctx, t, uc, orgId, "transactions", |
| 59 | + `{"object_id": "tx-1", "updated_at": "2026-01-01T00:00:00Z", "account_id": "acc-1", "amount": 30.0}`) |
| 60 | + |
| 61 | + // The point of the whole change: reachable with no build in between. |
| 62 | + afterIngestion := readGraphRows(ctx, t, orgId) |
| 63 | + assert.Contains(t, afterIngestion, graphTestRow{"accounts", "acc-1", "company_id", "comp-1"}, |
| 64 | + "the edge from the new account to its company is available immediately") |
| 65 | + assert.Contains(t, afterIngestion, graphTestRow{"transactions", "tx-1", "account_id", "acc-1"}) |
| 66 | + assert.Contains(t, afterIngestion, graphTestRow{"transactions", "tx-1", "object_id", "tx-1"}) |
| 67 | + |
| 68 | + // Only the linked fields and object_id: a field nothing traverses would bloat the table for |
| 69 | + // nothing, and one the walk reads but the table lacks silently finds nothing. |
| 70 | + assert.NotContains(t, afterIngestion, graphTestRow{"accounts", "acc-1", "name", "Acme Main"}) |
| 71 | + |
| 72 | + // And they are exactly the rows a build from scratch produces. |
| 73 | + require.NoError(t, builder.Build(ctx, orgId), "rebuild after incremental ingestion") |
| 74 | + assert.ElementsMatch(t, afterIngestion, readGraphRows(ctx, t, orgId), |
| 75 | + "the incremental writer and the build must agree on every row, byte for byte") |
| 76 | +} |
| 77 | + |
| 78 | +func TestGraphIncrementalRetractsAndUpdates(t *testing.T) { |
| 79 | + ctx := utils.StoreLoggerInContext(context.Background(), utils.NewLogger("text")) |
| 80 | + ctx = utils.StoreSegmentClientInContext(ctx, analytics.New("dummy key")) |
| 81 | + |
| 82 | + creds, _, _ := setupOrgAndCreds(ctx, t, "test org for graph retraction") |
| 83 | + orgId := creds.OrganizationId |
| 84 | + uc := generateUsecaseWithCreds(testUsecases, creds) |
| 85 | + |
| 86 | + builder := newTestGraphBuilder() |
| 87 | + require.NoError(t, builder.Build(ctx, orgId), "first build") |
| 88 | + |
| 89 | + ingestGraphObject(ctx, t, uc, orgId, "accounts", |
| 90 | + `{"object_id": "acc-1", "updated_at": "2026-01-01T00:00:00Z", "company_id": "comp-1", "name": "Acme Main"}`) |
| 91 | + require.Contains(t, readGraphRows(ctx, t, orgId), |
| 92 | + graphTestRow{"accounts", "acc-1", "company_id", "comp-1"}) |
| 93 | + |
| 94 | + // A newer version pointing at a different company: the old value must not linger, or the account |
| 95 | + // would appear to belong to both. |
| 96 | + ingestGraphObject(ctx, t, uc, orgId, "accounts", |
| 97 | + `{"object_id": "acc-1", "updated_at": "2026-01-02T00:00:00Z", "company_id": "comp-2", "name": "Acme Main"}`) |
| 98 | + |
| 99 | + moved := readGraphRows(ctx, t, orgId) |
| 100 | + assert.Contains(t, moved, graphTestRow{"accounts", "acc-1", "company_id", "comp-2"}) |
| 101 | + assert.NotContains(t, moved, graphTestRow{"accounts", "acc-1", "company_id", "comp-1"}, |
| 102 | + "the superseded value must not survive alongside the current one") |
| 103 | + |
| 104 | + // A newer version with no company at all. The adjacency table has no valid_until to mark a row |
| 105 | + // dead with and the upsert can only add or update, so this is what the retraction is for. |
| 106 | + ingestGraphObject(ctx, t, uc, orgId, "accounts", |
| 107 | + `{"object_id": "acc-1", "updated_at": "2026-01-03T00:00:00Z", "company_id": null, "name": "Acme Main"}`) |
| 108 | + |
| 109 | + retracted := readGraphRows(ctx, t, orgId) |
| 110 | + assert.NotContains(t, retracted, graphTestRow{"accounts", "acc-1", "company_id", "comp-2"}, |
| 111 | + "a field the new version left empty must lose its row, not keep the old value") |
| 112 | + assert.Contains(t, retracted, graphTestRow{"accounts", "acc-1", "object_id", "acc-1"}, |
| 113 | + "the record itself is still there") |
| 114 | + |
| 115 | + // Every step above must leave the table in the state a build would. |
| 116 | + require.NoError(t, builder.Build(ctx, orgId), "rebuild after updates and retraction") |
| 117 | + assert.ElementsMatch(t, retracted, readGraphRows(ctx, t, orgId)) |
| 118 | +} |
| 119 | + |
| 120 | +// TestGraphIncrementalRetractsAnOmittedField covers the way a value is most likely to actually go |
| 121 | +// away in production: not an explicit null, but a client that simply stops sending the field. A POST |
| 122 | +// does not carry missing fields over from the previous version — only a PATCH does — so an omitted |
| 123 | +// nullable field is ingested as NULL, and the row the previous version left in the adjacency table |
| 124 | +// has nothing to overwrite it. |
| 125 | +func TestGraphIncrementalRetractsAnOmittedField(t *testing.T) { |
| 126 | + ctx := utils.StoreLoggerInContext(context.Background(), utils.NewLogger("text")) |
| 127 | + ctx = utils.StoreSegmentClientInContext(ctx, analytics.New("dummy key")) |
| 128 | + |
| 129 | + creds, _, _ := setupOrgAndCreds(ctx, t, "test org for graph omitted field") |
| 130 | + orgId := creds.OrganizationId |
| 131 | + uc := generateUsecaseWithCreds(testUsecases, creds) |
| 132 | + |
| 133 | + builder := newTestGraphBuilder() |
| 134 | + require.NoError(t, builder.Build(ctx, orgId), "first build") |
| 135 | + |
| 136 | + ingestGraphObject(ctx, t, uc, orgId, "accounts", |
| 137 | + `{"object_id": "acc-1", "updated_at": "2026-01-01T00:00:00Z", "company_id": "comp-1"}`) |
| 138 | + require.Contains(t, readGraphRows(ctx, t, orgId), |
| 139 | + graphTestRow{"accounts", "acc-1", "company_id", "comp-1"}) |
| 140 | + |
| 141 | + // Same record, newer version, company_id simply not mentioned. |
| 142 | + ingestGraphObject(ctx, t, uc, orgId, "accounts", |
| 143 | + `{"object_id": "acc-1", "updated_at": "2026-01-02T00:00:00Z", "name": "Acme Main"}`) |
| 144 | + |
| 145 | + rows := readGraphRows(ctx, t, orgId) |
| 146 | + assert.NotContains(t, rows, graphTestRow{"accounts", "acc-1", "company_id", "comp-1"}, |
| 147 | + "an omitted nullable field is ingested as NULL, so its adjacency row is now stale") |
| 148 | + |
| 149 | + // The build reading the live row is the arbiter of what the table should hold: if it agrees the |
| 150 | + // field is gone, a row the incremental writer left behind would be an edge that does not exist. |
| 151 | + require.NoError(t, builder.Build(ctx, orgId), "rebuild after the omission") |
| 152 | + assert.ElementsMatch(t, rows, readGraphRows(ctx, t, orgId)) |
| 153 | +} |
| 154 | + |
| 155 | +// TestGraphReconcileCarriesRowsIngestedMidBuild drives the build a step at a time so a record can be |
| 156 | +// ingested at the one moment only the reconcile can rescue it: after the bulk catch-up has already run. |
| 157 | +// |
| 158 | +// This is the case the whole replay/reconcile machinery exists for. A build can take hours, and every |
| 159 | +// record ingested during it lands in the live table the swap is about to retire — so without this, each |
| 160 | +// build would silently discard a day's worth of incremental freshness. The other tests here ingest |
| 161 | +// before the build, where the bulk pass picks everything up and the reconcile has nothing to do. |
| 162 | +func TestGraphReconcileCarriesRowsIngestedMidBuild(t *testing.T) { |
| 163 | + ctx := utils.StoreLoggerInContext(context.Background(), utils.NewLogger("text")) |
| 164 | + ctx = utils.StoreSegmentClientInContext(ctx, analytics.New("dummy key")) |
| 165 | + |
| 166 | + creds, dataModel, _ := setupOrgAndCreds(ctx, t, "test org for graph mid-build ingestion") |
| 167 | + orgId := creds.OrganizationId |
| 168 | + uc := generateUsecaseWithCreds(testUsecases, creds) |
| 169 | + |
| 170 | + admin := generateUsecaseWithCredForMarbleAdmin(testUsecases) |
| 171 | + repo := admin.Repositories.MarbleDbRepository |
| 172 | + fields := models.GraphIndexedFields(dataModel, nil) |
| 173 | + |
| 174 | + // A live table has to exist for there to be anything to retire. |
| 175 | + require.NoError(t, newTestGraphBuilder().Build(ctx, orgId), "first build") |
| 176 | + |
| 177 | + clientExec, err := admin.NewExecutorFactory().NewClientDbExecutor(ctx, orgId) |
| 178 | + require.NoError(t, err) |
| 179 | + |
| 180 | + require.NoError(t, repo.CreateGraphBuildTable(ctx, clientExec)) |
| 181 | + |
| 182 | + watermark, err := repo.GraphReplayWatermark(ctx, clientExec) |
| 183 | + require.NoError(t, err) |
| 184 | + |
| 185 | + for recordType, recordFields := range fields { |
| 186 | + _, err := repo.PopulateGraphBuildTable(ctx, clientExec, recordType, recordFields) |
| 187 | + require.NoError(t, err) |
| 188 | + } |
| 189 | + require.NoError(t, repo.IndexGraphBuildTable(ctx, clientExec)) |
| 190 | + |
| 191 | + reconcileWatermark, err := repo.GraphReplayWatermark(ctx, clientExec) |
| 192 | + require.NoError(t, err) |
| 193 | + |
| 194 | + _, err = repo.ReplayGraphRows(ctx, clientExec, watermark) |
| 195 | + require.NoError(t, err) |
| 196 | + |
| 197 | + // The mid-build arrival. The bulk pass above has already run, so this row exists only in the live |
| 198 | + // table — the one the swap is about to rename aside. |
| 199 | + ingestGraphObject(ctx, t, uc, orgId, "accounts", |
| 200 | + `{"object_id": "acc-late", "updated_at": "2026-01-01T00:00:00Z", "company_id": "comp-late"}`) |
| 201 | + |
| 202 | + late := graphTestRow{"accounts", "acc-late", "company_id", "comp-late"} |
| 203 | + require.Contains(t, readGraphRows(ctx, t, orgId), late, |
| 204 | + "ingestion put it in the live table, which is the premise of the rest of this test") |
| 205 | + |
| 206 | + require.NoError(t, repo.AnalyzeGraphBuildTable(ctx, clientExec)) |
| 207 | + |
| 208 | + require.NoError(t, admin.NewTransactionFactory().TransactionInOrgSchema(ctx, orgId, |
| 209 | + func(tx repositories.Transaction) error { |
| 210 | + return repo.SwapGraphTable(ctx, tx) |
| 211 | + })) |
| 212 | + |
| 213 | + // The window the rename-aside design accepts, asserted rather than assumed: the new table is live |
| 214 | + // and does not yet hold the tail. Incompleteness, not corruption, and no reader was ever blocked |
| 215 | + // for it. |
| 216 | + assert.NotContains(t, readGraphRows(ctx, t, orgId), late, |
| 217 | + "the freshly built table cannot know about a record ingested after it was populated") |
| 218 | + |
| 219 | + var replayed int64 |
| 220 | + require.NoError(t, admin.NewTransactionFactory().TransactionInOrgSchema(ctx, orgId, |
| 221 | + func(tx repositories.Transaction) error { |
| 222 | + replayed, err = repo.ReconcileGraphFromOld(ctx, tx, reconcileWatermark) |
| 223 | + return err |
| 224 | + })) |
| 225 | + |
| 226 | + assert.Positive(t, replayed, "the reconcile is what carries the tail over, so it must have written") |
| 227 | + assert.Contains(t, readGraphRows(ctx, t, orgId), late, |
| 228 | + "a record ingested mid-build must survive the build that was running at the time") |
| 229 | + |
| 230 | + // And the previous generation is gone, so the next build starts clean. |
| 231 | + var oldExists bool |
| 232 | + require.NoError(t, clientExec.QueryRow(ctx, |
| 233 | + `select exists(select 1 from information_schema.tables |
| 234 | + where table_name = '_graph_old' and table_schema = $1)`, |
| 235 | + clientExec.DatabaseSchema().Schema).Scan(&oldExists)) |
| 236 | + assert.False(t, oldExists, "the reconcile discards the generation it drained") |
| 237 | +} |
| 238 | + |
| 239 | +func newTestGraphBuilder() worker_jobs.GraphBuilder { |
| 240 | + admin := generateUsecaseWithCredForMarbleAdmin(testUsecases) |
| 241 | + |
| 242 | + return worker_jobs.NewGraphBuilder( |
| 243 | + admin.NewExecutorFactory(), |
| 244 | + admin.NewTransactionFactory(), |
| 245 | + admin.NewFeatureAccessReader(), |
| 246 | + admin.Repositories.MarbleDbRepository, |
| 247 | + admin.Repositories.MarbleDbRepository, |
| 248 | + admin.Repositories.MarbleDbRepository, |
| 249 | + ) |
| 250 | +} |
| 251 | + |
| 252 | +func ingestGraphObject( |
| 253 | + ctx context.Context, |
| 254 | + t *testing.T, |
| 255 | + uc usecases.UsecasesWithCreds, |
| 256 | + orgId uuid.UUID, |
| 257 | + objectType string, |
| 258 | + payload string, |
| 259 | +) { |
| 260 | + t.Helper() |
| 261 | + |
| 262 | + ingestion := uc.NewIngestionUseCase() |
| 263 | + _, err := ingestion.IngestObject(ctx, orgId, objectType, []byte(payload), models.IngestionOptions{}) |
| 264 | + require.NoErrorf(t, err, "could not ingest %s %s", objectType, payload) |
| 265 | +} |
| 266 | + |
| 267 | +// graphTestRow is a row of the adjacency table, comparable so a whole table can be compared as a set. |
| 268 | +// updated_at is left out on purpose: it is bookkeeping for the replay, and a build and an incremental |
| 269 | +// write will never agree on it. |
| 270 | +type graphTestRow struct { |
| 271 | + RecordType string |
| 272 | + RecordId string |
| 273 | + FieldName string |
| 274 | + FieldValue string |
| 275 | +} |
| 276 | + |
| 277 | +func readGraphRows(ctx context.Context, t *testing.T, orgId uuid.UUID) []graphTestRow { |
| 278 | + t.Helper() |
| 279 | + |
| 280 | + exec, err := testUsecases.NewExecutorFactory().NewClientDbExecutor(ctx, orgId) |
| 281 | + require.NoError(t, err) |
| 282 | + |
| 283 | + sql := fmt.Sprintf( |
| 284 | + "select record_type, record_id, field_name, field_value from %s", |
| 285 | + pgx.Identifier{exec.DatabaseSchema().Schema, "_graph"}.Sanitize()) |
| 286 | + |
| 287 | + rows, err := exec.Query(ctx, sql) |
| 288 | + require.NoError(t, err) |
| 289 | + defer rows.Close() |
| 290 | + |
| 291 | + out := make([]graphTestRow, 0) |
| 292 | + for rows.Next() { |
| 293 | + var row graphTestRow |
| 294 | + require.NoError(t, rows.Scan(&row.RecordType, &row.RecordId, &row.FieldName, &row.FieldValue)) |
| 295 | + out = append(out, row) |
| 296 | + } |
| 297 | + require.NoError(t, rows.Err()) |
| 298 | + |
| 299 | + return out |
| 300 | +} |
0 commit comments