Skip to content

Commit 55ebe36

Browse files
committed
Maintain graph adjacency table incrementally during ingestion.
`_graph` was previously refreshed only by a periodic full rebuild (default 24h), so a record ingested right after a build was invisible to graph walks until the next cycle. Now we upsert into the table inside the existing ingestion transaction, behind a subtransaction so a failure never fails ingestion itself. Retraction covers a field an ingested version leaves null/empty, which the upsert cannot express since the table has no valid_until. Rework the periodic build's swap to rename the live table aside instead of dropping it, so records ingested while a build is running are reconciled from the previous generation after the swap rather than replayed under a lock that graph read queries would otherwise block on.
1 parent df23724 commit 55ebe36

12 files changed

Lines changed: 1516 additions & 55 deletions
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
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+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package mocks
2+
3+
import (
4+
"context"
5+
6+
"github.com/stretchr/testify/mock"
7+
8+
"github.com/checkmarble/marble-backend/models"
9+
"github.com/checkmarble/marble-backend/repositories"
10+
)
11+
12+
type GraphIncrementalRepository struct {
13+
mock.Mock
14+
}
15+
16+
func (r *GraphIncrementalRepository) UpsertGraphRows(
17+
ctx context.Context,
18+
exec repositories.Executor,
19+
recordType string,
20+
fields []models.Field,
21+
objectIds []string,
22+
) (int64, error) {
23+
args := r.Called(ctx, exec, recordType, fields, objectIds)
24+
return args.Get(0).(int64), args.Error(1)
25+
}
26+
27+
func (r *GraphIncrementalRepository) RetractGraphRows(
28+
ctx context.Context,
29+
exec repositories.Executor,
30+
recordType string,
31+
fields []models.Field,
32+
objectIds []string,
33+
) (int64, error) {
34+
args := r.Called(ctx, exec, recordType, fields, objectIds)
35+
return args.Get(0).(int64), args.Error(1)
36+
}

0 commit comments

Comments
 (0)