/* * Picture loader benchmark / regression suite against the real card image hosts. * * Deliberately not registered with ctest: it hits live Scryfall / Gatherer * endpoints at ~10 requests per second and takes minutes. Run it by hand. * * picture_loader_benchmark_test --carddb /path/to/cards.xml [options] * * Modes * ----- * default : for every URL template in the configured download list (or for each * --url given), load #count pictures twice: once cold (network) and * once cached (served from the QNetworkDiskCache). Both passes must * load every card with zero failures. The cached pass must complete * well under the cold time, which is the regression gate for serving * cached pictures instead of re-fetching them. The cold pass must stay * above a pacing lower bound, the regression gate for burst-free * request throttling. * --stress: two CardPictureLoaderWorker instances loading the same cards * concurrently against one host (~20 req/s aggregate), which forces * real 429 responses. Both workers must still complete 100% of their * cards via the shared backoff logic. */ #include "client/settings/cache_settings.h" #include "interface/card_picture_loader/card_picture_loader_worker.h" #include "interface/card_picture_loader/card_picture_to_load.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include class BenchmarkCardDatabasePathProvider : public ICardDatabasePathProvider { public: BenchmarkCardDatabasePathProvider(QString _cardsXml, QString _customSetsDir) : cardsXml(std::move(_cardsXml)), customSetsDir(std::move(_customSetsDir)) { } QString getCardDatabasePath() const override { return cardsXml; } QString getCustomCardDatabasePath() const override { return customSetsDir; } QString getTokenDatabasePath() const override { return QString(); } QString getSpoilerCardDatabasePath() const override { return QString(); } private: QString cardsXml; QString customSetsDir; }; struct PassResult { int enqueued = 0; int finished = 0; int failed = 0; qint64 elapsedMs = 0; QStringList failedCards; }; static QList selectCardsForTemplate(CardDatabase &db, const QString &urlTemplate, int maxCards) { QList selected; const QList cards = db.getCardList().values(); for (const CardInfoPtr &card : cards) { if (selected.size() >= maxCards) { break; } const SetToPrintingsMap &sets = card->getSets(); if (sets.isEmpty()) { continue; } const QList printings = sets.first(); if (printings.isEmpty()) { continue; } const ExactCard cardToLoad(card, printings.first()); if (CardPictureToLoad(cardToLoad).transformUrl(urlTemplate).isEmpty()) { continue; } selected.append(cardToLoad); } return selected; } static PassResult runPass(CardPictureLoaderWorker *worker, const QList &cards, int timeoutMs) { PassResult result; result.enqueued = cards.size(); QEventLoop loop; QTimer watchdog; watchdog.setSingleShot(true); watchdog.setInterval(timeoutMs); QObject::connect(&watchdog, &QTimer::timeout, &loop, &QEventLoop::quit); QElapsedTimer clock; QObject::connect(worker, &CardPictureLoaderWorker::imageLoaded, &loop, [&](const ExactCard &card, const QImage &image) { ++result.finished; if (image.isNull()) { ++result.failed; if (result.failedCards.size() < 10) { result.failedCards.append(card.getName()); } } if (result.finished >= result.enqueued) { loop.quit(); } }); clock.start(); for (const ExactCard &card : cards) { worker->enqueueImageLoad(card); } watchdog.start(); loop.exec(); result.elapsedMs = clock.elapsed(); return result; } struct StressResult { PassResult a; PassResult b; int http429Count = 0; }; struct LogCounters { int http429 = 0; QMutex mutex; }; static std::atomic s_activeCounters{nullptr}; static QtMessageHandler s_previousMessageHandler = nullptr; static void stressLogHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg) { if (LogCounters *counters = s_activeCounters.load(std::memory_order_acquire); counters && msg.contains(QStringLiteral("Too many requests from"))) { QMutexLocker locker(&counters->mutex); ++counters->http429; } if (s_previousMessageHandler) { s_previousMessageHandler(type, context, msg); } else { // qInstallMessageHandler() reports the built-in handler as nullptr, so a plain `if` would // swallow every message - including the 429 warnings this run is meant to surface. Fall // back to Qt's message pattern written to stderr instead. std::fprintf(stderr, "%s\n", qPrintable(qFormatLogMessage(type, context, msg))); } } // Stops a worker's thread and frees it. shutdownThread()'s bounded wait guarantees that the worker // object was freed by its finished() -> deleteLater chain, so the thread itself can then be deleted // safely. A worker whose thread refused to stop is left alone (and leaked) rather than freed while // still running. static void destroyWorker(CardPictureLoaderWorker *worker) { if (!worker) { return; } QThread *thread = worker->workerThread(); if (worker->shutdownThread()) { delete thread; } } static StressResult runStress(CardPictureLoaderWorker *workerA, CardPictureLoaderWorker *workerB, const QList &cards, int timeoutMs) { StressResult result; result.a.enqueued = cards.size(); result.b.enqueued = cards.size(); int completed = 0; QMutex completedMutex; QEventLoop loop; QTimer watchdog; watchdog.setSingleShot(true); watchdog.setInterval(timeoutMs); QObject::connect(&watchdog, &QTimer::timeout, &loop, &QEventLoop::quit); const auto finishOne = [&](PassResult &pass, const ExactCard &card, const QImage &image) { ++pass.finished; if (image.isNull()) { ++pass.failed; if (pass.failedCards.size() < 10) { pass.failedCards.append(card.getName()); } } QMutexLocker locker(&completedMutex); ++completed; if (completed >= result.a.enqueued + result.b.enqueued) { loop.quit(); } }; QElapsedTimer clock; QObject::connect(workerA, &CardPictureLoaderWorker::imageLoaded, &loop, [&](const ExactCard &card, const QImage &image) { finishOne(result.a, card, image); }); QObject::connect(workerB, &CardPictureLoaderWorker::imageLoaded, &loop, [&](const ExactCard &card, const QImage &image) { finishOne(result.b, card, image); }); // Count 429 responses as seen by the shared rate limiter. LogCounters counters; s_activeCounters = &counters; s_previousMessageHandler = qInstallMessageHandler(stressLogHandler); clock.start(); for (const ExactCard &card : cards) { workerA->enqueueImageLoad(card); workerB->enqueueImageLoad(card); } watchdog.start(); loop.exec(); const qint64 elapsedMs = clock.elapsed(); result.a.elapsedMs = elapsedMs; result.b.elapsedMs = elapsedMs; // Stop both workers before touching the counters or restoring the message handler: their // threads log from stressLogHandler, and must not outlive the stack-local counters (which is // guaranteed on the watchdog path, where requests and deferred retries are still pending). destroyWorker(workerA); destroyWorker(workerB); result.http429Count = counters.http429; s_activeCounters.store(nullptr, std::memory_order_release); qInstallMessageHandler(s_previousMessageHandler); return result; } static QString formatDuration(qint64 ms) { return QStringLiteral("%1.%2 s").arg(ms / 1000).arg((ms % 1000) / 100); } static bool likelyRedirects(const QString &urlTemplate) { return urlTemplate.contains(QStringLiteral("api.scryfall.com")); } static QString hostOf(const QString &urlTemplate) { return QUrl(urlTemplate).host(); } static void printUsage() { std::printf("usage: picture_loader_benchmark_test --carddb [options]\n" "\n" "Loads card pictures from the real configured hosts (not a mock server) and\n" "verifies the picture loader's pacing / cache 429 behavior.\n" "\n" "options:\n" " --carddb cards.xml to load card data from (required)\n" " --count cards to load per template (default 300)\n" " --url