Skip to content

Implement String interning store - #7111

Merged
OriolMunoz-da merged 12 commits into
mainfrom
oriol/interned-strings
Sep 9, 2026
Merged

Implement String interning store#7111
OriolMunoz-da merged 12 commits into
mainfrom
oriol/interned-strings

Conversation

@OriolMunoz-da

@OriolMunoz-da OriolMunoz-da commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes #7075

@OriolMunoz-da OriolMunoz-da changed the title [wip] Oriol/interned strings Implement String interning store Sep 4, 2026
) extends InternedStringStore
with NamedLogging {

override def getOrIntern(value: String)(implicit tc: TraceContext): Future[InternedId] = {

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.

the query proposed in the design was:

with new_row as ( 
  insert into interned_strings (value) values ($v) 
  on conflict (value) do nothing returning id 
) 
select id from new_row union all select id from interned_strings where value = $v limit 1;

this does not work with contention:

[info] - handle concurrent interning of the same value without cache *** FAILED ***
[info]   java.util.NoSuchElementException: empty result setSome( when invoking statement
[info]             with new_row as (
[info]               insert into interned_strings (value) values (?)
[info]               on conflict (value) do nothing returning id
[info]             )
[info]             select id from new_row union all select id from interned_strings where value = ? limit 1;
[info]           )

the reason for that is that if there's two queries, you can end up in this situation (as far as I understand):

  • q1 runs the insert successfully
  • q2 runs the insert which conflicts and returns nothing
  • q2 runs the select which returns nothing
  • q2 returns nothing
  • q1 commits

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.

And q1 returns the right set?

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.

yeah but q2 returns nothing

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, q1 behaves properly, but q2 blows up because nothing is returned

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.

yeah your version is safer, is 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.

but in the new way you can still have concurrent insert/select no? the flatmap is not a tx boundary or is 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.

(if it is, then it explains it, otherwise you might still have a problem?)

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.

ah ok by default that is autocommit per statement

@OriolMunoz-da OriolMunoz-da Sep 4, 2026

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.

the flatmap is not a tx boundary

it is; it's separate txs (storage.query twice, as opposed to DBIO composition)

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.

ok cool thanks I get it

metrics.UpdateHistory.assignments.mark()
sqlu"""
for {
_ <- DBIO.from(internTemplateId(templateId))

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 wrapping the future: i.e., it's not transactional with the rest of the statements. That should be fine since 99.99% of the time we're using the cache anyway

@OriolMunoz-da
OriolMunoz-da marked this pull request as ready for review September 4, 2026 10:28

// This does not need to be transactional with the rest of the transactions in UpdateHistory
private def internTemplateId(identifier: Identifier)(implicit tc: TraceContext): Future[Unit] = {
// TODO (#6257): use the returned ids in the partitioned table

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 start to fill up the store now and use it later in the partitioned tables
from the scan pruning doc:

The dualWrite/readFromId rollout in the String Interning design covers only the legacy tables, which are dropped wholesale, so it is no longer needed.

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.

"fetchAllInternedStrings",
)
.map { all =>
if (all.size >= maxSize) {

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.

why don't you just do a count(1) on interned_strings (or similar on metadata of postgres), and then compare so maxSize? Also, we do not necessarily need all strings in cache, this depends on access pattern, will this not just lead to unnecessary warnings?

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.

from the design doc, this warning should never be hit and everything should be fine to be in the cache:
image

if it is hit, we have a problem

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.

ah right, thanks lol

@OriolMunoz-da OriolMunoz-da Sep 4, 2026

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.

actually from this i realize i'm missing the package name and choice name in the updatehistory intern call done

participantId = mkParticipantId(this.getClass.getSimpleName),
updateStreamParty = dsoParty,
backfillingRequired = UpdateHistory.BackfillingRequirement.BackfillingNotRequired,
internedStringStore = InternedStringStore.createWithoutWarmup(

@ray-roestenburg-da ray-roestenburg-da Sep 4, 2026

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.

Are you doing withoutWarmup in tests just to speed up the tests? Any idea how long this takes in ScanApp with the warmup?

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.

Are you doing withoutWarmup in tests just to speed up the tests?

doesn't really matter, the DB will be empty there

Any idea how long this takes in ScanApp with the warmup?

it's a query fetching 0-1000 rows, that shouldn't be too long

@ray-roestenburg-da ray-roestenburg-da Sep 4, 2026

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.

so we don't need the withoutWarmup version?

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.

I guess not, I can remove 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.

not a huge deal, was just wondering

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.

it does have the annoyance that warmup returns a Future, whereas the other one can just return the cache. Will leave as-is unless you disagree

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.

is fine, leave as is

@ray-roestenburg-da ray-roestenburg-da left a comment

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.

Looks good, thanks!

@rautenrieth-da rautenrieth-da left a comment

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.

Thanks!

.query(
sql"""
insert into interned_strings (value) values ($value)
on conflict (value) do nothing returning id;

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.

This might consume an entry from the auto-increment sequence on each read. We should still be fine for the next billion years before the bigint overflows.

config.acsStoreDescriptorUserVersion,
config.txLogStoreDescriptorUserVersion,
)
internedStringStore <- InternedStringStore.createAndWarmupCache(

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.

Loading all interned strings from the database will delay application startup slightly, do you know by how much?

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.

same answer as in #7111 (comment), there will be <10k strings that should fit in the cache and be trivially fetchable from a single query

Signed-off-by: Oriol Muñoz <oriol.munoz@digitalasset.com>
Signed-off-by: Oriol Muñoz <oriol.munoz@digitalasset.com>
Signed-off-by: Oriol Muñoz <oriol.munoz@digitalasset.com>
Signed-off-by: Oriol Muñoz <oriol.munoz@digitalasset.com>
Signed-off-by: Oriol Muñoz <oriol.munoz@digitalasset.com>
Signed-off-by: Oriol Muñoz <oriol.munoz@digitalasset.com>
Signed-off-by: Oriol Muñoz <oriol.munoz@digitalasset.com>
Signed-off-by: Oriol Muñoz <oriol.munoz@digitalasset.com>
Signed-off-by: Oriol Muñoz <oriol.munoz@digitalasset.com>
Signed-off-by: Oriol Muñoz <oriol.munoz@digitalasset.com>
Signed-off-by: Oriol Muñoz <oriol.munoz@digitalasset.com>
Signed-off-by: Oriol Muñoz <oriol.munoz@digitalasset.com>
@OriolMunoz-da
OriolMunoz-da merged commit 411cfcd into main Sep 9, 2026
115 of 118 checks passed
@OriolMunoz-da
OriolMunoz-da deleted the oriol/interned-strings branch September 9, 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.

Implement String interning store

3 participants