IodidDOCUMENTATION

persistence/mongo.mdx

/doc/persistence/mongo

PUBLIC DOCUMENTATION

Persistence — MongoDB

Database beebot vs beebot_test, 15 collections, document shapes, indexes with IXSCAN, WiredTiger zstd and codec.

Sources: src/main/java/com/safjnest/nosql/MongoDB.java, src/main/java/com/safjnest/nosql/AbstractEntity.java, src/main/java/com/safjnest/nosql/NoSqlEntityExecutor.java, src/main/java/com/safjnest/utils/JsonCodec.java.

Database — beebot vs beebot_test

MongoDB (src/main/java/com/safjnest/nosql/MongoDB.java:152):

java
public static final String PRODUCTION_DATABASE = "beebot";
public static final String TEST_DATABASE = "beebot_test";
public static String getDatabaseName(boolean testing) { return testing ? TEST_DATABASE : PRODUCTION_DATABASE; }
public static synchronized MongoDatabase getDatabase() {
    if (database == null) database = getClient().getDatabase(App.isTesting() ? TEST_DATABASE : PRODUCTION_DATABASE);
    if (!collectionsReady) { ensureCollections(database); collectionsReady = true; }
    return database;
}
  • App.isTesting() determines the database — never hard-coded in the URI (SettingsLoader rejects URI with selected database :194).
  • getClient() reads settings.json: mongo URI, applyConnectionString, build (:189).
  • close() (:204) resets client/database/collectionsReady — callable for tests/hot-reload.

No TTL index, no sharding configured.

15 collections

COLLECTION_NAMES (MongoDB.java:131):

java
private static final List<String> COLLECTION_NAMES = List.of(
    "summoner", "match", "match_events", "profile_statistics", "champion",
    "champion_builds", "champion_stats", "profile_activity", "profile_matchups", PROFILE_RECORDS_COLLECTION, LEADERBOARD_AGGREGATES_COLLECTION,
    COMPETITIVE_COLLECTION,
    CHAMPION_INDEXABLES_COLLECTION, PROFILE_INDEXABLES_COLLECTION, "migration_runs");
#CollectionConstant / _id shapeAccessorDescription
1summoner_id = puuid (String)summoners() :639identity + rank + mastery
2match_id = gameId (String, e.g. EUW1_1234567890)matches()normalized match + participants
3match_events_id = gameIdmatchEvents()timeline events (JSON payload data String)
4profile_statistics_id = puuid + ":" + filter.toSummonerKey()via database().getCollectionaggregated statistics per Filter (see below)
5championlegacy—single champion doc (deprecated in favor of champion_stats)
6champion_builds_id = champion + filter.genericKey()via database()aggregated builds per champion
7champion_stats_id = champion + filter.genericKey()via database()aggregated stats per champion
8profile_activity_id = puuid + ":" + filter.toSummonerKey()via database()aggregated activity per period
9profile_matchups_id = puuid + ":" + filter.toStateKey()via database()matchups per opponent
10profile_recordsPROFILE_RECORDS_COLLECTION = "profile_records" _id = segment keyprofileRecords() :3320records per metric + region
11leaderboard_aggregatesLEADERBOARD_AGGREGATES_COLLECTION = "leaderboard_aggregates" _id = rank + region + queue + roleleaderboardAggregates() :643leaderboard counts/aggregates
12competitiveCOMPETITIVE_COLLECTION = "competitive" _id = 16B SHA256 (Binary)competitive() :647competitive entry per leaderboard segment
13champions_indexableCHAMPION_INDEXABLES_COLLECTION = "champions_indexable" _id = champion + role + patchMajorchampionIndexables() :3332(champion,role) indexable per patch
14profiles_indexablePROFILE_INDEXABLES_COLLECTION = "profiles_indexable" _id = puuidprofileIndexables() :3336Master+ indexable profiles
15migration_runs_id = migration namevia database()tracking MongoMigration runs

Only summoner and match use AbstractEntity (collectionName()/entityId()). Others are direct Document access via JsonCodec.

Document shapes

summoner

Summoner (src/main/java/com/safjnest/lol/model/summoner/Summoner.java:18):

json
{
  "_id": "puuid-abc123",
  "puuid": "puuid-abc123",
  "riotId": "Faker#KR1",
  "riotSearch": "faker#kr1",
  "region": "KR",
  "level": 512,
  "icon": 4567,
  "userId": "discord-123",
  "tracking": true,
  "ranks": {
    "RANKED_SOLO_5X5": { "tier": "CHALLENGER", "division": 1, "lp": 1280, "wins": 342, "losses": 298, "tierDivisionType": "CHALLENGER_I" },
    "RANKED_FLEX_SR": { "tier": "MASTER", "lp": 45 }
  },
  "masteries": [{ "championId": 157, "championPoints": 1234567, "championLevel": 7, "lastPlayTime": 1715434800000 }],
  "lastUpdate": 1715434800000,
  "lastSeenAt": 1715434800000
}
  • _id = entityId() = puuid (Summoner.java:185).
  • collectionName() = "summoner" (:180).
  • ranks map GameQueueType.canonicalQueue (:137).
  • masteries list Mastery (Mastery.java).

match

Match (src/main/java/com/safjnest/lol/model/match/Match.java:24):

json
{
  "_id": "EUW1_1234567890",
  "id": 1234567890,
  "gameId": "EUW1_1234567890",
  "region": "EUW1",
  "leagueShard": "EUW1",
  "queue": "RANKED_SOLO_5X5",
  "rank": "EMERALD",
  "patch": "15.6.1",
  "patchMajor": "15.6",
  "timeStart": 1715000000000,
  "timeEnd": 1715001800000,
  "lastUpdate": 1715434800000,
  "tracked": true,
  "bans": { "BLUE": [157, 64], "RED": [238, 122] },
  "participants": [
    {
      "puuid": "abc", "champion": 157, "lane": "MID", "team": "BLUE", "win": true, "kda": "8/2/6",
      "kills": 8, "deaths": 2, "assists": 6, "cs": 245, "goldEarned": 14200, "visionScore": 23,
      "damage": 28000, "rankProgress": { "rank": "EMERALD_I", "lp": 45, "gain": 18, "previousRank": "EMERALD_I", "previousLp": 27 },
      "starterItems": [1055, 2003], "boots": 3006, "supportItem": 0,
      "item0": 6672, "item1": 3031, "item2": 3508, "item3": 3072, "item4": 3033, "item5": 0,
      "skillOrder": [1,3,2,1,1,4], "augments": [], "primaryRunes": [8005], "secondaryRunes": [8275], "statsRunes": [5005]
    }
  ],
  "events": { "events": [] },
  "eventData": {}
}
  • _id and gameId coincide (Match.java:139), leagueShard = region.
  • tracked: boolean — invariant false -> true only via Tracker (docs/architecture/README.md:106), MatchService.insert creates only tracked=false (MatchService.java:126).
  • patchMajor derived from PatchUtils.patchMajor(patch) (Match.java:75).

match_events

json
{
  "_id": "EUW1_1234567890",
  "gameId": "EUW1_1234567890",
  "data": "{\"events\": [...], \"frames\": [...]}",
  "patchMajor": "15.6",
  "lastUpdate": 1715434800000
}

data is a JSON String (not a Document) for compactness. WiredTiger block_compressor=zstd (EVENTS_STORAGE_ENGINE_CONFIG:106).

profile_statistics

json
{
  "_id": "abc-puuid:BASE64URL_FILTER_SUMMONER_KEY",
  "puuid": "abc-puuid",
  "filterKey": "BASE64URL_SUMMONER_KEY",
  "filter": { "champion": 0, "lane": null, "queue": null, "rank": null, "patch": null, "region": null, "opponent": 0, "duo": 0, "timeStart": 1700000000000, "timeEnd": 1716000000000 },
  "overview": { "games": 320, "wins": 180, "losses": 140, "winrate": 0.562, "kda": 3.2, "csPerMinute": 7.1 },
  "rows": [{ "champion": 157, "lane": "MID", "games": 80, "wins": 45, "winrate": 0.562 }],
  "lastUpdate": 1715434800000
}

Key _id = puuid + ":" + filter.toSummonerKey() (Filter.java:283) — URL-safe Base64 encodeUrl (FilterCodec.java:15). Collection without AbstractEntity, access via MongoDB.findProfileStatisticsMatches / forEachProfileStatisticsMatch.

profile_activity

Similar to profile_statistics but with activity aggregates, same _id = puuid:filterKey pattern.

json
{
  "_id": "abc:BASE64URL_KEY",
  "puuid": "abc",
  "filterKey": "BASE64URL",
  "overview": { "games": 120, "wins": 68, "kda": 3.1 },
  "champions": [{ "champion": 157, "games": 40, "wins": 24 }],
  "lanes": [{ "lane": "MID", "games": 80, "wins": 45 }],
  "lastUpdate": 1715434800000
}

profile_matchups

json
{
  "_id": "abc:BASE64URL_STATE_KEY",
  "puuid": "abc",
  "filterKey": "BASE64URL_STATE_KEY",
  "filter": { "queue": "RANKED_SOLO_5X5", "lane": "MID", "patch": "15.6", "minGames": 5 },
  "matchups": { "238": [{ "opponent": 238, "games": 12, "wins": 5, "losses": 7, "winrate": 0.416 }] },
  "lastUpdate": 1715434800000
}

Key toStateKey() (Filter.java:266) — ordinal encoding for compactness.

profile_records / champion_builds / champion_stats / competitive

  • profile_records: _id = segment key per RecordMetric + region + Filter.canonical(), field records: List<ProfileRecord> with puuid, metricValue, gameId.
  • champion_builds: _id = champion + genericKey (Filter.genericKey :300), field builds: List<Build>.
  • champion_stats: _id = champion + genericKey, field stats: ChampionStatistics.
  • competitive: _id = Binary 16B = first 16 bytes of SHA256(puuid) (MongoDB.COMPETITIVE_ID_BYTES:103), fields puuid, region, queue, rank, mmr, lastUpdate, access via MessageDigest.getInstance("SHA-256") (MongoDB.java:16).

champions_indexable / profiles_indexable

json
// champions_indexable
{ "_id": "157_MID_15.6", "championId": 157, "role": "MID", "patchMajor": "15.6", "games": 12400, "indexable": true, "lastUpdate": 1715434800000 }

// profiles_indexable
{ "_id": "abc-puuid", "puuid": "abc-puuid", "riotId": "Faker#KR1", "region": "KR", "rank": "CHALLENGER_I", "lastUpdate": 1715434800000 }

Only TierDivisionType in INDEXABLE_PROFILE_RANKS: MASTER_I, GRANDMASTER_I, CHALLENGER_I (MongoDB.java:116).

Indexes — table with keys, unique and purpose

MongoDB creates collections with ensureCollections (MongoDB.java:136) but does not declare explicit indexes via driver in this file — indexes are created via ensureIndexesAsync in services or required as a manual step (see docs/architecture/). Expected indexed keys (from available queries) are:

CollectionKeysUniquePurposeQuery that uses IXSCAN
summoner{ _id: 1 }yes (PK)lookup by puuidfindSummoner(puuid), findProfileProjection
summoner{ riotSearch: 1, region: 1 }noprefix autocomplete searchfindSummonerSearch (Filters.regex("riotSearch", prefix) + region)
summoner{ riotId: 1, region: 1 }no (case-insensitive via regex)lookup by Riot IDfindPuuid(riotId, shard)
summoner{ userId: 1 }noDiscord account linkfindAccountsByUserId
summoner{ "ranks.RANKED_SOLO_5X5.tier": 1 }noleaderboard rank filterLeaderboardService aggregation
match{ _id: 1 }yesdetailfindMatch(fullGameId)
match{ "participants.puuid": 1, timeStart: -1 }nomatch history per puuidgetMatches, findMatchResults (elemMatch puuid + sort timeStart)
match{ "participants.puuid": 1, region: 1, queue: 1, timeStart: -1 }nohistory with queue filterbuildMatchFilter(puuid, shard, filter)
match{ patchMajor: 1 }nochampion tier-list per patchfindChampionRoleGames, findChampionIndexables
match{ patchMajor: 1, "participants.champion": 1 }nochampion win queryfindChampionWins
match_events{ _id: 1 }yesjoin with matchattachEvents
profile_statistics{ _id: 1 }yespuuid:filterKeyfindProfileStatistics(puuid, filter)
profile_activity{ _id: 1 }yespuuid:filterKeygetActivity
profile_matchups{ _id: 1 }yespuuid:stateKeygetMatchups
champion_stats{ _id: 1 }yeschampion:genericKeyChampionService.get
champion_builds{ _id: 1 }yesas aboveChampionService.getBuild
competitive{ _id: 1 }yes (Binary 16B)lookup by puuid hashfindCompetitive(puuid) via SHA256
leaderboard_aggregates{ _id: 1 }yesaggregates per segmentLeaderboardService

IXSCAN: every query above must show IXSCAN in explain("executionStats"), never COLLSCAN. Verify with db.match.explain("executionStats").find({"participants.puuid":"abc"}).sort({timeStart:-1}) — winningPlan.stage must be IXSCAN or FETCH on IXSCAN.

Explain — IXSCAN

js
// Verify indexes
db.summoner.getIndexes()
db.match.getIndexes()

// Explain for match history
db.match.explain("executionStats").find(
  { "participants.puuid": "abc-puuid", "region": "EUW1", "queue": "RANKED_SOLO_5X5" }
).sort({ timeStart: -1 }).limit(20)

// Must show:
// winningPlan: { stage: "FETCH", inputStage: { stage: "IXSCAN", keyPattern: { "participants.puuid":1, timeStart:-1 } } }
// executionStats.totalDocsExamined == nReturned (no COLLSCAN)

WiredTiger zstd

Only match_events uses custom storageEngineOptions (MongoDB.java:147):

java
private static CreateCollectionOptions collectionOptions(String collection) {
    if (!"match_events".equals(collection)) return new CreateCollectionOptions();
    return new CreateCollectionOptions().storageEngineOptions(new Document("wiredTiger", new Document("configString", EVENTS_STORAGE_ENGINE_CONFIG)));
}
// EVENTS_STORAGE_ENGINE_CONFIG = "block_compressor=zstd" (:106)

zstd compresses better than the default snappy for the data String payload (timeline events), saving ~30-50% on the largest collection. Other collections use the default WiredTiger (snappy).

No TTL / No Sharding

  • No TTL index — db.getCollectionInfos({name:"match"}) must not show expireAfterSeconds. Retention is logical (filters by timeStart/Filter.canonical()), not physical.
  • No sharding — sh.status() must not show sharded for beebot.*. Scalability via indexes and projections (Projections.include) to limit bsonBytes.

AbstractEntity / NoSqlEntityExecutor / JsonCodec

AbstractEntity

AbstractEntity<E> (src/main/java/com/safjnest/nosql/AbstractEntity.java:10):

  • Fields: pendingChanges: PendingChanges, filters: Map, instant: boolean, existing: boolean.
  • API: setValue(path,value), unsetValue, pushValue, pullValue, replaceOrAppendArrayElement, setArrayElementField — accumulate operations, flushIfInstant() if instant=true.
  • upsert() / update() / updateNow() → flush(upsert) → NoSqlEntityExecutor.execute(collectionName(), entityId(), operations, persistenceFilters, upsert).
  • snapshotValues() overridden by Summoner/Match for initial state on insert.
  • collectionName() and entityId() abstract — only summoner and match implement them.

NoSqlEntityExecutor

NoSqlEntityExecutor (src/main/java/com/safjnest/nosql/NoSqlEntityExecutor.java:5):

java
interface EntityWriter { boolean execute(String collection, String id, List<Map<String, Object>> operations, Map<String, Object> filters, boolean upsert); }
private static final EntityWriter DEFAULT_WRITER = MongoDB::applyEntityUpdate;
static boolean execute(...) { return writer.execute(collection, id, operations, filters, upsert); }
static void installWriterForTests(EntityWriter testWriter) { writer = testWriter == null ? DEFAULT_WRITER : testWriter; }

MongoDB.applyEntityUpdate (:1958) translates set/unset/push/pull operations into driver Updates, uses Filters.eq("_id", id) + additional filters, UpdateOptions.upsert(upsert).

JsonCodec

JsonCodec (src/main/java/com/safjnest/utils/JsonCodec.java:15):

java
private static final ObjectMapper MAPPER = JsonMapper.builder()
    .addModule(new JavaTimeModule())
    .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
    .configure(MapperFeature.CAN_OVERRIDE_ACCESS_MODIFIERS, true)
    .visibility(GETTER, NONE).visibility(IS_GETTER, NONE).visibility(FIELD, ANY)
    .build();
public static String toJson(Object value) { return MAPPER.writeValueAsString(value); }
public static <T> T fromJson(String json, Class<T> type) { return MAPPER.readValue(json, type); }
public static Document toDocument(Object value) { return Document.parse(toJson(value)); }

Used for Document ↔ POJO both in Mongo (toDocument/fromDocument) and in Redis (RedisClient.set/get with JsonCodec.toJson).