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

CBL-6131: Race creating the expiration column in a collection table #2160

Merged
merged 9 commits into from
Oct 25, 2024
Merged
5 changes: 0 additions & 5 deletions LiteCore/Query/SQLiteQuery.cc
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,6 @@ namespace litecore {
error::_throw(error::NoSuchIndex, "'match' test requires a full-text index");
}

// If expiration is queried, ensure the table(s) have the expiration column:
if ( qp.usesExpiration() ) {
for ( auto ks : _keyStores ) ks->addExpiration();
}

LogTo(SQL, "Compiled {Query#%u}: %s", getObjectRef(), sql.c_str());
_statement = dataFile.compile(sql.c_str());

Expand Down
5 changes: 0 additions & 5 deletions LiteCore/Storage/BothKeyStore.hh
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,6 @@ namespace litecore {

bool mayHaveExpiration() override { return _liveStore->mayHaveExpiration() || _deadStore->mayHaveExpiration(); }

void addExpiration() override {
_liveStore->addExpiration();
_deadStore->addExpiration();
}

bool setExpiration(slice key, expiration_t exp) override {
return _liveStore->setExpiration(key, exp) || _deadStore->setExpiration(key, exp);
}
Expand Down
2 changes: 0 additions & 2 deletions LiteCore/Storage/KeyStore.hh
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,6 @@ namespace litecore {
/** Does this KeyStore potentially have records that expire? (May return false positives.) */
virtual bool mayHaveExpiration() = 0;

virtual void addExpiration() = 0;

/** Sets a record's expiration time. Zero means 'never'.
@return true if the time was set, false if no record with that key exists. */
virtual bool setExpiration(slice key, expiration_t) = 0;
Expand Down
16 changes: 13 additions & 3 deletions LiteCore/Storage/SQLiteDataFile.cc
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,19 @@ namespace litecore {
error::_throw(error::CantUpgradeDatabase);
}

(void)upgradeSchema(SchemaVersion::WithExpirationColumn, "Adding `expiration` column", [&] {
// Add the 'expiration' column to every KeyStore:
for ( string& name : allKeyStoreNames() ) {
if ( name.find("::") == string::npos ) {
// Only update data tables, not FTS index tables
_exec(format(
"ALTER TABLE \"kv_%s\" ADD COLUMN expiration INTEGER; "
"CREATE INDEX \"kv_%s_expiration\" ON \"kv_%s\" (expiration) WHERE expiration not null",
name.c_str(), name.c_str(), name.c_str()));
}
}
});

(void)upgradeSchema(SchemaVersion::WithIndexesLastSeq, "Adding indexes.lastSeq column", [&] {
string sql;
if ( getSchema("indexes", "table", "indexes", sql) ) {
Expand Down Expand Up @@ -602,9 +615,6 @@ namespace litecore {
// Wrap the store in a BothKeyStore that manages it and the deleted store:
auto deletedStore = new SQLiteKeyStore(*this, kDeletedKeyStorePrefix + name, options);

keyStore->addExpiration();
deletedStore->addExpiration();

// Create a SQLite view of a union of both stores, for use in queries:
#define COLUMNS "key,sequence,flags,version,body,extra,expiration"
// Invarient: keyStore->tablaName() == kv_<tableName>
Expand Down
7 changes: 4 additions & 3 deletions LiteCore/Storage/SQLiteDataFile.hh
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,10 @@ namespace litecore {

WithNewDocs = 400, // New document/revision storage (CBL 3.0)

WithDeletedTable = 500, // Added 'deleted' KeyStore for deleted docs (CBL 3.0?)
WithIndexesLastSeq = 501, // Added 'lastSeq' column to 'indexes' table (CBL 3.2)
MaxReadable = 599, // Cannot open versions newer than this
WithExpirationColumn = 490, // Added 'expiration' column to KeyStore
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you insert a new version before existing versions? How would a version at 500 or 501 get upgraded? There should be a test, I think.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Any database at version >=500 already has the expiration column, because addExpiration was added to KeyStore::reopen in the same commit that WithDeletedTable (500) migration was added.

Copy link
Contributor

Choose a reason for hiding this comment

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

So you actually try to retrofit into current upgrade path. This relies on very precise understanding of other part of code and the schema.
Recognizing this fact, we can also treat it as a new version, but because of the above fact, do
ALTER TABLE ..... IF NOT EXISTS
I am little concerned of retrofitting a new version into the history

Copy link
Contributor Author

@callumbirks callumbirks Oct 24, 2024

Choose a reason for hiding this comment

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

ALTER TABLE ..... IF NOT EXISTS

There is no such syntax. We need to check the existence of the column manually

Copy link
Contributor

Choose a reason for hiding this comment

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

If you check the existence, do we still need to insert a new version into past history? This is the new version, after this new version, we will know schema has the expiration table at the time of creation.

Copy link
Contributor

Choose a reason for hiding this comment

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

What's the code path that supports your statement? Is it in the same transaction of the upgrade transaction? Is it possible that some failure occurs outside the upgrade transaction that arrives following state:

  • version bumped 500
  • some keystore not opened successfully and expiration not added successfully.
    Since you removed the addExpiration completely, there is no way to have the column.

Copy link
Contributor Author

@callumbirks callumbirks Oct 24, 2024

Choose a reason for hiding this comment

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

SQLiteKeyStore::reopen calls addExpiration (on the main branch).
So if a database is version >=500, every collection which has been used since it upgraded to version 500 has the expiration column.

Copy link
Contributor

Choose a reason for hiding this comment

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

"which has been used since ..." -- what if not used?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If not used then I'm not sure

Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think it's worth trying to optimize. Seems like you need to make WithExpiration = 502 and always do it (once) for every database, just to make sure.

WithDeletedTable = 500, // Added 'deleted' KeyStore for deleted docs (CBL 3.0?)
WithIndexesLastSeq = 501, // Added 'lastSeq' column to 'indexes' table (CBL 3.2)
MaxReadable = 599, // Cannot open versions newer than this

Current = WithDeletedTable
};
Expand Down
32 changes: 11 additions & 21 deletions LiteCore/Storage/SQLiteKeyStore.cc
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,16 @@ namespace litecore {
// more efficient in SQLite to keep large columns at the end of a row.
// Create the sequence and flags columns regardless of options, otherwise it's too
// complicated to customize all the SQL queries to conditionally use them...
db().execWithLock(subst("CREATE TABLE IF NOT EXISTS kv_@ ("
" key TEXT PRIMARY KEY,"
" sequence INTEGER,"
" flags INTEGER DEFAULT 0,"
" version BLOB,"
" body BLOB,"
" extra BLOB)"));
db().execWithLock(
subst("CREATE TABLE IF NOT EXISTS kv_@ ("
" key TEXT PRIMARY KEY,"
" sequence INTEGER,"
" flags INTEGER DEFAULT 0,"
" version BLOB,"
" body BLOB,"
" expiration INTEGER,"
" extra BLOB);"
"CREATE INDEX IF NOT EXISTS \"kv_@_expiration\" ON kv_@ (expiration) WHERE expiration not null"));
_uncommitedTable = db().inTransaction();
}

Expand Down Expand Up @@ -179,12 +182,10 @@ namespace litecore {
_purgeCountValid = false;

if ( !commit ) {
if ( _uncommittedExpirationColumn ) _hasExpirationColumn = false;
if ( _uncommitedTable ) { close(); }
}

_uncommittedExpirationColumn = false;
_uncommitedTable = false;
_uncommitedTable = false;
}

/*static*/ slice SQLiteKeyStore::columnAsSlice(const SQLite::Column& col) {
Expand Down Expand Up @@ -520,19 +521,8 @@ namespace litecore {
return _hasExpirationColumn;
}

// Adds the 'expiration' column to the table.
void SQLiteKeyStore::addExpiration() {
if ( mayHaveExpiration() ) return;
db()._logVerbose("Adding the `expiration` column & index to kv_%s", name().c_str());
db().execWithLock(subst("ALTER TABLE kv_@ ADD COLUMN expiration INTEGER; "
"CREATE INDEX \"kv_@_expiration\" ON kv_@ (expiration) WHERE expiration not null"));
_hasExpirationColumn = true;
_uncommittedExpirationColumn = true;
}

bool SQLiteKeyStore::setExpiration(slice key, expiration_t expTime) {
Assert(expTime >= expiration_t(0), "Invalid (negative) expiration time");
addExpiration();
auto& stmt = compileCached("UPDATE kv_@ SET expiration=? WHERE key=?");
UsingStatement u(stmt);
if ( expTime > expiration_t::None ) stmt.bind(1, (long long)expTime);
Expand Down
4 changes: 0 additions & 4 deletions LiteCore/Storage/SQLiteKeyStore.hh
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,6 @@ namespace litecore {
void createConflictsIndex();
void createBlobsIndex();

/// Adds the `expiration` column to the table. Called only by SQLiteQuery.
void addExpiration() override;

void shareSequencesWith(KeyStore&) override;

protected:
Expand Down Expand Up @@ -161,7 +158,6 @@ namespace litecore {
mutable std::optional<sequence_t> _lastSequence;
mutable std::atomic<uint64_t> _purgeCount{0};
bool _hasExpirationColumn{false};
bool _uncommittedExpirationColumn{false};
bool _uncommitedTable{false};
SQLiteKeyStore* _sequencesOwner{nullptr};
};
Expand Down
29 changes: 29 additions & 0 deletions LiteCore/tests/c4BaseTest.cc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

#include "c4Test.hh"
#include "c4Internal.hh"
#include "c4Collection.h"
#include "c4ExceptionUtils.hh"
#include "fleece/InstanceCounted.hh"
#include "catch.hpp"
Expand All @@ -27,6 +28,7 @@
# include "Error.hh"
# include <winerror.h>
#endif
#include <future>
#include <sstream>

using namespace fleece;
Expand Down Expand Up @@ -139,6 +141,33 @@ TEST_CASE("C4Error Reporting Macros", "[Errors][C]") {
#endif
}

TEST_CASE_METHOD(C4Test, "Create collection concurrently", "[Database][C]") {
const slice dbName = db->getName();
const C4DatabaseConfig2 config = db->getConfiguration();

c4::ref db2 = c4db_openNamed(dbName, &config, ERROR_INFO());
REQUIRE(db2);

char buf[6]{};
for ( int i = 0; i < 5; i++ ) {
C4Error err{};
C4Error err2{};

snprintf(buf, 6, "coll%i", i);

{
slice collName{buf};
const C4CollectionSpec spec{collName, "scope"_sl};

auto a1 = std::async(std::launch::async, c4db_createCollection, db, spec, ERROR_INFO(&err));
callumbirks marked this conversation as resolved.
Show resolved Hide resolved
auto a2 = std::async(std::launch::async, c4db_createCollection, db2.get(), spec, ERROR_INFO(&err2));
}

CHECK(err.code == 0);
CHECK(err2.code == 0);
}
}

TEST_CASE_METHOD(C4Test, "Database Flag FullSync", "[Database][C]") {
// Ensure that, by default, diskSyncFull is false.
CHECK(!litecore::asInternal(db)->dataFile()->options().diskSyncFull);
Expand Down
Loading