Skip to content

feat(event cache): introduce an absolute local event ordering #5225

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 30, 2025

Conversation

bnjbvr
Copy link
Member

@bnjbvr bnjbvr commented Jun 12, 2025

This PR introduces a local absolute ordering for items of a linked chunk, or equivalently, for events within a room's timeline. The idea is to reuse the same underlying mechanism we had for AsVector, but restricting it to only counting the number of items in a chunk; given an item's Position, we can then compute its absolute order as the total number of items before its containing chunk + its index within the chunk.

This will help us order edits that would apply to a thread event, for instance; this is deferred to a future PR, to not make this one too heavyweight.

Attention to reviewers: sorry, this is a bulky PR (mostly because of tests), but I think it's important to see how the OrderTracker methods are used in 280bd32, to make sense of their raison d'être.

Part of #4869 / #5122.

Copy link

codecov bot commented Jun 12, 2025

Codecov Report

Attention: Patch coverage is 91.93548% with 65 lines in your changes missing coverage. Please review.

Project coverage is 88.73%. Comparing base (3c7683e) to head (e04f87b).
Report is 13 commits behind head on main.

✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
crates/matrix-sdk/src/event_cache/room/mod.rs 80.40% 34 Missing and 5 partials ⚠️
...s/matrix-sdk-common/src/linked_chunk/relational.rs 85.93% 8 Missing and 1 partial ⚠️
crates/matrix-sdk-sqlite/src/event_cache_store.rs 76.47% 0 Missing and 8 partials ⚠️
...atrix-sdk-common/src/linked_chunk/order_tracker.rs 98.77% 3 Missing and 1 partial ⚠️
crates/matrix-sdk-common/src/linked_chunk/mod.rs 91.66% 0 Missing and 2 partials ⚠️
crates/matrix-sdk/src/event_cache/room/events.rs 96.92% 0 Missing and 2 partials ⚠️
...rix-sdk-base/src/event_cache/store/memory_store.rs 85.71% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5225      +/-   ##
==========================================
+ Coverage   88.69%   88.73%   +0.03%     
==========================================
  Files         329      330       +1     
  Lines       88747    89513     +766     
  Branches    88747    89513     +766     
==========================================
+ Hits        78717    79427     +710     
- Misses       6241     6278      +37     
- Partials     3789     3808      +19     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@bnjbvr bnjbvr force-pushed the bnjbvr/linked-chunk-ordering branch 2 times, most recently from 2acde34 to fc99597 Compare June 18, 2025 18:06
@bnjbvr bnjbvr marked this pull request as ready for review June 18, 2025 18:07
@bnjbvr bnjbvr requested a review from a team as a code owner June 18, 2025 18:07
@bnjbvr bnjbvr requested review from andybalaam and Hywan and removed request for a team and andybalaam June 18, 2025 18:07
@bnjbvr bnjbvr marked this pull request as draft June 18, 2025 18:10
@bnjbvr bnjbvr requested review from Hywan and removed request for Hywan June 18, 2025 18:10
@bnjbvr bnjbvr marked this pull request as ready for review June 18, 2025 18:12
@bnjbvr bnjbvr force-pushed the bnjbvr/linked-chunk-ordering branch 2 times, most recently from 7673e19 to b218b10 Compare June 19, 2025 13:52
Copy link
Member

@Hywan Hywan left a comment

Choose a reason for hiding this comment

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

Thanks for the code, it's clear and well explained.

I'm wondering how the event order will be used. I'm actually intrigued by the complexity induced by a lazy-loaded vs. a fully-loaded. I've a feeling that having a negative or positive positions would work and could simplify stuff. I'm not super comfortable with the idea of loading all the chunks (even without the events), it defeats the purpose of having such a light structure.

Are you loading all the chunks to always return the position of an event? It means you want the position of an event from the store, not from the in-memory room event cache? If this is the case, I think the strategy should entirely move onto the EventCacheStore store trait. If that's not the case, I don't understand why we need to load all the chunks. Can you explain to me please?

@bnjbvr
Copy link
Member Author

bnjbvr commented Jun 25, 2025

Thanks for the review! These are very valid questions.

I'm wondering how the event order will be used.

The requirement is that we want to be able to compare the relative position of two events, be they loaded in the in-memory linked chunk or not. So, this must work, independently of the actual position of the event.

Some alternative ways to build this:

  • as you suggest, the most obvious optimization is to not load all chunks with their events, but only some "metadata" (prev/next/number of items in it).
  • we could also move the entire computation inside the store trait, and make it stateless; in this case, comparing the relative positions of two events would be equivalent to finding their owning chunks, i.e. find the relative ordering of the chunks (they belong to). But we have a linked list of chunks, here, so we can't immediately answer that query without going through the next/prev link of one of the two chunks. I suspect the optimal algorithm would be a "ping-pong" strategy: start with the next; if it's not this one, continue to the previous; if it's not this one, continue with the next's next; if if it's not this one, continue with the previous' previous, and so on. But worst case scenario, we now have to iterate over all the chunks every time we want to order two events.
    • Could be mitigated by keeping a local cache for the ordering of chunks, that's blown up as soon as we add / remove a chunk, maybe… but at this point, we might as well keep the current solution, that constructs the full ordering, then maintains it over time.
  • maybe there's a way to have a mixed, incremental solution where we only get the initial chunk in the total ordering; every time we don't find the chunk owning an event in the local state, we'd lazily load the previous chunk in the order tracker (and maybe we could use negative indices as you suggest). Worst case, we'd get to the initial chunk (but then we'd have a fully-loaded order tracker, which will be faster every time it's called subsequently). Now, this might be tricky because we don't want to lazy-load the room's linked chunk just so as to maintain the order tracking up to date; but if the room's linked chunk does lazy-load, we want the order tracker to benefit from this. Grmpf.

It sounds like the first and last solutions might be preferable in the short term, and would both require loading only the metadata of a chunk. So I could start with this.

Then, I find the concern around performance totally legit. The best way to resolve it would be benchmarking or using some real-world measures, so I may look into this and get back to you here.

Are you loading all the chunks to always return the position of an event? It means you want the position of an event from the store, not from the in-memory room event cache? If this is the case, I think the strategy should entirely move onto the EventCacheStore store trait. If that's not the case, I don't understand why we need to load all the chunks. Can you explain to me please?

As said above, a solution only based on the EventCacheStore trait would likely be slow, as in the worst case you go through the entire prev/next chain of nodes in the linked list (in sqlite, that's probably one query per "deref").

Loading all the chunks at start seemed like a reasonable solution to build the initial state, and then maintain it cleanly over time. But yeah, at the very least we'd need to only load the minimal amount of data for the order tracker to work correctly.

@bnjbvr
Copy link
Member Author

bnjbvr commented Jun 25, 2025

We've discussed this, and lazy loading the order tracker brings many other complications, so we're going to roll with the first suggestion only (load a limited set of metadata about each chunk of a linked chunk, and use that instead of the fully loaded linked chunk), and see what comes out of that in terms of performance. We can load the entire metadata in a single SQL query, so that ought to be rather efficient.

@bnjbvr bnjbvr force-pushed the bnjbvr/linked-chunk-ordering branch from 68d7da4 to 5a55d8b Compare June 26, 2025 11:21
@bnjbvr
Copy link
Member Author

bnjbvr commented Jun 26, 2025

Rebased on top of main, but only the last three commits are really new. Time for another round of review \o/

@bnjbvr bnjbvr requested a review from Hywan June 26, 2025 11:22
@bnjbvr bnjbvr force-pushed the bnjbvr/linked-chunk-ordering branch from 5c1be4a to e8f12a1 Compare June 26, 2025 11:44
Comment on lines +145 to +144
pub fn from_metadata(metas: Vec<ChunkMetadata>) -> Self {
let initial_chunk_lengths =
metas.into_iter().map(|meta| (meta.identifier, meta.num_items)).collect();
Copy link
Member

Choose a reason for hiding this comment

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

Not a blocker!

We are doing a double allocation here. One collect in from_metadata, and one collect in LinkedChunk::order_tracker. I propose the following:

Suggested change
pub fn from_metadata(metas: Vec<ChunkMetadata>) -> Self {
let initial_chunk_lengths =
metas.into_iter().map(|meta| (meta.identifier, meta.num_items)).collect();
pub fn from_metadata<M>(metas: M) -> Self
where M: Iterator<Item = ChunkMetadata>
{
let initial_chunk_lengths =
metas.map(|meta| (meta.identifier, meta.num_items)).collect();

and in order_tracker, we pass the iterator directly, without calling collect. I don't know how it will work with the unwrap_or_else though.

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmm, it's a good idea and I've tried it, but the one caller looks like this:

        Some(OrderTracker::new(
            updates,
            token,
            all_chunks.unwrap_or_else(|| {
                // Consider the linked chunk as fully loaded.
                self.chunks()
                    .map(|chunk| ChunkMetadata {
                        identifier: chunk.identifier(),
                        num_items: chunk.num_items(),
                        previous: chunk.previous().map(|prev| prev.identifier()),
                        next: chunk.next().map(|next| next.identifier()),
                    })
                    .collect()
            }),
        ))

where all_chunks is a Option<Vec<ChunkMetadata>>. If I wanted OrderTracker::new() to accept an Iterator<Item = ChunkMetadata> as the last parameter, then I'd have some code like this:

all_chunks.map(Vec::into_iter).unwrap_or_else(|| {
                // Consider the linked chunk as fully loaded.
                self.chunks()
                    .map(|chunk| ChunkMetadata {
                        identifier: chunk.identifier(),
                        num_items: chunk.num_items(),
                        previous: chunk.previous().map(|prev| prev.identifier()),
                        next: chunk.next().map(|next| next.identifier()),
                    })
            }

But now, you can see what the problem is:

  • when all_chunks is Some, then the iterator has the concrete type vec::IntoIter
  • and when it's None, then the iterator has the concrete type Map<...>

so the compiler complains (correctly) they're not the same type. I think I recall some Rust proposals to basically allow this, but it's not a reality right now, unfortunately. If we found a better solution later, happy to rewrite this code in another way that would make this possible, of course.

bnjbvr added 2 commits June 30, 2025 10:39
…ferent accumulators

In the next patch, we're going to introduce another user of
`UpdatesToVectorDiff` which doesn't require accumulating the
`VectorDiff` updates; so as to make it optional, let's generalize the
algorithm with a trait, that carries the same semantics.

No changes in functionality.
bnjbvr added 6 commits June 30, 2025 10:39
…ordering of the current items

This is a new data structure that will help figuring out a local,
absolute ordering for events in the current linked chunk. It's designed
to work even if the linked chunk is being lazily loaded, and it provides
a few high-level primitives that make it possible to work nicely with
the event cache.
…by the event cache

The one hardship is that lazy-loading updates must NOT affect the order
tracker, otherwise its internal state will be incorrect (disynchronized
from the store) and thus return incorrect values upon shrink/lazy-load.

In this specific case, some updates must be ignored, the same way we do
it for the store using `let _ = store_updates().take()` in a few places.

The author considered that a right place where to flush the pending
updates was at the same time we flushed the updates-as-vector-diffs,
since they would be observable at the same time.
@bnjbvr bnjbvr force-pushed the bnjbvr/linked-chunk-ordering branch from e0ca18f to e04f87b Compare June 30, 2025 08:57
@bnjbvr bnjbvr enabled auto-merge (rebase) June 30, 2025 08:57
@bnjbvr bnjbvr merged commit cef1f8c into main Jun 30, 2025
42 of 43 checks passed
@bnjbvr bnjbvr deleted the bnjbvr/linked-chunk-ordering branch June 30, 2025 09:09
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