Skip to content

Use the HC-Tree journal on flagged leaders and followers - #2818

Merged
tylerkaraszewski merged 53 commits into
mainfrom
tyler-hctree-follower-journal-commits
Oct 2, 2026
Merged

tylerkaraszewski merged 53 commits into
mainfrom
tyler-hctree-follower-journal-commits

Conversation

@tylerkaraszewski

@tylerkaraszewski tylerkaraszewski commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer Notes

There are a ton of test changes in this PR but the bulk of the actual change is approximately 400 lines of changes in SQLite.cpp.

The rollout plan for this:

  1. Enable it on a single webrock follower with the command line flag, and wait and see how it works.
  2. After that, we can add a second webrock follower, and allow it to acquire a fairly long journal of hct_journal commits.
  3. Make the second follower leader and make sure that we can both lead and follow with the new journal.
  4. Enable it on db2.rno to test on auth.

After that, we can move forward with the next HC-Tree changes.

Explanation of Change

This adds an -hctreeExperimentalMode flag to bedrock. This flag is intended to be re-used for future changes as what counts as "experimental" changes over time. For this change, this allows us to start using HC-Tree's built-in journal.

-hctreeExperimentalMode is ignored on databases that are not HC-Tree, i.e., WAL2 dbs.

The rest of this describes what -hctreeExperimentalMode does in this change, when it is enabled on a HC-Tree database.

Followers, or essentially any node that is not leader (i.e., this includes nodes that are SYNCHRONIZING, etc) write replicated commits to hct_journal instead of the legacy journals. When leader is not also on -hctreeExperimentalMode (more on if leader is in -hctreeExperimentalMode below), then these look largely like existing journal commits - namely, commit conflicts do not allocate blank journal entries on the leader. The layout is different - hct_journal does not have a separate hash column, and so we concatenate $HASH:$QUERY in hct_journal.

In a transitional phase, it is possible that nodes will need to synchronize commits that are in both the old and new journals. This is handled by looking up commits to send to SYNCHRONIZING peers in both journal types. They are sent in the existing format, and can be committed to the old or new journals as appropriate on the receiver (only other HC-Tree nodes with -hctreeExperimentalMode will write them to hct_journal).

One-way switch

Once a DB has had -hctreeExperimentalMode turned on and hct_journal initialized, we do not allow switching back. Bedrock will log an error and exit if you start this DB without -hctreeExperimentalMode after this point. The expected way to disable this on a node is to restore the node from a backup with no hct_journal.

This prevents interleaved journal entries in different journal types that break what's required for contiguous journal entries on leader with -hctreeExperimentalMode enabled.

Enabling -hctreeExperimentalMode on leader.

When leader has -hctreeExperimentalMode it can result in blank journal entries in the case of conflicts. These are recorded and broadcast to followers, in accordance with the HC-Tree spec. This is expected but this will be the first time this is tested in production.

BlockWrites
We have a BlockWrites command that does what it says. The behavior of this changes - previously, it froze transactions in place, refusing to let them start new writes. This provided ambiguous behavior (and possible race conditions) if you called this on a transaction that was mid-write. The behavior for blocking writes now allows all existing write transactions to complete.

Minor differences

  • On the first startup with the new flag, the newest row in the existing journal is moved to hct_journal to seed it.
  • New databases now how their first real entry at ID 2, with a blank entry at ID 1, even for WAL2. This does not matter in production.
  • The deleter thread also trims hct_journal

Fixed Issues

For https://github.com/Expensify/Expensify/issues/337537

Tests

Several new tests added.

@tylerkaraszewski tylerkaraszewski self-assigned this Sep 23, 2026
@tylerkaraszewski tylerkaraszewski changed the title Use the HC-Tree journal on flagged followers Use the HC-Tree journal on flagged leaders and followers Sep 23, 2026
@tylerkaraszewski
tylerkaraszewski marked this pull request as ready for review September 29, 2026 15:45
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-29T15:51:06.631057Z 143da21 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Comment thread sqlitecluster/SQLite.cpp
SQResult latest;
SASSERT(!SQuery(db, "SELECT MAX(id) FROM (" + _getJournalQuery(journalNames, {"SELECT MAX(id) AS id FROM"}, true) + ")", latest));
if (latest.empty() || latest[0][0].empty()) {
SASSERT(!SQuery(db, "INSERT INTO " + journalNames.front() + " (id, query, hash) VALUES (1, X'', '')"));

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.

what does X'' do here?

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 binary data and inserts a blank BLOB instead of a blanks STRING. It doesn't make a huge practical difference here when it's blank, but since we're inserting compressed binary data in the journal, this just matches that.

Comment thread sqlitecluster/SQLite.cpp
}

if (experimentalHCTree && !hctJournalExists) {
initializeHCTreeJournal(db, journalNames);

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 is sending the same db handle used in the insert above, but we don't commit that insert. In this function, we call begin/create table/commit|rollback. Is that a problem? Or since we didn't create a BEGIN/COMMIT for the insert above, SQlite is doing it automatically?

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. I will add a comment about it next to the insert.

Comment thread sqlitecluster/SQLite.cpp
Comment on lines +345 to +346
{":cid", Parameter::i(legacyCommitID)},
{":query", Parameter::blob(legacy[0]["hash"] + ":" + legacy[0]["query"])},

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 like this. Should we start using this format in Auth queries?

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 added this here in May:
#2600

And also added it to Auth:
https://github.com/Expensify/Auth/pull/21810

So there's no reason not to use it.

Comment thread sqlitecluster/SQLite.cpp
Comment on lines +566 to +575
if (!_journalNames.empty() && (!experimentalHCTree || (_sharedData.legacyMaxID && fromIndex <= _sharedData.legacyMaxID))) {
const string where = " WHERE id >= " + SQ(fromIndex) + (toIndex ? " AND id <= " + SQ(toIndex) : "");
for (const string& name : _journalNames) {
sources.push_back("SELECT id, query, hash FROM " + name + where);
}
}
if (experimentalHCTree && (!toIndex || toIndex >= _sharedData.hctMinID)) {
sources.push_back(_getHCTreeJournalQuery() + " WHERE cid >= " + SQ(fromIndex) +
(toIndex ? " AND cid <= " + SQ(toIndex) : ""));
}

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.

A comment here would be helpful

Comment thread sqlitecluster/SQLite.cpp
SINFO("writeLock Locked");
uint64_t count;
while ((count = _sharedData.openWriteTransactionCount.load())) {
_sharedData.openWriteTransactionCount.wait(count);

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.

TIL that atomic has a wait implementation

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's relatively new in C++20.

Comment thread sqlitecluster/SQLite.cpp
Comment on lines +627 to 630
// After locking writeLock, we wait for all existing writers to finish. They will likely require commitLock
// as well, which we only acquire once they all complete. No new writes can start once we lock writeLock.
// This function blocks until in-progress writes finish.
try {

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 is a pretty nice change that make things easier to understand. Thanks

Comment thread sqlitecluster/SQLite.cpp
Comment thread sqlitecluster/SQLite.cpp Outdated
rollback();
STHROW("Attempted to begin transaction while in invalid state: _uncommittedQuery not empty");
}
if (type == TRANSACTION_TYPE::EXCLUSIVE) {

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 only on type exclusive?

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 if this is readlly necessary, why are we doing it here instead of in like 745 for example?

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.

So in general, a transaction is marked as a write transaction on the first attempt it makes to write. This is used for when we need to let all of the write transactions complete - anything that was marked as write gets to complete, but nothing not marked as write gets to start writing. If any non-write transaction wants to write, it will get blocked attempting to acquire writeLock.

We simplified the rules around acquiring writeLock and commitLock so that we always lock writeLock first, when we start writing, and then commitLock later, when we want to commit.

EXCLUSIVE transactions lock commitLock immediately, which is how they enforce exclusiveness, so in order to avoid deadlocks, they also need to lock writeLock immediately - before they lock commitLock, so we need to do this right away here.

But yes, it can move to line 746, I'll move it.

Comment thread sqlitecluster/SQLite.cpp Outdated
if (SToUInt64(bounds[0][0]) >= oldestCommitToKeep || bounds[0][0] == bounds[0][1]) {
return true;
}
// Delete a prefix, never the final entry. HC-Tree requires the journal

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 don't understand what this means. What's prefix/final entry?

Comment thread sqlitecluster/SQLite.cpp
Comment on lines +1129 to +1142
uint64_t oldestCommitToKeep = commitCount < _maxJournalSize ? 0 : commitCount - _maxJournalSize;
if (state.hashCommitID) {
oldestCommitToKeep = min(oldestCommitToKeep, state.hashCommitID);
}
if (oldestCommitToKeep <= _sharedData.hctMinID) {
return true;
}
SQResult bounds;
if (!read("SELECT MIN(cid), MAX(cid) FROM hct_journal", bounds) || bounds.empty() || bounds[0][0].empty()) {
return false;
}
if (SToUInt64(bounds[0][0]) >= oldestCommitToKeep || bounds[0][0] == bounds[0][1]) {
return true;
}

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.

Coments would help here

@tylerkaraszewski
tylerkaraszewski merged commit 740e769 into main Oct 2, 2026
9 of 10 checks passed
@tylerkaraszewski
tylerkaraszewski deleted the tyler-hctree-follower-journal-commits branch October 2, 2026 16:47
dominictb pushed a commit to dominictb/Bedrock that referenced this pull request Oct 4, 2026
…-follower-journal-commits"

This reverts commit 740e769, reversing changes made to 6df552e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tylerkaraszewski

Copy link
Copy Markdown
Contributor Author

The Auth-side test compatibility fixes for the re-land are in Auth PR 25180: local test handles now match the servers' native-journal mode, and the query-plan test uses its own table instead of the legacy journal. The affected fixtures pass against both Bedrock versions. The full native Auth suite still has 30 failures, including a reproduced read-corruption error and shutdown hang; details are in the Auth PR.

heekinho pushed a commit to heekinho/Bedrock that referenced this pull request Oct 8, 2026
Revert the revert in 11fbdb2, restoring the original change from Expensify#2818 before applying follow-up fixes.
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