Skip to content

Write per snapshot table - #6742

Draft
OriolMunoz-da wants to merge 17 commits into
oriol/e1.1-acs-snapshot-migrationfrom
oriol/e1.2-write-per-snapshot-table-after-token
Draft

Write per snapshot table#6742
OriolMunoz-da wants to merge 17 commits into
oriol/e1.1-acs-snapshot-migrationfrom
oriol/e1.2-write-per-snapshot-table-after-token

Conversation

@OriolMunoz-da

@OriolMunoz-da OriolMunoz-da commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Part of #6263
Part of #6264 (only done here because otherwise the tests do nothing)

The interface of AcsSnapshotStore stays the same, we just have a new IncrementalAcsSnapshotTable that defines the behavior on insertion

TODOs (later PRs because this is big enough):

  • Intern strings
  • Create indexes on snapshot tables: Add ACS snapshot table indexes #6834 (draft)
  • Better naming?
  • Test copy tables performance on cilr
  • forceSnapshot behavior & enable by default in integration tests only
  • drop table name from migration

Signed-off-by: Oriol Muñoz <[email protected]>
Signed-off-by: Oriol Muñoz <[email protected]>
@@ -1,3 +1,3 @@
alter table acs_snapshot
-- the name 'table_name' is not reserved, but it is a PostgreSQL keyword, so we use a different name to avoid confusion
add column data_table_name text default null,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

observation: this column is pointless:

  • You need a table name for both _stakeholders and _creates
  • The code can derive the table name from the snapshot record time anyway

any arguments for keeping this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Argument for keeping it is that you can change the naming scheme in the future. IMO that's worth the slightly higher complexity.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to that: since technically every snapshot table can have a different schema, do we want to include some schema version number column here?

Any time we change acs_snapshot_creates_template, we'd also change the scala constant used for populating the version column.

snapshot_id bigint not null references acs_incremental_snapshot (snapshot_id),
contract_id text not null,

-- All the data necessary to reconstruct a created event

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can't join with update_history_creates because of pruning

)
}

def assertFromString(s: String): PackageQualifiedName = {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mirrors QualifiedName's impl

Comment on lines +38 to +42
if (storageConfig.perAcsSnapshotTablesEnabled) {
AcsSnapshotStore.IncrementalAcsSnapshotTable.NextV2
} else {
AcsSnapshotStore.IncrementalAcsSnapshotTable.Next
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will define the storage behavior. note that this is not done for backfilling, as we don't care what happens to those as they'll get pruned

override val nextTable: IncrementalAcsSnapshotTable = IncrementalAcsSnapshotTable.Next
}

class TablePerAcsSnapshotStoreTest extends AcsSnapshotStoreTest {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assumption: if the tests pass by replacing the nextTable, then the behavior is correct (insofar as the tests cover it, ofc)

sql"where snapshot.row_id >= $firstRowId and snapshot.row_id <= $lastRowId"
case Some(_: PerTableAcsSnapshot) =>
throw io.grpc.Status.UNIMPLEMENTED.withDescription("TODO #6264").asRuntimeException()
throw io.grpc.Status.UNIMPLEMENTED

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is actually called during the sanity check, but it uses the old (pre-incremental snapshot!) behavior, so it was already testing irrelevant code. TODO

)
case _: PerTableAcsSnapshot =>
throw io.grpc.Status.UNIMPLEMENTED.withDescription("TODO #6263").asRuntimeException()
case table: PerTableAcsSnapshot =>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure this case can happen in prod, but tbf it's trivial to implement

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this not happen all the time with pruning?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duh, yeah, I'd expect so. Meant more in the context of the existing code, not realizing this will most likely used by pruning too

}
}

private def querySnapshotInOwnTable(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clearly I don't know what to name things, so better ideas are more than welcome, for the whole "pertable" stuff in general

Comment on lines +640 to +641
// If we enable PerTableAcsSnapshots, we will necessarily initialize from a LegacyAcsSnapshot,
// never from a PerTableAcsSnapshot. Then initializeIncrementalSnapshot will never be called again.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pls verify my hypothesis is correct

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will initializeIncrementalSnapshot ever be called at all? I would expect that by now all SVs have switched to incremental snapshots, so the whole code path for "copy a non-incremental snapshot to the incremental table" is dead. How about leaving the implementation as is, and create an issue for deleting all dead code?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will execute at least once when we turn on per-table acs snapshots: the table acs_incremental_snapshot_data_next_v2 is empty so we need to initialize it from the last-existing snapshot

Comment on lines +874 to +875
// This doesn't make much sense anymore
copiedCreateRows

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is used for metrics, what would we like here? i guess ideally track both tables? (legacy will fail to track one of them but that's fine)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracking both sounds good.

QueryParts.legacyCopyFromUpdateHistorySourceColumns
}

object QueryParts {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved and added v2, sorry for the noise

})
}

object AcsTableDDL {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as mentioned in the migration: we don't need to keep the table name, lmk if you see an issue with that

@OriolMunoz-da OriolMunoz-da changed the title [wip] e1.2 Write per snapshot table Write per snapshot table Aug 11, 2026
Signed-off-by: Oriol Muñoz <[email protected]>
Signed-off-by: Oriol Muñoz <[email protected]>
@OriolMunoz-da
OriolMunoz-da marked this pull request as ready for review August 11, 2026 10:03
…e-per-snapshot-table-after-token

[ci]

Signed-off-by: Oriol Muñoz <[email protected]>
Comment on lines +820 to +829
copiedCreateRows <- (sql"""
insert into #$createsTableName (contract_id, create_arguments, event_id, record_time, template_id_package_id, contract_key, created_at, signatories, observers, unlocked_amulet_balance, locked_amulet_balance)
select s.contract_id, s.create_arguments, s.event_id, s.record_time, s.template_id_package_id, s.contract_key, s.created_at, s.signatories, s.observers, """ ++ IncrementalAcsSnapshotTable.QueryParts
.unlockedAmuletBalance() ++ sql", " ++ IncrementalAcsSnapshotTable.QueryParts
.lockedAmuletBalance() ++ sql"""
from #${table.tableName} s
where s.snapshot_id = ${snapshot.snapshotId}
-- ensure consistent ordering across SVs
order by created_at, contract_id
""").toActionBuilder.asUpdate

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using cilr's latest snapshot on sv-5:

scan_sv_5=> explain insert into oriol_acs_snapshot_creates_template (contract_id, create_arguments, event_id, record_time, template_id_package_id, contract_key, created_at, signatories, observers, unlocked_amulet_balance, locked_amulet_balance)
select s.contract_id, s.create_arguments, s.event_id, s.record_time, s.template_id_package_id, s.contract_key, s.created_at, s.signatories, s.observers, s.unlocked_amulet_balance, s.locked_amulet_balance
from oriol_acs_incremental_snapshot_data_next_v2 s
where s.snapshot_id = 1
-- ensure consistent ordering across SVs
order by created_at, contract_id;
                                                                             QUERY PLAN                                                                              
---------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Insert on oriol_acs_snapshot_creates_template  (cost=0.43..356025.68 rows=0 width=0)
   ->  Index Scan using acs_incremental_snapshot_data_next_v2_ca_ci on oriol_acs_incremental_snapshot_data_next_v2 s  (cost=0.43..356025.68 rows=1484054 width=1300)
         Index Cond: (snapshot_id = 1)
(3 rows)

Time: 1.572 ms
scan_sv_5=> insert into oriol_acs_snapshot_creates_template (contract_id, create_arguments, event_id, record_time, template_id_package_id, contract_key, created_at, signatories, observers, unlocked_amulet_balance, locked_amulet_balance)
select s.contract_id, s.create_arguments, s.event_id, s.record_time, s.template_id_package_id, s.contract_key, s.created_at, s.signatories, s.observers, s.unlocked_amulet_balance, s.locked_amulet_balance
from oriol_acs_incremental_snapshot_data_next_v2 s
where s.snapshot_id = 1
-- ensure consistent ordering across SVs
order by created_at, contract_id;
INSERT 0 1483146
Time: 294432.204 ms (04:54.432)

~5m

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Query plan looks good, but it's much slower than I hoped 😞

At https://docs.google.com/document/d/11BE-R2kVL3pnC20fDKWhzr-PN1iBf7Su-DtcmXUpUGk/edit?tab=t.0#bookmark=id.qk54mjxeefga, we've seen a dumb copy of 1.5M rows complete in 8sec.

Do you know if the difference is due to having to do an index scan (as opposed to a seq scan), or due to having much wider rows? Or perhaps a cold cache?

Can we run EXPLAIN (ANALYZE, BUFFERS) on the whole thing or just on the select part, to figure out what part is expensive?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

 Insert on oriol_acs_snapshot_creates_template  (cost=0.43..356025.68 rows=0 wid
th=0) (actual time=667449.982..667449.984 rows=0.00 loops=1)
   Buffers: shared hit=8255941 read=1215075 dirtied=325542 written=300217
   I/O Timings: shared read=582711.099 write=9218.870
   ->  Index Scan using acs_incremental_snapshot_data_next_v2_ca_ci on oriol_acs
_incremental_snapshot_data_next_v2 s  (cost=0.43..356025.68 rows=1484054 width=1
300) (actual time=3.502..592369.286 rows=1483146.00 loops=1)
         Index Cond: (snapshot_id = 1)
         Index Searches: 1
         Buffers: shared hit=322584 read=1205956 written=25
         I/O Timings: shared read=580559.069 write=1.316
 Planning:
   Buffers: shared hit=132 read=19
   I/O Timings: shared read=6.269
 Planning Time: 7.988 ms
 Execution Time: 667450.191 ms

cross join unnest(array_cat(s.observers, s.signatories)) as stakeholder
where s.snapshot_id = ${snapshot.snapshotId}
order by created_at, contract_id
"""

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scan_sv_5=> explain insert into oriol_acs_snapshot_stakeholders_template (stakeholder, template_id, contract_id)
select stakeholder, concat(s.package_name, ':', s.template_id_module_name, ':', s.template_id_entity_name) as template_id, contract_id
from oriol_acs_incremental_snapshot_data_next_v2 s
    cross join unnest(array_cat(s.observers, s.signatories)) as stakeholder
where s.snapshot_id = 1
order by created_at, contract_id;
                                                                                   QUERY PLAN                                                                                   
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Insert on oriol_acs_snapshot_stakeholders_template  (cost=0.43..875444.59 rows=0 width=0)
   ->  Subquery Scan on "*SELECT*"  (cost=0.43..875444.59 rows=14840540 width=214)
         ->  Nested Loop  (cost=0.43..689937.84 rows=14840540 width=214)
               ->  Index Scan using acs_incremental_snapshot_data_next_v2_ca_ci on oriol_acs_incremental_snapshot_data_next_v2 s  (cost=0.43..356025.68 rows=1484054 width=464)
                     Index Cond: (snapshot_id = 1)
               ->  Function Scan on unnest stakeholder  (cost=0.01..0.11 rows=10 width=32)
(6 rows)

Time: 10.872 ms
scan_sv_5=> insert into oriol_acs_snapshot_stakeholders_template (stakeholder, template_id, contract_id)
select stakeholder, concat(s.package_name, ':', s.template_id_module_name, ':', s.template_id_entity_name) as template_id, contract_id
from oriol_acs_incremental_snapshot_data_next_v2 s
    cross join unnest(array_cat(s.observers, s.signatories)) as stakeholder
where s.snapshot_id = 1
order by created_at, contract_id;
INSERT 0 3824687
Time: 178101.374 ms (02:58.101)

where s.snapshot_id = ${snapshot.snapshotId}
order by created_at, contract_id
"""
// TODO: we should create the necessary indexes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scan_sv_5=> create index on oriol_acs_snapshot_stakeholders_template (stakeholder, template_id, row_id);
CREATE INDEX
Time: 42237.803 ms (00:42.238)
scan_sv_5=> create index on oriol_acs_snapshot_stakeholders_template (stakeholder, row_id);
CREATE INDEX
Time: 40198.560 ms (00:40.199)

faster than I expected

Comment on lines +840 to +846
(unlocked_amulet_balance, locked_amulet_balance) <- sql"""
select
sum(s.unlocked_amulet_balance) AS unlocked_amulet_balance,
sum(s.locked_amulet_balance) AS locked_amulet_balance
from #${table.tableName} s
where snapshot_id = ${snapshot.snapshotId}
""".as[(BigDecimal, BigDecimal)].head

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scan_sv_5=> explain select
    sum(s.unlocked_amulet_balance) AS unlocked_amulet_balance,
    sum(s.locked_amulet_balance) AS locked_amulet_balance
from oriol_acs_incremental_snapshot_data_next_v2 s
where snapshot_id = 1;
                                                            QUERY PLAN                                                            
----------------------------------------------------------------------------------------------------------------------------------
 Finalize Aggregate  (cost=279980.47..279980.48 rows=1 width=64)
   ->  Gather  (cost=279980.23..279980.44 rows=2 width=64)
         Workers Planned: 2
         ->  Partial Aggregate  (cost=278980.23..278980.24 rows=1 width=64)
               ->  Parallel Seq Scan on oriol_acs_incremental_snapshot_data_next_v2 s  (cost=0.00..275888.45 rows=618356 width=6)
                     Filter: (snapshot_id = 1)
(6 rows)


scan_sv_5=> select
    sum(s.unlocked_amulet_balance) AS unlocked_amulet_balance,
    sum(s.locked_amulet_balance) AS locked_amulet_balance
from oriol_acs_incremental_snapshot_data_next_v2 s
where snapshot_id = 1;
 unlocked_amulet_balance | locked_amulet_balance 
-------------------------+-----------------------
                       0 |                     0
(1 row)

Time: 8763.449 ms (00:08.763)

@@ -1,3 +1,3 @@
alter table acs_snapshot
-- the name 'table_name' is not reserved, but it is a PostgreSQL keyword, so we use a different name to avoid confusion
add column data_table_name text default null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Argument for keeping it is that you can change the naming scheme in the future. IMO that's worth the slightly higher complexity.

@@ -12,3 +12,80 @@ alter table acs_snapshot
((first_row_id is null and last_row_id is null and data_table_name is not null) or
-- legacy table
(first_row_id is not null and last_row_id is not null and data_table_name is null));

-- TODO: template ids can be interned already

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this will be fixed in some upcoming PR before we merge the feature branch?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes

Comment on lines +23 to +24
-- column will be the same for all rows. However, in tests we can have multiple
-- scan instances writing to the same table, so we need to include it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, in tests we can have multiple scan instances writing to the same table, so we need to include it.

We could get rid of this column if we created this table on demand, and included the store id in the table name. Not worth the effort.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(disclaimer: the comment was copied from the incremental snapshots migration because the same logic applies)

@@ -1,3 +1,3 @@
alter table acs_snapshot
-- the name 'table_name' is not reserved, but it is a PostgreSQL keyword, so we use a different name to avoid confusion
add column data_table_name text default null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to that: since technically every snapshot table can have a different schema, do we want to include some schema version number column here?

Any time we change acs_snapshot_creates_template, we'd also change the scala constant used for populating the version column.

templatesFilter(templates) ++
afterFilter ++ sql"""
order by s.row_id
limit ${sqlLimit(limit)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we need some group by contract_id here? This looks like it will return one row for each combination of stakeholder and contract id.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duh, of course.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note to self: a bunch of the tests use the old insertNewSnapshot which creates legacy snapshots so it's not testing anything

reflection of self: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

Comment on lines +640 to +641
// If we enable PerTableAcsSnapshots, we will necessarily initialize from a LegacyAcsSnapshot,
// never from a PerTableAcsSnapshot. Then initializeIncrementalSnapshot will never be called again.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will initializeIncrementalSnapshot ever be called at all? I would expect that by now all SVs have switched to incremental snapshots, so the whole code path for "copy a non-incremental snapshot to the incremental table" is dead. How about leaving the implementation as is, and create an issue for deleting all dead code?

Comment on lines +820 to +829
copiedCreateRows <- (sql"""
insert into #$createsTableName (contract_id, create_arguments, event_id, record_time, template_id_package_id, contract_key, created_at, signatories, observers, unlocked_amulet_balance, locked_amulet_balance)
select s.contract_id, s.create_arguments, s.event_id, s.record_time, s.template_id_package_id, s.contract_key, s.created_at, s.signatories, s.observers, """ ++ IncrementalAcsSnapshotTable.QueryParts
.unlockedAmuletBalance() ++ sql", " ++ IncrementalAcsSnapshotTable.QueryParts
.lockedAmuletBalance() ++ sql"""
from #${table.tableName} s
where s.snapshot_id = ${snapshot.snapshotId}
-- ensure consistent ordering across SVs
order by created_at, contract_id
""").toActionBuilder.asUpdate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Query plan looks good, but it's much slower than I hoped 😞

At https://docs.google.com/document/d/11BE-R2kVL3pnC20fDKWhzr-PN1iBf7Su-DtcmXUpUGk/edit?tab=t.0#bookmark=id.qk54mjxeefga, we've seen a dumb copy of 1.5M rows complete in 8sec.

Do you know if the difference is due to having to do an index scan (as opposed to a seq scan), or due to having much wider rows? Or perhaps a cold cache?

Can we run EXPLAIN (ANALYZE, BUFFERS) on the whole thing or just on the select part, to figure out what part is expensive?

Comment on lines +874 to +875
// This doesn't make much sense anymore
copiedCreateRows

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracking both sounds good.

saveLegacyIncrementalSnapshotStatement(table, snapshot, nextSnapshotTargetRecordTime)
}
storage.queryAndUpdate(
withExclusiveSnapshotDataLock(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
withExclusiveSnapshotDataLock(
withDdlStatement(

depending on whether we expect that create index X on A (SqlIndexInitializationTrigger) and create index Y on B (AcsSnapshotStore) can conflict on the catalog update.

Signed-off-by: Oriol Muñoz <[email protected]>
Signed-off-by: Oriol Muñoz <[email protected]>
Signed-off-by: Oriol Muñoz <[email protected]>
Signed-off-by: Oriol Muñoz <[email protected]>
@OriolMunoz-da
OriolMunoz-da removed the request for review from ray-roestenburg-da August 19, 2026 14:39
Signed-off-by: Oriol Muñoz <[email protected]>
@OriolMunoz-da
OriolMunoz-da marked this pull request as draft August 19, 2026 14:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants