diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index c7226d0d7e..6cdb8fd0f2 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -5,6 +5,7 @@ - **llmath** - Math primitives: vectors, matrices, quaternions, bounding boxes. Also includes meshoptimizer **Data/IO layer:** +- **aldb** - SQLite files (`ALDatabase`): each file's writes are jobs a thread of its own commits together, each in a savepoint, so a job that fails is dropped alone and a failure that may pass is tried again; a profile by what losing the last writes would cost (Durable, Journal, Regenerable), tables made by migrations on `user_version`, a check after a crash that moves a damaged file aside and salvages it, and reads on a pool of read-only connections. `ALDbConnection` caches statements and reads a file from someone else with SQLite's defensive settings and limits. Depends on llcommon - **llfilesystem** - Virtual filesystem and asset cache - **llxml** - XML parsing utilities (depends on llfilesystem) - **llimage** - Image decoding/encoding (JPEG, PNG, TGA, J2C via OpenJPEG or KDU) diff --git a/indra/CMakeLists.txt b/indra/CMakeLists.txt index 36c69693e1..e28261c4f9 100644 --- a/indra/CMakeLists.txt +++ b/indra/CMakeLists.txt @@ -571,6 +571,7 @@ if(AL_BUILD_TESTS) message(FATAL_ERROR "AL_REQUIRE_LSCRIPT: LL's LSL compiler and its tests are ${lscript_status}") endif() endif() +add_subdirectory(aldb) add_subdirectory(alrender) add_subdirectory(llaudio) add_subdirectory(llappearance) diff --git a/indra/aldb/CMakeLists.txt b/indra/aldb/CMakeLists.txt new file mode 100644 index 0000000000..a512dddc60 --- /dev/null +++ b/indra/aldb/CMakeLists.txt @@ -0,0 +1,35 @@ +# -*- cmake -*- + +# SQLite database files, for what the viewer keeps: settings first, then the +# cache's index and the chat logs. One writer thread a file, whose jobs are +# written in batches, each in a savepoint of its own; a pool of readers; +# migrations, with a copy kept before each; backups; and a damaged file moved +# aside and salvaged with SQLite's recover API. + +set(aldb_SOURCES aldatabase.cpp aldbconnection.cpp aldbrecovery.cpp aldbwriter.cpp) + +set(aldb_HEADERS aldatabase.h aldbconnection.h aldbrecovery.h aldbwriter.h) + +add_library(aldb STATIC) +target_sources(aldb PRIVATE ${aldb_SOURCES} PUBLIC FILE_SET HEADERS FILES ${aldb_HEADERS}) + +target_link_libraries(aldb PRIVATE al::flags PUBLIC llcommon ll::sqlite3) + +target_precompile_headers(aldb REUSE_FROM llprecompiled) + +if(AL_BUILD_TESTS) + # A VFS over the platform's that fails or holds writes, or offers no shared + # memory, on request: for the tests of what the writer does when the disk + # does. + add_library(aldb_test_support OBJECT tests/aldbfaultvfs.cpp) + target_link_libraries(aldb_test_support PRIVATE al::flags al_test_support aldb) + set_target_properties(aldb_test_support PROPERTIES FOLDER "Tests/aldb") + + al_add_test_binary( + aldb_tests + PROJECT aldb + SOURCES tests/aldatabase_test.cpp tests/aldbconnection_test.cpp tests/aldbrecovery_test.cpp + LIBRARIES aldb + SUPPORT aldb_test_support llcommon_test_checks + ) +endif() diff --git a/indra/aldb/aldatabase.cpp b/indra/aldb/aldatabase.cpp new file mode 100644 index 0000000000..58c3a9b7dc --- /dev/null +++ b/indra/aldb/aldatabase.cpp @@ -0,0 +1,703 @@ +/** + * @file aldatabase.cpp + * @brief One SQLite database file: opened, checked and migrated on its own + * writer thread, written in batches there, read through a pool. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "aldatabase.h" + +#include "aldbrecovery.h" +#include "aldbwriter.h" +#include "fsyspath.h" +#include "llcoros.h" + +#include LLCOROS_MUTEX_HEADER + +#include + +#include + +#include +#include +#include +#include +#include + +struct ALDbReaderPool +{ + LLCoros::Mutex mMutex; + std::vector> mIdle; + std::string mPath; + std::string mVfs; + U32 mKeep = 4; + int mBusyMs = 2000; + bool mOpen = false; +}; + +namespace +{ + namespace fs = std::filesystem; + + std::atomic sTransientCount{ 0 }; + + std::string fileName(const std::string& path) + { + return fsyspath(fsyspath(path).filename()).string(); + } + + bool fileHasContent(const std::string& path) + { + std::error_code ec; + return fs::exists(fsyspath(path), ec) && fs::file_size(fsyspath(path), ec) > 0 && !ec; + } + + // The pragmas a profile sets, in the order they must go: the journal + // first, since what synchronous means depends on it. Where WAL cannot + // run -- its shared memory does not work on a network filesystem -- the + // file keeps a rollback journal, and says so. + void configure(ALDbConnection& connection, ALDbProfile profile, const ALDbOptions& options, bool in_memory, + std::string& journal_mode) + { + sqlite3_busy_timeout(connection.handle(), int(options.busyTimeout.count())); + + if (in_memory) + { + journal_mode = connection.queryText("PRAGMA journal_mode").value_or("memory"); + } + else + { + journal_mode = connection.queryText("PRAGMA journal_mode=WAL").value_or(""); + if (journal_mode != "wal") + { + LL_WARNS("ALDb") << connection.path() << ": WAL is not available here (" << journal_mode + << "); using a rollback journal" << LL_ENDL; + connection.clearFirstError(); + journal_mode = connection.queryText("PRAGMA journal_mode=DELETE").value_or("delete"); + } + } + + switch (profile) + { + case ALDbProfile::Durable: + connection.exec("PRAGMA synchronous=FULL"); +#if LL_DARWIN + // Without these an fsync on macOS reaches the drive's cache, + // not the disk. + connection.exec("PRAGMA fullfsync=ON"); + connection.exec("PRAGMA checkpoint_fullfsync=ON"); +#endif + break; + case ALDbProfile::Journal: + connection.exec("PRAGMA synchronous=NORMAL"); + break; + case ALDbProfile::Regenerable: + connection.exec("PRAGMA synchronous=OFF"); + connection.exec("PRAGMA mmap_size=268435456"); + connection.exec("PRAGMA cache_size=-65536"); + break; + } + connection.exec("PRAGMA foreign_keys=ON"); + connection.exec("PRAGMA temp_store=MEMORY"); + connection.exec("PRAGMA journal_size_limit=4194304"); + connection.exec("PRAGMA wal_autocheckpoint=1000"); + } + + // A whole copy of what `connection` holds at `destination`, which needs + // no WAL beside it, checked before it takes the name. + bool copyTo(ALDbConnection& connection, const std::string& destination, std::string* error) + { + const std::string temporary = destination + ".tmp"; + std::error_code ec; + ALDbRecovery::remove(temporary); + + connection.clearFirstError(); + ALDbStatement vacuum = connection.prepare("VACUUM INTO ?1"); + vacuum.bind(1, std::string_view(temporary)); + if (vacuum.run() != SQLITE_OK) + { + if (error) + { + *error = connection.firstErrorMessage(); + } + connection.clearFirstError(); + ALDbRecovery::remove(temporary); + return false; + } + vacuum = ALDbStatement(); + + { + // Its own journal, so that the copy is one file. + int rc = SQLITE_OK; + std::unique_ptr copy = + ALDbConnection::open(temporary, ALDbConnection::Access::ReadWrite, {}, &rc, error); + if (!copy || copy->queryText("PRAGMA journal_mode=DELETE").value_or("") != "delete") + { + copy.reset(); + ALDbRecovery::remove(temporary); + return false; + } + } + if (!ALDbConnection::openForeign(temporary, error)) + { + ALDbRecovery::remove(temporary); + return false; + } + + fs::rename(fsyspath(temporary), fsyspath(destination), ec); + if (ec) + { + if (error) + { + *error = fmt::format("could not move the copy into place: {}", ec.message()); + } + ALDbRecovery::remove(temporary); + return false; + } + return true; + } + + // The copies taken before migrating, oldest first: .v.bak. + void pruneMigrationBackups(const std::string& path, U32 keep) + { + const fsyspath file = fsyspath(path); + const std::string prefix = fsyspath(file.filename()).string() + ".v"; + std::vector> found; + std::error_code ec; + for (const fs::directory_entry& entry : fs::directory_iterator(file.parent_path(), ec)) + { + const std::string name = fsyspath(entry.path().filename()).string(); + if (name.size() <= prefix.size() + 4 || name.compare(0, prefix.size(), prefix) != 0 || + name.compare(name.size() - 4, 4, ".bak") != 0) + { + continue; + } + S32 version = 0; + const char* first = name.data() + prefix.size(); + const char* last = name.data() + name.size() - 4; + if (std::from_chars(first, last, version).ptr == last) + { + found.emplace_back(version, entry.path()); + } + } + std::sort(found.begin(), found.end()); + while (found.size() > keep) + { + fs::remove(found.front().second, ec); + found.erase(found.begin()); + } + } + + S32 latestVersion(const std::vector& migrations) + { + S32 latest = 0; + for (const ALDbMigration& migration : migrations) + { + latest = std::max(latest, migration.version); + } + return latest; + } +} + +// +// ALDbReadLease +// + +ALDbReadLease::ALDbReadLease(std::shared_ptr pool, std::unique_ptr connection) +: mPool(std::move(pool)), + mConnection(std::move(connection)) +{ +} + +ALDbReadLease::ALDbReadLease(ALDbReadLease&& other) noexcept = default; + +ALDbReadLease& ALDbReadLease::operator=(ALDbReadLease&& other) noexcept +{ + if (this != &other) + { + giveBack(); + mPool = std::move(other.mPool); + mConnection = std::move(other.mConnection); + } + return *this; +} + +ALDbReadLease::~ALDbReadLease() +{ + giveBack(); +} + +void ALDbReadLease::giveBack() +{ + if (mPool && mConnection) + { + mConnection->clearFirstError(); + LLCoros::LockType lock(mPool->mMutex); + if (mPool->mOpen && mPool->mIdle.size() < mPool->mKeep) + { + mPool->mIdle.push_back(std::move(mConnection)); + } + } + mConnection.reset(); + mPool.reset(); +} + +// +// ALDatabase +// + +ALDatabase::ALDatabase(std::string path, ALDbOptions options) +: mPath(std::move(path)), + mOptions(std::move(options)), + mReaders(std::make_shared()) +{ + std::sort(mOptions.migrations.begin(), mOptions.migrations.end(), + [](const ALDbMigration& a, const ALDbMigration& b) { return a.version < b.version; }); +} + +ALDatabase::~ALDatabase() +{ + close(); +} + +bool ALDatabase::isOpen() const +{ + return mOpened && !mClosed && mOpenResult.usable(); +} + +bool ALDatabase::transient() const +{ + return mOpenResult.outcome == ALDbOpenResult::Outcome::Transient; +} + +const ALDbOpenResult& ALDatabase::open() +{ + if (mOpened) + { + return mOpenResult; + } + mOpened = true; + + mWriter = std::make_shared(fileName(mPath), mOptions); + mWriter->start(); + + ALDbOpenResult result; + std::string reader_path = mPath; + const bool ran = mWriter->control([this, &result, &reader_path](std::unique_ptr& slot) { + const ALDbOptions& options = mOptions; + const bool existed = fileHasContent(mPath); + const S32 latest = latestVersion(options.migrations); + bool damaged = false; + std::string problem; + + std::unique_ptr connection; + int rc = SQLITE_OK; + connection = ALDbConnection::open(mPath, ALDbConnection::Access::ReadWrite, options.vfs, &rc, &problem); + if (!connection) + { + damaged = ALDb::isCorrupt(rc); + } + else + { + // Before the first read: a file another viewer is writing is + // waited for, not taken for damage. + sqlite3_busy_timeout(connection->handle(), int(options.busyTimeout.count())); + // Reading the header is the first read: a file that is not a + // database, or one with a bad first page, fails here. + const std::optional application_id = connection->queryInt("PRAGMA application_id"); + if (!application_id) + { + damaged = ALDb::isCorrupt(connection->firstError()); + problem = connection->firstErrorMessage(); + } + else if (*application_id != 0 && options.applicationId != 0 && U32(*application_id) != options.applicationId) + { + result.outcome = ALDbOpenResult::Outcome::Refused; + result.message = fmt::format("{} is another kind of file (application_id {:#x})", mPath, + U32(*application_id)); + LL_WARNS("ALDb") << result.message << LL_ENDL; + return; + } + else if (existed && options.checkIntegrity) + { + const std::optional check = connection->queryText("PRAGMA quick_check"); + if (check) + { + damaged = *check != "ok"; + problem = *check; + } + else + { + // A check that could not read the file -- busy past the + // timeout, an I/O error -- says nothing of what is in it. + damaged = ALDb::isCorrupt(connection->firstError()); + problem = connection->firstErrorMessage(); + if (!damaged) + { + LL_WARNS("ALDb") << mPath << " could not be checked (" << problem << "); opened unchecked" + << LL_ENDL; + } + } + } + } + + if (damaged) + { + connection.reset(); + LL_WARNS("ALDb") << mPath << " is damaged: " << problem << LL_ENDL; + if (!options.mayQuarantine) + { + result.message = "damaged, and another process may hold it: " + problem; + } + else if (options.profile == ALDbProfile::Regenerable) + { + std::string error; + if (ALDbRecovery::remove(mPath, &error)) + { + result.outcome = ALDbOpenResult::Outcome::Rebuilt; + } + else + { + result.message = error; + } + } + else + { + std::string error; + const std::optional moved = ALDbRecovery::quarantine(mPath, &error); + if (moved) + { + result.quarantinedTo = *moved; + result.outcome = ALDbOpenResult::Outcome::Recovered; + if (!ALDbRecovery::salvage(*moved, mPath, &error)) + { + // Nothing could be read: start again, the damaged file + // kept where it was moved. + LL_WARNS("ALDb") << error << LL_ENDL; + ALDbRecovery::remove(mPath); + } + } + else + { + result.message = error; + } + } + + if (result.outcome == ALDbOpenResult::Outcome::Recovered || + result.outcome == ALDbOpenResult::Outcome::Rebuilt) + { + connection = + ALDbConnection::open(mPath, ALDbConnection::Access::ReadWrite, options.vfs, &rc, &problem); + if (!connection) + { + result.message = problem; + } + } + } + + bool in_memory = false; + if (!connection) + { + // Nothing on disk to use this session: keep going in memory, and + // say so. The name is the memdb VFS's shared form, so that the + // pool's readers see what the writer writes. + reader_path = + fmt::format("file:/aldb-transient-{}-{}?vfs=memdb", sTransientCount++, fileName(mPath)); + connection = ALDbConnection::open(reader_path, ALDbConnection::Access::ReadWrite, {}, &rc, &problem); + if (!connection) + { + result.outcome = ALDbOpenResult::Outcome::Failed; + result.message = "could not open even a database in memory: " + problem; + return; + } + LL_WARNS("ALDb") << mPath << " cannot be used this session (" << result.message + << "); running in memory" << LL_ENDL; + result.outcome = ALDbOpenResult::Outcome::Transient; + in_memory = true; + } + + connection->clearFirstError(); + configure(*connection, options.profile, options, in_memory, result.journalMode); + connection->clearFirstError(); + + // SQLite opens a file it may not write -- marked read-only, owned by + // another user, on read-only media -- for reading alone, and says so + // only here. + result.readOnly = !in_memory && sqlite3_db_readonly(connection->handle(), "main") == 1; + if (result.readOnly) + { + LL_WARNS("ALDb") << mPath << " can only be read: nothing will be written to it" << LL_ENDL; + } + + // The version the file is at, and the steps that bring it to now. + // New: in memory, or at version 0 with nothing in it -- a file made + // now, rebuilt, or that nothing could be salvaged from. + S32 before = S32(connection->queryInt("PRAGMA user_version").value_or(0)); + bool is_new = + in_memory || (before == 0 && connection->queryInt("SELECT count(*) FROM sqlite_schema").value_or(0) == 0); + result.versionBefore = before; + result.versionAfter = before; + if (before > latest) + { + // Written by a newer build: read and written as it is, its newer + // tables left alone, nothing run backwards. + LL_INFOS("ALDb") << mPath << " is at version " << before << ", newer than this build's " << latest + << "; opened as it is" << LL_ENDL; + } + else if (before < latest) + { + if (!is_new && before > 0) + { + std::string error; + const std::string backup = fmt::format("{}.v{}.bak", mPath, before); + if (!copyTo(*connection, backup, &error)) + { + LL_WARNS("ALDb") << mPath << ": could not keep a copy before migrating: " << error << LL_ENDL; + } + pruneMigrationBackups(mPath, options.keepMigrationBackups); + } + + connection->clearFirstError(); + connection->exec("BEGIN IMMEDIATE"); + // Read again under the lock: another viewer opening the file at + // the same moment may have migrated it while this one waited. + if (connection->firstError() == SQLITE_OK) + { + if (const std::optional locked = connection->queryInt("PRAGMA user_version"); + locked && S32(*locked) != before) + { + LL_INFOS("ALDb") << mPath << " was brought to version " << *locked + << " by another process as this one opened it" << LL_ENDL; + before = S32(*locked); + is_new = false; + result.versionBefore = before; + result.versionAfter = before; + } + } + for (const ALDbMigration& migration : options.migrations) + { + if (migration.version <= before || connection->firstError() != SQLITE_OK) + { + continue; + } + try + { + migration.apply(*connection); + } + catch (const std::exception& e) + { + connection->fail(SQLITE_ABORT, std::string("the migration threw: ") + e.what()); + } + if (connection->firstError() == SQLITE_OK) + { + connection->exec(fmt::format("PRAGMA user_version={}", migration.version)); + } + if (connection->firstError() != SQLITE_OK) + { + result.message = fmt::format("migrating to version {} failed: {}", migration.version, + connection->firstErrorMessage()); + } + } + if (connection->firstError() == SQLITE_OK) + { + connection->exec("COMMIT"); + } + if (connection->firstError() != SQLITE_OK) + { + if (result.message.empty()) + { + result.message = connection->firstErrorMessage(); + } + sqlite3_exec(connection->handle(), "ROLLBACK", nullptr, nullptr, nullptr); + LL_WARNS("ALDb") << mPath << ": " << result.message << "; the file is as it was" << LL_ENDL; + result.outcome = ALDbOpenResult::Outcome::Failed; + return; + } + if (before < latest) + { + result.versionAfter = latest; + result.migrated = true; + } + } + + if (is_new && options.applicationId != 0) + { + connection->exec(fmt::format("PRAGMA application_id={}", S64(options.applicationId))); + } + connection->clearFirstError(); + + if (result.outcome == ALDbOpenResult::Outcome::Failed) + { + result.outcome = is_new ? ALDbOpenResult::Outcome::Created : ALDbOpenResult::Outcome::Opened; + } + slot = std::move(connection); + }); + if (!ran) + { + result.outcome = ALDbOpenResult::Outcome::Failed; + result.message = "opening threw"; + } + mOpenResult = std::move(result); + + if (mOpenResult.usable()) + { + LLCoros::LockType lock(mReaders->mMutex); + mReaders->mPath = reader_path; + mReaders->mVfs = transient() ? std::string() : mOptions.vfs; + mReaders->mKeep = mOptions.readers; + mReaders->mBusyMs = int(mOptions.busyTimeout.count()); + mReaders->mOpen = true; + } + else + { + mWriter->close(mOptions.closeWait); + } + return mOpenResult; +} + +bool ALDatabase::post(Job job) +{ + return isOpen() && mWriter->post(std::move(job)); +} + +bool ALDatabase::post(Job job, Completion completed) +{ + return isOpen() && mWriter->post(std::move(job), nullptr, std::move(completed)); +} + +bool ALDatabase::postAndWait(Job job) +{ + if (!isOpen()) + { + return false; + } + auto done = std::make_shared>(); + LLCoros::Future result = done->get_future(); + if (!mWriter->post(std::move(job), done)) + { + return false; + } + return result.get(); +} + +bool ALDatabase::flush() +{ + if (!isOpen()) + { + return false; + } + // Its job holds nothing of the caller's, so it can be left queued. + auto done = std::make_shared>(); + LLCoros::Future result = done->get_future(); + if (!mWriter->post([](ALDbConnection&) {}, done)) + { + return false; + } + if (result.wait_for(mOptions.closeWait) != boost::fibers::future_status::ready) + { + LL_WARNS("ALDb") << mPath << ": not written within " << mOptions.closeWait.count() + << " ms; the writer is still trying" << LL_ENDL; + return false; + } + return result.get(); +} + +ALDbReadLease ALDatabase::read() +{ + std::string path; + std::string vfs; + int busy_ms = 0; + { + LLCoros::LockType lock(mReaders->mMutex); + if (!mReaders->mOpen) + { + return {}; + } + if (!mReaders->mIdle.empty()) + { + std::unique_ptr connection = std::move(mReaders->mIdle.back()); + mReaders->mIdle.pop_back(); + return ALDbReadLease(mReaders, std::move(connection)); + } + path = mReaders->mPath; + vfs = mReaders->mVfs; + busy_ms = mReaders->mBusyMs; + } + + int rc = SQLITE_OK; + std::string error; + std::unique_ptr connection = + ALDbConnection::open(path, ALDbConnection::Access::ReadOnly, vfs, &rc, &error); + if (!connection) + { + LL_WARNS("ALDb") << mPath << ": could not open a reader: " << error << LL_ENDL; + return {}; + } + sqlite3_busy_timeout(connection->handle(), busy_ms); + connection->exec("PRAGMA query_only=1"); + connection->clearFirstError(); + return ALDbReadLease(mReaders, std::move(connection)); +} + +bool ALDatabase::backupTo(const std::string& destination, std::string* error) +{ + if (!isOpen()) + { + if (error) + { + *error = "the database is not open"; + } + return false; + } + bool copied = false; + std::string why; + // On the writer, between transactions: VACUUM INTO cannot run inside one. + const bool ran = mWriter->control([&](std::unique_ptr& connection) { + copied = connection && copyTo(*connection, destination, &why); + }); + if (error && !(ran && copied)) + { + *error = ran ? why : "the database is closing"; + } + return ran && copied; +} + +bool ALDatabase::close() +{ + if (!mOpened || mClosed) + { + return true; + } + mClosed = true; + { + LLCoros::LockType lock(mReaders->mMutex); + mReaders->mOpen = false; + mReaders->mIdle.clear(); + } + return mWriter->close(mOptions.closeWait); +} + +ALDbStats ALDatabase::stats() const +{ + return mWriter ? mWriter->stats() : ALDbStats(); +} diff --git a/indra/aldb/aldatabase.h b/indra/aldb/aldatabase.h new file mode 100644 index 0000000000..7af996caca --- /dev/null +++ b/indra/aldb/aldatabase.h @@ -0,0 +1,246 @@ +/** + * @file aldatabase.h + * @brief One SQLite database file: opened, checked and migrated on its own + * writer thread, written in batches there, read through a pool. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "aldbconnection.h" + +#include +#include +#include +#include +#include + +class ALDbWriter; +struct ALDbReaderPool; + +// How a file is kept, by what losing its last writes would cost. +enum class ALDbProfile +{ + // The user's own choices: every commit synced to the disk, an fsync a + // commit on the writer thread. Damage is salvaged. + Durable, + // Survives the application crashing; a power loss may drop the last + // commits. + Journal, + // Rebuilt from elsewhere when lost: not synced, mapped into memory, and + // deleted and made again, not salvaged, when damaged. + Regenerable +}; + +// One step of a file's tables: run, inside the open's transaction, on a +// file whose user_version is below `version`, which the file then has. +struct ALDbMigration +{ + S32 version = 0; + std::function apply; +}; + +// A failure the writer could not get past by itself: the disk full, the file +// locked by another process for longer than a retry or two. Said once, on +// the writer thread, until the writer next succeeds; damage found while +// writing, once for as long as the file is open. +struct ALDbFailure +{ + int code = 0; + std::string message; +}; + +struct ALDbOptions +{ + ALDbProfile profile = ALDbProfile::Durable; + // The four bytes of a file's header saying what kind of file it is + // (PRAGMA application_id), as a FourCC: 'ALST'. A file that carries + // another is refused, not written. 0 neither sets nor checks one. + U32 applicationId = 0; + // In ascending order of version. + std::vector migrations; + // The last run of this process ended without closing the file: check it + // before trusting it. + bool checkIntegrity = false; + // This process may move a damaged file aside. Only one process should: + // the one that holds the application's instance lock. + bool mayQuarantine = true; + // A VFS of SQLite's registry to open the file through, for the tests. + std::string vfs; + std::chrono::milliseconds busyTimeout{ 2000 }; + // The writer's wait before trying a failed batch again, doubling up to + // the longest. + std::chrono::milliseconds retryFirst{ 100 }; + std::chrono::milliseconds retryLongest{ 5000 }; + // How long close() lets the writer finish what was posted. + std::chrono::milliseconds closeWait{ 2000 }; + // The copies taken before migrating that are kept: .v.bak. + U32 keepMigrationBackups = 2; + // Reading connections kept open at most. + U32 readers = 4; + // On the writer thread. + std::function onFailure; +}; + +struct ALDbOpenResult +{ + enum class Outcome + { + // An existing file, migrated or not. + Opened, + // A new file, its tables made. + Created, + // A damaged file, moved aside and what could be read of it written + // into a new one. + Recovered, + // A damaged file of the Regenerable profile, deleted and made again. + Rebuilt, + // The file could not be opened or moved aside: this session runs on + // a database in memory, and nothing it writes is kept. + Transient, + // The file is another kind of file (its application_id): untouched. + Refused, + // A migration failed: the file is as it was, and not open. + Failed + }; + + Outcome outcome = Outcome::Failed; + S32 versionBefore = 0; + S32 versionAfter = 0; + bool migrated = false; + // The file could be opened only for reading -- marked read-only, owned + // by another user, on read-only media -- so its reads run and every + // write is refused, said once through onFailure. + bool readOnly = false; + // "wal", or "delete" where WAL cannot run (a network filesystem). + std::string journalMode; + // Where a damaged file was moved. + std::string quarantinedTo; + std::string message; + + bool usable() const { return outcome != Outcome::Refused && outcome != Outcome::Failed; } +}; + +struct ALDbStats +{ + U64 commits = 0; + U64 committedItems = 0; + U64 droppedItems = 0; + U64 retries = 0; + U64 queued = 0; +}; + +// A reading connection of the pool's, this thread's until the lease goes. +class ALDbReadLease +{ +public: + ALDbReadLease() = default; + ~ALDbReadLease(); + ALDbReadLease(ALDbReadLease&& other) noexcept; + ALDbReadLease& operator=(ALDbReadLease&& other) noexcept; + ALDbReadLease(const ALDbReadLease&) = delete; + ALDbReadLease& operator=(const ALDbReadLease&) = delete; + + explicit operator bool() const { return mConnection != nullptr; } + ALDbConnection* operator->() const { return mConnection.get(); } + ALDbConnection& operator*() const { return *mConnection; } + +private: + friend class ALDatabase; + ALDbReadLease(std::shared_ptr pool, std::unique_ptr connection); + void giveBack(); + + std::shared_ptr mPool; + std::unique_ptr mConnection; +}; + +// One database file. open() starts its writer thread, which opens the file, +// checks it after a crash, moves it aside and salvages it if it is damaged, +// and migrates it; then every write goes to that thread as a job, and the +// jobs queued while it worked on the last are written together in one +// transaction, each in a savepoint of its own: a job that fails is undone +// alone. A job may be run twice -- a batch that could not commit is tried +// again whole -- so each is written to be safe to repeat: an upsert keyed by +// what it writes, never by a row id held in memory. +// +// Opened, posted to and closed from one thread; reads and backups from any. +class ALDatabase +{ +public: + using Job = std::function; + // Told whether a posted job committed: on the writer thread, and maybe + // after close() returned false, from a writer left to finish -- so it + // holds what it needs rather than pointing into its owner. + using Completion = std::function; + + ALDatabase(std::string path, ALDbOptions options); + ~ALDatabase(); + ALDatabase(const ALDatabase&) = delete; + ALDatabase& operator=(const ALDatabase&) = delete; + + // Opens the file, on the writer thread, and waits for it. Once. + const ALDbOpenResult& open(); + const ALDbOpenResult& openResult() const { return mOpenResult; } + + bool isOpen() const; + // Running on a database in memory (ALDbOpenResult::Outcome::Transient). + bool transient() const; + const std::string& path() const { return mPath; } + + // A job for the writer's next transaction. False, and the job not run, + // when the database is not open or is closing. + bool post(Job job); + // The same, and `completed` told whether it committed. + bool post(Job job, Completion completed); + // The same, and waits for it: true when it committed, false when it + // failed and was undone, or could not be run. + bool postAndWait(Job job); + // Waits until everything posted before it has been written or dropped, + // for `closeWait` at most: false if it was not, and the writer goes on + // trying -- a disk that hangs, a file another process holds locked. + bool flush(); + + // A reading connection, made the first time a thread asks for one more + // than the pool holds: empty when the database is not open. + ALDbReadLease read(); + + // A whole, self-contained copy of the database at `destination`, written + // by VACUUM INTO a temporary file beside it, made to need no WAL, + // checked, and renamed into place: there is a whole copy there or none. + bool backupTo(const std::string& destination, std::string* error = nullptr); + + // Stops taking jobs, gives the writer `closeWait` to write what was + // posted, checkpoints and closes the file. A writer still busy after + // that -- a disk that hangs -- is left to finish on its own, and close + // returns false; WAL leaves the file whole either way. + bool close(); + + ALDbStats stats() const; + +private: + std::string mPath; + ALDbOptions mOptions; + ALDbOpenResult mOpenResult; + std::shared_ptr mWriter; + std::shared_ptr mReaders; + bool mOpened = false; + bool mClosed = false; +}; diff --git a/indra/aldb/aldbconnection.cpp b/indra/aldb/aldbconnection.cpp new file mode 100644 index 0000000000..6ba544ab8d --- /dev/null +++ b/indra/aldb/aldbconnection.cpp @@ -0,0 +1,633 @@ +/** + * @file aldbconnection.cpp + * @brief One SQLite connection, its prepared statements, and the savepoints and + * transactions work runs inside. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "aldbconnection.h" + +#include "lluuid.h" + +#include + +#include + +namespace +{ + // What a file of someone else's may ask of SQLite: generous for any + // honest backup, small next to what a hostile one could otherwise make + // the reader allocate or recurse through. + void limitForeign(sqlite3* db) + { + sqlite3_limit(db, SQLITE_LIMIT_LENGTH, 32 * 1024 * 1024); + sqlite3_limit(db, SQLITE_LIMIT_SQL_LENGTH, 1024 * 1024); + sqlite3_limit(db, SQLITE_LIMIT_COLUMN, 256); + sqlite3_limit(db, SQLITE_LIMIT_EXPR_DEPTH, 100); + sqlite3_limit(db, SQLITE_LIMIT_COMPOUND_SELECT, 50); + sqlite3_limit(db, SQLITE_LIMIT_FUNCTION_ARG, 32); + sqlite3_limit(db, SQLITE_LIMIT_ATTACHED, 0); + sqlite3_limit(db, SQLITE_LIMIT_LIKE_PATTERN_LENGTH, 1000); + sqlite3_limit(db, SQLITE_LIMIT_VARIABLE_NUMBER, 1000); + sqlite3_limit(db, SQLITE_LIMIT_TRIGGER_DEPTH, 1); + } + + bool isFailure(int rc) + { + return rc != SQLITE_OK && rc != SQLITE_ROW && rc != SQLITE_DONE; + } + + // Whether what follows a prepared statement holds no other. + bool nothingAfter(std::string_view rest) + { + return rest.find_first_not_of(" \t\r\n\f\v;") == std::string_view::npos; + } +} + +// +// ALDbStatement +// + +ALDbStatement::ALDbStatement(ALDbConnection* connection, sqlite3_stmt* statement, bool owned) +: mConnection(connection), + mStatement(statement), + mOwned(owned) +{ +} + +ALDbStatement::~ALDbStatement() +{ + release(); +} + +ALDbStatement::ALDbStatement(ALDbStatement&& other) noexcept +: mConnection(other.mConnection), + mStatement(other.mStatement), + mOwned(other.mOwned) +{ + other.mConnection = nullptr; + other.mStatement = nullptr; + other.mOwned = false; +} + +ALDbStatement& ALDbStatement::operator=(ALDbStatement&& other) noexcept +{ + if (this != &other) + { + release(); + mConnection = other.mConnection; + mStatement = other.mStatement; + mOwned = other.mOwned; + other.mConnection = nullptr; + other.mStatement = nullptr; + other.mOwned = false; + } + return *this; +} + +void ALDbStatement::release() +{ + if (mStatement && mConnection) + { + mConnection->giveBack(mStatement, mOwned); + } + mConnection = nullptr; + mStatement = nullptr; + mOwned = false; +} + +ALDbStatement& ALDbStatement::noteBind(int rc, int index) +{ + if (rc != SQLITE_OK && mConnection) + { + mConnection->note(rc, "bind " + std::to_string(index)); + } + return *this; +} + +ALDbStatement& ALDbStatement::bind(int index, S32 value) +{ + return mStatement ? noteBind(sqlite3_bind_int(mStatement, index, value), index) : *this; +} + +ALDbStatement& ALDbStatement::bind(int index, U32 value) +{ + return mStatement ? noteBind(sqlite3_bind_int64(mStatement, index, sqlite3_int64(value)), index) : *this; +} + +ALDbStatement& ALDbStatement::bind(int index, S64 value) +{ + return mStatement ? noteBind(sqlite3_bind_int64(mStatement, index, value), index) : *this; +} + +ALDbStatement& ALDbStatement::bind(int index, F64 value) +{ + return mStatement ? noteBind(sqlite3_bind_double(mStatement, index, value), index) : *this; +} + +ALDbStatement& ALDbStatement::bind(int index, std::string_view text) +{ + if (!mStatement) + { + return *this; + } + return noteBind(sqlite3_bind_text64(mStatement, index, text.data(), text.size(), SQLITE_TRANSIENT, SQLITE_UTF8), + index); +} + +ALDbStatement& ALDbStatement::bind(int index, std::span blob) +{ + if (!mStatement) + { + return *this; + } + // A zero-length blob is still a blob, not a NULL: SQLite needs a + // non-null pointer to tell them apart. + static const std::byte empty{}; + const void* data = blob.empty() ? &empty : blob.data(); + return noteBind(sqlite3_bind_blob64(mStatement, index, data, blob.size(), SQLITE_TRANSIENT), index); +} + +ALDbStatement& ALDbStatement::bind(int index, const LLUUID& id) +{ + return bind(index, std::span(reinterpret_cast(id.mData), UUID_BYTES)); +} + +ALDbStatement& ALDbStatement::bindNull(int index) +{ + return mStatement ? noteBind(sqlite3_bind_null(mStatement, index), index) : *this; +} + +bool ALDbStatement::step() +{ + if (!mStatement) + { + return false; + } + const int rc = sqlite3_step(mStatement); + if (rc == SQLITE_ROW) + { + return true; + } + if (rc != SQLITE_DONE && mConnection) + { + mConnection->note(rc, sqlite3_sql(mStatement)); + } + return false; +} + +int ALDbStatement::run() +{ + if (!mStatement) + { + return SQLITE_MISUSE; + } + int rc = SQLITE_ROW; + while (rc == SQLITE_ROW) + { + rc = sqlite3_step(mStatement); + } + if (rc == SQLITE_DONE) + { + return SQLITE_OK; + } + if (mConnection) + { + mConnection->note(rc, sqlite3_sql(mStatement)); + } + return rc; +} + +void ALDbStatement::reset() +{ + if (mStatement) + { + sqlite3_reset(mStatement); + } +} + +int ALDbStatement::columnCount() const +{ + return mStatement ? sqlite3_column_count(mStatement) : 0; +} + +int ALDbStatement::columnType(int column) const +{ + return mStatement ? sqlite3_column_type(mStatement, column) : SQLITE_NULL; +} + +bool ALDbStatement::columnNull(int column) const +{ + return columnType(column) == SQLITE_NULL; +} + +S64 ALDbStatement::columnInt(int column) const +{ + return mStatement ? sqlite3_column_int64(mStatement, column) : 0; +} + +F64 ALDbStatement::columnReal(int column) const +{ + return mStatement ? sqlite3_column_double(mStatement, column) : 0.0; +} + +std::string_view ALDbStatement::columnText(int column) const +{ + if (!mStatement) + { + return {}; + } + const unsigned char* text = sqlite3_column_text(mStatement, column); + const int size = sqlite3_column_bytes(mStatement, column); + return text ? std::string_view(reinterpret_cast(text), size_t(size)) : std::string_view(); +} + +std::span ALDbStatement::columnBlob(int column) const +{ + if (!mStatement) + { + return {}; + } + const void* data = sqlite3_column_blob(mStatement, column); + const int size = sqlite3_column_bytes(mStatement, column); + return data ? std::span(static_cast(data), size_t(size)) + : std::span(); +} + +LLUUID ALDbStatement::columnUUID(int column) const +{ + LLUUID id; + const std::span blob = columnBlob(column); + if (blob.size() == UUID_BYTES) + { + std::memcpy(id.mData, blob.data(), UUID_BYTES); + } + return id; +} + +// +// ALDbConnection +// + +size_t ALDbConnection::StringHash::operator()(std::string_view text) const noexcept +{ + return std::hash{}(text); +} + +ALDbConnection::ALDbConnection(sqlite3* db, std::string path, Access access) +: mDb(db), + mPath(std::move(path)), + mAccess(access) +{ +} + +ALDbConnection::~ALDbConnection() +{ + // Every borrower is gone by now: a statement does not outlive the + // connection it was borrowed from. + llassert(mBorrowed.empty()); + for (auto& [sql, statement] : mStatements) + { + sqlite3_finalize(statement); + } + mStatements.clear(); + // v2: a statement something leaked keeps the connection a zombie rather + // than leaving it open and unusable. + sqlite3_close_v2(mDb); +} + +std::unique_ptr ALDbConnection::open(const std::string& path, Access access, std::string_view vfs, + int* rc_out, std::string* error) +{ + int flags = SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_EXRESCODE; + flags |= access == Access::ReadWrite ? (SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE) : SQLITE_OPEN_READONLY; + // A database in memory is named as a URI (the memdb VFS's shared form); + // a file of someone else's never is, so its name cannot ask for a VFS or + // a mode of its own. + if (access != Access::Foreign && path.starts_with("file:")) + { + flags |= SQLITE_OPEN_URI; + } + + const std::string vfs_name(vfs); + sqlite3* db = nullptr; + const int rc = sqlite3_open_v2(path.c_str(), &db, flags, vfs_name.empty() ? nullptr : vfs_name.c_str()); + if (rc_out) + { + *rc_out = rc; + } + if (rc != SQLITE_OK) + { + if (error) + { + *error = db ? sqlite3_errmsg(db) : sqlite3_errstr(rc); + } + sqlite3_close_v2(db); + return nullptr; + } + sqlite3_extended_result_codes(db, 1); + + if (access == Access::Foreign) + { + // A file is code as much as data: its schema can hold triggers and + // views that run as it is read, and functions with side effects in + // either. None of them runs here. + sqlite3_db_config(db, SQLITE_DBCONFIG_DEFENSIVE, 1, nullptr); + sqlite3_db_config(db, SQLITE_DBCONFIG_TRUSTED_SCHEMA, 0, nullptr); + sqlite3_db_config(db, SQLITE_DBCONFIG_ENABLE_TRIGGER, 0, nullptr); + sqlite3_db_config(db, SQLITE_DBCONFIG_ENABLE_VIEW, 0, nullptr); + limitForeign(db); + } + + std::unique_ptr connection(new ALDbConnection(db, path, access)); + if (access == Access::Foreign) + { + connection->exec("PRAGMA mmap_size=0"); + connection->exec("PRAGMA query_only=1"); + } + return connection; +} + +std::unique_ptr ALDbConnection::openForeign(const std::string& path, std::string* error) +{ + int rc = SQLITE_OK; + std::unique_ptr connection = open(path, Access::Foreign, {}, &rc, error); + if (!connection) + { + return nullptr; + } + // Every page read and checked before any row of it is trusted. A file + // that is not a database fails here too, at its first read. + const std::optional check = connection->queryText("PRAGMA integrity_check"); + if (!check || *check != "ok") + { + if (error) + { + *error = check ? *check : connection->firstErrorMessage(); + } + return nullptr; + } + connection->clearFirstError(); + return connection; +} + +int ALDbConnection::note(int rc, std::string_view what) +{ + if (isFailure(rc) && mFirstError == SQLITE_OK) + { + mFirstError = rc; + mFirstErrorMessage = std::string(what); + if (mDb) + { + mFirstErrorMessage += ": "; + mFirstErrorMessage += sqlite3_errmsg(mDb); + } + } + return rc; +} + +void ALDbConnection::fail(int rc, std::string_view what) +{ + if (mFirstError == SQLITE_OK) + { + mFirstError = isFailure(rc) ? rc : SQLITE_ERROR; + mFirstErrorMessage = std::string(what); + } +} + +void ALDbConnection::clearFirstError() +{ + mFirstError = SQLITE_OK; + mFirstErrorMessage.clear(); +} + +int ALDbConnection::exec(std::string_view sql) +{ + auto found = mStatements.find(sql); + if (found == mStatements.end()) + { + sqlite3_stmt* statement = nullptr; + const char* tail = nullptr; + const char* end = sql.data() + sql.size(); + if (sqlite3_prepare_v3(mDb, sql.data(), int(sql.size()), SQLITE_PREPARE_PERSISTENT, &statement, &tail) != + SQLITE_OK || + !statement || !nothingAfter(std::string_view(tail, size_t(end - tail)))) + { + // Several statements, none, or one that will not prepare: run as + // a script, which says why it failed. + sqlite3_finalize(statement); + const std::string text(sql); + char* message = nullptr; + const int rc = sqlite3_exec(mDb, text.c_str(), nullptr, nullptr, &message); + if (rc != SQLITE_OK && mFirstError == SQLITE_OK) + { + mFirstError = rc; + mFirstErrorMessage = text + ": " + (message ? message : sqlite3_errstr(rc)); + } + sqlite3_free(message); + return rc; + } + found = mStatements.emplace(std::string(sql), statement).first; + } + return borrow(found->second, sql).run(); +} + +ALDbStatement ALDbConnection::prepare(std::string_view sql) +{ + if (auto found = mStatements.find(sql); found != mStatements.end()) + { + return borrow(found->second, sql); + } + + sqlite3_stmt* statement = nullptr; + if (note(sqlite3_prepare_v3(mDb, sql.data(), int(sql.size()), SQLITE_PREPARE_PERSISTENT, &statement, nullptr), + sql) != SQLITE_OK || + !statement) + { + // Text with no statement in it prepares to null. + sqlite3_finalize(statement); + return {}; + } + mStatements.emplace(std::string(sql), statement); + return borrow(statement, sql); +} + +ALDbStatement ALDbConnection::borrow(sqlite3_stmt* statement, std::string_view sql) +{ + if (mBorrowed.insert(statement).second) + { + return ALDbStatement(this, statement, false); + } + // Borrowed already: one of its own, for this borrower alone. + sqlite3_stmt* own = nullptr; + if (note(sqlite3_prepare_v3(mDb, sql.data(), int(sql.size()), 0, &own, nullptr), sql) != SQLITE_OK) + { + return {}; + } + return ALDbStatement(this, own, true); +} + +void ALDbConnection::giveBack(sqlite3_stmt* statement, bool owned) +{ + if (owned) + { + sqlite3_finalize(statement); + return; + } + sqlite3_reset(statement); + sqlite3_clear_bindings(statement); + mBorrowed.erase(statement); +} + +std::optional ALDbConnection::queryInt(std::string_view sql) +{ + ALDbStatement statement = prepare(sql); + if (!statement.step()) + { + return std::nullopt; + } + return statement.columnInt(0); +} + +std::optional ALDbConnection::queryText(std::string_view sql) +{ + ALDbStatement statement = prepare(sql); + if (!statement.step()) + { + return std::nullopt; + } + return std::string(statement.columnText(0)); +} + +S64 ALDbConnection::lastInsertRowId() const +{ + return sqlite3_last_insert_rowid(mDb); +} + +S32 ALDbConnection::changes() const +{ + return sqlite3_changes(mDb); +} + +// +// ALDbSavepoint +// + +ALDbSavepoint::ALDbSavepoint(ALDbConnection& connection, std::string_view name) +: mConnection(connection), + mName(name) +{ + mBegun = mConnection.exec("SAVEPOINT " + mName) == SQLITE_OK; +} + +ALDbSavepoint::~ALDbSavepoint() +{ + if (mBegun) + { + release(); + } +} + +void ALDbSavepoint::rollback() +{ + if (!mBegun) + { + return; + } + // Run whatever failed before: these two must not be the ones the + // connection remembers. + const std::string rollback = "ROLLBACK TO " + mName; + const std::string release = "RELEASE " + mName; + sqlite3_exec(mConnection.handle(), rollback.c_str(), nullptr, nullptr, nullptr); + sqlite3_exec(mConnection.handle(), release.c_str(), nullptr, nullptr, nullptr); + mBegun = false; +} + +int ALDbSavepoint::release() +{ + if (!mBegun) + { + return SQLITE_MISUSE; + } + mBegun = false; + return mConnection.exec("RELEASE " + mName); +} + +// +// Result codes +// + +bool ALDb::isTransient(int rc) +{ + switch (rc & 0xff) + { + case SQLITE_BUSY: + case SQLITE_LOCKED: + case SQLITE_FULL: + case SQLITE_IOERR: + case SQLITE_CANTOPEN: + case SQLITE_NOMEM: + case SQLITE_PROTOCOL: + return true; + default: + return false; + } +} + +bool ALDb::isCorrupt(int rc) +{ + const int primary = rc & 0xff; + return primary == SQLITE_CORRUPT || primary == SQLITE_NOTADB; +} + +std::string_view ALDb::errorName(int rc) +{ + switch (rc & 0xff) + { + case SQLITE_OK: return "SQLITE_OK"; + case SQLITE_ERROR: return "SQLITE_ERROR"; + case SQLITE_INTERNAL: return "SQLITE_INTERNAL"; + case SQLITE_PERM: return "SQLITE_PERM"; + case SQLITE_ABORT: return "SQLITE_ABORT"; + case SQLITE_BUSY: return "SQLITE_BUSY"; + case SQLITE_LOCKED: return "SQLITE_LOCKED"; + case SQLITE_NOMEM: return "SQLITE_NOMEM"; + case SQLITE_READONLY: return "SQLITE_READONLY"; + case SQLITE_INTERRUPT: return "SQLITE_INTERRUPT"; + case SQLITE_IOERR: return "SQLITE_IOERR"; + case SQLITE_CORRUPT: return "SQLITE_CORRUPT"; + case SQLITE_NOTFOUND: return "SQLITE_NOTFOUND"; + case SQLITE_FULL: return "SQLITE_FULL"; + case SQLITE_CANTOPEN: return "SQLITE_CANTOPEN"; + case SQLITE_PROTOCOL: return "SQLITE_PROTOCOL"; + case SQLITE_SCHEMA: return "SQLITE_SCHEMA"; + case SQLITE_TOOBIG: return "SQLITE_TOOBIG"; + case SQLITE_CONSTRAINT: return "SQLITE_CONSTRAINT"; + case SQLITE_MISMATCH: return "SQLITE_MISMATCH"; + case SQLITE_MISUSE: return "SQLITE_MISUSE"; + case SQLITE_AUTH: return "SQLITE_AUTH"; + case SQLITE_RANGE: return "SQLITE_RANGE"; + case SQLITE_NOTADB: return "SQLITE_NOTADB"; + default: return "SQLITE_?"; + } +} diff --git a/indra/aldb/aldbconnection.h b/indra/aldb/aldbconnection.h new file mode 100644 index 0000000000..b90eab229e --- /dev/null +++ b/indra/aldb/aldbconnection.h @@ -0,0 +1,236 @@ +/** + * @file aldbconnection.h + * @brief One SQLite connection, its prepared statements, and the savepoints and + * transactions work runs inside. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "stdtypes.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +struct sqlite3; +struct sqlite3_stmt; +class LLUUID; +class ALDbConnection; + +// A statement of a connection's cache, borrowed: reset and its bindings +// cleared when it goes, so the next borrower of the same SQL starts clean. +// Binds and columns are numbered as SQLite numbers them: binds from 1, +// columns from 0. A failure is recorded on the connection (firstError), so +// a run of calls needs no check after each. +class ALDbStatement +{ +public: + ALDbStatement() = default; + ~ALDbStatement(); + ALDbStatement(ALDbStatement&& other) noexcept; + ALDbStatement& operator=(ALDbStatement&& other) noexcept; + ALDbStatement(const ALDbStatement&) = delete; + ALDbStatement& operator=(const ALDbStatement&) = delete; + + // Whether the SQL prepared. A statement that did not does nothing. + bool valid() const { return mStatement != nullptr; } + + ALDbStatement& bind(int index, S32 value); + ALDbStatement& bind(int index, U32 value); + ALDbStatement& bind(int index, S64 value); + ALDbStatement& bind(int index, F64 value); + ALDbStatement& bind(int index, bool value) { return bind(index, S32(value ? 1 : 0)); } + // Copied: the view need not outlive the call. + ALDbStatement& bind(int index, std::string_view text); + ALDbStatement& bind(int index, const char* text) { return bind(index, std::string_view(text)); } + ALDbStatement& bind(int index, std::span blob); + // Sixteen bytes, as a BLOB. + ALDbStatement& bind(int index, const LLUUID& id); + ALDbStatement& bindNull(int index); + + // Steps once. True with a row to read; false when done or failed. + bool step(); + // Steps to the end, for a statement that returns no rows. The result + // code: SQLITE_DONE's success as SQLITE_OK. + int run(); + // Back to before its first step, its bindings kept. + void reset(); + + int columnCount() const; + // SQLITE_INTEGER, SQLITE_FLOAT, SQLITE_TEXT, SQLITE_BLOB or SQLITE_NULL. + int columnType(int column) const; + bool columnNull(int column) const; + S64 columnInt(int column) const; + F64 columnReal(int column) const; + // Valid until the next step, reset or the statement's end. + std::string_view columnText(int column) const; + std::span columnBlob(int column) const; + // A null UUID unless the column holds sixteen bytes. + LLUUID columnUUID(int column) const; + +private: + friend class ALDbConnection; + ALDbStatement(ALDbConnection* connection, sqlite3_stmt* statement, bool owned); + void release(); + ALDbStatement& noteBind(int rc, int index); + + ALDbConnection* mConnection = nullptr; + sqlite3_stmt* mStatement = nullptr; + // Prepared for this borrower alone, because the cached one was already + // borrowed: finalized, not returned, when it goes. + bool mOwned = false; +}; + +// One SQLite connection, used by one thread at a time: the writer's, a +// reader's, or a file of someone else's opened to be read once. It keeps the +// statements it has prepared, by their SQL, for as long as it lives. +// +// It remembers the first failure since it was last cleared, with SQLite's +// message for it: a job the writer runs makes its calls one after another +// and the writer looks once, at the end, at whether one failed. +class ALDbConnection +{ +public: + enum class Access + { + // Created if absent. + ReadWrite, + // Never written; may be shared with another process's writer. + ReadOnly, + // A file this install did not write: read-only, defensive, with no + // triggers, views or memory mapping, held to tight limits, and + // checked whole before anything else reads it. + Foreign + }; + + // Opens the file at a UTF-8 path. On failure returns null and says why; + // `rc` takes SQLite's result code. + static std::unique_ptr open(const std::string& path, Access access, std::string_view vfs, + int* rc, std::string* error); + // A file of someone else's, for reading: Access::Foreign, then + // `PRAGMA integrity_check`, refused (null) unless it says "ok". + static std::unique_ptr openForeign(const std::string& path, std::string* error); + + ~ALDbConnection(); + ALDbConnection(const ALDbConnection&) = delete; + ALDbConnection& operator=(const ALDbConnection&) = delete; + + sqlite3* handle() const { return mDb; } + const std::string& path() const { return mPath; } + Access access() const { return mAccess; } + + // Runs SQL that returns nothing, one statement or several. The result + // code; a failure is recorded. A single statement is kept with those + // prepare() keeps, so that one run for every transaction or job (BEGIN, + // COMMIT, a savepoint) is prepared once; text of several runs as a + // script and is not kept. + int exec(std::string_view sql); + // The cached statement for this SQL, prepared the first time. + ALDbStatement prepare(std::string_view sql); + // The first column of the first row, or nothing when there is no row + // or the query failed. + std::optional queryInt(std::string_view sql); + std::optional queryText(std::string_view sql); + + S64 lastInsertRowId() const; + S32 changes() const; + + // The first failure recorded since clearFirstError, and SQLite's message + // for it: SQLITE_OK while there is none. SQLITE_DONE and SQLITE_ROW are + // never failures. + int firstError() const { return mFirstError; } + const std::string& firstErrorMessage() const { return mFirstErrorMessage; } + void clearFirstError(); + // Records a failure of the caller's own, as one of SQLite's would be. + void fail(int rc, std::string_view what); + // Records `rc` if it is a failure, and returns it. + int note(int rc, std::string_view what); + + // Statements prepared and kept, for the tests. + size_t cachedStatements() const { return mStatements.size(); } + +private: + friend class ALDbStatement; + ALDbConnection(sqlite3* db, std::string path, Access access); + // A cached statement lent out, or one of its own for this borrower when + // it is lent out already. + ALDbStatement borrow(sqlite3_stmt* statement, std::string_view sql); + void giveBack(sqlite3_stmt* statement, bool owned); + + struct StringHash + { + using is_transparent = void; + size_t operator()(std::string_view text) const noexcept; + }; + + sqlite3* mDb = nullptr; + std::string mPath; + Access mAccess; + boost::unordered_flat_map> mStatements; + // The cached statements borrowed now: a second borrower of one of them + // gets a statement of its own instead, so a query run inside the loop + // over another's rows does not reset it. + boost::unordered_flat_set mBorrowed; + int mFirstError = 0; + std::string mFirstErrorMessage; +}; + +// A savepoint: released when it goes unless rolled back first. Nested ones +// nest. A savepoint that could not be begun does nothing. +class ALDbSavepoint +{ +public: + explicit ALDbSavepoint(ALDbConnection& connection, std::string_view name = "aldb"); + ~ALDbSavepoint(); + ALDbSavepoint(const ALDbSavepoint&) = delete; + ALDbSavepoint& operator=(const ALDbSavepoint&) = delete; + + bool begun() const { return mBegun; } + // Undoes everything since it began, and ends it. + void rollback(); + // Keeps everything since it began, and ends it. The result code. + int release(); + +private: + ALDbConnection& mConnection; + std::string mName; + bool mBegun = false; +}; + +// SQLite's own result codes, sorted by what a writer does about them. +namespace ALDb +{ + // A failure that may pass: busy or locked by another connection, the + // disk full, an I/O error. The work is kept and tried again. + bool isTransient(int rc); + // The file is not a database, or is damaged. + bool isCorrupt(int rc); + // SQLite's name for a result code, extended codes included. + std::string_view errorName(int rc); +} diff --git a/indra/aldb/aldbrecovery.cpp b/indra/aldb/aldbrecovery.cpp new file mode 100644 index 0000000000..4474867df6 --- /dev/null +++ b/indra/aldb/aldbrecovery.cpp @@ -0,0 +1,178 @@ +/** + * @file aldbrecovery.cpp + * @brief Moving a damaged database file aside, and salvaging what can be read + * of it into a new one. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "aldbrecovery.h" + +#include "fsyspath.h" +#include "lldate.h" + +#include +#include + +#include + +#include +#include +#include +#include + +namespace +{ + constexpr std::array SIDE_FILES = { "-wal", "-shm" }; + + void say(std::string* error, std::string text) + { + if (error) + { + *error = std::move(text); + } + } +} + +std::string ALDbRecovery::stamp() +{ + const std::time_t now = std::time_t(LLDate::now().secondsSinceEpoch()); + std::tm utc{}; +#if LL_WINDOWS + gmtime_s(&utc, &now); +#else + gmtime_r(&now, &utc); +#endif + return fmt::format("{:04}{:02}{:02}T{:02}{:02}{:02}Z", utc.tm_year + 1900, utc.tm_mon + 1, utc.tm_mday, utc.tm_hour, + utc.tm_min, utc.tm_sec); +} + +std::optional ALDbRecovery::quarantine(const std::string& path, std::string* error) +{ + namespace fs = std::filesystem; + std::error_code ec; + + // A name nothing has: a second damaged copy within one second gets -2. + const std::string base = path + ".corrupt-" + stamp(); + std::string target = base; + for (int n = 2; fs::exists(fsyspath(target), ec) || fs::exists(fsyspath(target + "-wal"), ec); ++n) + { + target = base + "-" + std::to_string(n); + } + + fs::rename(fsyspath(path), fsyspath(target), ec); + if (ec) + { + say(error, fmt::format("could not move {} aside: {}", path, ec.message())); + return std::nullopt; + } + for (const char* side : SIDE_FILES) + { + const std::string from = path + side; + if (!fs::exists(fsyspath(from), ec)) + { + continue; + } + fs::rename(fsyspath(from), fsyspath(target + side), ec); + if (ec) + { + // All or none: put back what moved, so the file and its WAL stay + // together where they were. + say(error, fmt::format("could not move {} aside: {}", from, ec.message())); + std::error_code back; + for (const char* moved : SIDE_FILES) + { + if (moved == side) + { + break; + } + fs::rename(fsyspath(target + moved), fsyspath(path + moved), back); + } + fs::rename(fsyspath(target), fsyspath(path), back); + return std::nullopt; + } + } + return target; +} + +bool ALDbRecovery::salvage(const std::string& from, const std::string& to, std::string* error) +{ + sqlite3* source = nullptr; + int rc = sqlite3_open_v2(from.c_str(), &source, SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX, nullptr); + if (rc != SQLITE_OK) + { + say(error, fmt::format("could not open {}: {}", from, source ? sqlite3_errmsg(source) : sqlite3_errstr(rc))); + sqlite3_close_v2(source); + return false; + } + + sqlite3_recover* recover = sqlite3_recover_init(source, "main", to.c_str()); + if (!recover) + { + say(error, "the recover API could not start"); + sqlite3_close_v2(source); + return false; + } + sqlite3_recover_run(recover); + rc = sqlite3_recover_errcode(recover); + const std::string message = sqlite3_recover_errmsg(recover) ? sqlite3_recover_errmsg(recover) : ""; + sqlite3_recover_finish(recover); + sqlite3_close_v2(source); + + // A read-only connection leaves the -shm it made, and a -wal it made + // empty, where a writer's close would remove them. The index is rebuilt + // from the WAL whenever one is opened, and an empty WAL holds nothing. + { + namespace fs = std::filesystem; + std::error_code ec; + fs::remove(fsyspath(from + "-shm"), ec); + const fs::path wal = fsyspath(from + "-wal"); + if (fs::exists(wal, ec) && fs::file_size(wal, ec) == 0 && !ec) + { + fs::remove(wal, ec); + } + } + + if (rc != SQLITE_OK) + { + say(error, fmt::format("recovering {} failed: {}", from, message)); + return false; + } + return true; +} + +bool ALDbRecovery::remove(const std::string& path, std::string* error) +{ + namespace fs = std::filesystem; + bool ok = true; + for (const std::string& file : { path, path + "-wal", path + "-shm" }) + { + std::error_code ec; + fs::remove(fsyspath(file), ec); + if (ec) + { + say(error, fmt::format("could not remove {}: {}", file, ec.message())); + ok = false; + } + } + return ok; +} diff --git a/indra/aldb/aldbrecovery.h b/indra/aldb/aldbrecovery.h new file mode 100644 index 0000000000..40203c2671 --- /dev/null +++ b/indra/aldb/aldbrecovery.h @@ -0,0 +1,51 @@ +/** + * @file aldbrecovery.h + * @brief Moving a damaged database file aside, and salvaging what can be read + * of it into a new one. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include +#include + +namespace ALDbRecovery +{ + // Moves a database file and its -wal and -shm to .corrupt-, + // all three or none: a WAL left behind would be replayed into whatever + // file next takes the name. Fails, moving nothing, where the file is held + // open by another process (Windows will not rename it). Where it lies + // now, or nothing. + std::optional quarantine(const std::string& path, std::string* error = nullptr); + + // Writes what SQLite's recover API can read of the database at `from` + // into a new database at `to`, its user_version and application_id + // among it. `to` must not exist. + bool salvage(const std::string& from, const std::string& to, std::string* error = nullptr); + + // Removes a database file and its -wal and -shm. False if any of them + // is still there. + bool remove(const std::string& path, std::string* error = nullptr); + + // "20261026T204812Z": the moment, in UTC, as a file name takes it. + std::string stamp(); +} diff --git a/indra/aldb/aldbwriter.cpp b/indra/aldb/aldbwriter.cpp new file mode 100644 index 0000000000..c3f6bcb19c --- /dev/null +++ b/indra/aldb/aldbwriter.cpp @@ -0,0 +1,581 @@ +/** + * @file aldbwriter.cpp + * @brief The one thread that writes a database file, and its queue. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "aldbwriter.h" + +#include "threadpool.h" + +#include + +#include + +#include + +namespace +{ + // Every live pool has a name of its own, and two files of one name -- + // the global settings.db and an account's -- may be open at once. + std::atomic sThreads{ 0 }; +} + +void ALDbWriter::finish(Item& item, bool committed) +{ + if (item.done) + { + item.done->set_value(committed); + } + if (item.completed) + { + try + { + item.completed(committed); + } + catch (const std::exception& e) + { + LL_WARNS("ALDb") << "a job's completion threw: " << e.what() << LL_ENDL; + } + catch (...) + { + LL_WARNS("ALDb") << "a job's completion threw" << LL_ENDL; + } + } +} + +// The writer's thread: a pool of one, whose run() is the writer's loop. It +// is closed by the writer, not by the viewer's quitting, which would leave +// what was posted unwritten. +class ALDbWriter::Thread final : public LL::ThreadPool +{ +public: + Thread(const std::string& name, std::shared_ptr writer) + : LL::ThreadPool(name, 1, 1, /*auto_shutdown*/ false, /*fixed_width*/ true), + mWriter(std::move(writer)) + { + } + + ~Thread() override { close(); } + + void run() override + { + // The writer's, until the loop is done. + std::shared_ptr writer = std::move(mWriter); + writer->run(); + } + +private: + std::shared_ptr mWriter; +}; + +ALDbWriter::ALDbWriter(std::string name, const ALDbOptions& options) +: mName(std::move(name)), + mRetryFirst(options.retryFirst), + mRetryLongest(options.retryLongest), + mOnFailure(options.onFailure) +{ +} + +ALDbWriter::~ALDbWriter() = default; + +void ALDbWriter::start() +{ + mThread = std::make_unique(fmt::format("ALDb:{}#{}", mName, ++sThreads), shared_from_this()); + mThread->start(); +} + +bool ALDbWriter::closing() const +{ + LLCoros::LockType lock(mMutex); + return mClosing; +} + +bool ALDbWriter::post(Job job, Done done, Completion completed) +{ + { + LLCoros::LockType lock(mMutex); + if (mClosing) + { + return false; + } + mQueue.push_back(Item{ std::move(job), nullptr, std::move(done), std::move(completed) }); + mQueued = mQueue.size(); + } + mWake.notify_one(); + return true; +} + +bool ALDbWriter::control(Control control) +{ + llassert(!onWriterThread()); + auto done = std::make_shared>(); + LLCoros::Future result = done->get_future(); + { + LLCoros::LockType lock(mMutex); + if (mClosing) + { + return false; + } + mQueue.push_back(Item{ nullptr, std::move(control), done, nullptr }); + mQueued = mQueue.size(); + } + mWake.notify_one(); + return result.get(); +} + +bool ALDbWriter::close(std::chrono::milliseconds wait) +{ + { + LLCoros::LockType lock(mMutex); + if (!mClosing) + { + mClosing = true; + mDeadline = std::chrono::steady_clock::now() + wait; + } + } + mWake.notify_all(); + if (!mThread) + { + return true; + } + // A little past the deadline: the writer gives up at the deadline and + // still has the file to checkpoint and close. + if (mFinished.wait_for(wait + std::chrono::milliseconds(500))) + { + mThread.reset(); + return true; + } + LL_WARNS("ALDb") << mName << ": the writer did not finish within " << wait.count() + << " ms of closing; left to finish on its own" << LL_ENDL; + // The pool goes with its thread, and the writer the thread holds with it. + LL::ThreadPoolBase::abandon(std::move(mThread)); + return false; +} + +ALDbStats ALDbWriter::stats() const +{ + ALDbStats stats; + stats.commits = mCommits; + stats.committedItems = mCommittedItems; + stats.droppedItems = mDroppedItems; + stats.retries = mRetries; + stats.queued = mQueued; + return stats; +} + +void ALDbWriter::run() +{ + mThreadId = LLThread::currentID(); + + std::deque pending; + for (;;) + { + { + LLCoros::LockType lock(mMutex); + if (pending.empty()) + { + mWake.wait(lock, [this] { return !mQueue.empty() || mClosing; }); + if (mQueue.empty()) + { + break; // closing, and nothing left + } + } + // Whatever waits behind what is being tried again goes in after it. + while (!mQueue.empty()) + { + pending.push_back(std::move(mQueue.front())); + mQueue.pop_front(); + } + } + + const bool stopped = drain(pending); + mQueued = pending.size(); + if (!stopped) + { + noteSuccess(); + continue; + } + + // A failure that may pass: wait, longer each time, then try again. + ++mRetries; + mDelay = mDelay.count() == 0 ? mRetryFirst : std::min(mDelay * 2, mRetryLongest); + LLCoros::LockType lock(mMutex); + const auto now = std::chrono::steady_clock::now(); + if (mClosing && now >= mDeadline) + { + lock.unlock(); + giveUp(pending); + continue; + } + // Woken early by close(), to wait no longer than its deadline. + const bool was_closing = mClosing; + const auto until = mClosing ? std::min(now + mDelay, mDeadline) : now + mDelay; + mWake.wait_until(lock, until, [this, was_closing] { return mClosing && !was_closing; }); + } + + if (mConnection) + { + // Back into the file, and the WAL emptied, so that a file copied or + // moved once the viewer is gone is whole without it. + mConnection->exec("PRAGMA wal_checkpoint(TRUNCATE)"); + mConnection.reset(); + } + mQueued = 0; + mFinished.set_all(); +} + +bool ALDbWriter::drain(std::deque& pending) +{ + while (!pending.empty()) + { + if (pending.front().control) + { + Item item = std::move(pending.front()); + pending.pop_front(); + runControl(item); + continue; + } + size_t count = 0; + while (count < pending.size() && !pending[count].control) + { + ++count; + } + if (runBatch(pending, count)) + { + return true; + } + } + return false; +} + +void ALDbWriter::runControl(Item& item) +{ + bool ok = true; + try + { + item.control(mConnection); + } + catch (const std::exception& e) + { + LL_WARNS("ALDb") << mName << ": a control job threw: " << e.what() << LL_ENDL; + ok = false; + } + catch (...) + { + LL_WARNS("ALDb") << mName << ": a control job threw" << LL_ENDL; + ok = false; + } + finish(item, ok); +} + +bool ALDbWriter::runBatch(std::deque& pending, size_t count) +{ + LL_PROFILE_ZONE_SCOPED; + + // Nothing open to write to: the open failed, or the file was closed. + if (!mConnection) + { + for (size_t i = 0; i < count; ++i) + { + finish(pending.front(), false); + pending.pop_front(); + ++mDroppedItems; + } + return false; + } + + ALDbConnection& connection = *mConnection; + sqlite3* const db = connection.handle(); + connection.clearFirstError(); + // A file this process may only read takes no write lock: its reads run, + // and its writes fail alone. One that becomes so under the connection -- + // moved aside, its media made read-only -- is begun the same way. + bool begun = connection.exec(sqlite3_db_readonly(db, "main") == 1 ? "BEGIN" : "BEGIN IMMEDIATE") == SQLITE_OK; + if (!begun && (connection.firstError() & 0xff) == SQLITE_READONLY) + { + connection.clearFirstError(); + begun = connection.exec("BEGIN") == SQLITE_OK; + } + if (!begun) + { + const int code = connection.firstError(); + const std::string message = connection.firstErrorMessage(); + connection.clearFirstError(); + if (ALDb::isTransient(code)) + { + noteFailure(code, message); + return true; + } + LL_WARNS("ALDb") << mName << ": could not begin a transaction, " << count << " jobs dropped: " << message + << LL_ENDL; + for (size_t i = 0; i < count; ++i) + { + finish(pending.front(), false); + pending.pop_front(); + ++mDroppedItems; + } + return false; + } + + // Each job in a savepoint: one that fails is undone alone. One that + // failed for good is dropped; one that failed in a way that may pass + // stops the batch there, and it and what follows wait to be tried again. + size_t ran = 0; + bool stopped = false; + bool undone = false; + int stop_code = SQLITE_OK; + std::string stop_message; + std::vector dropped(count, false); + for (; ran < count; ++ran) + { + Item& item = pending[ran]; + connection.clearFirstError(); + ALDbSavepoint savepoint(connection, "aldb_job"); + if (savepoint.begun()) + { + try + { + item.job(connection); + } + catch (const std::exception& e) + { + connection.fail(SQLITE_ABORT, std::string("the job threw: ") + e.what()); + } + catch (...) + { + connection.fail(SQLITE_ABORT, "the job threw"); + } + } + if (connection.firstError() == SQLITE_OK) + { + savepoint.release(); + } + const int code = connection.firstError(); + if (code == SQLITE_OK) + { + continue; + } + const std::string message = connection.firstErrorMessage(); + savepoint.rollback(); + // An I/O error, a full disk or memory running out can make SQLite + // undo the whole transaction, not the job alone: then nothing of the + // batch is in the file, and no job may run on outside a transaction. + undone = sqlite3_get_autocommit(db) != 0; + if (ALDb::isTransient(code)) + { + stopped = true; + stop_code = code; + stop_message = message; + break; + } + LL_WARNS("ALDb") << mName << ": a job failed and was dropped: " << message << LL_ENDL; + if (ALDb::isCorrupt(code) && !mDamageReported) + { + // Damage found after the open, which only looks after a crash. + mDamageReported = true; + report(code, message); + } + else if ((code & 0xff) == SQLITE_READONLY && !mReadOnlyReported) + { + mReadOnlyReported = true; + report(code, message); + } + dropped[ran] = true; + if (undone) + { + // Failed for good, and took the jobs before it with it. + stop_code = code; + stop_message = message; + ++ran; + break; + } + } + + if (undone) + { + // Nothing of the batch reached the file. The jobs that failed for + // good are done with; the others that ran, and the one that stopped + // the batch, are tried again with what waited behind them. + connection.clearFirstError(); + std::deque kept; + for (size_t i = 0; i < count; ++i) + { + Item item = std::move(pending.front()); + pending.pop_front(); + if (i < ran && dropped[i]) + { + finish(item, false); + ++mDroppedItems; + } + else + { + kept.push_back(std::move(item)); + } + } + while (!kept.empty()) + { + pending.push_front(std::move(kept.back())); + kept.pop_back(); + } + LL_DEBUGS("ALDb") << mName << ": the transaction was undone (" << ALDb::errorName(stop_code) + << "); its jobs are tried again: " << stop_message << LL_ENDL; + if (stopped) + { + noteFailure(stop_code, stop_message); + } + return true; + } + + connection.clearFirstError(); + if (connection.exec("COMMIT") != SQLITE_OK) + { + const int code = connection.firstError(); + const std::string message = connection.firstErrorMessage(); + connection.clearFirstError(); + sqlite3_exec(connection.handle(), "ROLLBACK", nullptr, nullptr, nullptr); + + // Nothing of the batch is in the file. The jobs that failed for good + // are done with; the others that ran are tried again, or, if the + // commit itself failed for good, dropped with them. Those the batch + // stopped before had no part in it and wait their turn. + const bool retry = ALDb::isTransient(code); + std::deque kept; + for (size_t i = 0; i < count; ++i) + { + Item item = std::move(pending.front()); + pending.pop_front(); + if (i >= ran || (retry && !dropped[i])) + { + kept.push_back(std::move(item)); + } + else + { + finish(item, false); + ++mDroppedItems; + } + } + while (!kept.empty()) + { + pending.push_front(std::move(kept.back())); + kept.pop_back(); + } + if (retry) + { + noteFailure(code, message); + return true; + } + LL_WARNS("ALDb") << mName << ": a commit failed, the " << ran << " jobs in it dropped: " << message << LL_ENDL; + if (stopped) + { + noteFailure(stop_code, stop_message); + } + return stopped; + } + + ++mCommits; + for (size_t i = 0; i < ran; ++i) + { + Item item = std::move(pending.front()); + pending.pop_front(); + if (dropped[i]) + { + finish(item, false); + ++mDroppedItems; + } + else + { + finish(item, true); + ++mCommittedItems; + } + } + if (stopped) + { + noteFailure(stop_code, stop_message); + } + return stopped; +} + +void ALDbWriter::giveUp(std::deque& pending) +{ + size_t jobs = 0; + while (!pending.empty()) + { + Item item = std::move(pending.front()); + pending.pop_front(); + if (item.control) + { + runControl(item); + continue; + } + finish(item, false); + ++mDroppedItems; + ++jobs; + } + if (jobs) + { + LL_WARNS("ALDb") << mName << ": closing before " << jobs + << " jobs could be written; they are lost" << LL_ENDL; + } +} + +void ALDbWriter::noteFailure(int code, const std::string& message) +{ + ++mConsecutiveFailures; + LL_DEBUGS("ALDb") << mName << ": " << ALDb::errorName(code) << ", trying again: " << message << LL_ENDL; + // Busy or locked for a moment is ordinary; anything else, or being busy + // past a few tries, is worth saying. + const int primary = code & 0xff; + const bool serious = (primary != SQLITE_BUSY && primary != SQLITE_LOCKED) || mConsecutiveFailures >= 3; + if (serious && !mReported) + { + mReported = true; + report(code, message); + } +} + +void ALDbWriter::report(int code, const std::string& message) +{ + LL_WARNS("ALDb") << mName << ": cannot write (" << ALDb::errorName(code) << "): " << message << LL_ENDL; + if (mOnFailure) + { + try + { + mOnFailure(ALDbFailure{ code, message }); + } + catch (...) + { + } + } +} + +void ALDbWriter::noteSuccess() +{ + if (mReported) + { + LL_INFOS("ALDb") << mName << ": writing again" << LL_ENDL; + } + mReported = false; + mConsecutiveFailures = 0; + mDelay = std::chrono::milliseconds(0); +} diff --git a/indra/aldb/aldbwriter.h b/indra/aldb/aldbwriter.h new file mode 100644 index 0000000000..cc0e7f4751 --- /dev/null +++ b/indra/aldb/aldbwriter.h @@ -0,0 +1,138 @@ +/** + * @file aldbwriter.h + * @brief The one thread that writes a database file, and its queue. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "aldatabase.h" + +#include "llcond.h" +#include "llcoros.h" +#include "llthread.h" + +#include LLCOROS_MUTEX_HEADER +#include LLCOROS_CONDVAR_HEADER + +#include +#include + +// The writer of one file: a thread, the connection it alone uses, and the +// jobs waiting for it. Jobs run in transactions, each job in a savepoint of +// its own; control jobs run alone, between transactions, with the connection +// itself to open, replace or close. +// +// Posted to, and waited on, from any thread or coroutine: its locks and the +// promises it keeps are the coroutines' own, so a coroutine that waits for +// it gives the rest of its thread's coroutines their turn. +// +// It lives as long as its thread: a writer left behind by close() -- on a +// disk that hangs -- keeps what it needs until it is done. +class ALDbWriter : public std::enable_shared_from_this +{ +public: + using Job = ALDatabase::Job; + using Completion = ALDatabase::Completion; + using Control = std::function&)>; + using Done = std::shared_ptr>; + + ALDbWriter(std::string name, const ALDbOptions& options); + ~ALDbWriter(); + ALDbWriter(const ALDbWriter&) = delete; + ALDbWriter& operator=(const ALDbWriter&) = delete; + + void start(); + + // A job for the next transaction; `done` and `completed`, if given, say + // whether it committed. False when closing. + bool post(Job job, Done done = nullptr, Completion completed = nullptr); + // Runs on the writer, alone, and waits for it. False when closing or + // when the job threw. + bool control(Control control); + + // See ALDatabase::close. + bool close(std::chrono::milliseconds wait); + bool closing() const; + + bool onWriterThread() const { return LLThread::currentID() == mThreadId.load(); } + ALDbStats stats() const; + +private: + class Thread; + + struct Item + { + Job job; + Control control; + Done done; + Completion completed; + }; + + // Tells the item's waiter and its completion how it ended. + static void finish(Item& item, bool committed); + + void run(); + // Writes what it can of `pending`, from the front. True when it stopped + // at a failure that may pass, the rest left in `pending`. + bool drain(std::deque& pending); + bool runBatch(std::deque& pending, size_t count); + void runControl(Item& item); + void giveUp(std::deque& pending); + void noteFailure(int code, const std::string& message); + void noteSuccess(); + // Says a failure through the log and the owner's onFailure. + void report(int code, const std::string& message); + + const std::string mName; + const std::chrono::milliseconds mRetryFirst; + const std::chrono::milliseconds mRetryLongest; + const std::function mOnFailure; + + mutable LLCoros::Mutex mMutex; + LLCoros::ConditionVariable mWake; + std::deque mQueue; + bool mClosing = false; + std::chrono::steady_clock::time_point mDeadline; + + std::unique_ptr mThread; + std::atomic mThreadId; + LLOneShotCond mFinished; + + // The writer thread's alone. + std::unique_ptr mConnection; + std::chrono::milliseconds mDelay{ 0 }; + U32 mConsecutiveFailures = 0; + // A failure that may pass is said once until the writer next succeeds; + // damage once for the writer's life, since writes that miss the damaged + // pages still succeed and the file stays damaged until the next open; + // a file that takes no writes once too, as a connection opened only to + // read stays one. + bool mReported = false; + bool mDamageReported = false; + bool mReadOnlyReported = false; + + std::atomic mCommits{ 0 }; + std::atomic mCommittedItems{ 0 }; + std::atomic mDroppedItems{ 0 }; + std::atomic mRetries{ 0 }; + std::atomic mQueued{ 0 }; +}; diff --git a/indra/aldb/tests/aldatabase_test.cpp b/indra/aldb/tests/aldatabase_test.cpp new file mode 100644 index 0000000000..bd1444b73b --- /dev/null +++ b/indra/aldb/tests/aldatabase_test.cpp @@ -0,0 +1,873 @@ +/** + * @file aldatabase_test.cpp + * @brief ALDatabase: opening and the profiles' pragmas, migrations and the + * copies kept before them, the writer's batches, failures and retries, + * closing, readers, backups, and two processes on one file. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "aldatabase.h" +#include "aldbfaultvfs.h" + +#include "altesthelper.h" +#include "fsyspath.h" +#include "llcond.h" +#include "llevents.h" +#include "llprocess.h" +#include "llstring.h" +#include "lltimer.h" +#include "threadpool.h" + +#include + +#include + +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; + +namespace +{ + // 'ALTS': a kind of file of the tests' own. + constexpr U32 TEST_APPLICATION_ID = 0x414C5453; + + const char* const SECOND_WRITER_PATH = "AL_ALDB_TEST_SECOND_WRITER_PATH"; + + ALDbMigration makeTable(S32 version, const std::string& table) + { + return ALDbMigration{ version, [table](ALDbConnection& connection) { + connection.exec("CREATE TABLE " + table + "(k TEXT PRIMARY KEY, v)"); + } }; + } + + ALDbOptions testOptions(ALDbProfile profile = ALDbProfile::Durable) + { + ALDbOptions options; + options.profile = profile; + options.applicationId = TEST_APPLICATION_ID; + options.migrations = { makeTable(1, "kv") }; + options.busyTimeout = 20ms; + options.retryFirst = 5ms; + options.retryLongest = 40ms; + options.closeWait = 2000ms; + return options; + } + + // An upsert keyed by what it writes, safe to run twice. + ALDatabase::Job put(std::string key, S64 value) + { + return [key = std::move(key), value](ALDbConnection& connection) { + connection.prepare("INSERT INTO kv(k, v) VALUES (?1, ?2) ON CONFLICT(k) DO UPDATE SET v = excluded.v") + .bind(1, std::string_view(key)) + .bind(2, value) + .run(); + }; + } + + std::unique_ptr raw(const std::string& path) + { + int rc = SQLITE_OK; + std::string error; + auto connection = ALDbConnection::open(path, ALDbConnection::Access::ReadWrite, {}, &rc, &error); + INFO(error); + REQUIRE(connection); + return connection; + } + + S64 rowsIn(const std::string& path, const std::string& table = "kv") + { + return raw(path)->queryInt("SELECT count(*) FROM " + table).value_or(-1); + } + + bool exists(const std::string& path) + { + std::error_code ec; + return std::filesystem::exists(fsyspath(path), ec); + } + + // Holds the writer inside a job until released, so that what is posted + // meanwhile waits for one batch. + struct Gate + { + LLOneShotCond mStarted; + LLOneShotCond mRelease; + std::atomic mRan{ false }; + + ALDatabase::Job job() + { + return [this](ALDbConnection&) { + if (!mRan.exchange(true)) + { + mStarted.set_all(); + } + mRelease.wait(); + }; + } + }; + + class ScopedEnvironment + { + public: + ScopedEnvironment(const char* name, const std::string& value) + : mName(name) + { + const std::string before = LLStringUtil::getenv(name); + mHad = !before.empty(); + mBefore = before; + set(value.c_str()); + } + ~ScopedEnvironment() { set(mHad ? mBefore.c_str() : nullptr); } + + private: + void set(const char* value) + { +#if LL_WINDOWS + _putenv_s(mName.c_str(), value ? value : ""); +#else + if (value) + { + setenv(mName.c_str(), value, 1); + } + else + { + unsetenv(mName.c_str()); + } +#endif + } + + std::string mName; + std::string mBefore; + bool mHad = false; + }; + + TEST_CASE("aldatabase: a new file is made with its tables and the profile's pragmas", "[aldatabase]") + { + ll_test::ALDbTempDir dir; + const std::array, 3> profiles = { { { ALDbProfile::Durable, 2 }, + { ALDbProfile::Journal, 1 }, + { ALDbProfile::Regenerable, 0 } } }; + for (const auto& [profile, synchronous] : profiles) + { + const std::string path = dir.file("profile" + std::to_string(int(profile)) + ".db"); + ALDatabase db(path, testOptions(profile)); + const ALDbOpenResult& result = db.open(); + INFO(result.message); + REQUIRE(result.outcome == ALDbOpenResult::Outcome::Created); + CHECK(result.journalMode == "wal"); + CHECK(result.versionAfter == 1); + CHECK(db.isOpen()); + CHECK_FALSE(db.transient()); + + S64 sync = -1; + S64 keys = -1; + std::string mode; + REQUIRE(db.postAndWait([&](ALDbConnection& connection) { + sync = connection.queryInt("PRAGMA synchronous").value_or(-1); + keys = connection.queryInt("PRAGMA foreign_keys").value_or(-1); + mode = connection.queryText("PRAGMA journal_mode").value_or(""); + })); + CHECK(sync == synchronous); + CHECK(keys == 1); + CHECK(mode == "wal"); + CHECK(db.close()); + // Closing emptied the WAL into the file. + CHECK_FALSE(exists(path + "-wal")); + + auto file = raw(path); + CHECK(file->queryInt("PRAGMA application_id") == S64(TEST_APPLICATION_ID)); + CHECK(file->queryInt("PRAGMA user_version") == 1); + CHECK(file->queryInt("SELECT count(*) FROM kv") == 0); + } + } + + TEST_CASE("aldatabase: a file of another kind is refused and left as it was", "[aldatabase]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("other.db"); + { + auto file = raw(path); + REQUIRE(file->exec("PRAGMA application_id=12345; CREATE TABLE theirs(x); INSERT INTO theirs VALUES(1);") == + SQLITE_OK); + } + ALDatabase db(path, testOptions()); + const ALDbOpenResult& result = db.open(); + CHECK(result.outcome == ALDbOpenResult::Outcome::Refused); + CHECK_FALSE(db.isOpen()); + CHECK_FALSE(db.post(put("a", 1))); + db.close(); + + auto file = raw(path); + CHECK(file->queryInt("PRAGMA application_id") == 12345); + CHECK(file->queryInt("SELECT count(*) FROM theirs") == 1); + CHECK_FALSE(file->queryInt("SELECT count(*) FROM kv").has_value()); + } + + TEST_CASE("aldatabase: migrations run in order and one that fails leaves the file as it was", "[aldatabase]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("migrate.db"); + + std::vector order; + ALDbOptions failing = testOptions(); + failing.migrations = { + { 2, [&](ALDbConnection& c) { order.push_back(2); c.exec("CREATE TABLE two(x)"); } }, + { 1, [&](ALDbConnection& c) { order.push_back(1); c.exec("CREATE TABLE kv(k TEXT PRIMARY KEY, v)"); } }, + { 3, [&](ALDbConnection& c) { order.push_back(3); c.exec("CREATE TABLE three(x"); } }, + }; + { + ALDatabase db(path, failing); + const ALDbOpenResult& result = db.open(); + CHECK(result.outcome == ALDbOpenResult::Outcome::Failed); + CHECK_FALSE(result.message.empty()); + CHECK_FALSE(db.isOpen()); + } + CHECK(order == std::vector{ 1, 2, 3 }); + { + auto file = raw(path); + CHECK(file->queryInt("PRAGMA user_version") == 0); + CHECK(file->queryInt("SELECT count(*) FROM sqlite_schema") == 0); + } + + ALDbOptions good = testOptions(); + good.migrations = { makeTable(1, "kv"), makeTable(2, "two") }; + { + ALDatabase db(path, good); + CHECK(db.open().outcome == ALDbOpenResult::Outcome::Created); + CHECK(db.openResult().versionAfter == 2); + } + CHECK(raw(path)->queryInt("PRAGMA user_version") == 2); + } + + TEST_CASE("aldatabase: a copy is kept before migrating and only the last few are kept", "[aldatabase]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("backups.db"); + + ALDbOptions options = testOptions(); + options.migrations = { makeTable(1, "kv") }; + { + ALDatabase db(path, options); + REQUIRE(db.open().outcome == ALDbOpenResult::Outcome::Created); + REQUIRE(db.postAndWait(put("kept", 7))); + } + // A new file is not copied: there was nothing to lose. + CHECK_FALSE(exists(path + ".v0.bak")); + + for (S32 version = 2; version <= 4; ++version) + { + options.migrations.push_back(makeTable(version, "t" + std::to_string(version))); + ALDatabase db(path, options); + const ALDbOpenResult& result = db.open(); + INFO(result.message); + CHECK(result.outcome == ALDbOpenResult::Outcome::Opened); + CHECK(result.migrated); + CHECK(result.versionBefore == version - 1); + CHECK(result.versionAfter == version); + } + // Copies of versions 1, 2 and 3 were taken; the last two are kept. + CHECK_FALSE(exists(path + ".v1.bak")); + CHECK(exists(path + ".v2.bak")); + CHECK(exists(path + ".v3.bak")); + + // A copy is whole: version 3, the row in it, no WAL beside it. + CHECK_FALSE(exists(path + ".v3.bak-wal")); + std::string error; + auto copy = ALDbConnection::openForeign(path + ".v3.bak", &error); + INFO(error); + REQUIRE(copy); + CHECK(copy->queryInt("PRAGMA user_version") == 3); + CHECK(copy->queryInt("SELECT v FROM kv WHERE k = 'kept'") == 7); + } + + TEST_CASE("aldatabase: a file from a newer build opens without migrating and stays writable", "[aldatabase]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("newer.db"); + + ALDbOptions newer = testOptions(); + newer.migrations = { makeTable(1, "kv"), makeTable(2, "two"), makeTable(3, "three") }; + { + ALDatabase db(path, newer); + REQUIRE(db.open().usable()); + } + + ALDatabase db(path, testOptions()); + const ALDbOpenResult& result = db.open(); + CHECK(result.outcome == ALDbOpenResult::Outcome::Opened); + CHECK_FALSE(result.migrated); + CHECK(result.versionBefore == 3); + CHECK(result.versionAfter == 3); + CHECK(db.postAndWait(put("from the older build", 1))); + CHECK(db.close()); + + auto file = raw(path); + CHECK(file->queryInt("PRAGMA user_version") == 3); + CHECK(file->queryInt("SELECT count(*) FROM three") == 0); + CHECK(file->queryInt("SELECT count(*) FROM kv") == 1); + } + + TEST_CASE("aldatabase: jobs queued together are written in one transaction", "[aldatabase]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("batch.db"); + ALDatabase db(path, testOptions()); + REQUIRE(db.open().usable()); + + Gate gate; + REQUIRE(db.post(gate.job())); + gate.mStarted.wait(); + const U64 commits_before = db.stats().commits; + for (int i = 0; i < 1000; ++i) + { + REQUIRE(db.post(put("key" + std::to_string(i), i))); + } + gate.mRelease.set_all(); + REQUIRE(db.flush()); + + // The gate's batch, then the thousand in one, and the flush either + // with them or after. + const ALDbStats stats = db.stats(); + CHECK(stats.commits - commits_before >= 2); + CHECK(stats.commits - commits_before <= 3); + CHECK(stats.committedItems >= 1001); + CHECK(stats.droppedItems == 0); + CHECK(db.close()); + CHECK(rowsIn(path) == 1000); + } + + TEST_CASE("aldatabase: a job that fails for good is dropped alone and the rest commit", "[aldatabase]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("drop.db"); + ALDatabase db(path, testOptions()); + REQUIRE(db.open().usable()); + + Gate gate; + REQUIRE(db.post(gate.job())); + gate.mStarted.wait(); + REQUIRE(db.post(put("first", 1))); + // A constraint: the same primary key inserted twice in one job. + REQUIRE(db.post([](ALDbConnection& connection) { + connection.exec("INSERT INTO kv(k, v) VALUES ('dup', 1)"); + connection.exec("INSERT INTO kv(k, v) VALUES ('dup', 2)"); + })); + REQUIRE(db.post([](ALDbConnection&) { throw std::runtime_error("a job that throws"); })); + REQUIRE(db.post(put("last", 3))); + gate.mRelease.set_all(); + REQUIRE(db.flush()); + + CHECK(db.stats().droppedItems == 2); + CHECK_FALSE(db.postAndWait([](ALDbConnection& connection) { connection.fail(SQLITE_CONSTRAINT, "no"); })); + CHECK(db.close()); + + auto file = raw(path); + CHECK(file->queryInt("SELECT count(*) FROM kv") == 2); + CHECK(file->queryInt("SELECT count(*) FROM kv WHERE k = 'dup'") == 0); + } + + TEST_CASE("aldatabase: a file another connection holds locked is tried again until the lock goes", + "[aldatabase]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("busy.db"); + ALDatabase db(path, testOptions()); + REQUIRE(db.open().usable()); + + auto other = raw(path); + sqlite3_busy_timeout(other->handle(), 1000); + REQUIRE(other->exec("BEGIN IMMEDIATE") == SQLITE_OK); + + auto done = std::async(std::launch::async, [&db] { return db.postAndWait(put("waited", 1)); }); + // Long enough for several of the writer's tries to find it locked. + ms_sleep(150); + CHECK(done.wait_for(0ms) == std::future_status::timeout); + REQUIRE(other->exec("COMMIT") == SQLITE_OK); + + CHECK(done.get()); + CHECK(db.stats().retries > 0); + CHECK(db.close()); + other.reset(); + CHECK(rowsIn(path) == 1); + } + + TEST_CASE("aldatabase: a full disk keeps what was posted and says so once", "[aldatabase]") + { + ll_test::ALDbTempDir dir; + ll_test::ALDbFaults faults; + const std::string path = dir.file("full.db"); + + std::atomic reported{ 0 }; + std::atomic code{ 0 }; + ALDbOptions options = testOptions(); + options.vfs = ll_test::ALDbFaultVfs::name(); + options.onFailure = [&](const ALDbFailure& failure) { + ++reported; + code = failure.code; + }; + ALDatabase db(path, options); + REQUIRE(db.open().usable()); + + ll_test::ALDbFaultVfs::failWrites(SQLITE_FULL); + auto done = std::async(std::launch::async, [&db] { return db.postAndWait(put("survives", 42)); }); + REQUIRE(ll_test::waitUntil(5s, [&db] { return db.stats().retries >= 5; })); + CHECK(done.wait_for(0ms) == std::future_status::timeout); + CHECK(reported == 1); + CHECK((code & 0xff) == SQLITE_FULL); + + ll_test::ALDbFaultVfs::failWrites(SQLITE_OK); + CHECK(done.get()); + CHECK(reported == 1); + CHECK(db.close()); + CHECK(rowsIn(path) == 1); + } + + TEST_CASE("aldatabase: close writes everything posted before it", "[aldatabase]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("close.db"); + { + ALDatabase db(path, testOptions()); + REQUIRE(db.open().usable()); + for (int i = 0; i < 100; ++i) + { + REQUIRE(db.post(put("key" + std::to_string(i), i))); + } + CHECK(db.close()); + CHECK_FALSE(db.post(put("after", 1))); + CHECK_FALSE(db.isOpen()); + } + CHECK(rowsIn(path) == 100); + } + + TEST_CASE("aldatabase: close gives up on a writer stuck on a disk that hangs", "[aldatabase]") + { + ll_test::ALDbTempDir dir; + ll_test::ALDbFaults faults; + const std::string path = dir.file("hang.db"); + { + ALDbOptions options = testOptions(); + options.vfs = ll_test::ALDbFaultVfs::name(); + options.closeWait = 200ms; + ALDatabase db(path, options); + REQUIRE(db.open().usable()); + + ll_test::ALDbFaultVfs::holdWrites(true); + REQUIRE(db.post(put("held", 1))); + REQUIRE(ll_test::waitUntil(5s, [] { return ll_test::ALDbFaultVfs::heldWrites() > 0; })); + + LLTimer timer; + CHECK_FALSE(db.close()); + CHECK(timer.getElapsedTimeF32() < 3.f); + } + // Let the writer left behind finish, and wait for it to close the file + // and for its thread to take its pool with it. + ll_test::ALDbFaultVfs::holdWrites(false); + CHECK(ll_test::waitUntil(10s, [] { return ll_test::ALDbFaultVfs::openFiles() == 0; })); + CHECK(ll_test::waitUntil(10s, [] { + for (const std::string& name : LL::ThreadPoolBase::key_snapshot()) + { + if (name.starts_with("ALDb:hang.db")) + { + return false; + } + } + return true; + })); + + // The file is whole, whether the held write made it or not. + std::string error; + auto file = ALDbConnection::openForeign(path, &error); + INFO(error); + CHECK(file); + } + + TEST_CASE("aldatabase: without shared memory the file keeps a rollback journal", "[aldatabase]") + { + ll_test::ALDbTempDir dir; + ll_test::ALDbFaults faults; + const std::string path = dir.file("noshm.db"); + ll_test::ALDbFaultVfs::withoutSharedMemory(true); + + ALDbOptions options = testOptions(); + options.vfs = ll_test::ALDbFaultVfs::name(); + ALDatabase db(path, options); + const ALDbOpenResult& result = db.open(); + INFO(result.message); + REQUIRE(result.usable()); + CHECK(result.journalMode == "delete"); + CHECK(db.postAndWait(put("written", 1))); + CHECK(db.close()); + CHECK_FALSE(exists(path + "-wal")); + CHECK(rowsIn(path) == 1); + } + + TEST_CASE("aldatabase: readers see what the writer committed and are kept for the next", "[aldatabase]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("readers.db"); + ALDatabase db(path, testOptions()); + REQUIRE(db.open().usable()); + REQUIRE(db.postAndWait(put("a", 1))); + + ALDbConnection* first = nullptr; + { + ALDbReadLease lease = db.read(); + REQUIRE(lease); + first = &*lease; + CHECK(lease->queryInt("SELECT v FROM kv WHERE k = 'a'") == 1); + CHECK(lease->exec("INSERT INTO kv(k, v) VALUES ('b', 2)") != SQLITE_OK); + + // Two at once are two connections. + ALDbReadLease second = db.read(); + REQUIRE(second); + CHECK(&*second != first); + } + ALDbReadLease again = db.read(); + REQUIRE(again); + CHECK(again->firstError() == SQLITE_OK); + + REQUIRE(db.postAndWait(put("a", 5))); + CHECK(again->queryInt("SELECT v FROM kv WHERE k = 'a'") == 5); + again = ALDbReadLease(); + CHECK(db.close()); + CHECK_FALSE(db.read()); + } + + TEST_CASE("aldatabase: a backup is whole and needs nothing beside it", "[aldatabase]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("live.db"); + const std::string backup = dir.file("live.albackup"); + ALDatabase db(path, testOptions()); + REQUIRE(db.open().usable()); + for (int i = 0; i < 50; ++i) + { + REQUIRE(db.post(put("key" + std::to_string(i), i))); + } + REQUIRE(db.flush()); + + std::string error; + CHECK(db.backupTo(backup, &error)); + INFO(error); + CHECK_FALSE(exists(backup + ".tmp")); + CHECK_FALSE(exists(backup + "-wal")); + // A second backup replaces the first. + REQUIRE(db.postAndWait(put("later", 1))); + CHECK(db.backupTo(backup, &error)); + CHECK(db.close()); + + auto copy = ALDbConnection::openForeign(backup, &error); + INFO(error); + REQUIRE(copy); + CHECK(copy->queryText("PRAGMA journal_mode") == "delete"); + CHECK(copy->queryInt("SELECT count(*) FROM kv") == 51); + CHECK(copy->queryInt("PRAGMA application_id") == S64(TEST_APPLICATION_ID)); + } + + TEST_CASE("aldatabase: a file that cannot be opened runs in memory for the session", "[aldatabase]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("no/such/directory/x.db"); + ALDatabase db(path, testOptions()); + const ALDbOpenResult& result = db.open(); + CHECK(result.outcome == ALDbOpenResult::Outcome::Transient); + CHECK(db.transient()); + CHECK(db.isOpen()); + + // It works as a file would, readers included, and keeps nothing. + REQUIRE(db.postAndWait(put("in memory", 1))); + ALDbReadLease lease = db.read(); + REQUIRE(lease); + CHECK(lease->queryInt("SELECT count(*) FROM kv") == 1); + lease = ALDbReadLease(); + CHECK(db.close()); + CHECK_FALSE(exists(path)); + } + + // The other process of the next case: writes its rows into the file the + // environment names while the test's own process writes its own. + AL_TEST_HELPER(aldb_second_writer) + { + const std::string path = LLStringUtil::getenv(SECOND_WRITER_PATH); + if (path.empty()) + { + return 2; + } + ALDatabase db(path, testOptions()); + if (!db.open().usable()) + { + return 3; + } + for (int i = 0; i < 200; ++i) + { + db.post(put("child" + std::to_string(i), i)); + if (i % 20 == 0) + { + db.flush(); + } + } + const bool flushed = db.flush(); + return db.close() && flushed ? 0 : 4; + } + + TEST_CASE("aldatabase: two processes write one file and both keep what they wrote", "[aldatabase][slow]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("shared.db"); + ALDatabase db(path, testOptions()); + REQUIRE(db.open().usable()); + + LLProcessPtr child; + { + ScopedEnvironment environment(SECOND_WRITER_PATH, path); + LLProcess::Params params; + params.executable = ll_test::currentExecutable(); + params.args.add(ll_test::Helper::argument("aldb_second_writer")); + params.desc = "aldb second writer"; + child = LLProcess::create(params); + } + REQUIRE(child); + + for (int i = 0; i < 200; ++i) + { + REQUIRE(db.post(put("parent" + std::to_string(i), i))); + if (i % 20 == 0) + { + REQUIRE(db.flush()); + } + } + REQUIRE(db.flush()); + + const bool exited = ll_test::waitUntil(60s, [&child] { + LLEventPumps::instance().obtain("mainloop").post(LLSD()); + return !child->isRunning(); + }); + REQUIRE(exited); + const LLProcess::Status status = child->getStatus(); + CHECK(status.mState == LLProcess::EXITED); + CHECK(status.mData == 0); + + CHECK(db.close()); + auto file = raw(path); + CHECK(file->queryInt("SELECT count(*) FROM kv WHERE k LIKE 'parent%'") == 200); + CHECK(file->queryInt("SELECT count(*) FROM kv WHERE k LIKE 'child%'") == 200); + } + + TEST_CASE("aldatabase: a job that makes SQLite undo the whole batch has the jobs before it tried again", + "[aldatabase]") + { + ll_test::ALDbTempDir dir; + ll_test::ALDbFaults faults; + const std::string path = dir.file("undone.db"); + // A rollback journal: a page's first change in a transaction writes + // the journal there and then, so a job can fail on a full disk. + ll_test::ALDbFaultVfs::withoutSharedMemory(true); + ALDbOptions options = testOptions(); + options.vfs = ll_test::ALDbFaultVfs::name(); + options.migrations = { makeTable(1, "kv"), makeTable(2, "other") }; + ALDatabase db(path, options); + REQUIRE(db.open().usable()); + REQUIRE(db.openResult().journalMode == "delete"); + + Gate gate; + std::atomic filled{ false }; + REQUIRE(db.post(gate.job())); + gate.mStarted.wait(); + // The first job writes, then fills the disk; the second's first + // change to its table cannot be journalled, and a full disk in a + // statement with no journal of its own undoes the whole transaction. + REQUIRE(db.post([&filled](ALDbConnection& connection) { + connection.exec("INSERT INTO kv(k, v) VALUES ('first', 1)"); + if (!filled.exchange(true)) + { + ll_test::ALDbFaultVfs::failWrites(SQLITE_FULL); + } + })); + REQUIRE(db.post([](ALDbConnection& connection) { connection.exec("INSERT INTO other(k, v) VALUES ('second', 2)"); })); + gate.mRelease.set_all(); + + REQUIRE(ll_test::waitUntil(5s, [&db] { + const ALDbStats stats = db.stats(); + return stats.retries > 0 || stats.droppedItems > 0; + })); + ll_test::ALDbFaultVfs::failWrites(SQLITE_OK); + REQUIRE(db.flush()); + CHECK(db.stats().droppedItems == 0); + CHECK(db.close()); + CHECK(rowsIn(path, "kv") == 1); + CHECK(rowsIn(path, "other") == 1); + } + + // Takes a file's write permission away, and gives it back as it goes. + class ReadOnlyFile + { + public: + explicit ReadOnlyFile(const std::string& path) + : mPath(path) + { + namespace fs = std::filesystem; + fs::permissions(fsyspath(mPath), fs::perms::owner_write | fs::perms::group_write | fs::perms::others_write, + fs::perm_options::remove); + } + + ~ReadOnlyFile() + { + namespace fs = std::filesystem; + std::error_code ec; + fs::permissions(fsyspath(mPath), fs::perms::owner_write, fs::perm_options::add, ec); + } + + ReadOnlyFile(const ReadOnlyFile&) = delete; + ReadOnlyFile& operator=(const ReadOnlyFile&) = delete; + + private: + std::string mPath; + }; + + TEST_CASE("aldatabase: a file this process may only read is read and its writes are refused and said once", + "[aldatabase]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("readonly.db"); + { + ALDatabase db(path, testOptions()); + REQUIRE(db.open().usable()); + REQUIRE(db.postAndWait(put("kept", 7))); + REQUIRE(db.close()); + } + ReadOnlyFile read_only(path); + + std::atomic reported{ 0 }; + std::atomic code{ 0 }; + ALDbOptions options = testOptions(); + options.onFailure = [&](const ALDbFailure& failure) { + ++reported; + code = failure.code; + }; + ALDatabase db(path, options); + const ALDbOpenResult& result = db.open(); + INFO(result.message); + REQUIRE(result.usable()); + CHECK(result.readOnly); + + S64 value = 0; + CHECK(db.postAndWait([&value](ALDbConnection& connection) { + value = connection.queryInt("SELECT v FROM kv WHERE k = 'kept'").value_or(-1); + })); + CHECK(value == 7); + + for (int i = 0; i < 3; ++i) + { + CHECK_FALSE(db.postAndWait(put("refused" + std::to_string(i), i))); + } + CHECK(reported == 1); + CHECK((code & 0xff) == SQLITE_READONLY); + CHECK(db.close()); + } + + TEST_CASE("aldatabase: a file another viewer migrates while this one opens is not migrated twice", + "[aldatabase]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("raced.db"); + // A file with nothing in it yet, as two viewers starting at once + // would both find. + REQUIRE(raw(path)->exec("PRAGMA journal_mode=WAL") == SQLITE_OK); + + auto other = raw(path); + REQUIRE(other->exec("BEGIN IMMEDIATE") == SQLITE_OK); + + ALDbOptions options = testOptions(); + options.busyTimeout = 5000ms; + ALDatabase db(path, options); + auto opening = std::async(std::launch::async, [&db] { db.open(); }); + // The open reads the file's version, then waits for the lock the + // other viewer holds while it makes the same tables. + ms_sleep(300); + REQUIRE(other->exec("CREATE TABLE kv(k TEXT PRIMARY KEY, v)") == SQLITE_OK); + REQUIRE(other->exec("PRAGMA user_version=1") == SQLITE_OK); + REQUIRE(other->exec("COMMIT") == SQLITE_OK); + + opening.get(); + const ALDbOpenResult& result = db.openResult(); + INFO(result.message); + REQUIRE(result.usable()); + CHECK_FALSE(result.migrated); + CHECK(db.postAndWait(put("after", 1))); + CHECK(db.close()); + other.reset(); + CHECK(rowsIn(path) == 1); + } + + TEST_CASE("aldatabase: flush gives up waiting on a disk that hangs", "[aldatabase]") + { + ll_test::ALDbTempDir dir; + ll_test::ALDbFaults faults; + const std::string path = dir.file("slowflush.db"); + ALDbOptions options = testOptions(); + options.vfs = ll_test::ALDbFaultVfs::name(); + options.closeWait = 300ms; + ALDatabase db(path, options); + REQUIRE(db.open().usable()); + + ll_test::ALDbFaultVfs::holdWrites(true); + REQUIRE(db.post(put("held", 1))); + REQUIRE(ll_test::waitUntil(5s, [] { return ll_test::ALDbFaultVfs::heldWrites() > 0; })); + + auto flushing = std::async(std::launch::async, [&db] { + LLTimer timer; + const bool written = db.flush(); + return std::make_pair(written, F32(timer.getElapsedTimeF32())); + }); + const bool returned = flushing.wait_for(3s) == std::future_status::ready; + // Lets a flush that waits for ever finish, so the test can. + ll_test::ALDbFaultVfs::holdWrites(false); + REQUIRE(returned); + const auto [written, seconds] = flushing.get(); + CHECK_FALSE(written); + CHECK(seconds < 2.f); + CHECK(db.close()); + } + + TEST_CASE("aldatabase: a job posted with a completion hears whether it was written", "[aldatabase]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("completion.db"); + ALDatabase db(path, testOptions()); + REQUIRE(db.open().usable()); + + std::atomic written{ -1 }; + std::atomic refused{ -1 }; + REQUIRE(db.post(put("one", 1), [&written](bool committed) { written = committed ? 1 : 0; })); + REQUIRE(db.post([](ALDbConnection& connection) { connection.fail(SQLITE_CONSTRAINT, "no"); }, + [&refused](bool committed) { refused = committed ? 1 : 0; })); + REQUIRE(db.flush()); + CHECK(written == 1); + CHECK(refused == 0); + CHECK(db.close()); + } +} diff --git a/indra/aldb/tests/aldbconnection_test.cpp b/indra/aldb/tests/aldbconnection_test.cpp new file mode 100644 index 0000000000..4c602cc277 --- /dev/null +++ b/indra/aldb/tests/aldbconnection_test.cpp @@ -0,0 +1,302 @@ +/** + * @file aldbconnection_test.cpp + * @brief ALDbConnection, ALDbStatement and ALDbSavepoint: statements cached + * and borrowed, every bind and column, the first failure, savepoints, and + * a file of someone else's read safely. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "aldbconnection.h" +#include "aldbfaultvfs.h" + +#include "llfile.h" +#include "lluuid.h" + +#include + +#include + +#include +#include + +namespace +{ + std::unique_ptr openFile(const std::string& path) + { + int rc = SQLITE_OK; + std::string error; + auto connection = ALDbConnection::open(path, ALDbConnection::Access::ReadWrite, {}, &rc, &error); + INFO(error); + REQUIRE(connection); + return connection; + } + + TEST_CASE("aldbconnection: a statement is cached by its SQL and a second borrower gets one of its own", + "[aldbconnection]") + { + ll_test::ALDbTempDir dir; + auto db = openFile(dir.file("cache.db")); + REQUIRE(db->exec("CREATE TABLE t(id INTEGER PRIMARY KEY, v TEXT)") == SQLITE_OK); + const size_t before = db->cachedStatements(); + for (int i = 0; i < 3; ++i) + { + db->prepare("INSERT INTO t(v) VALUES (?1)").bind(1, std::string_view("row")).run(); + } + CHECK(db->cachedStatements() == before + 1); + + // A query run inside the loop over another's rows, of the same SQL: + // the outer one must keep its place. + ALDbStatement outer = db->prepare("SELECT id FROM t ORDER BY id"); + int rows = 0; + while (outer.step()) + { + ALDbStatement inner = db->prepare("SELECT id FROM t ORDER BY id"); + REQUIRE(inner.step()); + CHECK(inner.columnInt(0) == 1); + ++rows; + } + CHECK(rows == 3); + CHECK(db->cachedStatements() == before + 2); + CHECK(db->firstError() == SQLITE_OK); + } + + TEST_CASE("aldbconnection: every bind comes back as the column it was written as", "[aldbconnection]") + { + ll_test::ALDbTempDir dir; + auto db = openFile(dir.file("types.db")); + REQUIRE(db->exec("CREATE TABLE t(i, u, l, r, s, b, e, id, n)") == SQLITE_OK); + + const std::array blob = { std::byte{ 0 }, std::byte{ 0xff }, std::byte{ 7 } }; + LLUUID id; + id.generate(); + db->prepare("INSERT INTO t VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)") + .bind(1, S32(-7)) + .bind(2, U32(0xffffffffu)) + .bind(3, S64(1) << 40) + .bind(4, F64(0.25)) + .bind(5, std::string_view("text with\0a nul", 15)) + .bind(6, std::span(blob)) + .bind(7, std::span()) + .bind(8, id) + .bindNull(9) + .run(); + REQUIRE(db->firstError() == SQLITE_OK); + + ALDbStatement row = db->prepare("SELECT i, u, l, r, s, b, e, id, n FROM t"); + REQUIRE(row.step()); + CHECK(row.columnInt(0) == -7); + CHECK(row.columnInt(1) == S64(0xffffffffu)); + CHECK(row.columnInt(2) == (S64(1) << 40)); + CHECK(row.columnReal(3) == 0.25); + CHECK(row.columnText(4) == std::string_view("text with\0a nul", 15)); + const std::span read = row.columnBlob(5); + CHECK(std::vector(read.begin(), read.end()) == std::vector(blob.begin(), blob.end())); + // An empty blob is a blob, not a NULL. + CHECK(row.columnType(6) == SQLITE_BLOB); + CHECK(row.columnBlob(6).empty()); + CHECK(row.columnUUID(7) == id); + CHECK(row.columnNull(8)); + CHECK(row.columnUUID(8).isNull()); + } + + TEST_CASE("aldbconnection: the first failure is remembered until cleared", "[aldbconnection]") + { + ll_test::ALDbTempDir dir; + auto db = openFile(dir.file("errors.db")); + REQUIRE(db->exec("CREATE TABLE t(id INTEGER PRIMARY KEY)") == SQLITE_OK); + db->prepare("INSERT INTO t VALUES (1)").run(); + CHECK(db->firstError() == SQLITE_OK); + + db->prepare("INSERT INTO t VALUES (1)").run(); + CHECK((db->firstError() & 0xff) == SQLITE_CONSTRAINT); + const std::string first = db->firstErrorMessage(); + CHECK_FALSE(first.empty()); + + // A second failure does not replace the first. + db->exec("SELECT * FROM no_such_table"); + CHECK((db->firstError() & 0xff) == SQLITE_CONSTRAINT); + CHECK(db->firstErrorMessage() == first); + + db->clearFirstError(); + CHECK(db->firstError() == SQLITE_OK); + CHECK(db->firstErrorMessage().empty()); + + // Text with no statement in it is nothing, not a failure. + CHECK_FALSE(db->prepare(" ").valid()); + CHECK(db->firstError() == SQLITE_OK); + } + + TEST_CASE("aldbconnection: savepoints nest and one rolled back undoes only itself", "[aldbconnection]") + { + ll_test::ALDbTempDir dir; + auto db = openFile(dir.file("savepoints.db")); + REQUIRE(db->exec("CREATE TABLE t(v)") == SQLITE_OK); + { + ALDbSavepoint outer(*db, "outer"); + REQUIRE(outer.begun()); + db->exec("INSERT INTO t VALUES (1)"); + { + ALDbSavepoint inner(*db, "inner"); + db->exec("INSERT INTO t VALUES (2)"); + inner.rollback(); + } + { + ALDbSavepoint kept(*db, "kept"); + db->exec("INSERT INTO t VALUES (3)"); + } + } + CHECK(db->queryInt("SELECT group_concat(v) = '1,3' FROM t") == 1); + CHECK(db->firstError() == SQLITE_OK); + } + + TEST_CASE("aldbconnection: a single statement exec runs is prepared once and a script is not kept", + "[aldbconnection]") + { + ll_test::ALDbTempDir dir; + auto db = openFile(dir.file("exec.db")); + REQUIRE(db->exec("CREATE TABLE t(v UNIQUE)") == SQLITE_OK); + + // What a writer runs for every transaction and job, one statement + // each: prepared the first time and kept. + const size_t before = db->cachedStatements(); + for (int i = 0; i < 100; ++i) + { + REQUIRE(db->exec("BEGIN IMMEDIATE") == SQLITE_OK); + { + ALDbSavepoint job(*db, "job"); + REQUIRE(job.begun()); + db->prepare("INSERT INTO t VALUES (?1)").bind(1, S32(i)).run(); + } + REQUIRE(db->exec("COMMIT") == SQLITE_OK); + } + // BEGIN IMMEDIATE, SAVEPOINT job, RELEASE job, COMMIT and the insert. + CHECK(db->cachedStatements() == before + 5); + CHECK(db->queryInt("SELECT count(*) FROM t") == 100); + + // Text of several statements runs whole, and is not kept. + const size_t kept = db->cachedStatements(); + CHECK(db->exec("CREATE TABLE a(v); INSERT INTO a VALUES (1); INSERT INTO a VALUES (2);") == SQLITE_OK); + CHECK(db->cachedStatements() == kept); + CHECK(db->queryInt("SELECT count(*) FROM a") == 2); + + // A kept statement that fails is recorded with its SQL, and runs + // again from the start the next time. + CHECK(db->exec("INSERT INTO t VALUES (0)") != SQLITE_OK); + CHECK((db->firstError() & 0xff) == SQLITE_CONSTRAINT); + CHECK(db->firstErrorMessage().find("INSERT INTO t VALUES (0)") != std::string::npos); + db->clearFirstError(); + REQUIRE(db->exec("DELETE FROM t WHERE v = 0") == SQLITE_OK); + CHECK(db->exec("INSERT INTO t VALUES (0)") == SQLITE_OK); + CHECK(db->firstError() == SQLITE_OK); + } + + TEST_CASE("aldbconnection: a file of someone else's is checked whole and runs no trigger or view", + "[aldbconnection]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("foreign.db"); + { + auto db = openFile(path); + REQUIRE(db->exec("CREATE TABLE t(v);" + "CREATE TABLE log(v);" + "CREATE TRIGGER t_log AFTER INSERT ON t BEGIN INSERT INTO log VALUES (new.v); END;" + "CREATE VIEW t_view AS SELECT v FROM t;" + "INSERT INTO t VALUES (1);") == SQLITE_OK); + } + + std::string error; + auto foreign = ALDbConnection::openForeign(path, &error); + INFO(error); + REQUIRE(foreign); + CHECK(foreign->queryInt("SELECT count(*) FROM t") == 1); + // Views are refused, and nothing can be written to fire a trigger. + CHECK_FALSE(foreign->queryInt("SELECT count(*) FROM t_view").has_value()); + foreign->clearFirstError(); + CHECK(foreign->exec("INSERT INTO t VALUES (2)") != SQLITE_OK); + // Nothing written here could fire a trigger anyway, so each defence + // is read back as well. + const auto setting = [&](int option) { + int value = -1; + sqlite3_db_config(foreign->handle(), option, -1, &value); + return value; + }; + CHECK(setting(SQLITE_DBCONFIG_DEFENSIVE) == 1); + CHECK(setting(SQLITE_DBCONFIG_TRUSTED_SCHEMA) == 0); + CHECK(setting(SQLITE_DBCONFIG_ENABLE_TRIGGER) == 0); + CHECK(setting(SQLITE_DBCONFIG_ENABLE_VIEW) == 0); + foreign.reset(); + + auto db = openFile(path); + CHECK(db->queryInt("SELECT count(*) FROM log") == 1); + } + + TEST_CASE("aldbconnection: a file of someone else's that is not a database is refused", "[aldbconnection]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("garbage.db"); + { + llofstream out(path, std::ios::binary); + const std::string garbage(8192, 'x'); + out.write(garbage.data(), std::streamsize(garbage.size())); + REQUIRE(out); + } + std::string error; + CHECK_FALSE(ALDbConnection::openForeign(path, &error)); + CHECK_FALSE(error.empty()); + } + + TEST_CASE("aldbconnection: a file of someone else's cannot hand the reader a value past its limits", + "[aldbconnection]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("oversized.db"); + { + auto db = openFile(path); + REQUIRE(db->exec("CREATE TABLE t(v)") == SQLITE_OK); + // One byte past the 32 MB a foreign file may hold in a value. + db->prepare("INSERT INTO t VALUES (zeroblob(?1))").bind(1, S64(32 * 1024 * 1024 + 1)).run(); + REQUIRE(db->firstError() == SQLITE_OK); + } + std::string error; + auto foreign = ALDbConnection::openForeign(path, &error); + INFO(error); + REQUIRE(foreign); + ALDbStatement read = foreign->prepare("SELECT v FROM t"); + CHECK_FALSE(read.step()); + CHECK((foreign->firstError() & 0xff) == SQLITE_TOOBIG); + } + + TEST_CASE("aldbconnection: result codes sort into those that may pass and damage", "[aldbconnection]") + { + CHECK(ALDb::isTransient(SQLITE_BUSY)); + CHECK(ALDb::isTransient(SQLITE_BUSY_SNAPSHOT)); + CHECK(ALDb::isTransient(SQLITE_FULL)); + CHECK(ALDb::isTransient(SQLITE_IOERR_WRITE)); + CHECK_FALSE(ALDb::isTransient(SQLITE_CONSTRAINT_PRIMARYKEY)); + CHECK_FALSE(ALDb::isTransient(SQLITE_CORRUPT)); + CHECK(ALDb::isCorrupt(SQLITE_CORRUPT_INDEX)); + CHECK(ALDb::isCorrupt(SQLITE_NOTADB)); + CHECK_FALSE(ALDb::isCorrupt(SQLITE_IOERR)); + CHECK(ALDb::errorName(SQLITE_IOERR_FSYNC) == "SQLITE_IOERR"); + } +} diff --git a/indra/aldb/tests/aldbfaultvfs.cpp b/indra/aldb/tests/aldbfaultvfs.cpp new file mode 100644 index 0000000000..f68661807f --- /dev/null +++ b/indra/aldb/tests/aldbfaultvfs.cpp @@ -0,0 +1,448 @@ +/** + * @file aldbfaultvfs.cpp + * @brief A VFS over the platform's that fails or holds writes on request, and + * what else the aldb tests share. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "aldbfaultvfs.h" + +#include "fsyspath.h" +#include "llcond.h" +#include "lltimer.h" +#include "lluuid.h" + +#include + +#include +#include +#include +#include + +#if LL_WINDOWS +#include "llwin32headers.h" +#elif LL_DARWIN +#include +#else +#include +#endif + +namespace +{ + // The wrapper a file opened through the VFS is: its own methods, and the + // platform's file right after it in the same allocation. + struct FaultFile + { + sqlite3_file base; + sqlite3_file* real; + }; + + sqlite3_file* realOf(sqlite3_file* file) + { + return reinterpret_cast(file)->real; + } + + sqlite3_vfs* sPlatform = nullptr; + sqlite3_vfs sFault{}; + std::atomic sFailWrites{ SQLITE_OK }; + std::atomic sSharedLocks{ 0 }; + std::atomic sBusyFrom{ 0 }; + std::atomic sBusyCount{ 0 }; + std::atomic sWithoutShm{ false }; + std::atomic sOpenFiles{ 0 }; + std::atomic sHeldWrites{ 0 }; + LLBoolCond sHold{ false }; + + int fileClose(sqlite3_file* file) + { + sqlite3_file* real = realOf(file); + const int rc = real->pMethods ? real->pMethods->xClose(real) : SQLITE_OK; + --sOpenFiles; + return rc; + } + + int fileRead(sqlite3_file* file, void* buffer, int amount, sqlite3_int64 offset) + { + return realOf(file)->pMethods->xRead(realOf(file), buffer, amount, offset); + } + + int fileWrite(sqlite3_file* file, const void* buffer, int amount, sqlite3_int64 offset) + { + if (sHold.get()) + { + ++sHeldWrites; + sHold.wait_equal(false); + --sHeldWrites; + } + if (const int rc = sFailWrites.load(); rc != SQLITE_OK) + { + return rc; + } + return realOf(file)->pMethods->xWrite(realOf(file), buffer, amount, offset); + } + + int fileTruncate(sqlite3_file* file, sqlite3_int64 size) + { + return realOf(file)->pMethods->xTruncate(realOf(file), size); + } + + int fileSync(sqlite3_file* file, int flags) + { + return realOf(file)->pMethods->xSync(realOf(file), flags); + } + + int fileSize(sqlite3_file* file, sqlite3_int64* size) + { + return realOf(file)->pMethods->xFileSize(realOf(file), size); + } + + int fileLock(sqlite3_file* file, int lock) + { + if (lock == SQLITE_LOCK_SHARED && sBusyCount.load() > 0) + { + const int attempt = ++sSharedLocks; + if (attempt >= sBusyFrom.load() && attempt < sBusyFrom.load() + sBusyCount.load()) + { + return SQLITE_BUSY; + } + } + return realOf(file)->pMethods->xLock(realOf(file), lock); + } + + int fileUnlock(sqlite3_file* file, int lock) + { + return realOf(file)->pMethods->xUnlock(realOf(file), lock); + } + + int fileCheckReserved(sqlite3_file* file, int* reserved) + { + return realOf(file)->pMethods->xCheckReservedLock(realOf(file), reserved); + } + + int fileControl(sqlite3_file* file, int op, void* arg) + { + return realOf(file)->pMethods->xFileControl(realOf(file), op, arg); + } + + int fileSectorSize(sqlite3_file* file) + { + return realOf(file)->pMethods->xSectorSize(realOf(file)); + } + + int fileDeviceCharacteristics(sqlite3_file* file) + { + return realOf(file)->pMethods->xDeviceCharacteristics(realOf(file)); + } + + int fileShmMap(sqlite3_file* file, int region, int size, int extend, void volatile** out) + { + return realOf(file)->pMethods->xShmMap(realOf(file), region, size, extend, out); + } + + int fileShmLock(sqlite3_file* file, int offset, int count, int flags) + { + return realOf(file)->pMethods->xShmLock(realOf(file), offset, count, flags); + } + + void fileShmBarrier(sqlite3_file* file) + { + realOf(file)->pMethods->xShmBarrier(realOf(file)); + } + + int fileShmUnmap(sqlite3_file* file, int remove) + { + return realOf(file)->pMethods->xShmUnmap(realOf(file), remove); + } + + int fileFetch(sqlite3_file* file, sqlite3_int64 offset, int amount, void** out) + { + return realOf(file)->pMethods->xFetch(realOf(file), offset, amount, out); + } + + int fileUnfetch(sqlite3_file* file, sqlite3_int64 offset, void* page) + { + return realOf(file)->pMethods->xUnfetch(realOf(file), offset, page); + } + + // Version 3: shared memory and memory mapping, as the platform's has. + const sqlite3_io_methods sMethods = { + 3, fileClose, fileRead, fileWrite, fileTruncate, + fileSync, fileSize, fileLock, fileUnlock, fileCheckReserved, + fileControl, fileSectorSize, fileDeviceCharacteristics, + fileShmMap, fileShmLock, fileShmBarrier, fileShmUnmap, fileFetch, + fileUnfetch, + }; + + // Version 1: no shared memory, so SQLite will not put the file in WAL. + const sqlite3_io_methods sMethodsWithoutShm = { + 1, fileClose, fileRead, fileWrite, fileTruncate, + fileSync, fileSize, fileLock, fileUnlock, fileCheckReserved, + fileControl, fileSectorSize, fileDeviceCharacteristics, + nullptr, nullptr, nullptr, nullptr, nullptr, + nullptr, + }; + + int vfsOpen(sqlite3_vfs*, sqlite3_filename name, sqlite3_file* file, int flags, int* out_flags) + { + FaultFile* fault = reinterpret_cast(file); + fault->real = reinterpret_cast(fault + 1); + const int rc = sPlatform->xOpen(sPlatform, name, fault->real, flags, out_flags); + if (fault->real->pMethods) + { + file->pMethods = sWithoutShm ? &sMethodsWithoutShm : &sMethods; + ++sOpenFiles; + } + else + { + file->pMethods = nullptr; + } + return rc; + } + + int vfsDelete(sqlite3_vfs*, const char* name, int sync) + { + return sPlatform->xDelete(sPlatform, name, sync); + } + + int vfsAccess(sqlite3_vfs*, const char* name, int flags, int* out) + { + return sPlatform->xAccess(sPlatform, name, flags, out); + } + + int vfsFullPathname(sqlite3_vfs*, const char* name, int size, char* out) + { + return sPlatform->xFullPathname(sPlatform, name, size, out); + } + + void* vfsDlOpen(sqlite3_vfs*, const char* name) + { + return sPlatform->xDlOpen(sPlatform, name); + } + + void vfsDlError(sqlite3_vfs*, int size, char* out) + { + sPlatform->xDlError(sPlatform, size, out); + } + + void (*vfsDlSym(sqlite3_vfs*, void* handle, const char* symbol))(void) + { + return sPlatform->xDlSym(sPlatform, handle, symbol); + } + + void vfsDlClose(sqlite3_vfs*, void* handle) + { + sPlatform->xDlClose(sPlatform, handle); + } + + int vfsRandomness(sqlite3_vfs*, int size, char* out) + { + return sPlatform->xRandomness(sPlatform, size, out); + } + + int vfsSleep(sqlite3_vfs*, int microseconds) + { + return sPlatform->xSleep(sPlatform, microseconds); + } + + int vfsCurrentTime(sqlite3_vfs*, double* out) + { + return sPlatform->xCurrentTime(sPlatform, out); + } + + int vfsGetLastError(sqlite3_vfs*, int size, char* out) + { + return sPlatform->xGetLastError ? sPlatform->xGetLastError(sPlatform, size, out) : 0; + } + + int vfsCurrentTimeInt64(sqlite3_vfs*, sqlite3_int64* out) + { + return sPlatform->xCurrentTimeInt64(sPlatform, out); + } + + void registerFault() + { + sPlatform = sqlite3_vfs_find(nullptr); + sFault.iVersion = 2; + sFault.szOsFile = int(sizeof(FaultFile)) + sPlatform->szOsFile; + sFault.mxPathname = sPlatform->mxPathname; + sFault.zName = "aldb_fault"; + sFault.xOpen = vfsOpen; + sFault.xDelete = vfsDelete; + sFault.xAccess = vfsAccess; + sFault.xFullPathname = vfsFullPathname; + sFault.xDlOpen = vfsDlOpen; + sFault.xDlError = vfsDlError; + sFault.xDlSym = vfsDlSym; + sFault.xDlClose = vfsDlClose; + sFault.xRandomness = vfsRandomness; + sFault.xSleep = vfsSleep; + sFault.xCurrentTime = vfsCurrentTime; + sFault.xGetLastError = vfsGetLastError; + sFault.xCurrentTimeInt64 = vfsCurrentTimeInt64; + sqlite3_vfs_register(&sFault, 0); + } +} + +namespace ll_test +{ + const char* ALDbFaultVfs::name() + { + static const bool registered = (registerFault(), true); + (void)registered; + return sFault.zName; + } + + void ALDbFaultVfs::failWrites(int rc) + { + sFailWrites = rc; + } + + void ALDbFaultVfs::busySharedLocks(int from, int count) + { + sBusyCount = 0; + sSharedLocks = 0; + sBusyFrom = from; + sBusyCount = count; + } + + void ALDbFaultVfs::holdWrites(bool hold) + { + sHold.set_all(hold); + } + + void ALDbFaultVfs::withoutSharedMemory(bool without) + { + sWithoutShm = without; + } + + void ALDbFaultVfs::reset() + { + failWrites(SQLITE_OK); + busySharedLocks(0, 0); + withoutSharedMemory(false); + holdWrites(false); + } + + int ALDbFaultVfs::openFiles() + { + return sOpenFiles; + } + + int ALDbFaultVfs::heldWrites() + { + return sHeldWrites; + } + + ALDbTempDir::ALDbTempDir() + { + namespace fs = std::filesystem; + std::error_code ec; + const fs::path root = fs::temp_directory_path(ec) / "aldb-tests"; + // A name already taken is drawn again; a directory that cannot be made + // at all (no space, no permission, a file in the way) fails the test. + for (int attempt = 0; attempt < 8; ++attempt) + { + const fs::path candidate = root / LLUUID::generateNewID().asString(); + if (fs::create_directories(candidate, ec)) + { + mPath = fsyspath(candidate).string(); + return; + } + if (ec) + { + break; + } + } + throw std::runtime_error("cannot make a directory under " + fsyspath(root).string() + ": " + ec.message()); + } + + ALDbTempDir::~ALDbTempDir() + { + // A file a test still held open on Windows cannot go at once; a few + // tries, then left for the system's own cleaning. + for (int attempt = 0; attempt < 20; ++attempt) + { + std::error_code ec; + std::filesystem::remove_all(fsyspath(mPath), ec); + if (!ec) + { + return; + } + ms_sleep(50); + } + } + + std::string ALDbTempDir::file(const std::string& name) const + { + return fsyspath(fsyspath(mPath) / fsyspath(name)).string(); + } + + bool waitUntil(std::chrono::milliseconds limit, const std::function& done) + { + LLTimer timer; + timer.setTimerExpirySec(F32(limit.count()) / 1000.f); + while (!done()) + { + if (timer.hasExpired()) + { + return false; + } + ms_sleep(5); + } + return true; + } + + std::string currentExecutable() + { +#if LL_WINDOWS + std::wstring buffer(MAX_PATH, L'\0'); + for (;;) + { + const DWORD length = GetModuleFileNameW(nullptr, buffer.data(), DWORD(buffer.size())); + if (length == 0) + { + return {}; + } + if (length < buffer.size()) + { + buffer.resize(length); + return fsyspath(std::filesystem::path(buffer)).string(); + } + buffer.resize(buffer.size() * 2); + } +#elif LL_DARWIN + uint32_t size = 0; + _NSGetExecutablePath(nullptr, &size); + std::string buffer(size, '\0'); + if (_NSGetExecutablePath(buffer.data(), &size) != 0) + { + return {}; + } + buffer.resize(std::strlen(buffer.c_str())); + return buffer; +#else + std::error_code ec; + return std::filesystem::read_symlink("/proc/self/exe", ec).string(); +#endif + } +} diff --git a/indra/aldb/tests/aldbfaultvfs.h b/indra/aldb/tests/aldbfaultvfs.h new file mode 100644 index 0000000000..f4dde115ad --- /dev/null +++ b/indra/aldb/tests/aldbfaultvfs.h @@ -0,0 +1,94 @@ +/** + * @file aldbfaultvfs.h + * @brief A VFS over the platform's that fails or holds writes on request, and + * what else the aldb tests share. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include +#include +#include + +namespace ll_test +{ + // SQLite's platform VFS with faults on request, registered as + // "aldb_fault" the first time its name is asked for. What it is told + // applies to every file opened through it from then on, and stays until + // reset(); a test resets it as it ends (ALDbFaults). + class ALDbFaultVfs + { + public: + static const char* name(); + + // Every write returns `rc` (SQLITE_FULL, SQLITE_IOERR_WRITE), or + // writes again at SQLITE_OK. + static void failWrites(int rc); + // Counting the shared locks asked for from now, numbers `from` to + // `from + count - 1` find the file busy, as when another process is + // writing it; a count of 0 stops it. + static void busySharedLocks(int from, int count); + // Every write waits until released: a disk that hangs. + static void holdWrites(bool hold); + // Files opened from now on offer no shared memory, as on a network + // filesystem, so that a WAL cannot run over them. + static void withoutSharedMemory(bool without); + static void reset(); + + // Files open through it now. + static int openFiles(); + // Writes waiting, held. + static int heldWrites(); + }; + + struct ALDbFaults + { + ALDbFaults() = default; + ~ALDbFaults() { ALDbFaultVfs::reset(); } + ALDbFaults(const ALDbFaults&) = delete; + ALDbFaults& operator=(const ALDbFaults&) = delete; + }; + + // A directory of the test's own under the system's temporary one, + // removed with everything in it as the test ends. + class ALDbTempDir + { + public: + ALDbTempDir(); + ~ALDbTempDir(); + ALDbTempDir(const ALDbTempDir&) = delete; + ALDbTempDir& operator=(const ALDbTempDir&) = delete; + + const std::string& path() const { return mPath; } + // A path inside it, as UTF-8. + std::string file(const std::string& name) const; + + private: + std::string mPath; + }; + + // Waits up to `limit` for `done` to say true, polling. + bool waitUntil(std::chrono::milliseconds limit, const std::function& done); + + // The running test binary, to run again as a helper. + std::string currentExecutable(); +} diff --git a/indra/aldb/tests/aldbrecovery_test.cpp b/indra/aldb/tests/aldbrecovery_test.cpp new file mode 100644 index 0000000000..7427a10318 --- /dev/null +++ b/indra/aldb/tests/aldbrecovery_test.cpp @@ -0,0 +1,378 @@ +/** + * @file aldbrecovery_test.cpp + * @brief Damaged files: moved aside with their WAL and salvaged, rebuilt, or + * left alone and the session run in memory. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "aldatabase.h" +#include "aldbfaultvfs.h" +#include "aldbrecovery.h" + +#include "fsyspath.h" +#include "llfile.h" + +#include + +#include + +#include +#include +#include + +using namespace std::chrono_literals; + +namespace +{ + // 'ALTR' + constexpr U32 TEST_APPLICATION_ID = 0x414C5452; + constexpr int ROWS = 200; + + ALDbOptions testOptions(ALDbProfile profile = ALDbProfile::Durable) + { + ALDbOptions options; + options.profile = profile; + options.applicationId = TEST_APPLICATION_ID; + options.migrations = { { 1, [](ALDbConnection& connection) { + connection.exec("CREATE TABLE kv(k TEXT PRIMARY KEY, v);" + "CREATE INDEX kv_v ON kv(v);"); + } } }; + options.retryFirst = 5ms; + options.retryLongest = 40ms; + return options; + } + + std::unique_ptr raw(const std::string& path) + { + int rc = SQLITE_OK; + std::string error; + auto connection = ALDbConnection::open(path, ALDbConnection::Access::ReadWrite, {}, &rc, &error); + INFO(error); + REQUIRE(connection); + return connection; + } + + bool exists(const std::string& path) + { + std::error_code ec; + return std::filesystem::exists(fsyspath(path), ec); + } + + std::string contents(const std::string& path) + { + llifstream in(fsyspath(path), std::ios::binary); + return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); + } + + // A file of ROWS rows, closed and its WAL emptied into it. + void makeFile(const std::string& path) + { + ALDatabase db(path, testOptions()); + REQUIRE(db.open().usable()); + for (int i = 0; i < ROWS; ++i) + { + REQUIRE(db.post([i](ALDbConnection& connection) { + connection.prepare("INSERT INTO kv(k, v) VALUES (?1, ?2)") + .bind(1, std::string_view("key" + std::to_string(i))) + .bind(2, S64(i)) + .run(); + })); + } + REQUIRE(db.flush()); + REQUIRE(db.close()); + } + + // Overwrites the index's root page with junk: the table's rows are + // whole, and the file fails its check. + void damageIndex(const std::string& path) + { + S64 page = 0; + S64 page_size = 0; + { + auto file = raw(path); + page = file->queryInt("SELECT rootpage FROM sqlite_schema WHERE name = 'kv_v'").value_or(0); + page_size = file->queryInt("PRAGMA page_size").value_or(0); + } + REQUIRE(page > 0); + REQUIRE(page_size > 0); + // Opened for reading too, so that it is written in place, not emptied. + llofstream file(fsyspath(path), std::ios::in | std::ios::out | std::ios::binary); + REQUIRE(file); + file.seekp(std::streamoff((page - 1) * page_size)); + const std::string junk(size_t(page_size), '\xa5'); + file.write(junk.data(), std::streamsize(junk.size())); + REQUIRE(file); + } + + std::vector quarantined(const ll_test::ALDbTempDir& dir, const std::string& stem) + { + std::vector found; + std::error_code ec; + for (const auto& entry : std::filesystem::directory_iterator(fsyspath(dir.path()), ec)) + { + const std::string name = fsyspath(entry.path().filename()).string(); + if (name.rfind(stem + ".corrupt-", 0) == 0) + { + found.push_back(name); + } + } + return found; + } + + TEST_CASE("aldbrecovery: a damaged file is moved aside and what it held salvaged into a new one", + "[aldbrecovery]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("damaged.db"); + makeFile(path); + damageIndex(path); + + ALDbOptions options = testOptions(); + options.checkIntegrity = true; + ALDatabase db(path, options); + const ALDbOpenResult& result = db.open(); + INFO(result.message); + REQUIRE(result.outcome == ALDbOpenResult::Outcome::Recovered); + CHECK(exists(result.quarantinedTo)); + CHECK(quarantined(dir, "damaged.db").size() == 1); + // The salvage keeps the header's version: no migration ran again. + CHECK(result.versionBefore == 1); + CHECK_FALSE(result.migrated); + + S64 rows = 0; + S64 sum = 0; + REQUIRE(db.postAndWait([&](ALDbConnection& connection) { + rows = connection.queryInt("SELECT count(*) FROM kv").value_or(-1); + sum = connection.queryInt("SELECT sum(v) FROM kv INDEXED BY kv_v WHERE v >= 0").value_or(-1); + })); + CHECK(rows == ROWS); + CHECK(sum == S64(ROWS) * (ROWS - 1) / 2); + CHECK(db.close()); + + auto file = raw(path); + CHECK(file->queryText("PRAGMA integrity_check") == "ok"); + CHECK(file->queryInt("PRAGMA application_id") == S64(TEST_APPLICATION_ID)); + } + + TEST_CASE("aldbrecovery: a file that is not a database is moved aside and begun again", "[aldbrecovery]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("garbage.db"); + { + llofstream out(fsyspath(path), std::ios::binary); + const std::string garbage(8192, 'x'); + out.write(garbage.data(), std::streamsize(garbage.size())); + } + + ALDatabase db(path, testOptions()); + const ALDbOpenResult& result = db.open(); + INFO(result.message); + REQUIRE(result.outcome == ALDbOpenResult::Outcome::Recovered); + CHECK(contents(result.quarantinedTo) == std::string(8192, 'x')); + CHECK(result.versionAfter == 1); + CHECK(db.postAndWait([](ALDbConnection& connection) { connection.exec("INSERT INTO kv VALUES ('a', 1)"); })); + CHECK(db.close()); + CHECK(raw(path)->queryInt("SELECT count(*) FROM kv") == 1); + } + + TEST_CASE("aldbrecovery: a damaged Regenerable file is deleted and made again", "[aldbrecovery]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("cache.db"); + makeFile(path); + damageIndex(path); + + ALDbOptions options = testOptions(ALDbProfile::Regenerable); + options.checkIntegrity = true; + ALDatabase db(path, options); + const ALDbOpenResult& result = db.open(); + INFO(result.message); + REQUIRE(result.outcome == ALDbOpenResult::Outcome::Rebuilt); + CHECK(result.quarantinedTo.empty()); + CHECK(quarantined(dir, "cache.db").empty()); + S64 rows = -1; + REQUIRE(db.postAndWait([&](ALDbConnection& c) { rows = c.queryInt("SELECT count(*) FROM kv").value_or(-1); })); + CHECK(rows == 0); + CHECK(db.close()); + } + + TEST_CASE("aldbrecovery: a damaged file this process may not move is left alone and the session runs in memory", + "[aldbrecovery]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("notmine.db"); + makeFile(path); + damageIndex(path); + const std::string before = contents(path); + + ALDbOptions options = testOptions(); + options.checkIntegrity = true; + options.mayQuarantine = false; + ALDatabase db(path, options); + const ALDbOpenResult& result = db.open(); + CHECK(result.outcome == ALDbOpenResult::Outcome::Transient); + CHECK(db.postAndWait([](ALDbConnection& connection) { connection.exec("INSERT INTO kv VALUES ('a', 1)"); })); + CHECK(db.close()); + + CHECK(contents(path) == before); + CHECK(quarantined(dir, "notmine.db").empty()); + } + +#if LL_WINDOWS + TEST_CASE("aldbrecovery: a damaged file another connection holds open is left alone and the session runs in memory", + "[aldbrecovery]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("held.db"); + makeFile(path); + damageIndex(path); + + // Open, and read, so that the handle is held: Windows renames no file + // another process -- or connection -- has open. + auto holder = raw(path); + REQUIRE(holder->queryInt("SELECT count(*) FROM sqlite_schema").value_or(0) > 0); + + ALDbOptions options = testOptions(); + options.checkIntegrity = true; + ALDatabase db(path, options); + const ALDbOpenResult& result = db.open(); + INFO(result.message); + CHECK(result.outcome == ALDbOpenResult::Outcome::Transient); + CHECK(db.close()); + holder.reset(); + + CHECK(exists(path)); + CHECK(quarantined(dir, "held.db").empty()); + } +#endif + + TEST_CASE("aldbrecovery: a quarantined file takes its WAL with it and the salvage reads it", "[aldbrecovery]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("withwal.db"); + { + // Rows left in the WAL alone: no checkpoint as the connection goes. + auto file = raw(path); + REQUIRE(file->queryText("PRAGMA journal_mode=WAL") == "wal"); + REQUIRE(file->exec("CREATE TABLE kv(k TEXT PRIMARY KEY, v)") == SQLITE_OK); + int unused = 0; + sqlite3_db_config(file->handle(), SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE, 1, &unused); + REQUIRE(file->exec("PRAGMA wal_autocheckpoint=0") == SQLITE_OK); + REQUIRE(file->exec("INSERT INTO kv VALUES ('only in the wal', 1)") == SQLITE_OK); + } + REQUIRE(exists(path + "-wal")); + + std::string error; + const std::optional moved = ALDbRecovery::quarantine(path, &error); + INFO(error); + REQUIRE(moved); + CHECK_FALSE(exists(path)); + CHECK_FALSE(exists(path + "-wal")); + CHECK(exists(*moved)); + CHECK(exists(*moved + "-wal")); + + const std::string salvaged = dir.file("salvaged.db"); + REQUIRE(ALDbRecovery::salvage(*moved, salvaged, &error)); + CHECK(raw(salvaged)->queryInt("SELECT count(*) FROM kv WHERE k = 'only in the wal'") == 1); + } + + TEST_CASE("aldbrecovery: damage found after an open that did not check is said once", "[aldbrecovery]") + { + ll_test::ALDbTempDir dir; + const std::string path = dir.file("latent.db"); + makeFile(path); + damageIndex(path); + + std::atomic reported{ 0 }; + std::atomic code{ 0 }; + ALDbOptions options = testOptions(); + options.onFailure = [&](const ALDbFailure& failure) { + ++reported; + code = failure.code; + }; + ALDatabase db(path, options); + const ALDbOpenResult& result = db.open(); + REQUIRE(result.outcome == ALDbOpenResult::Outcome::Opened); + + // Writing through the index reads its damaged root. + for (int i = 0; i < 3; ++i) + { + CHECK_FALSE(db.postAndWait([i](ALDbConnection& connection) { + connection.prepare("INSERT INTO kv(k, v) VALUES (?1, ?2)") + .bind(1, std::string_view("new" + std::to_string(i))) + .bind(2, S64(1000 + i)) + .run(); + })); + } + CHECK(reported == 1); + CHECK(ALDb::isCorrupt(code)); + CHECK(db.close()); + } + + TEST_CASE("aldbrecovery: a check that finds the file busy waits rather than taking it for damage", + "[aldbrecovery]") + { + ll_test::ALDbTempDir dir; + ll_test::ALDbFaults faults; + const std::string path = dir.file("busy.db"); + // A rollback journal, as on a network filesystem: each read takes its + // own lock, where a WAL reader takes one and keeps it. + ll_test::ALDbFaultVfs::withoutSharedMemory(true); + ALDbOptions options = testOptions(); + options.vfs = ll_test::ALDbFaultVfs::name(); + { + ALDatabase db(path, options); + REQUIRE(db.open().journalMode == "delete"); + for (int i = 0; i < ROWS; ++i) + { + REQUIRE(db.post([i](ALDbConnection& connection) { + connection.prepare("INSERT INTO kv(k, v) VALUES (?1, ?2)") + .bind(1, std::string_view("key" + std::to_string(i))) + .bind(2, S64(i)) + .run(); + })); + } + REQUIRE(db.close()); + } + + // The header's read takes the first lock; the check's next ones -- its + // schema as it is prepared, its pages as it runs -- find the file + // busy, as when another viewer is writing it. + ll_test::ALDbFaultVfs::busySharedLocks(2, 3); + options.checkIntegrity = true; + ALDatabase db(path, options); + const ALDbOpenResult& result = db.open(); + INFO(result.message); + CHECK(result.outcome == ALDbOpenResult::Outcome::Opened); + CHECK(result.quarantinedTo.empty()); + CHECK(quarantined(dir, "busy.db").empty()); + + ll_test::ALDbFaultVfs::busySharedLocks(0, 0); + S64 rows = 0; + CHECK(db.postAndWait([&rows](ALDbConnection& connection) { + rows = connection.queryInt("SELECT count(*) FROM kv").value_or(-1); + })); + CHECK(rows == ROWS); + CHECK(db.close()); + } +} diff --git a/indra/cmake/Dependencies.cmake b/indra/cmake/Dependencies.cmake index f2f3bf4168..b5b882e87b 100644 --- a/indra/cmake/Dependencies.cmake +++ b/indra/cmake/Dependencies.cmake @@ -91,6 +91,7 @@ al_import(ll::re2 PACKAGE re2 CONFIG TARGETS re2::re2) al_import(ll::simdjson PACKAGE simdjson CONFIG TARGETS simdjson::simdjson) al_import(ll::simdutf PACKAGE simdutf TARGETS simdutf::simdutf) al_import(ll::slua PACKAGE unofficial-slua CONFIG TARGETS unofficial::slua::Luau.Analysis unofficial::slua::Luau.Compiler unofficial::slua::Luau.Require unofficial::slua::Luau.VM) +al_import(ll::sqlite3 PACKAGE unofficial-sqlite3 CONFIG TARGETS unofficial::sqlite3::sqlite3) al_import(ll::tailslide PACKAGE unofficial-slua CONFIG TARGETS unofficial::slua::libtailslide) al_import(ll::tinyexr PACKAGE tinyexr CONFIG TARGETS unofficial::tinyexr::tinyexr) al_import(ll::vorbis PACKAGE Vorbis CONFIG TARGETS Vorbis::vorbisfile Vorbis::vorbisenc Vorbis::vorbis) diff --git a/indra/cmake/attribution.json b/indra/cmake/attribution.json index 121bb6dac6..ef789a87df 100644 --- a/indra/cmake/attribution.json +++ b/indra/cmake/attribution.json @@ -52,6 +52,7 @@ "openssl": { "holder": "Copyright (c) The OpenSSL Project Authors" }, "opus": { "holder": "Copyright 2001-2023 Xiph.Org, Skype Limited, Octasic, Jean-Marc Valin, Timothy B. Terriberry, CSIRO, Gregory Maxwell, Mark Borgerding, Erik de Castro Lopo, Mozilla, Amazon" }, "pffft": { "license": "LicenseRef-FFTPACK" }, + "sqlite3": { "holder": "Dedicated to the public domain by its authors" }, "stb": { "license": "MIT OR Unlicense" }, "tinyexr": { "holder": "Copyright (c) 2014-2021 Syoyo Fujita and many contributors" }, "tailslide": { "holder": "Copyright (c) the Tailslide authors, after pclewis's lslint; the bitstream code Copyright (c) Kai Mast" }, diff --git a/indra/llcommon/tests/threadpool_test.cpp b/indra/llcommon/tests/threadpool_test.cpp index 5d4653b434..4ffc4f8eb5 100644 --- a/indra/llcommon/tests/threadpool_test.cpp +++ b/indra/llcommon/tests/threadpool_test.cpp @@ -1,6 +1,8 @@ /** * @file threadpool_test.cpp - * @brief A pool's worker sleeps until work or a deadline wakes it. + * @brief A pool's worker sleeps until work or a deadline wakes it, and a pool + * let go of while its threads still run is destroyed by the last of + * them. * * $LicenseInfo:firstyear=2026&license=viewerlgpl$ * Alchemy Viewer Source Code @@ -26,10 +28,20 @@ #include "../threadpool.h" +#include "../llcond.h" +#include "../llcoros.h" +#include "../llevents.h" +#include "../llthread.h" +#include "../lltimer.h" + #if LL_WINDOWS #include "../llwin32headers.h" #endif +#include "altestsanitizer.h" + +#include LLCOROS_MUTEX_HEADER +#include LLCOROS_CONDVAR_HEADER #include #include @@ -38,6 +50,7 @@ #include #include #include +#include #include #include @@ -138,4 +151,278 @@ namespace CHECK(median < 0.0015); } #endif + + // A pool whose thread answers each ping, waiting between them as a + // worker waits for work: on a condition of the coroutines' own. + class EchoPool final : public LL::ThreadPool + { + public: + explicit EchoPool(const std::string& name) + : LL::ThreadPool(name, 1, 1, /*auto_shutdown*/ false, /*fixed_width*/ true) + { + } + + ~EchoPool() override + { + { + LLCoros::LockType lock(mMutex); + mStop = true; + } + mWake.notify_all(); + close(); + } + + // Asks the thread for an answer and spins until it comes: seconds. + F64 ping() + { + U32 asked = 0; + { + LLCoros::LockType lock(mMutex); + asked = ++mAsked; + } + LLTimer timer; + mWake.notify_one(); + while (mAnswered.load() != asked && timer.getElapsedTimeF64() < 5.0) + { + LLThread::yield(); + } + return timer.getElapsedTimeF64(); + } + + void run() override + { + LLCoros::LockType lock(mMutex); + while (!mStop) + { + mWake.wait(lock, [this] { return mStop || mAsked != mAnswered.load(); }); + mAnswered = mAsked; + } + } + + private: + LLCoros::Mutex mMutex; + LLCoros::ConditionVariable mWake; + U32 mAsked = 0; + std::atomic mAnswered{ 0 }; + bool mStop = false; + }; + + TEST_CASE("threadpool: a pool's thread wakes as soon as work is posted to it", "[threadpool]") + { + EchoPool pool("threadpool_test_echo"); + pool.start(); + std::vector waits; + for (int i = 0; i < 51; ++i) + { + waits.push_back(pool.ping()); + } + std::nth_element(waits.begin(), waits.begin() + waits.size() / 2, waits.end()); + const F64 median = waits[waits.size() / 2]; + INFO("median wake: " << median * 1000.0 << " ms"); + // A thread that looks for work only once a tick of the system timer + // answers half a tick late on average: 7.8 ms at Windows' default. A + // sanitizer's run is too slow and too loaded to time a wake by: one + // took 3 ms beside the other tests on a CI runner. + if constexpr (!ll_test::SLOW_SANITIZER) + { + CHECK(median < 0.0005); + } + } + + // Whether a pool of this name is alive. + bool live(const std::string& name) + { + for (const std::string& key : LL::ThreadPoolBase::key_snapshot()) + { + if (key == name) + { + return true; + } + } + return false; + } + + // Waits for a pool of this name to be gone, a while at most. + bool waitGone(const std::string& name) + { + LLTimer timer; + timer.setTimerExpirySec(10.f); + while (live(name)) + { + if (timer.hasExpired()) + { + return false; + } + ms_sleep(5); + } + return true; + } + + // A pool whose threads wait to be let go, as one stuck in a call to a + // disk that does not answer would. + class StuckPool final : public LL::ThreadPool + { + public: + StuckPool(const std::string& name, size_t width, LLOneShotCond& release, std::atomic& finished) + : LL::ThreadPool(name, width, 1, /*auto_shutdown*/ false, /*fixed_width*/ true), + mRelease(release), + mFinished(finished) + { + } + + ~StuckPool() override { close(); } + + void run() override + { + mRelease.wait(); + ++mFinished; + } + + private: + LLOneShotCond& mRelease; + std::atomic& mFinished; + }; + + TEST_CASE("threadpool: abandon returns at once and the last thread to finish destroys the pool", "[threadpool]") + { + const std::string name = "threadpool_test_stuck"; + LLOneShotCond release; + std::atomic finished{ 0 }; + { + auto pool = std::make_unique(name, 3, release, finished); + pool->start(); + LLTimer timer; + LL::ThreadPoolBase::abandon(std::move(pool)); + CHECK(timer.getElapsedTimeF32() < 1.f); + } + // The threads still run, and the pool is theirs. + CHECK(live(name)); + CHECK(finished == 0); + + release.set_all(); + CHECK(waitGone(name)); + CHECK(finished == 3); + } + + TEST_CASE("threadpool: abandon of a pool that never started destroys it at once", "[threadpool]") + { + const std::string name = "threadpool_test_unstarted"; + auto pool = std::make_unique(name, 2, 16, /*auto_shutdown*/ false, true); + REQUIRE(live(name)); + LL::ThreadPoolBase::abandon(std::move(pool)); + CHECK_FALSE(live(name)); + } + + TEST_CASE("threadpool: abandon closes the queue and its threads finish and take the pool", "[threadpool]") + { + const std::string name = "threadpool_test_queue"; + std::atomic ran{ 0 }; + { + auto pool = std::make_unique(name, 2, 16, /*auto_shutdown*/ false, true); + pool->start(); + for (int i = 0; i < 4; ++i) + { + REQUIRE(pool->getQueue().post([&ran] { ++ran; })); + } + LL::ThreadPoolBase::abandon(std::move(pool)); + } + CHECK(waitGone(name)); + // What was posted before the queue closed is still run: a closed + // queue is drained. + CHECK(ran == 4); + } + + TEST_CASE("threadpool: a pool listening for the viewer's shutdown stops on abandon", "[threadpool]") + { + const std::string name = "threadpool_test_listening"; + const std::string listener = "ThreadPool:" + name; + { + auto pool = std::make_unique(name, 1, 16, /*auto_shutdown*/ true, true); + pool->start(); + REQUIRE(LLEventPumps::instance().obtain("LLApp").getListener(listener).connected()); + LL::ThreadPoolBase::abandon(std::move(pool)); + } + CHECK_FALSE(LLEventPumps::instance().obtain("LLApp").getListener(listener).connected()); + CHECK(waitGone(name)); + } + + // Set as a thread's thread-local objects go, after everything its + // function did. + std::atomic sThreadGone{ false }; + + struct ThreadGone + { + ~ThreadGone() { sThreadGone = true; } + }; + + // Where a pool that destroyed itself was: kept rather than freed, with + // its bytes as its destructors left them, so that a write after shows. + void* sRemains = nullptr; + std::vector sAsDestroyed; + + // Lets SelfDeletingPool's thread end; outside the pool, which is gone + // once the thread wakes. + LLOneShotCond sLetGo; + + // A pool whose run() destroys it, as the Win32 window's thread does: its + // owner lets the thread go and closes the queue, and the thread deletes + // the pool as it ends. + class SelfDeletingPool final : public LL::ThreadPool + { + public: + explicit SelfDeletingPool(const std::string& name) + : LL::ThreadPool(name, 1, 1, /*auto_shutdown*/ false, /*fixed_width*/ true) + { + } + + static void* operator new(size_t size) { return ::operator new(size); } + + static void operator delete(void* block, size_t size) + { + sRemains = block; + const auto* bytes = static_cast(block); + sAsDestroyed.assign(bytes, bytes + size); + } + + // What LLWindowWin32Thread::wakeAndDestroy() does. + void letGo() + { + for (auto& pair : mThreads) + { + pair.second.detach(); + } + mQueue->close(); + sLetGo.set_all(); + } + + void run() override + { + thread_local ThreadGone gone; + (void)gone; + sLetGo.wait(); + delete this; + } + }; + + TEST_CASE("threadpool: a pool its own run() destroys is not touched after", "[threadpool]") + { + sThreadGone = false; + auto* pool = new SelfDeletingPool("threadpool_test_self_deleting"); + pool->start(); + // From here the thread may destroy the pool at any moment. + pool->letGo(); + + LLTimer timer; + timer.setTimerExpirySec(10.f); + while (!sThreadGone && !timer.hasExpired()) + { + ms_sleep(1); + } + REQUIRE(sThreadGone); + REQUIRE(sRemains); + const auto* now = static_cast(sRemains); + CHECK(std::equal(sAsDestroyed.begin(), sAsDestroyed.end(), now)); + ::operator delete(sRemains); + sRemains = nullptr; + } } diff --git a/indra/llcommon/threadpool.cpp b/indra/llcommon/threadpool.cpp index 546127e994..af71610975 100644 --- a/indra/llcommon/threadpool.cpp +++ b/indra/llcommon/threadpool.cpp @@ -18,11 +18,13 @@ // external library headers // other Linden headers #include "commoncontrol.h" +#include "llcoros.h" #include "llerror.h" #include "llevents.h" #include "llsd.h" #include "stringize.h" +#include LLCOROS_MUTEX_HEADER #include #include @@ -141,6 +143,15 @@ struct sleepy_robin: public boost::fibers::algo::round_robin /***************************************************************************** * ThreadPoolBase *****************************************************************************/ +struct LL::ThreadPoolBase::RunState +{ + LLCoros::Mutex mMutex; + // The threads started and not yet done. + size_t mRunning = 0; + // Set by abandon(): the pool, for the last of them to destroy. + ThreadPoolBase* mOrphan = nullptr; +}; + LL::ThreadPoolBase::ThreadPoolBase(const std::string& name, size_t threads, WorkQueueBase* queue, @@ -150,7 +161,8 @@ LL::ThreadPoolBase::ThreadPoolBase(const std::string& name, mName("ThreadPool:" + name), mThreadCount(fixed_width ? threads : getConfiguredWidth(name, threads)), mQueue(queue), - mAutomaticShutdown(auto_shutdown) + mAutomaticShutdown(auto_shutdown), + mRunState(std::make_shared()) {} void LL::ThreadPoolBase::start() @@ -172,15 +184,32 @@ void LL::ThreadPoolBase::start() std::string os_name{ mThreadCount > 1 ? stringize(':', (i+1)) : std::string() }; os_name.insert(0, getKey().substr(0, OS_NAME_MAX - os_name.size())); #endif - mThreads.emplace_back(tname, [this, tname, os_name]() - { - // Tracy names the OS thread too, cut to the same fifteen - // bytes, so it goes first and the short name is the one left - LL_PROFILER_SET_THREAD_NAME(tname.c_str()); - set_thread_name(os_name.c_str()); - set_thread_fp_mode(); - run(tname); - }); + // Counted before it starts, so that it cannot be done before it is + // counted, and not counted if it never starts. + { + LLCoros::LockType lock(mRunState->mMutex); + ++mRunState->mRunning; + } + try + { + mThreads.emplace_back(tname, [this, tname, os_name, state = mRunState]() + { + // Tracy names the OS thread too, cut to the same fifteen + // bytes, so it goes first and the short name is the one left + LL_PROFILER_SET_THREAD_NAME(tname.c_str()); + set_thread_name(os_name.c_str()); + set_thread_fp_mode(); + run(tname); + // The pool may be gone by now. + threadDone(state); + }); + } + catch (...) + { + LLCoros::LockType lock(mRunState->mMutex); + --mRunState->mRunning; + throw; + } } if (!mAutomaticShutdown) @@ -192,6 +221,7 @@ void LL::ThreadPoolBase::start() // Listen on "LLApp", and when the app is shutting down, close the queue // and join the workers. + mListening = true; LLEventPumps::instance().obtain("LLApp").listen( mName, [this](const LLSD& stat) @@ -210,10 +240,69 @@ void LL::ThreadPoolBase::start() LL::ThreadPoolBase::~ThreadPoolBase() { close(); - if (!LLEventPumps::wasDeleted()) + stopListening(); +} + +void LL::ThreadPoolBase::stopListening() +{ + if (mListening && !LLEventPumps::wasDeleted()) { LLEventPumps::instance().obtain("LLApp").stopListening(mName); } + mListening = false; +} + +void LL::ThreadPoolBase::abandon(std::unique_ptr pool) +{ + if (!pool) + { + return; + } + pool->mQueue->close(); + // Here, not on the pool's last thread: the event pumps are the main + // thread's. + pool->stopListening(); + { + // The state outlives the pool, which its last thread may destroy as + // soon as this lets go of the lock. + const std::shared_ptr state = pool->mRunState; + LLCoros::LockType lock(state->mMutex); + if (state->mRunning > 0) + { + LL_DEBUGS("ThreadPool") << pool->mName << " abandoned to its threads" << LL_ENDL; + for (auto& pair: pool->mThreads) + { + pair.second.detach(); + } + state->mOrphan = pool.release(); + return; + } + } + // Every thread has finished. close() joins only while the queue is + // open, so they are joined here, and the pool destroyed as it would + // have been. + for (auto& pair: pool->mThreads) + { + if (pair.second.joinable()) + { + pair.second.join(); + } + } +} + +//static +void LL::ThreadPoolBase::threadDone(const std::shared_ptr& state) +{ + ThreadPoolBase* orphan = nullptr; + { + LLCoros::LockType lock(state->mMutex); + if (--state->mRunning == 0) + { + orphan = state->mOrphan; + } + } + // An abandoned pool's threads were let go, so none is joined. + delete orphan; } void LL::ThreadPoolBase::close() diff --git a/indra/llcommon/threadpool.h b/indra/llcommon/threadpool.h index 1b3fb96c0d..7da5eb544e 100644 --- a/indra/llcommon/threadpool.h +++ b/indra/llcommon/threadpool.h @@ -59,6 +59,18 @@ namespace LL */ void close(); + /** + * abandon() lets a pool go whose threads may not finish soon -- one + * stuck in a call that does not return, on a disk that does not + * answer -- where close() and destroying it would wait for them. It + * closes the queue and stops listening for the viewer's shutdown; + * then, if every thread has finished, it destroys the pool at once, + * and otherwise the pool is its threads', and the last of them to + * finish destroys it, touching nothing of it after. Call it in place + * of destroying the pool, on the thread that would have. + */ + static void abandon(std::unique_ptr pool); + std::string getName() const { return mName; } size_t getWidth() const { return mThreads.size(); } @@ -95,10 +107,20 @@ namespace LL bool mAutomaticShutdown; private: + // What a pool's threads share with it, and keep once it is gone: a + // pool's own run() may destroy it, as the Win32 window's does. + struct RunState; + void run(const std::string& name); + // Each thread's last call, which touches nothing of the pool but + // what abandon() left it to destroy. + static void threadDone(const std::shared_ptr& state); + void stopListening(); std::string mName; size_t mThreadCount; + std::shared_ptr mRunState; + bool mListening = false; }; /** diff --git a/indra/test/altestsanitizer.h b/indra/test/altestsanitizer.h index 41323a347a..13ccb31cd4 100644 --- a/indra/test/altestsanitizer.h +++ b/indra/test/altestsanitizer.h @@ -65,6 +65,22 @@ namespace ll_test #else inline constexpr bool SANITIZER_ALLOCATOR = false; #endif + + // Whether every memory access goes through a sanitizer's checks, as + // AddressSanitizer's and ThreadSanitizer's do: a run several times slower, + // which CI runs beside other such tests, so a case that times something + // against the wall clock passes its bound over under it. +#if defined(__SANITIZE_ADDRESS__) || defined(__SANITIZE_THREAD__) + inline constexpr bool SLOW_SANITIZER = true; +#elif defined(__has_feature) +#if __has_feature(address_sanitizer) || __has_feature(thread_sanitizer) + inline constexpr bool SLOW_SANITIZER = true; +#else + inline constexpr bool SLOW_SANITIZER = false; +#endif +#else + inline constexpr bool SLOW_SANITIZER = false; +#endif } #endif // AL_ALTESTSANITIZER_H diff --git a/indra/vcpkg-configuration.json b/indra/vcpkg-configuration.json index db196d2fa9..443b285cd0 100644 --- a/indra/vcpkg-configuration.json +++ b/indra/vcpkg-configuration.json @@ -2,7 +2,7 @@ "registries": [ { "kind": "git", - "baseline": "ee9cfc3d9ac97195a137d8fc1a1fd1902f1858d6", + "baseline": "f516d9466a89e76fdc90f23f0902a5ecc4304a53", "reference": "main", "repository": "https://github.com/AlchemyViewer/alchemy-registry", "packages": [ @@ -36,6 +36,7 @@ "sentry-cocoa", "sentry-native", "slua", + "sqlite3", "tinygltf", "tracy", "v-hacd", diff --git a/indra/vcpkg.json b/indra/vcpkg.json index da0ef5cd80..e80bb15f97 100644 --- a/indra/vcpkg.json +++ b/indra/vcpkg.json @@ -147,6 +147,13 @@ "simdjson", "simdutf", "slua", + { + "name": "sqlite3", + "features": [ + "fts5", + "omit-load-extension" + ] + }, "tinyexr", "tinygltf", "v-hacd",