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

Introduce sqlite for storing of filters for IndexStore. #10272

Merged

Conversation

kiminuo
Copy link
Collaborator

@kiminuo kiminuo commented Mar 10, 2023

Fixes #7324
Fixes #10235

I have been working on IndexStore and I took a bit radical approach by switching from the plaintext storage approach to storing in an sqlite database. It's radical because it adds a new dependency Microsoft.Data.Sqlite but it has some interesting pros:

  • With the plaintext approach (current master), we periodically rewrite all the mature index data to add new immature data. This operation takes seconds and more importantly it's not disk-friendly (it shortens lives of SSD disks). On mobile devices it would be a no-go.
  • Sqlite supports storing in binary form, so my mature index is 2.18 GB on master branch and 1.23 GB with my PR. That's like 40% improvement. To inspect the data, it's easy to convert to hexadecal form.
  • Sqlite guarrantees that data does not get corrupted. The plaintext approach does not guarrantee that. Hence we fight the Crash at startup - Index file inconsistency detected #7324 in the first place.
  • It looks to me that the code will be simpler as a result.
  • bonus: We might be able to download filters from the wallet height and then download OLDER filters if an older wallet is imported.
  • bonus: The sqlite database file can be shared with people and it will just work. Current plaintexts files differ on Windows and remaining platforms by newlines (\r\n vs \n). That is all to say that having a single file to distribute and having a single checksum seems ideal in the long run.
  • bonus: We can speed up wallet load by a few seconds by avoiding reading complete (mature) index with all filters because we believe that sqlite is more robust wrt power outages, etc.
  • bonus: After WW is launched (testnet), I can see that memory use is 256 MB. On master branch, it is 276 MB on my machine. That's a minor 7% improvement.

Regarding the new dependency. Yes, that's typically forbidden but there are some justifications:

  • We generally trust Microsoft packages as we use ASP.NET and we trust .NET as a platform.
  • Sqlite is the database engine that Bitcoin Core switched to from LevelDB.

So my conclusion here is that it's as safe as it can be from our point of view. But still it adds a dependency. True.

API design notes

  • This PR puts all data to IndexStore.sqlite file. There was a question whether everything different data should belong to this database as well. I think it would be beneficial to have 2 sqlite database files. One for filters and one for everything else. There are multiple reasons for that:
    • Currently, MatureIndex.dat is a separate file and it's not mixed with anything else too (it cannot be really)
    • Suppose that all data are in one file, then if one wants to do a backup, it will be at least 1 GB, that's not a small backup.
    • If filters are mixed with other data, it's not possible to provide a snapshot of IndexStorage.sqlite for users to download it fast (say over clearnet, not everyone wants to wait to download 1 GB over Tor and it might be pretty slow sometimes; in the meanwhile you can't use your wallet, so no payments just waiting that's not good UX).

Measurements

A. Measure how much time is spent in IndexStore.AddNewFiltersAsync in total on testnet

  • master - Total filter processing time for complete testnet sync: 44062ms (44s).
  • PR - Total filter processing time for complete testnet sync: 19279ms (19s)

-> 56% improvement.

This measurement is not up-to-date. I believe it's even better now. On mainnet I expect bigger saving. I need to re-measure if the PR is not NACKed immediately.

B. Measure with Tor off

  1. Complete downloading of filters on testnet using clearnet (using Tor distorts numbers too much):

    master
    Run n.1
    2023-03-10 08:31:56.606 [9] INFO        FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 828576 to 838575.
    ...
    2023-03-10 08:33:36.725 [26] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 2418576 to 2423851.
    -> ~1m40s
    
    Run n.2
    2023-03-10 08:49:50.006 [26] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 828576 to 838575.
    ...
    2023-03-10 08:51:22.776 [24] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 2418576 to 2423852.
    -> ~1m32s
    
    Run n.3
    2023-03-10 08:53:33.011 [8] INFO        FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 828576 to 838575.
    ...
    2023-03-10 08:55:08.545 [9] INFO        FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 2418576 to 2423852.
    -> ~1m35s
    
    
    PR
    Run n.1  
    2023-03-10 09:01:09.406 [24] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 828576 to 838575.
    ...
    2023-03-10 09:02:20.789 [24] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 2418576 to 2423853.  
    -> ~1m10s
    
    Run n.2  
    2023-03-10 09:10:05.317 [26] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 828576 to 838575.
    ...
    2023-03-10 09:11:19.522 [26] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 2418576 to 2423853.  
    -> ~1m14s
    
    Run n.3  
    2023-03-10 09:12:38.498 [25] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 828576 to 838575.
    ...
    2023-03-10 09:13:50.996 [25] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 2418576 to 2423853.  
    -> ~1m12s
    
    
  2. Complete downloading of filters on mainnet using clearnet (using Tor distorts numbers too much):

    master
    Run n.1
    2023-03-10 09:22:08.073 [6] INFO        FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 481825 to 482824.
    ...
    2023-03-10 09:27:10.326 [28] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 779825 to 780114.
    -> ~5m2s
    
    Run n.2
    2023-03-10 09:42:27.813 [8] INFO        FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 481825 to 482824.
    ...
    2023-03-10 09:47:42.755 [20] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 779825 to 780114.
    -> ~5m15s
    
    Run n.3
    2023-03-10 09:49:35.656 [28] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 481825 to 482824.
    ...
    2023-03-10 09:54:59.665 [28] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 779825 to 780114.
    -> ~5m24s
    
    PR
    Run n.1  
    2023-03-10 10:03:30.496 [4] INFO        FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 481825 to 482824.
    ...
    2023-03-10 10:08:24.887 [25] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 779825 to 780114.
    -> ~4m54s
    
    Run n.2  
    2023-03-10 10:10:45.845 [25] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 481825 to 482824.
    ...
    2023-03-10 10:15:39.986 [26] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 779825 to 780116.
    -> ~4m54s
    
    Run n.3  
    2023-03-10 10:17:13.590 [9] INFO        FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 481825 to 482824.
    ...
    2023-03-10 10:22:15.186 [29] INFO       FilterProcessor.ProcessAsync (59)       Downloaded filters for blocks from 779825 to 780118.
    -> ~5m2s
    
    

C. Measure max filter batch size in a single HTTP request

Note that this is a one-line change that's independent on this PR but I find it interesting to measure it too:

  • 10000 filters per HTTP request
    • Total Tor data downloading time: 299s
    • Whole process took:
      • start: 2023-03-09 09:09:43.761;Network.IndexStore.WasabiSynchronizer.Request.828575;1959
      • end: 2023-03-09 09:16:06.193;Network.IndexStore.WasabiSynchronizer.FilterProcessor.2423516.NewFilters.[Fluent] Added Skip navigation mode #4941;54
      • 6 minutes and 23 seconds = 383s
  • 30000 filters per HTTP request
    • Total Tor data downloading time: 106s
    • Whole process took:
      • start: 2023-03-09 09:01:32.757;Network.IndexStore.WasabiSynchronizer.Request.828575;1806
      • end: 2023-03-09 09:04:42.131;Network.IndexStore.WasabiSynchronizer.FilterProcessor.2423516.NewFilters.[Fluent] Added Skip navigation mode #4941;307
      • 3 minutes and 10 seconds = 190s

-> 50% improvement when switching from 10000 to 30000. This change is not in this PR.

Inspecting sqlite databases

I recommend https://sqlitebrowser.org/ to open .sqlite files.

SQL query to inspect data:

select block_height, hex(block_hash), hex(filter_data), hex(previous_block_hash), epoch_block_time from filter

Note that block_hash is stored in little endian format so hashes are in reverse order.

Next steps

If the PR is merged, then there are several steps worth considering:

  • Add migration code to convert existing MatureIndex.dat to sqlite format (or add a static sqlite file to a wasabi server that can be downloaded by users (automatically / manually)). And don't forget to delete plaint text files (.dat files).
  • Use sqlite for storing blocks to avoid having many files in a directory (file systems do not like it)
  • Consider using multiple SqliteConnections to avoid IndexLock that serializes accesses to the sqlite database.
  • Consider putting SmartHeaderChain in a database too.

@jmacato
Copy link
Contributor

jmacato commented Mar 10, 2023

Would it be possible to use LiteDB instead? To at least avoid another native dependency again?

@kiminuo
Copy link
Collaborator Author

kiminuo commented Mar 10, 2023

Would it be possible to use LiteDB instead? To at least avoid another native dependency again?

Good question. I did not know we depend on this. However, I have personal experience with the LiteDB database and there are several issues:

  • LiteDB is a one-man project mostly (https://github.com/mbdavid/LiteDB/graphs/contributors) who is seemingly busy with other projects lately.
  • Sqlite exists for 20+ years, it's battle tested. Features and shortcomings are known at this point. You can ask a question and most likely you'll get a reply (not the case for LiteDB).
  • I waited for a quite big bug in LiteDB to be fixed for a year. It did not happen so I gave up.
  • LiteDB used to have issues with "ever growing database file size". Not sure if it is still the case.

To be honest, I would not put LiteDB in anything but a toy project. But I guess I'm much more strict than other people wrt what I expect from software. But still not a fan of LiteDB for projects that must work rock solid.

native dependency

What are the cons? It seems sqlite works everywhere https://www.nuget.org/packages/Microsoft.Data.Sqlite/#supportedframeworks-body-tab.

@lontivero
Copy link
Collaborator

Concept ACK.

…05-IndexStore-with-sqlite

# Conflicts:
#	WalletWasabi.Fluent/Global.cs
#	WalletWasabi.Tests/UnitTests/Stores/IndexStoreTests.cs
#	WalletWasabi/Blockchain/BlockFilters/FilterProcessor.cs
#	WalletWasabi/Stores/IndexStore.cs
Copy link
Collaborator

@turbolay turbolay left a comment

Choose a reason for hiding this comment

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

Code overall is nice.
I tried to break the feature but was not able to do so.

I don't like that it almost doubles the disk space filters use. Could we delete the MatureIndex once it has been pushed into the db?

WalletWasabi/Stores/SqliteStorage.cs Outdated Show resolved Hide resolved
WalletWasabi/Stores/IndexStore.cs Show resolved Hide resolved
WalletWasabi/Stores/IndexStore.cs Show resolved Hide resolved
@kiminuo
Copy link
Collaborator Author

kiminuo commented Mar 18, 2023

Code overall is nice.
I tried to break the feature but was not able to do so.

🎉

I don't like that it almost doubles the disk space filters use. Could we delete the MatureIndex once it has been pushed into the db?

This PR adds an .sqlite file to user profile and this PR does not delete plaintext indices (mature and immature) as you say. So you are right that if this feature is accepted, then this is likely a good thing to modify (i.e. removing old indices). However, I don't find this a great disadvantage at this point because one can switch between this PR and master and everything works so this PR is reviewer-friendly at this point and I can implement the suggestion in a follow-up. Tx.

block_height INTEGER NOT NULL PRIMARY KEY,
block_hash BLOB NOT NULL,
filter_data BLOB NOT NULL,
previous_block_hash BLOB NOT NULL,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why BLOB not VARCHAR(64) for hashes?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hash in binary is 32 bytes long. Varchar(64) would require 64 bytes. My only motivation here was to save space.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ok, makes sense. Counter-argument would be that with varchars it's easier to examine database using sqlite tools. But this is anyway something that is not too hard to change in future if needed.

Copy link
Collaborator Author

@kiminuo kiminuo Mar 18, 2023

Choose a reason for hiding this comment

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

Yeah, I was thinking about that and there are free tools like https://sqlitebrowser.org/ and you can just type query:

SELECT hex(previous_block_hash) FROM filter

and you'll see the hash as a string of characters. Sure, there is some mental overhead for people (i.e. developers) doing that debugging but my reasoning was that saving space has bigger priority (for actual users) for something that still grows.

edit: But then I don't know much much actual data saving in kB/MB we are talking about from the top of my head.

Copy link
Collaborator

Choose a reason for hiding this comment

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

edit: But then I don't know much much actual data saving in kB/MB we are talking about from the top of my head.

64 bytes per block for block_hash and previous_block_hash combined. Is there all blocks or only after segwit activation? For all blocks it would be 781411*64=50010304, so around 50 MiB if my calculations are correct.

@turbolay
Copy link
Collaborator

However, I don't find this a great disadvantage at this point because one can switch between this PR and master and everything works so this PR is reviewer-friendly at this point and I can implement the suggestion in a follow-up. Tx.

ACK, maybe implement it shortly before 2.0.4 release so reviewers won't have issues by going back and forth and users won't ever have duplicated filters.

@kiminuo
Copy link
Collaborator Author

kiminuo commented Mar 18, 2023

However, I don't find this a great disadvantage at this point because one can switch between this PR and master and everything works so this PR is reviewer-friendly at this point and I can implement the suggestion in a follow-up. Tx.

ACK, maybe implement it shortly before 2.0.4 release so reviewers won't have issues by going back and forth and users won't ever have duplicated filters.

@molnard and @nopara73 has not greenlighted this PR yet. It may happen that they will NACK it. Just FYI.

And yes, we can then come up with a strategy to migrate user to the new format. Imho it would be great to have a conversion routine that just converts mature index to sqlite and consequently deletes mature index ... then users would avoid re-downloading all filters which would be great (imho not that hard to implement so worth it imo).

@nopara73
Copy link
Contributor

Normally I'd scream bloody murder, but this time I must admit it is well argued, so cACK.

Reflections

Let me reflect on the specifics points:

With the plaintext approach (current master), we periodically rewrite all the mature index data to add new immature data.

Assuming it is wrong, which I am not convinced of, we can easily "fix" it without a database.

This operation takes seconds and more importantly it's not disk-friendly (it shortens lives of SSD disks).

Meh

On mobile devices it would be a no-go.

?

Sqlite supports storing in binary form, so my mature index is 2.18 GB on master branch and 1.23 GB with my PR. That's like 40% improvement. To inspect the data, it's easy to convert to hexadecal form.

Not as easy as opening a file :) Anyhow, if we'd to change to binary for disk saving, then we wouldn't be able to inspect. So with file you can't have your cake and eat it, too, with this you can at least be able to eat a part of it, so this is compelling.

Sqlite guarrantees that data does not get corrupted. The plaintext approach does not guarrantee that. Hence we fight the #7324 in the first place.

I don't believe this for a second

It looks to me that the code will be simpler as a result.

You added twice as much code than you removed here. Lines of code is usually a good proxy for complexity. Maybe it's not the case this time as CodeScene reports improvements:
image

bonus: We might be able to download filters from the wallet height and then download OLDER filters if an older wallet is imported.

We don't need a database for that.

bonus: The sqlite database file can be shared with people and it will just work. Current plaintexts files differ on Windows and remaining platforms by newlines (\r\n vs \n). That is all to say that having a single file to distribute and having a single checksum seems ideal in the long run.

Small pro.

bonus: We can speed up wallet load by a few seconds by avoiding reading complete (mature) index with all filters because we believe that sqlite is more robust wrt power outages, etc.

I don't see any logical implication between these two claims. The first claim seems to stand strong, but the second I don't believe, in fact I wouldn't be surprised if the current solution is more robust.

bonus: After WW is launched (testnet), I can see that memory use is 256 MB. On master branch, it is 276 MB on my machine. That's a minor 7% improvement.

Small pro.

We generally trust Microsoft packages as we use ASP.NET and we trust .NET as a platform.
Sqlite is the database engine that Bitcoin Core switched to from LevelDB.

It does not get any better than this.

@kristapsk
Copy link
Collaborator

it's not disk-friendly (it shortens lives of SSD disks). On mobile devices it would be a no-go.

It's not so big problem with modern SSDs, they have smart firmware that does wear leveling. When you write data to specific logical sector, physically it's written to different part of SSD each time. But it is true for SD cards, which don't have smart firmware.

@kiminuo
Copy link
Collaborator Author

kiminuo commented Mar 20, 2023

Normally I'd scream bloody murder, but this time I must admit it is well argued, so cACK.

Reflections

Let me reflect on the specifics points:

With the plaintext approach (current master), we periodically rewrite all the mature index data to add new immature data.

Assuming it is wrong, which I am not convinced of, we can easily "fix" it without a database.

Not sure how to interpret "wrong" here. But it certainly not good in the sense that if such a big file is being stored and there is a power cut, then we might end up with a corrupted plaintext file.

This operation takes seconds and more importantly it's not disk-friendly (it shortens lives of SSD disks).

Meh

Time-tested universal argument. Haha.

On mobile devices it would be a no-go.

?

You actually might not have 2 more free GBs on your computer/phone just to update the mature index (mainnet). This is not so hard to imagine.

Likely on a phone, the storing of those 2 GBs might be slower than on laptops/desktops. But that's just efficiency.

Sqlite supports storing in binary form, so my mature index is 2.18 GB on master branch and 1.23 GB with my PR. That's like 40% improvement. To inspect the data, it's easy to convert to hexadecal form.

Not as easy as opening a file :) Anyhow, if we'd to change to binary for disk saving, then we wouldn't be able to inspect. So with file you can't have your cake and eat it, too, with this you can at least be able to eat a part of it, so this is compelling.

Sqlite guarrantees that data does not get corrupted. The plaintext approach does not guarrantee that. Hence we fight the #7324 in the first place.

I don't believe this for a second

Not sure why, databases typically strive for ACID properties. What database engines typically offers is "either your transaction is commited or not", so you might lose data if there is a power cut but then you typically always lose some data when there is a power cut.

Useful resources regarding what sqlite offers:

  • https://www.sqlite.org/transactional.html mentions A transactional database is one in which all changes and queries appear to be Atomic, Consistent, Isolated, and Durable (ACID). SQLite implements serializable transactions that are atomic, consistent, isolated, and durable, even if the transaction is interrupted by a program crash, an operating system crash, or a power failure to the computer.
  • There are some hardware assumptions here but tl;dr sqlite just relies on operating system not to be buggy and, eg, not to violate fsync API contract.
  • You can still corrupt an sqlite database by externally modifying it. But that's not surprising.
  • https://www.sqlite.org/atomiccommit.html#_things_that_can_go_wrong mentions The atomic commit mechanism in SQLite has proven to be robust, but it can be circumvented by a sufficiently creative adversary or a sufficiently broken operating system implementation. This section describes a few of the ways in which an SQLite database might be corrupted by a power failure or system crash. (See also: How To Corrupt Your Database Files.)
  • https://www.sqlite.org/atomiccommit.html explains quite nicely how that atomic data storing works.

It looks to me that the code will be simpler as a result.

You added twice as much code than you removed here. Lines of code is usually a good proxy for complexity. Maybe it's not the case this time as CodeScene reports improvements: image

git diff --numstat master..HEAD reports

73      0       WalletWasabi.Fluent.Desktop/packages.lock.json
73      0       WalletWasabi.Fluent/packages.lock.json
73      0       WalletWasabi.Packager/packages.lock.json
78      0       WalletWasabi/packages.lock.json
0       140     WalletWasabi.Tests/UnitTests/Stores/IndexStoreTests.cs
158     0       WalletWasabi.Tests/UnitTests/Stores/SqliteStorageTests.cs
7       3       WalletWasabi/Backend/Models/FilterModel.cs
5       3       WalletWasabi/Blockchain/BlockFilters/FilterProcessor.cs
92      261     WalletWasabi/Stores/IndexStore.cs
241     0       WalletWasabi/Stores/SqliteStorage.cs
1       0       WalletWasabi/WalletWasabi.csproj

The first 4 files are packages.lock.json and those changes add 73+73+73+78 = 297 lines of code.

IndexStore.cs appears to be smaller now1. SqliteStorage.cs which powers the sqlite adds 241 lines of code.

So if I do the math correctly, the PR adds about 100 lines of code.

Plus, DigestableSafeloManager.AppendAllLinesAsync is now unused and this PR does not remove that function. Maybe it should. IDK.

bonus: We might be able to download filters from the wallet height and then download OLDER filters if an older wallet is imported.

We don't need a database for that.

Yes, we don't. We can implement what this PR does without the new depency, but the thing that puts me off doing that is that one then starts to implement a database basically (or database features).

bonus: The sqlite database file can be shared with people and it will just work. Current plaintexts files differ on Windows and remaining platforms by newlines (\r\n vs \n). That is all to say that having a single file to distribute and having a single checksum seems ideal in the long run.

Small pro.

bonus: We can speed up wallet load by a few seconds by avoiding reading complete (mature) index with all filters because we believe that sqlite is more robust wrt power outages, etc.

I don't see any logical implication between these two claims. The first claim seems to stand strong, but the second I don't believe, in fact I wouldn't be surprised if the current solution is more robust.

My point was meant to be: databases allow the seek operation (get the last N rows). The plaintext files (as we currently have) allows us to read it from the start or read lines and skip processing them and start processing them from certain point on.

I don't believe that the current solution is more robust. I think that sqlite is more robust. Maybe we differ on definition of robustness though.

bonus: After WW is launched (testnet), I can see that memory use is 256 MB. On master branch, it is 276 MB on my machine. That's a minor 7% improvement.

Small pro.

We generally trust Microsoft packages as we use ASP.NET and we trust .NET as a platform.
Sqlite is the database engine that Bitcoin Core switched to from LevelDB.

It does not get any better than this.

it's not disk-friendly (it shortens lives of SSD disks). On mobile devices it would be a no-go.

It's not so big problem with modern SSDs, they have smart firmware that does wear leveling. When you write data to specific logical sector, physically it's written to different part of SSD each time. But it is true for SD cards, which don't have smart firmware.

I still don't see a good reason to do it if it is not necessary. It feels wrong.

Footnotes

  1. Two async locks were removed. One reads from a single storage rather than two, etc.

@kristapsk
Copy link
Collaborator

It's not so big problem with modern SSDs, they have smart firmware that does wear leveling. When you write data to specific logical sector, physically it's written to different part of SSD each time. But it is true for SD cards, which don't have smart firmware.

I still don't see a good reason to do it if it is not necessary. It feels wrong.

I don't disagree here. :)

@turbolay
Copy link
Collaborator

You actually might not have 2 more free GBs on your computer/phone just to update the mature index (mainnet). This is not so hard to imagine.

Just a note unrelated to the PR but because the topic was brought up: Should we even store the filters on mobile? I think the answer is no.
Disk space is much more scarce on mobile devices, and I can see a common scenario where it would be a big problem: You don't have space anymore (I don't know for you but I always buy cheapest device hence 32 Gb so I always run out of space), you go to your Applications list and sort by size to see who is at fault, you find out that Wasabi takes 2 Gb (+ blocks + software) and just uninstalls it.

@kiminuo
Copy link
Collaborator Author

kiminuo commented Mar 21, 2023

[..] You don't have space anymore [..]

I agree and I mentioned it here.

[..] Should we even store the filters on mobile? [..]

Imho it would be nice to summarize (in an issue?) how it would work or what it would entail to achieve that.

This PR (or any other that decreases disk space required) can be seen as another thing that allows to install WW on more devices.

@wieslawsoltes
Copy link
Collaborator

Another argument for using sqlite is that having 16k files in windows file system might pose huge perf issues:
image

@kristapsk
Copy link
Collaborator

Another argument for using sqlite is that having 16k files in windows file system might pose huge perf issues

Linux does not like a lots of files in single directory as well.

@kiminuo
Copy link
Collaborator Author

kiminuo commented Mar 24, 2023

So that would a next step too. Modified OP to mention this as "Next steps".

WalletWasabi/Stores/SqliteStorage.cs Outdated Show resolved Hide resolved
Connection = connection;
}

private SqliteConnection Connection { get; }
Copy link
Collaborator

Choose a reason for hiding this comment

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

Usually connections are not stored in fields and properties, but instantiated on demand. Internally there's a pool and an inner connection object (one more controversial thing in ADO.NET), so having a connection string cached in a field would be a better solution.

Sqlite is a bit different because it's a in-process RDBMS, so it can be kept as is.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

So just to provide more context here: Basically, every WW client downloads block filters to know which bitcoin blocks user should download to get info about his/her coins. New users typically suffer from "downloading tousands of filters" which takes 5 minutes when one does not have Tor enabled but with Tor enabled it takes significantly longer and it's a nuisance.

To have a long-lived sqlite connection makes sense to me because we want performance here. We do not want to spend more time than necessary as people wait for their wallet to actually work (if you hurry to send a payment, it's very bothering).

My second argument here is that I would love to use sqlite for another purpose -- storing RuntimeParams (some P2P data) (https://github.com/zkSNACKs/WalletWasabi/blob/9b7c67327f9775f532d86336d2ed5d344abc168b/WalletWasabi/Helpers/RuntimeParams.cs) and as such I don't see the benefit of having short lived connections.

I can see a small con of short-lived connections, some process can my sqlite datafile and WW will have a hard time dealing with that.

Is there any important aspect of this issue I'm missing?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Disposal of an ADO.NET connection may not dispose the underlying physical connection due to pooling turned on under the hood (SQLite isn't an exception and does it by default, see code). So, without opting out the code will continue to work exactly like it is now, but it will follow guidelines of ADO.NET usage and free resources when they are not used for some time (source.

// Makes a new logical connection.
using var connection = new SqliteConnection();

// Gets an existing physical connection from the pool or opens it if it doesn't exist yet.
connection.Open();

// Dispose just returns the physical connection to the pool.

There's no performance penalties since you don't opt out from pooling (:

That design is weird, but it was made to improve performance of applications which have been made before pooling got its place in ADO.NET without changing these applications.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I understand what you are saying. But I don't follow what it would bring us. Having a single connection to work with for the duration of application life seems easy to grasp and it seems it models what we actually do (storing filters as we get them).

Copy link
Collaborator

Choose a reason for hiding this comment

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

In case of SQLite it's fine to keep it as is (it is an in process database), but the general practice is disposal of a connection right after finishing all commands in the method. Why so? There are two reasons:

  1. A physical connection can be shared thankfully for pooling. That's useful when you have more than one place accessing the same database and potentially from different threads surely if both of them are not accessing it simultaneously.

  2. For out of process databases there's a limited number of connections, so a better option here is to limit physical connections made by clients. A good example is PostgreSQL which starts an instance for each incoming connection and stops it when the connection closes. It's a very expensive operation here. Moreover, the listening server has only 100 parallel connections by default.

Sure, we won't use any out of process database, but you said that you are going to use SQLite in another place, so reason number one still applies even if it's partly valid due to the nature of SQLite.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh, forgot to mention a primary reason behind having a new connection each time. The server can terminate it, so with a single open the service is screwed. Not sure how it applies to SQLite since there's no server, but I have no idea about it internals. Therefore, if a native connection referenced by a handle held can be invalidated for whatever reason, then that reason is valid too.

Honestly, I'm not aware about SQLite internals and wouldn't rely on its implementation. That's why I highly recommend getting a new connection each time.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oh, forgot to mention a primary reason behind having a new connection each time. The server can terminate it, so with a single open the service is screwed. Not sure how it applies to SQLite since there's no server, but I have no idea about it internals. Therefore, if a native connection referenced by a handle held can be invalidated for whatever reason, then that reason is valid too.

But we know that there is no server in sqlite.

A physical connection can be shared thankfully for pooling. That's useful when you have more than one place accessing the same database and potentially from different threads surely if both of them are not accessing it simultaneously.

All we have at the moment is this PR. RuntimeParams can be added to the database or not - no decision has been made. It can also be put in a different sqlite file (I think it's preferable thinking about it). That is, we can have one sqlite file for filters and one for other things and even though it sounds weird, it seems quite rational because IndexStore.sqlite should be a file that we should provide as a static file that is to be downloaded with Wasabi Wallet OR we should provide put it to (say) https://data.wasabiwallet.io/IndexStore.sqlite so that users or the software can download it to skip the initial pain of downloading the filters. That means that IndexStore.sqlite is a database that:

  • has 1 writer
  • has 1 or more readers in general
  • the database is needed over all lifetime of the application (block can come at any minute)
  • noone outside of Wasabi Wallet app should touch the database
  • the database is used as a robust data storage more than anything else (i.e. we want it not to get corrupted and be reasonably fast but the protection against corruption is more important for me)

Also in my experience sqlite works OK with a persistent connection just OK and there is really no good reason why it shouldn't (apart from some bug but I have spent some time googling and I have not found anything of that sort).

I believe you that pooling leads would yield mostly the same performance but I still don't see a sufficient reason to go that way. I mean there is only Wasabi Wallet and the database file, there are no third entities and as I explained the way we interact with the database is quite simple.

Now in my testing I have not found any significant issue. So I would suggest: Let's use the persistent connection for now and as the next step, let's try to measure if your approach is better. I'll be honest and say I don't believe that and I'll eat my hat if I'm wrong but I'm happy to be proven wrong by measurements. ;-)

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm okay with it.

I asked a question in Slack about locks and possibility to remove them due to having SQLite transactions. Perhaps, you have to instantiate a new connection per each method call of SqliteStorage since no parallel commands are allowed per connection in ADO.NET and databases I have used (except SQL Server with MARS enabled, but they still run synchronously). Maybe it's different with SQLite, no idea about that (:

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@YohDeadfall So I have checked SqliteConnection and added some info regarding thread-safety. And you are right that SqliteConnection is not supposed to be accessed from multiple threads:

Multi-thread. In this mode, SQLite can be safely used by multiple threads provided that no single database connection is used simultaneously in two or more threads.

So that's a very good point.

The point supports your approach of using multiple SqliteConnections in general. However, here in this specific case, I believe we need IndexStore.IndexLock because one wants to handle correctly bitcoin block reorgs. So we use IndexLock to synchronize reads/writes of the sqlite database. So to move to your idea with multiple sqlite connections, we would need to do some changes to how we use IndexLock. I'm not sure if it can be done (maybe yes, maybe no, IDK). I'm not too keen to do it in this PR. I'm happy with this PR as is - even though it can work even better. Let's reserve that for a future PR.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Added "using multiple sqlite connections" as a possible next step to the OP.

WalletWasabi/Stores/SqliteStorage.cs Outdated Show resolved Hide resolved
@lontivero
Copy link
Collaborator

Testing

Guys, this is a PR that has a big potential for good and can be a problem if it introduces a bug so, testing is important. So, here is how I propose to test it (you are free to add more tests or execute a subset of these).

  • Normal user tests
    • Pull the PR, compile and run. Open a Wallet that you know is in a good state.
      • Check the balance
      • Edit the wallet json file to go back in time and reopen
      • Check the balance
    • Corrupt the filters
      • sqlite3 -table IndexStore.sqlite "UPDATE filter SET filter_data = 'whatever' WHERE block_height = (SELECT MAX(block_height) FROM filter)"`
      • sqlite3 -json IndexStore.sqlite "SELECT * FROM filter WHERE block_height = (SELECT MAX(block_height) FROM filter)"
        [{"block_height":2429418,"block_hash":"OL?++\u0004D\u0000(K'6\u0000\u0000\u0000\u0000","filter_data":"whatever","previous_block_hash":"41n,W%b\u0002&\t7 Vf\u00031\u0019\u0000\u0000\u0000\u0000\u0000\u0000","epoch_block_time":1681817605}]
    • Corrupt the data
      • sqlite3 -table IndexStore.sqlite "DELETE FROM filter WHERE block_height = (SELECT MAX(block_height) FROM filter)"
      • sqlite3 -table IndexStore.sqlite "UPDATE filter SET block_hash = 'whatever' WHERE block_height = (SELECT MAX(block_height) FROM filter)"
      • This fails and it is not recoverable. IMO it should recover somehow
    • Corrupt the chain
      • sqlite3 -table IndexStore.sqlite "DELETE FROM filter WHERE block_height = (SELECT MAX(block_height) FROM filter)"
      • sqlite3 -table IndexStore.sqlite "UPDATE filter SET previous_block_hash = block_hash WHERE block_height = (SELECT MAX(block_height) FROM filter)"
      • Congratulations, this recovers successfully after restart it.

lontivero
lontivero previously approved these changes Apr 18, 2023
Copy link
Collaborator

@lontivero lontivero left a comment

Choose a reason for hiding this comment

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

It works well. It didn't recovered when the block_hash was corrupted but I think the same happened before and then there is not breaking change introduced here. Also, the filter_data could be corrupted but it wouldn't be detected so, i think it is okay.

@kiminuo kiminuo requested a review from molnard April 20, 2023 10:57
Copy link
Collaborator

@molnard molnard left a comment

Choose a reason for hiding this comment

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

test ACK to see if Kimi can press the merge button.

@kiminuo kiminuo mentioned this pull request Apr 24, 2023
Copy link
Collaborator

@molnard molnard left a comment

Choose a reason for hiding this comment

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

I made the IndexStore.sqlite file corrupt by removing a random part from it in a hex editor. Cannot start and quit wasabi anymore, crash report appears but stuck after that. After killing the process, the same happens. Wasabi won't work anymore. The recovery process we have in the master is not working.

TODO:

  • Crash properly. After the crash, Wasabi should report what happened and quit properly. The restart function in the crash report should work as well.
  • Recover properly. Detect the corruption, delete the file and crash. Next start, rebuild the database.

image

@molnard
Copy link
Collaborator

molnard commented Apr 24, 2023

The migration code should be finished first as a proof of concept. If migration takes too much time it means we have to figure out the UX for this - if that is even possible. I do prefer to think about these in advance, not after this PR merged.

For example: if migration takes 20 minutes, every user who will download the next release will have to wait 20 minutes before they can use Wasabi and that is not OK.

  • How long does it take to migrate on MainNet?

@lontivero
Copy link
Collaborator

How long does it take to migrate on MainNet?

It should take a few seconds. I remember that decoding all the filters and re-encoding them in a binary format took about 2 seconds in my old machine using this code under "Convert filters to binary"
https://lontivero.github.io/Wiki/html/wasabi/misc.html

@kiminuo
Copy link
Collaborator Author

kiminuo commented Apr 25, 2023

@molnard So I tested the latest commit as you did and indeed there is that weird exception.

However, then I merged the latest master commit to this PR and it started to behave differently:

image

plus

image

So it is possible that what you saw was some artifact of the UI decoupling that has been taking place lately (or IDK).

FTR, it's important to say that the recovery mechanism on master does not work for cutting arbitrary characters as well from the MatureIndex.dat because there is that

private bool UseLastCharacterDigest { get; }
where we compute a hash over a collection of last characters of each line. So if you modify a different character then.. tough luck.

To answer that if anything can break, it will break argument: There are clear reasons why SQLite can break summarized here https://www.sqlite.org/howtocorrupt.html (many are good ones like if you delete a hot journal file) and it makes no sense to me to able to recover when a virus modifies a byte in our database. A thought experiment is that, if we were, then I can just create a virus with such behavior with the goal to take down the Wasabi Coordinator by making all Wasabi Clients to download filters all the time.

That is all to say that if the Wasabi app can break sqlite then yes, we should try to recover, if there is an external malicious actor in place, then it's not our responsibility to support/fix that. But given that sqlite is like several orders of magnitude more stable than what we have now on master (or vice versa, how easy it is to break the mature index on the master now), the discussion seems pretty academic to me.

Anyway, f3ae8f4 deletes the database if it is corrupted. It's just a bit more involved than expected.

@kiminuo
Copy link
Collaborator Author

kiminuo commented Apr 25, 2023

The migration code should be finished first as a proof of concept. If migration takes too much time it means we have to figure out the UX for this - if that is even possible. I do prefer to think about these in advance, not after this PR merged.

For testnet:

  • It takes 28 seconds to convert 1601723 filter lines to FilterModel. (This is horrible)
  • It takes 9 seconds to insert all the records in the database. (This is not great, but not terrible)

I think that both can be heavily improved. So it should be a few seconds as Lucas says.

Update with commit 2395e63ed4a1ef3d0467df9cfc70a3b13ff38937: For testnet:

  • It takes 9 seconds to convert 1601723 filter lines to FilterModel.
  • It takes 5 seconds to insert all the records in the database.

Still too much but at least some progress.

Copy link
Collaborator

@molnard molnard left a comment

Choose a reason for hiding this comment

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

tACK. 🎊

@molnard molnard merged commit 4efe565 into zkSNACKs:master Apr 25, 2023
6 of 7 checks passed
@kiminuo kiminuo deleted the feature/2023-03-05-IndexStore-with-sqlite branch April 25, 2023 13:55
@MaxHillebrand
Copy link
Member

Congrats on getting this merged, huge effort, well done gentlemen!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[IndexStore] Incorrect synchronization Crash at startup - Index file inconsistency detected
10 participants