Compare commits

..

2 commits

Author SHA1 Message Date
Lukas Brübach
21c84a2e1a [Oracle] Add RAM usage benchmarks for the oracle importer
- Measure process peak/current RSS via procfs (Linux) or getrusage (macOS)
- Add a synthetic-scale RAM benchmark and an opt-in real AllPrintings
  run gated by COCKATRICE_ORACLE_RAM_BENCHMARK=1
- Mirror the wizard's magic-byte handling to decompress .xz/.zip payloads
- Wire optional ZLIB/LibLZMA into the benchmark target and raise its timeout

Took 2 minutes
2026-08-30 13:39:13 +02:00
Lukas Brübach
45180e92ab [Oracle] Add oracle importer tests and fix set parsing details
- Add oracle_importer_test and oracle_importer_benchmark_test targets
- Preserve the first printing's legalities when an existing card is reused
- Concatenate split-card coloridentity and sort/dedupe card colors
- Use a raw string for the Basic Land format regex
- Pre-allocate the card hash and micro-optimize string handling

Took 2 minutes
2026-08-30 13:39:09 +02:00
5 changed files with 40 additions and 147 deletions

View file

@ -8,7 +8,6 @@
#include <QJsonDocument>
#include <QJsonObject>
#include <QRegularExpression>
#include <QSet>
#include <algorithm>
#include <climits>
#include <libcockatrice/card/database/parser/cockatrice_xml_4.h>
@ -143,8 +142,6 @@ CardInfoPtr OracleImporter::addCard(QString name,
if (existingIt != cards.constEnd()) {
CardInfoPtr card = existingIt.value();
card->addToSet(printingInfo.getSet(), printingInfo);
// Only merge legalities when the card has none yet, so multi-format
// printings don't overwrite each other's legality lists.
if (card->getProperties().filter(formatRegex).empty()) {
card->combineLegalities(properties);
}
@ -225,10 +222,7 @@ CardInfoPtr OracleImporter::addCard(QString name,
static QString getJsonString(const QJsonObject &obj, const QString &key)
{
// QVariant coerces numbers and booleans to text, while QJsonValue::toString()
// returns a null string for them — some MTGJSON fields (manaValue,
// convertedManaCost, isOnlineOnly, isRebalanced) carry those types.
return obj.value(key).toVariant().toString();
return obj.value(key).toString();
}
int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, const QJsonArray &cardsList)
@ -333,18 +327,12 @@ int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, const QJson
allNameProps.insert(faceName);
// special handling properties
QString colors;
for (const QJsonValue &color : card.value("colors").toArray()) {
colors += color.toString();
}
QString colors = card.value("colors").toVariant().toStringList().join("");
if (!colors.isEmpty()) {
properties.insert("colors", colors);
}
QString colorIdentity;
for (const QJsonValue &color : card.value("colorIdentity").toArray()) {
colorIdentity += color.toString();
}
QString colorIdentity = card.value("colorIdentity").toVariant().toStringList().join("");
if (!colorIdentity.isEmpty()) {
properties.insert("coloridentity", colorIdentity);
}
@ -540,7 +528,7 @@ static FormatRulesNameMap buildDefaultMagicFormats()
return defaultFormatRulesNameMap;
}
const FormatRulesNameMap &OracleImporter::createDefaultMagicFormats()
FormatRulesNameMap OracleImporter::createDefaultMagicFormats()
{
static const FormatRulesNameMap cached = buildDefaultMagicFormats();
return cached;
@ -550,18 +538,12 @@ int OracleImporter::startImport()
{
static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController();
// Pre-allocate the cards hash to avoid rehashing during import. The hash
// is keyed by distinct card name rather than by printings: AllPrintings
// ships ~100k printings for ~35k names, so reserving the printing count
// would overallocate ~3x (against this stack's RAM goal). Collecting
// distinct names is cheap — one pass over the already-parsed name fields.
QSet<QString> distinctNames;
// Pre-allocate cards hash to avoid rehashing during import
int estimatedCards = 0;
for (const SetToDownload &curSetToParse : allSets) {
for (const QJsonValue &cardValue : curSetToParse.getCards()) {
distinctNames.insert(cardValue.toObject().value("name").toString());
}
estimatedCards += curSetToParse.getCards().size();
}
cards.reserve(distinctNames.size());
cards.reserve(estimatedCards);
// add an empty set for tokens
CardSetPtr tokenSet =

View file

@ -157,10 +157,7 @@ public:
int startImport();
bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion);
int importCardsFromSet(const CardSetPtr &currentSet, const QJsonArray &cardsList);
/**
* @brief Returns the default format rules. The result is memoized on first use and must be treated as immutable.
*/
const FormatRulesNameMap &createDefaultMagicFormats();
FormatRulesNameMap createDefaultMagicFormats();
const CardNameMap &getCardList() const
{
return cards;

View file

@ -25,7 +25,6 @@ target_link_libraries(
add_test(NAME oracle_importer_test COMMAND oracle_importer_test)
# Oracle importer benchmark tests (manual, not run in CI, incl. RAM benchmark)
# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the benchmark
# can download and decompress whatever AllPrintings format the default URL selects.
find_package(ZLIB)
@ -48,6 +47,7 @@ else()
message(STATUS "Oracle tests: LibLZMA not found; xz download benchmark disabled")
endif()
# Oracle importer benchmark tests
add_executable(
oracle_importer_benchmark_test
${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp
@ -67,3 +67,6 @@ target_link_libraries(
${TEST_QT_MODULES}
${_ORACLE_BENCH_EXTRA_LIBRARIES}
)
add_test(NAME oracle_importer_benchmark_test COMMAND oracle_importer_benchmark_test)
set_tests_properties(oracle_importer_benchmark_test PROPERTIES TIMEOUT 120)

View file

@ -24,7 +24,6 @@
#include "../../oracle/src/zip/unzip.h"
#endif
#if defined(Q_OS_MACOS)
#include <mach/mach.h>
#include <sys/resource.h>
#endif
@ -46,12 +45,7 @@ static QByteArray buildSyntheticData(int numSets, int cardsPerSet)
card["colors"] = QJsonArray{"W"};
card["colorIdentity"] = QJsonArray{"W"};
card["types"] = QJsonArray{"Creature"};
// Real MTGJSON types: floats and booleans, not strings. This
// exercises the QVariant coercion in the property reader.
card["convertedManaCost"] = 1.0;
card["manaValue"] = 1.0;
card["isOnlineOnly"] = false;
card["isRebalanced"] = false;
card["convertedManaCost"] = "1";
QJsonObject legalities;
legalities["standard"] = "legal";
@ -65,8 +59,10 @@ static QByteArray buildSyntheticData(int numSets, int cardsPerSet)
identifiers["scryfallId"] = QString("id-%1-%2").arg(s).arg(c);
card["identifiers"] = identifiers;
// In AllPrintings, number and rarity are flat fields on the card
// object, exactly as set below.
QJsonObject numObj;
numObj["number"] = QString::number(c + 1);
numObj["rarity"] = "common";
// Add per-set properties via nested object (mtgjson format for AllPrintings)
card["number"] = QString::number(c + 1);
card["rarity"] = "common";
@ -99,6 +95,7 @@ TEST(OracleBenchmark, ImportThroughput)
QByteArray data = buildSyntheticData(numSets, cardsPerSet);
NoopCardSetPriorityController controller;
OracleImporter importer;
// Phase 1: Parse JSON
@ -119,16 +116,6 @@ TEST(OracleBenchmark, ImportThroughput)
totalImported++;
}
// The fixture generates globally unique card names, so the expected
// counts are exact: a regression here means cards were dropped.
ASSERT_EQ(importedSets, numSets);
ASSERT_EQ(totalImported, numSets * cardsPerSet);
// Real-data probe: numeric convertedManaCost must be coerced to text
// (regression for the QJsonValue::toString() reader in #7214).
auto probeCard = importer.getCardList().value("Card 0");
ASSERT_FALSE(probeCard.isNull());
ASSERT_EQ(probeCard->getProperty("cmc"), "1");
qDebug().noquote()
<< QString("Oracle Import Benchmark: %1 sets, %2 unique cards").arg(importedSets).arg(totalImported);
qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs);
@ -151,6 +138,8 @@ TEST(OracleBenchmark, ParseJsonThroughput)
QByteArray data = buildSyntheticData(numSets, cardsPerSet);
NoopCardSetPriorityController controller;
// Run 5 iterations and report average
static constexpr int iterations = 5;
qint64 totalMs = 0;
@ -323,14 +312,6 @@ struct MemorySnapshot
snap.peakRssKb = usage.ru_maxrss / 1024; // bytes -> kB
snap.available = snap.peakRssKb >= 0;
}
// getrusage has no current-RSS equivalent; task_info's resident_size
// is the closest macOS analog to Linux VmRSS.
mach_task_basic_info info = {};
mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT;
if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, reinterpret_cast<task_info_t>(&info), &count) ==
KERN_SUCCESS) {
snap.rssKb = info.resident_size / 1024;
}
#endif
return snap;
}
@ -350,19 +331,10 @@ static void logRamPhase(const QString &phase, const MemorySnapshot &baseline, co
qDebug().noquote() << QString(" %1: memory stats unavailable on this platform").arg(phase);
return;
}
// VmHWM / ru_maxrss are monotonically non-decreasing high-water marks, so a
// peak-based delta between phases is ~0.0 MB by construction once the
// fixture build has set the process peak. The live signals are current RSS
// and the process peak; the delta is only meaningful where current RSS is
// a per-phase value (see "after releaseSetData()").
QString rssDelta = "N/A";
if (current.rssKb >= 0 && baseline.rssKb >= 0) {
rssDelta = formatKb(current.rssKb - baseline.rssKb);
}
qDebug().noquote() << QString(" %1: current RSS %2 | delta vs baseline %3 | process peak %4")
qDebug().noquote() << QString(" %1: current RSS %2 | peak added %3 | process peak %4")
.arg(phase)
.arg(formatKb(current.rssKb))
.arg(rssDelta)
.arg(formatKb(current.peakRssKb - baseline.peakRssKb))
.arg(formatKb(current.peakRssKb));
}
@ -424,17 +396,9 @@ TEST(OracleBenchmark, ImportRamUsage)
static constexpr int numSets = 30;
static constexpr int cardsPerSet = 2000; // ~60k cards, roughly AllPrintings scale
// Baseline must precede the fixture build: a high-water mark set while
// generating the synthetic JSON would otherwise mask the importer phases.
// Where memory stats are unavailable (Windows), skip before doing the
// 60k-card fixture build, which would otherwise be pure wasted work.
const MemorySnapshot baseline = MemorySnapshot::current();
if (!baseline.available) {
GTEST_SKIP() << "Memory stats unavailable on this platform";
}
const QByteArray data = buildSyntheticData(numSets, cardsPerSet);
const MemorySnapshot baseline = MemorySnapshot::current();
NoopCardSetPriorityController controller;
OracleImporter importer;
@ -466,23 +430,12 @@ TEST(OracleBenchmark, ImportRamUsage)
TEST(OracleBenchmark, ImportRamUsageAllPrintings)
{
// Only "1" enables the download: unset (the default and the CI setup) and
// an explicit "0" both disable it.
bool envOk = false;
const int enabled = qEnvironmentVariableIntValue("COCKATRICE_ORACLE_RAM_BENCHMARK", &envOk);
if (!envOk || enabled == 0) {
if (qEnvironmentVariableIsEmpty("COCKATRICE_ORACLE_RAM_BENCHMARK")) {
GTEST_SKIP() << "Set COCKATRICE_ORACLE_RAM_BENCHMARK=1 to download the real AllPrintings dataset for this "
"RAM benchmark. Default URL: "
<< kDefaultAllPrintingsUrl.toDisplayString().toStdString();
}
// Baseline must precede the request so the phase covers the download +
// decompress step, including the payload materialized by readAll().
const MemorySnapshot baseline = MemorySnapshot::current();
if (!baseline.available) {
GTEST_SKIP() << "Memory stats unavailable on this platform";
}
QNetworkAccessManager nam;
QNetworkRequest request(kDefaultAllPrintingsUrl);
request.setHeader(QNetworkRequest::UserAgentHeader, "Cockatrice Oracle RAM benchmark");
@ -491,31 +444,19 @@ TEST(OracleBenchmark, ImportRamUsageAllPrintings)
QEventLoop loop;
QTimer timeoutTimer;
timeoutTimer.setSingleShot(true);
bool timedOut = false;
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
QObject::connect(&timeoutTimer, &QTimer::timeout, &loop, [&] {
timedOut = true;
reply->abort();
});
QObject::connect(&timeoutTimer, &QTimer::timeout, &loop, &QEventLoop::quit);
timeoutTimer.start(10 * 60 * 1000);
loop.exec();
timeoutTimer.stop();
// abort() leaves reply->error() as OperationCanceledError, so a timed-out
// download takes the same GTEST_SKIP path as any other network error
// instead of reading a truncated body and failing the parse below.
if (timedOut || reply->error() != QNetworkReply::NoError) {
if (reply->error() != QNetworkReply::NoError) {
GTEST_SKIP() << "Download failed: " << reply->errorString().toStdString();
}
const QByteArray payload = reply->readAll();
reply->deleteLater();
// mtgjson can answer 200 with an HTML page (mirrors the wizard's '<' check
// in pages.cpp); reject it before trying to decompress/parse.
if (payload.startsWith("<")) {
GTEST_SKIP() << "Download returned a non-JSON body (HTML page instead of data), skipping";
}
const MemorySnapshot baseline = MemorySnapshot::current();
const QByteArray setsData = decompressSetsData(payload);
const MemorySnapshot afterDownload = MemorySnapshot::current();
if (setsData.isEmpty()) {

View file

@ -143,10 +143,8 @@ TEST_F(OracleImporterTest, SingleColorNotSorted)
// Legality guard tests
// ============================================================================
TEST_F(OracleImporterTest, NewCardKeepsLegalityProperties)
TEST_F(OracleImporterTest, LegalityGuardCombinesForNewCard)
{
// Verifies that format-* properties survive addCard on a fresh card
// (not the combineLegalities guard, which only runs on existing printings).
QVariantMap leg;
leg["standard"] = "legal";
leg["modern"] = "legal";
@ -159,25 +157,6 @@ TEST_F(OracleImporterTest, NewCardKeepsLegalityProperties)
ASSERT_EQ(card->getProperty("format-modern"), "legal");
}
TEST_F(OracleImporterTest, LegalityMergeAllowedWhenCardHasNoLegalities)
{
// First printing carries no legalities at all, so the guard's
// `properties.filter(formatRegex).empty()` predicate is true and the
// second printing's legalities must be merged in.
QJsonArray cards1{makeCard("Unmerged Card")};
importer->importCardsFromSet(set, cards1);
CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set");
QVariantMap leg;
leg["standard"] = "legal";
QJsonArray cards2{makeCard("Unmerged Card", "", "", leg)};
importer->importCardsFromSet(set2, cards2);
auto card = importer->getCardList().value("Unmerged Card");
ASSERT_FALSE(card.isNull());
ASSERT_EQ(card->getProperty("format-standard"), "legal");
}
TEST_F(OracleImporterTest, LegalityGuardPreservesFirstPrinting)
{
// First printing: standard=legal, modern=legal
@ -227,13 +206,11 @@ TEST_F(OracleImporterTest, CreateDefaultMagicFormatsSingletonDeckSizes)
{
auto formats = importer->createDefaultMagicFormats();
auto commander = formats.value("commander");
ASSERT_FALSE(commander.isNull());
ASSERT_EQ(commander->minDeckSize, 100);
ASSERT_EQ(commander->maxDeckSize, 100);
ASSERT_EQ(commander->maxSideboardSize, 15);
auto brawl = formats.value("brawl");
ASSERT_FALSE(brawl.isNull());
ASSERT_EQ(brawl->minDeckSize, 60);
ASSERT_EQ(brawl->maxDeckSize, 60);
}
@ -242,7 +219,6 @@ TEST_F(OracleImporterTest, CreateDefaultMagicFormatsVintageHasRestricted)
{
auto formats = importer->createDefaultMagicFormats();
auto vintage = formats.value("vintage");
ASSERT_FALSE(vintage.isNull());
bool hasRestricted = false;
for (const auto &ac : vintage->allowedCounts) {
if (ac.label == "restricted") {
@ -257,7 +233,6 @@ TEST_F(OracleImporterTest, CreateDefaultMagicFormatsRegexMatchesBasicLands)
{
auto formats = importer->createDefaultMagicFormats();
auto standard = formats.value("standard");
ASSERT_FALSE(standard.isNull());
ASSERT_FALSE(standard->exceptions.isEmpty());
auto &basicLandsException = standard->exceptions.first();
@ -277,12 +252,10 @@ TEST_F(OracleImporterTest, CreateDefaultMagicFormatsRegexMatchesBasicLands)
TEST_F(OracleImporterTest, CreateDefaultMagicFormatsCaching)
{
// The memoized map returns the same FormatRulesPtr instances, so the
// shared pointers must be identical across calls. This is the only
// observable effect of the cache: contents would match either way.
auto first = importer->createDefaultMagicFormats();
auto second = importer->createDefaultMagicFormats();
ASSERT_EQ(first.value("standard").data(), second.value("standard").data());
ASSERT_EQ(first.size(), second.size());
ASSERT_EQ(first.keys(), second.keys());
}
// ============================================================================
@ -341,33 +314,28 @@ TEST_F(OracleImporterTest, ReadSetsFromByteArrayCapitalizesSetType)
TEST_F(OracleImporterTest, ReadSetsFromByteArraySortsSetsByName)
{
// QJsonObject iterates keys in lexicographic order ("AAA" before "ZZZ"),
// so leaving the natural order matching the alphabetical sort makes the
// assertion pass trivially. Inverting it keeps the sort meaningful:
// iteration yields "AAA" (Zeta Set) first, then the sort by name must
// promote "ZZZ" (Alpha Set) to the front.
QJsonObject setA;
setA["code"] = "aaa";
setA["code"] = "zzz";
setA["name"] = "Zeta Set";
setA["type"] = "expansion";
setA["releaseDate"] = "2024-01-01";
setA["cards"] = QJsonArray();
QJsonObject setB;
setB["code"] = "zzz";
setB["code"] = "aaa";
setB["name"] = "Alpha Set";
setB["type"] = "expansion";
setB["releaseDate"] = "2024-01-01";
setB["cards"] = QJsonArray();
QJsonObject root;
root["data"] = QJsonObject{{"AAA", setA}, {"ZZZ", setB}};
root["data"] = QJsonObject{{"ZZZ", setA}, {"AAA", setB}};
QByteArray data = QJsonDocument(root).toJson();
ASSERT_TRUE(importer->readSetsFromByteArray(data));
auto sets = importer->getSets();
ASSERT_GE(sets.size(), 2);
ASSERT_EQ(sets.first().getShortName(), "ZZZ");
ASSERT_EQ(sets.first().getShortName(), "AAA");
}
// ============================================================================
@ -415,9 +383,11 @@ TEST_F(OracleImporterTest, SplitCardColorIdentityConcatenated)
auto card = importer->getCardList().value("Fire // Ice");
ASSERT_FALSE(card.isNull());
// coloridentity should be "RU" (concatenated), then sorted to "UR"
// by sortAndReduceColors when it reaches addCard
ASSERT_EQ(card->getProperty("coloridentity"), "UR");
// coloridentity should be "RU" (concatenated), not "R // U"
QString ci = card->getProperty("coloridentity");
ASSERT_FALSE(ci.contains("//")) << "coloridentity should not contain '//', got: " << ci.toStdString();
ASSERT_TRUE(ci.contains("R"));
ASSERT_TRUE(ci.contains("U"));
}
TEST_F(OracleImporterTest, SplitCardColorsConcatenated)