Skip to content
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

indexer-alt: separate updates for consistent sequential pipelines #20482

Closed
wants to merge 3 commits into from

Conversation

amnn
Copy link
Member

@amnn amnn commented Dec 2, 2024

Description

Use object changes from transaction effects to figure out which changes to consistent tables correspond to new rows and which changes correspond to updates. This means we can avoid using INSERT ... ON CONFLICT DO UPDATE which requires postgres to try an insert, detect constraints, and then go for the update, which should hopefully improve performance. On the other hand, it means that these pipelines will not work at all if they are started at an arbitrary point in time (because the UPDATE-s will fail).

The largest part of this change was adding support for bulk-updates to diesel (see diesel-rs/diesel#2879). This requires opting in to breaking changes by exposing diesel's internals. To limit the fall out of that, this support has been added in its own crate.

Finally, as part of this change, I ran into a flag that can be set on model types: #[diesel(treat_none_as_default_value = ...)] which defaults to true. Setting this to false on models that contain optional values should improve statistics collection and may improve performance through prepared statement caching.

Test plan

Unit tests for update_from query generation, and E2E tests for running updates on a DB with the new DSL:

sui$ cargo nextest run -p diesel-update-from

Run the indexer before and after the change, dump the resulting tables and make sure the results are the same:

sui$ cargo run -p sui-indexer-alt -- generate-config > /tmp/indexer.toml
sui$ cargo run -p sui-indexer-alt -- indexer            \
  --remote-store-url https://checkpoints.mainnet.sui.io/ \
  --last-checkpoint 50000 --config /tmp/indexer.toml    \
  --pipeline sum_obj_types --pipeline sum_coin_balances

sui$ psql postgres://postgres:postgrespw@localhost:5432/sui_indexer_alt
sui_indexer_alt=# COPY
    (SELECT object_id, object_version, owner_kind, owner_id FROM sum_obj_types ORDER BY object_id)
TO
    '/tmp/objs.csv'
WITH
    DELIMITER ',' CSV HEADER;
sui_indexer_alt=# COPY
    (SELECT object_id, object_version, owner_id, coin_balance FROM sum_coin_balances ORDER BY object_id)
TO
    '/tmp/coins.csv'
WITH
    DELIMITER ',' CSV HEADER;

Release notes

Check each box that your changes affect. If none of the boxes relate to your changes, release notes aren't required.

For each box you select, include information after the relevant heading that describes the impact of your changes that a user might notice and any actions they must take to implement updates.

  • Protocol:
  • Nodes (Validators and Full nodes):
  • Indexer:
  • JSON-RPC:
  • GraphQL:
  • CLI:
  • Rust SDK:
  • REST API:

@amnn amnn self-assigned this Dec 2, 2024
Copy link

vercel bot commented Dec 2, 2024

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
sui-docs ✅ Ready (Inspect) Visit Preview 💬 Add feedback Dec 15, 2024 3:50pm
3 Skipped Deployments
Name Status Preview Comments Updated (UTC)
multisig-toolkit ⬜️ Ignored (Inspect) Visit Preview Dec 15, 2024 3:50pm
sui-kiosk ⬜️ Ignored (Inspect) Visit Preview Dec 15, 2024 3:50pm
sui-typescript-docs ⬜️ Ignored (Inspect) Visit Preview Dec 15, 2024 3:50pm

Copy link
Contributor

@gegaowp gegaowp left a comment

Choose a reason for hiding this comment

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

looks good overall, now I can relate more about the pain caused by diesel ..

@@ -59,38 +60,49 @@ impl Processor for SumCoinBalances {
}
}

// Deleted and wrapped coins
// Do a fist pass to add updates without their associated contents into the `values`
// mapping, based on the transaction's object changes.
for change in tx.effects.object_changes() {
Copy link
Contributor

Choose a reason for hiding this comment

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

TransactionEffectsAPI has methods of

    fn created(&self) -> Vec<(ObjectRef, Owner)>;
    fn mutated(&self) -> Vec<(ObjectRef, Owner)>;
    fn unwrapped(&self) -> Vec<(ObjectRef, Owner)>;
    fn deleted(&self) -> Vec<ObjectRef>;
    fn unwrapped_then_deleted(&self) -> Vec<ObjectRef>;
    fn wrapped(&self) -> Vec<ObjectRef>;

it seems cleaner to use that instead of classifying object changes again?

Copy link
Member Author

Choose a reason for hiding this comment

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

Maybe it's just me, but I don't find that cleaner. For me, object_changes is more uniform, because it separates out the detail of whether the object's ID was created/deleted from whether the object was added or removed from the global store, which means you can focus on just one part.

For example, in this case, we only care about visibility in the global store, and we are able to use the presence/absence of input/output versions to enumerate every possible case. As far as I can tell, if I wanted to do the same thing with the EffectsV1 compatibility APIs:

  • I would need to merge together the cases that I cared about (unwrapped and created go together, wrapped and deleted go together, mutated goes on its own),
  • I don't get the compiler's help to tell me I've handled every case,
  • the code around the part that figures the update kind would need to be duplicated, and
  • when these compatibility APIs get called on V2 effects (the majority on chain these days), they will scan overall object changes every time, filtering changes into these buckets, only for us to merge those buckets back together.

Maybe I'm missing something though -- @gegaowp, if you see a clearer path, can you elaborate?

Copy link
Contributor

@gegaowp gegaowp Dec 5, 2024

Choose a reason for hiding this comment

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

the main reason that I think compatibility API would help is, to use object changes, matching logic to get UpdateKind duplicates across files. If we use the compatibility API, we can do union them to get insert / update / delete, if so is UpdateKind enum still necessary?

I don't get the compiler's help to tell me I've handled every case,

this is a good point.

@@ -14,6 +14,7 @@ use crate::schema::{

#[derive(Insertable, Debug, Clone, FieldCount)]
#[diesel(table_name = kv_objects, primary_key(object_id, object_version))]
#[diesel(treat_none_as_default_value = false)]
Copy link
Contributor

Choose a reason for hiding this comment

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

👍

},
]);

assert_display_snapshot!(debug_query::<Pg, _>(&query), @r###"UPDATE "objects" SET "version" = excluded."version", "kind" = excluded."kind", "owner" = excluded."owner", "type_" = excluded."type_" FROM (VALUES ($1, $2, $3, $4, $5), ($6, $7, $8, $9, $10)) AS excluded ("object_id", "version", "kind", "owner", "type_") WHERE ("objects"."object_id" = excluded."object_id") -- binds: [[1, 2, 3], 1, 2, None, Some("type"), [4, 5, 6], 2, 3, Some([7, 8, 9]), None]"###);
Copy link
Contributor

Choose a reason for hiding this comment

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

👍

@lxfind
Copy link
Contributor

lxfind commented Dec 4, 2024

Shall we treat this as an experiment first?
It would break the ability to benchmark arbitrary range of checkpoint data, and introduces non-trivial complexity.
So I think we should merge it only if we are sure it's valuable and worth the cost.

.execute(conn),
)),
Either::Left(Either::Right(update)) => Either::Left(Either::Right(
update_from(sum_obj_types::table)
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems quite a big effort to introduce a new diesel-update-from crate just so we can use it here and in the equivalent place in sum_coin_balances. Could we just use raw query here instead and avoid adding that crate? The code in that crate seems to have done a lot just to make diesel happy. I don't fully understand the impl blocks and I fear it might be hard to maintain.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, the complexity in diesel-update-from is not great. The rationale behind isolating it in its own crate is that it is unlikely to change, and we should treat it as if it were part of diesel, so maintenance should be minimal but I would be lying if I said that there would be none (the fact that it requires opting in to breaking changes alone is a sign that there may be some).

To me this is the argument for not using diesel, though. If it does not support all the features we need, and we do not feel able to add and maintain those features ourselves, it is going to bias us towards doing things in a sub-optimal way to fit into the feature set that diesel offers conveniently, which we know we cannot afford to do and this is not the first time this has bitten us:

  • Our inserts and updates bind every value rather than UNNEST arrays of values.
  • We can't write comparisons over tuples, which would help postgres plan those queries more efficiently.
  • We can't use CTEs
  • ...I am sure there are other things that I can't remember, and this is not to mention that the errors we get out of diesel when we make a mistake are horrific.

In each of these occasions, we either put up with the inefficiency or drop into raw queries where we don't get support for detecting schema changes, dealing with type marshaling etc, and the story would be similar here -- it is possible to use raw queries but it defeats the point of the abstraction if we lose its safety features when the query gets more complex.

Copy link
Member Author

Choose a reason for hiding this comment

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

Remembered another one -- diesel's query types don't support Clone so we needed to bend over backwards in GraphQL to do stuff like "explain a query first, and then run it", or "print out the query".

};

match values.entry(object_id) {
Entry::Occupied(entry) => {
ensure!(entry.get().object_version > object_version);
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm how does this version's code work at all? Wouldn't we always hit this case (because we inserted into values on line 83) and error out as soon as we have an object touched by multiple transactions in a checkpoint?

Copy link
Member Author

Choose a reason for hiding this comment

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

In the previous version of the code, the first loop handled wrapped and deleted objects, and this loops handles modified and created objects, so they would never touch the same object ID (if the transaction deleted/wrapped the object, it's not going to be in the list of output objects we're iterating over here).

}
}

let update_chunks = updates.chunks(UPDATE_CHUNK_ROWS).map(Either::Left);
let insert_chunks = inserts
.chunks(UPDATE_CHUNK_ROWS)
Copy link
Contributor

Choose a reason for hiding this comment

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

I figured we may want to use a separate constant for insert chunk size?

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think so -- because the chunk size is determined by the number of binds, which is the same in both cases.

@@ -42,10 +42,10 @@ impl Handler for WalCoinBalances {
object_id: value.object_id.to_vec(),
object_version: value.object_version as i64,

owner_id: value.update.as_ref().map(|o| o.owner_id.clone()),
owner_id: value.value.as_ref().map(|o| o.owner_id.clone()),
Copy link
Contributor

Choose a reason for hiding this comment

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

value.value is not super readable. Could we change it to something like update.new_value here and in the other wal- pipeline?

Copy link
Member Author

Choose a reason for hiding this comment

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

Sure, we can change that.


#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum UpdateKind {
/// Object was created or unwrapped at this version.
Copy link
Contributor

Choose a reason for hiding this comment

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

I like the comments here mapping object change terms to db update operations.

Copy link
Member Author

@amnn amnn left a comment

Choose a reason for hiding this comment

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

Yeah, happy to run this as an experiment first, but this has also renewed my energy around trying an alternative to diesel, because it feels like it has held us back too many times.

As an aside, while the indexer does run from arbitrary checkpoints today, it's not correct to do that, both in terms of the results that are written to the table, and the performance of those writes (I believe the fact that those writes are treated as inserts rather than updates makes a big difference on performance).

}
}

let update_chunks = updates.chunks(UPDATE_CHUNK_ROWS).map(Either::Left);
let insert_chunks = inserts
.chunks(UPDATE_CHUNK_ROWS)
Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think so -- because the chunk size is determined by the number of binds, which is the same in both cases.

};

match values.entry(object_id) {
Entry::Occupied(entry) => {
ensure!(entry.get().object_version > object_version);
Copy link
Member Author

Choose a reason for hiding this comment

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

In the previous version of the code, the first loop handled wrapped and deleted objects, and this loops handles modified and created objects, so they would never touch the same object ID (if the transaction deleted/wrapped the object, it's not going to be in the list of output objects we're iterating over here).

.execute(conn),
)),
Either::Left(Either::Right(update)) => Either::Left(Either::Right(
update_from(sum_obj_types::table)
Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, the complexity in diesel-update-from is not great. The rationale behind isolating it in its own crate is that it is unlikely to change, and we should treat it as if it were part of diesel, so maintenance should be minimal but I would be lying if I said that there would be none (the fact that it requires opting in to breaking changes alone is a sign that there may be some).

To me this is the argument for not using diesel, though. If it does not support all the features we need, and we do not feel able to add and maintain those features ourselves, it is going to bias us towards doing things in a sub-optimal way to fit into the feature set that diesel offers conveniently, which we know we cannot afford to do and this is not the first time this has bitten us:

  • Our inserts and updates bind every value rather than UNNEST arrays of values.
  • We can't write comparisons over tuples, which would help postgres plan those queries more efficiently.
  • We can't use CTEs
  • ...I am sure there are other things that I can't remember, and this is not to mention that the errors we get out of diesel when we make a mistake are horrific.

In each of these occasions, we either put up with the inefficiency or drop into raw queries where we don't get support for detecting schema changes, dealing with type marshaling etc, and the story would be similar here -- it is possible to use raw queries but it defeats the point of the abstraction if we lose its safety features when the query gets more complex.

@@ -42,10 +42,10 @@ impl Handler for WalCoinBalances {
object_id: value.object_id.to_vec(),
object_version: value.object_version as i64,

owner_id: value.update.as_ref().map(|o| o.owner_id.clone()),
owner_id: value.value.as_ref().map(|o| o.owner_id.clone()),
Copy link
Member Author

Choose a reason for hiding this comment

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

Sure, we can change that.

amnn added 3 commits December 15, 2024 15:48
## Description

Although postgres supports bulk-updating rows using `VALUES`, diesel
does not natively support it. This change adds support for this. It is
in its own crate so that we can limit the fallout of depending on the
diesel breaking changes feature (which we need to depend on to access
the types that diesel converts collections of model types into, ready to
be inserted into the DB).

## Test plan

Unit tests for the query generation, and E2E tests for running updates
on a DB with this new DSL:

```
sui$ cargo nextest run -p diesel-update-from
```
## Description

Use object changes from transaction effects to figure out which changes
to consistent tables correspond to new rows and which changes correspond
to updates.

This means we can avoid using `INSERT ... ON CONFLICT DO UPDATE` which
requires postgres to try an insert, detect constraints, and then go for
the update, which should hopefully improve performance.

On the other hand, it means that these pipelines will not work at all if
they are started at an arbitrary point in time (because the `UPDATE`-s
will fail).

## Test plan

Run the indexer before and after the change, dump the resulting tables
and make sure the results are the same:

```
sui$ cargo run -p sui-indexer-alt -- generate-config > /tmp/indexer.toml
sui$ cargo run -p sui-indexer-alt -- indexer            \
  --remote-store-url https://checkpoints.mainnet.sui.io \
  --last-checkpoint 50000 --config /tmp/indexer.toml    \
  --pipeline sum_obj_types --pipeline sum_coin_balances

sui$ psql postgres://postgres:postgrespw@localhost:5432/sui_indexer_alt
sui_indexer_alt=# COPY
    (SELECT object_id, object_version, owner_kind, owner_id FROM sum_obj_types ORDER BY object_id)
TO
    '/tmp/objs.csv'
WITH
    DELIMITER ',' CSV HEADER;
sui_indexer_alt=# COPY
    (SELECT object_id, object_version, owner_id, coin_balance FROM sum_coin_balances ORDER BY object_id)
TO
    '/tmp/coins.csv'
WITH
    DELIMITER ',' CSV HEADER;
```
## Description

Add `#[diesel(treat_none_as_default_value = false)]` to model types that
include optional fields. This affects how those fields are written out
to SQL when they contain `None`. Previously (and by default), those
fields would be represented by the keyword `DEFAULT VALUE`, and after
this change, they will be represented by a parameter binding, which will
be bound to `NULL`.

This is semantically identical in our case, because we don't set default
values, but it also results in less variety in prepared statements
(because regardless of the content of fields, they will now all be
represented by a binding), which will improve grouping of statistics
per-statement, and could also improve performance, if those prepared
statements can be cached and re-used.

## Test plan

Re-run indexer on first 100000 checkpoints.
@amnn
Copy link
Member Author

amnn commented Dec 17, 2024

Experiments showed that even with this change we weren't matching the performance of the new obj_info and coin_balance_buckets pipelines, even before we landed further improvements to them. Given we are fairly confident that those pipelines will serve as well in production, I think it's safe for us wind this experiment down, and avoid taking on the extra complexity in our codebase!

@amnn amnn closed this Dec 17, 2024
amnn added a commit that referenced this pull request Dec 20, 2024
## Description

Pick out this change from the bigger #20482. `DEFAULT VALUE` is not
something we use, and treating `None` as `DEFAULT VALUE` means we
have to prepare different statements for each insert/update based on
where the `None`'s appear.

By treating `None` as `NULL`, we get to re-use prepared statements more,
and the query sampler will do a better job grouping similar
inserts/updates.

## Test plan

Run indexer on coin balances and object info, locally, for the first
10,000 checkpoints.
@amnn amnn mentioned this pull request Dec 20, 2024
8 tasks
amnn added a commit that referenced this pull request Dec 30, 2024
## Description

Pick out this change from the bigger #20482. `DEFAULT VALUE` is not
something we use, and treating `None` as `DEFAULT VALUE` means we
have to prepare different statements for each insert/update based on
where the `None`'s appear.

By treating `None` as `NULL`, we get to re-use prepared statements more,
and the query sampler will do a better job grouping similar
inserts/updates.

## Test plan

Run indexer on coin balances and object info, locally, for the first
10,000 checkpoints.
amnn added a commit that referenced this pull request Dec 30, 2024
## Description

Pick out this change from the bigger #20482. `DEFAULT VALUE` is not
something we use, and treating `None` as `DEFAULT VALUE` means we have
to prepare different statements for each insert/update based on where
the `None`'s appear.

By treating `None` as `NULL`, we get to re-use prepared statements more,
and the query sampler will do a better job grouping similar
inserts/updates.

## Test plan

Run indexer on coin balances and object info, locally, for the first
10,000 checkpoints.

---

## Release notes

Check each box that your changes affect. If none of the boxes relate to
your changes, release notes aren't required.

For each box you select, include information after the relevant heading
that describes the impact of your changes that a user might notice and
any actions they must take to implement updates.

- [ ] Protocol: 
- [ ] Nodes (Validators and Full nodes): 
- [ ] Indexer: 
- [ ] JSON-RPC: 
- [ ] GraphQL: 
- [ ] CLI: 
- [ ] Rust SDK:
- [ ] REST API:
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.

4 participants