Compare commits

...

4 commits

Author SHA1 Message Date
Lukas Brübach
e3820c3f34 [PictureLoader] Seed per-host allowances on demand and skip hosts in 429 backoff
The quota reset re-filled every host's remaining allowance to a full
MAX_REQUESTS_PER_SEC as soon as the queue had a request for it. A server
that was just rate limited could therefore be hammered again at full speed
immediately after (or even during) recovery.

Only seed a host's allowance the first time it is dispatched in the
current second, seeded from its reduced sustained quota, and skip hosts
still inside their 429 backoff window entirely. This makes the pacing
commit's burst-free behavior hold per host too, instead of just smoothing
the global aggregate.
2026-09-13 03:36:32 +02:00
Lukas Brübach
1c6ee62393 [PictureLoader] Pace requests and run the throttle timers on the worker thread
Previously the whole backed-up queue was drained in a burst as soon as a
request was enqueued, sending up to 10 requests back-to-back and then
immediately re-filling the quota one second later. That hard-bursts a
rate-limited API like Scryfall's (10 requests/second) into a 30 second
lockout.

Introduce a pacing timer that dispatches a single queue entry every
100 ms, so the per-second allowance is used smoothly instead of in spikes,
and keep the quota timer at 1 second. Also fix both timers' thread
affinity: they are QTimer value members and so are not QObject children,
meaning moveToThread() on the worker left them on the main thread while
the slot code started them from the picture thread, which was a no-op that
also warned. They are moved to the worker thread explicitly and started
lazily from there.
2026-09-13 03:36:31 +02:00
BruebachL
fd82b140a8
[PictureLoader] Serve cached pictures from the disk cache instead of re-fetching them (#7284)
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled
With picture downloads enabled, requests were issued with AlwaysNetwork
cache control, which per Qt never consults the disk cache. A picture that
had already been downloaded was therefore fetched from the network again
on every session start, with the queue bypass letting those re-fetches
skip the rate limit entirely.

Treat the network cache as the intent of the 'Network Cache' storage
method suggests: if the URL is already cached, serve it with AlwaysCache
(no network, no quota); only a genuine miss goes to the network, and only
when downloads are enabled. Cache hits skip the queue for free since they
never consume the per-second request allowance.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-13 03:36:29 +02:00
tooomm
5d025ca0bd
Use capitalized app names (#7255)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* use capitalized app name

* update urls

* app description

* Update main.cpp
2026-09-12 17:30:46 +02:00
17 changed files with 96 additions and 42 deletions

View file

@ -94,7 +94,7 @@ QStringMap &SoundEngine::getAvailableThemes()
QDir dir;
availableThemes.clear();
// load themes from user profile dir
// Load themes from user profile dir
dir.setPath(SettingsCache::instance().getDataPath() + "/sounds");
@ -104,7 +104,7 @@ QStringMap &SoundEngine::getAvailableThemes()
}
}
// load themes from cockatrice system dir
// Load themes from Cockatrice system dir
dir.setPath(qApp->applicationDirPath() +
#ifdef Q_OS_MAC
"/../Resources/sounds"

View file

@ -17,8 +17,10 @@
#include <version_string.h>
static constexpr int MAX_REQUESTS_PER_SEC = 10;
static constexpr int MIN_HOST_QUOTA = 1; ///< Floor for the per-host request allowance
static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering
static constexpr int MIN_HOST_QUOTA = 1; ///< Floor for the per-host request allowance
static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering
static constexpr int DISPATCH_INTERVAL_MS = 100; ///< Pacing between individual network requests
static constexpr qint64 QUOTA_RESET_INTERVAL_MS = 1000; ///< Interval at which the request quota resets
CardPictureLoaderWorker::CardPictureLoaderWorker()
: QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()),
@ -60,11 +62,18 @@ CardPictureLoaderWorker::CardPictureLoaderWorker()
pictureLoaderThread->start(QThread::LowPriority);
moveToThread(pictureLoaderThread);
// QTimer value members are not QObject children, so moveToThread on the worker doesn't move
// them. They must live in the worker's thread to be started from the slot code that runs there.
requestTimer.moveToThread(pictureLoaderThread);
dispatchTimer.moveToThread(pictureLoaderThread);
connect(this, &CardPictureLoaderWorker::imageLoadEnqueued, this, &CardPictureLoaderWorker::handleImageLoadEnqueued);
connect(&requestTimer, &QTimer::timeout, this, &CardPictureLoaderWorker::resetRequestQuota);
requestTimer.setInterval(1000);
requestTimer.start();
requestTimer.setInterval(static_cast<int>(QUOTA_RESET_INTERVAL_MS));
connect(&dispatchTimer, &QTimer::timeout, this, &CardPictureLoaderWorker::dispatchQueuedRequest);
dispatchTimer.setInterval(DISPATCH_INTERVAL_MS);
}
CardPictureLoaderWorker::~CardPictureLoaderWorker()
@ -84,8 +93,8 @@ void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWor
SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE &&
cache->metaData(url).isValid()) {
// If we hit a cached url, we get to make the request for free, since it won't contribute towards the
// rate-limit
// A request that will be served from the disk cache never touches the network and therefore
// doesn't use up any of the rate limit, so it gets to skip the queue.
makeRequest(url, worker);
return;
}
@ -107,10 +116,13 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/,/*;q=0.8");
bool useNetworkCache =
!picDownload && static_cast<CardPictureLoaderCacheMethod::CacheMethod>(
SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE;
// Cached entries are served straight from the disk cache even when picture downloads are
// enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a
// genuine cache miss goes to the network, and only when downloads are enabled.
bool useNetworkCache = static_cast<CardPictureLoaderCacheMethod::CacheMethod>(
SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE &&
(cache->metaData(url).isValid() || !picDownload);
req.setAttribute(QNetworkRequest::CacheLoadControlAttribute,
useNetworkCache ? QNetworkRequest::AlwaysCache : QNetworkRequest::AlwaysNetwork);
@ -126,6 +138,9 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture
void CardPictureLoaderWorker::resetRequestQuota()
{
requestQuota = MAX_REQUESTS_PER_SEC;
// Allowances are seeded per host on demand in processSingleRequest(), so a
// rate-limited host never gets a fresh full quota mid-second.
hostQuotaRemaining.clear();
QDateTime now = QDateTime::currentDateTime();
for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end(); ++it) {
@ -134,27 +149,53 @@ void CardPictureLoaderWorker::resetRequestQuota()
}
}
for (const auto &request : requestLoadQueue) {
const QString host = request.first.host();
hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC));
}
processQueuedRequests();
}
void CardPictureLoaderWorker::processQueuedRequests()
{
while (requestQuota > 0 && processSingleRequest()) {
if (requestLoadQueue.isEmpty()) {
dispatchTimer.stop();
return;
}
// Start lazily from the worker's own thread: QTimer must be started in the thread it lives in.
if (!requestTimer.isActive()) {
requestTimer.start();
}
dispatchTimer.start();
}
void CardPictureLoaderWorker::dispatchQueuedRequest()
{
if (requestLoadQueue.isEmpty() || requestQuota <= 0) {
dispatchTimer.stop();
return;
}
if (processSingleRequest()) {
--requestQuota;
} else {
// No queued host currently has allowance left in this second; wait for the quota reset.
dispatchTimer.stop();
}
}
bool CardPictureLoaderWorker::processSingleRequest()
{
QDateTime now = QDateTime::currentDateTime();
for (int i = 0; i < requestLoadQueue.size(); ++i) {
const auto &request = requestLoadQueue.at(i);
QString host = request.first.host();
int allowance = hostQuotaRemaining.value(host, MAX_REQUESTS_PER_SEC);
const QString host = request.first.host();
// Don't dispatch requests to a host that is currently in its 429 backoff.
if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) {
continue;
}
// Seed the allowance only now, so a host that was rate limited last second
// doesn't get a fresh full quota the moment it is queried mid-second.
if (!hostQuotaRemaining.contains(host)) {
hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC));
}
int allowance = hostQuotaRemaining.value(host);
if (allowance > 0) {
hostQuotaRemaining.insert(host, allowance - 1);
makeRequest(request.first, request.second);

View file

@ -89,6 +89,9 @@ public slots:
/** @brief Processes all queued requests respecting the request quota. */
void processQueuedRequests();
/** @brief Chooses a request from the queue and starts it, respecting the quota and pacing. */
void dispatchQueuedRequest();
/**
* @brief Processes a single queued request.
* @return true if a request was processed, false if queue is empty.
@ -120,6 +123,7 @@ private:
int requestQuota; ///< Remaining requests allowed per second
QTimer requestTimer; ///< Timer to reset the request quota
QTimer dispatchTimer; ///< Timer pacing individual network requests
QHash<QString, int> hostRequestQuota; ///< Sustained per-host request allowance
QHash<QString, int> hostQuotaRemaining; ///< Per-host allowance left in the current second
QHash<QString, QDateTime> hostLast429; ///< When each host was last rate limited

View file

@ -22,6 +22,11 @@ static const QStringList MD5_BLACKLIST = {
"fbc7d763c08771c260b39e2115414eeb" // Current card back hash
};
ServerRateLimiter &CardPictureLoaderWorkerWork::rateLimiter()
{
return s_rateLimiter;
}
CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad)
: QObject(nullptr), cardToDownload(CardPictureToLoad(toLoad)),
picDownload(SettingsCache::instance().downloads().getPicDownload())

View file

@ -43,6 +43,9 @@ public:
CardPictureToLoad cardToDownload; ///< The card and associated URLs to try downloading
/** @brief Shared per-server 429 backoff state. */
static ServerRateLimiter &rateLimiter();
public slots:
/**
* @brief Handles a finished network reply for the card image.

View file

@ -17,7 +17,7 @@ enum Format
PlainText,
/**
* This is cockatrice's native deck file format, and supports deck metadata such as banner cards and tags.
* This is Cockatrice's native deck file format, and supports deck metadata such as banner cards and tags.
* Stored as .cod files.
*/
Cockatrice

View file

@ -50,7 +50,7 @@ DeckLoader::loadFromFile(const QString &fileName, DeckFileFormat::Format fmt, bo
result = deckList.loadFromFile_Native(&file);
if (!result) {
qCInfo(DeckLoaderLog) << "Failed to load " << fileName
<< "as cockatrice format; retrying as plain format";
<< "as Cockatrice format; retrying as plain format";
file.seek(0);
result = deckList.loadFromFile_Plain(&file, CardNameNormalizer());
fmt = DeckFileFormat::PlainText;

View file

@ -131,7 +131,7 @@ public:
static void printDeckList(QPrinter *printer, const DeckList &deckList);
/**
* Converts the given deck's file to the cockatrice file format.
* Converts the given deck's file to the Cockatrice file format.
* Uses the lastLoadInfo in the LoadedDeck to determine the current name of the file and where to save to.
* @param deck The deck to convert. Should have valid lastLoadInfo. Will update the lastLoadInfo.
* @return Whether the conversion succeeded.

View file

@ -224,7 +224,7 @@ QStringMap &ThemeManager::getAvailableThemes()
}
}
// load themes from cockatrice system dir
// Load themes from Cockatrice system dir
dir.setPath(systemThemesBasePath());
for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) {

View file

@ -478,13 +478,13 @@ void DlgSettings::closeEvent(QCloseEvent *event)
case Invalid:
loadErrorMessage = tr("Your card database is invalid.\n\n"
"Cockatrice may not function correctly with an invalid database\n\n"
"You may need to rerun oracle to update your card database.\n\n"
"You may need to rerun Oracle to update your card database.\n\n"
"Would you like to change your database location setting?");
break;
case VersionTooOld:
loadErrorMessage = tr("Your card database version is too old.\n\n"
"This can cause problems loading card information or images\n\n"
"Usually this can be fixed by rerunning oracle to to update your card database.\n\n"
"Usually this can be fixed by rerunning Oracle to to update your card database.\n\n"
"Would you like to change your database location setting?");
break;
case NotLoaded:

View file

@ -92,8 +92,8 @@
#include <libcockatrice/settings/updates_settings.h>
#define GITHUB_PAGES_URL "https://cockatrice.github.io"
#define GITHUB_CONTRIBUTORS_URL "https://github.com/Cockatrice/Cockatrice/graphs/contributors?type=c"
#define GITHUB_CONTRIBUTE_URL "https://github.com/Cockatrice/Cockatrice#cockatrice"
#define GITHUB_CONTRIBUTORS_URL "https://github.com/Cockatrice/Cockatrice/graphs/contributors"
#define GITHUB_CONTRIBUTE_URL "https://github.com/Cockatrice/Cockatrice#"
#define GITHUB_TRANSIFEX_TRANSLATORS_URL "https://github.com/Cockatrice/Cockatrice/wiki/Translator-Hall-of-Fame"
#define GITHUB_TRANSLATOR_FAQ_URL "https://github.com/Cockatrice/Cockatrice/wiki/Translation-FAQ"
#define GITHUB_ISSUES_URL "https://github.com/Cockatrice/Cockatrice/issues"
@ -1050,7 +1050,7 @@ void MainWindow::createCardUpdateProcess(bool background)
if (dir.exists(binaryName)) {
updaterCmd = dir.absoluteFilePath(binaryName);
} else { // try and find the directory oracle is stored in the build directory
} else { // try and find the directory Oracle is stored in the build directory
QDir findLocalDir(dir);
findLocalDir.cdUp();
findLocalDir.cd(getCardUpdaterBinaryName());

View file

@ -231,7 +231,7 @@ int main(int argc, char *argv[])
// These values are only used by the settings loader/saver
// Wrong or outdated values are kept to not break things
QCoreApplication::setOrganizationName("Cockatrice");
QCoreApplication::setOrganizationDomain("cockatrice.de");
QCoreApplication::setOrganizationDomain("cockatrice.github.io");
QCoreApplication::setApplicationName("Cockatrice");
QCoreApplication::setApplicationVersion(VERSION_STRING);
@ -250,7 +250,7 @@ int main(int argc, char *argv[])
// Command-line parser
QCommandLineParser parser;
parser.setApplicationDescription("Cockatrice");
parser.setApplicationDescription("Cockatrice Client");
parser.addHelpOption();
parser.addVersionOption();
@ -350,8 +350,8 @@ int main(int argc, char *argv[])
qCInfo(MainLog) << "MainWindow constructor finished";
ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice")));
// set name of the app desktop file; used by wayland to load the window icon
QGuiApplication::setDesktopFileName("cockatrice");
// Set name of the app desktop file; used by wayland to load the window icon
QGuiApplication::setDesktopFileName("Cockatrice");
SettingsCache::instance().network().setClientID(generateClientID());

View file

@ -50,8 +50,8 @@ int main(int argc, char *argv[])
QApplication app(argc, argv);
QCoreApplication::setOrganizationName("Cockatrice");
QCoreApplication::setOrganizationDomain("cockatrice");
// this can't be changed, as it influences the default save path for cards.xml
QCoreApplication::setOrganizationDomain("Cockatrice");
// This can't be changed, as it influences the default save path for cards.xml
QCoreApplication::setApplicationName("Cockatrice");
// If the program is opened with the -s flag, it will only do spoilers. Otherwise it will do MTGJSON/Tokens
@ -83,7 +83,7 @@ int main(int argc, char *argv[])
QIcon icon("theme:appicon.svg");
wizard.setWindowIcon(icon);
// set name of the app desktop file; used by wayland to load the window icon
QGuiApplication::setDesktopFileName("oracle");
QGuiApplication::setDesktopFileName("Oracle");
wizard.show();

View file

@ -796,7 +796,7 @@ void SaveSetsPage::retranslateUi()
{
setTitle(tr("Sets imported"));
if (wizard()->downloadedPlainXml) {
setSubTitle(tr("A cockatrice database file of %1 MB has been downloaded.")
setSubTitle(tr("A Cockatrice card database file of %1 MB has been downloaded.")
.arg(qRound(wizard()->xmlData.size() / 1000000.0)));
} else {
setSubTitle(tr("The following sets have been found:"));

View file

@ -20,7 +20,7 @@
* Note that "...enters tapped unless..." returns false.
*
* @param name The name of the card
* @param text The oracle text of the card
* @param text The Oracle text of the card
*/
bool parseCipt(const QString &name, const QString &text)
{

View file

@ -54,7 +54,7 @@ using ScanProgressCallback = std::function<void(qsizetype bytesRead, qsizetype t
* @brief Scans a full MTGJSON document without materializing the JSON tree.
*
* Splits the top-level "data" object into per-set byte ranges and reads each
* set's metadata directly from the raw bytes. The oracle importer can then
* set's metadata directly from the raw bytes. The Oracle importer can then
* parse one set at a time during import, keeping peak memory far below a single
* QJsonDocument::fromJson() over the whole file.
*

View file

@ -86,9 +86,10 @@ bool Servatrice_DatabaseInterface::openDatabase()
<< dbversion << "to version" << expectedversion;
return false;
} else if (dbversion > expectedversion) {
qCCritical(DatabaseInterfaceLog) << poolStr << "Error opening database: the database schema version"
<< dbversion << "is too new, you need to update servatrice"
<< "(this servatrice actually uses version" << expectedversion << ")";
qCCritical(DatabaseInterfaceLog)
<< poolStr << "Error opening database: the database schema version" << dbversion
<< "is too new, you need to update Servatrice" << "(Currently running Servatrice actually uses version"
<< expectedversion << ")";
return false;
}
} else {