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):
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 (SettingsLoaderrejects URI with selected database:194).getClient()readssettings.json: mongoURI,applyConnectionString,build(:189).close()(:204) resetsclient/database/collectionsReady— callable for tests/hot-reload.
No TTL index, no sharding configured.
15 collections
COLLECTION_NAMES (MongoDB.java:131):
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");
| # | Collection | Constant / _id shape | Accessor | Description |
|---|---|---|---|---|
| 1 | summoner | _id = puuid (String) | summoners() :639 | identity + rank + mastery |
| 2 | match | _id = gameId (String, e.g. EUW1_1234567890) | matches() | normalized match + participants |
| 3 | match_events | _id = gameId | matchEvents() | timeline events (JSON payload data String) |
| 4 | profile_statistics | _id = puuid + ":" + filter.toSummonerKey() | via database().getCollection | aggregated statistics per Filter (see below) |
| 5 | champion | legacy | — | single champion doc (deprecated in favor of champion_stats) |
| 6 | champion_builds | _id = champion + filter.genericKey() | via database() | aggregated builds per champion |
| 7 | champion_stats | _id = champion + filter.genericKey() | via database() | aggregated stats per champion |
| 8 | profile_activity | _id = puuid + ":" + filter.toSummonerKey() | via database() | aggregated activity per period |
| 9 | profile_matchups | _id = puuid + ":" + filter.toStateKey() | via database() | matchups per opponent |
| 10 | profile_records | PROFILE_RECORDS_COLLECTION = "profile_records" _id = segment key | profileRecords() :3320 | records per metric + region |
| 11 | leaderboard_aggregates | LEADERBOARD_AGGREGATES_COLLECTION = "leaderboard_aggregates" _id = rank + region + queue + role | leaderboardAggregates() :643 | leaderboard counts/aggregates |
| 12 | competitive | COMPETITIVE_COLLECTION = "competitive" _id = 16B SHA256 (Binary) | competitive() :647 | competitive entry per leaderboard segment |
| 13 | champions_indexable | CHAMPION_INDEXABLES_COLLECTION = "champions_indexable" _id = champion + role + patchMajor | championIndexables() :3332 | (champion,role) indexable per patch |
| 14 | profiles_indexable | PROFILE_INDEXABLES_COLLECTION = "profiles_indexable" _id = puuid | profileIndexables() :3336 | Master+ indexable profiles |
| 15 | migration_runs | _id = migration name | via 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):
{
"_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).ranksmapGameQueueType.canonicalQueue(:137).masterieslistMastery(Mastery.java).
match
Match (src/main/java/com/safjnest/lol/model/match/Match.java:24):
{
"_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": {}
}
_idandgameIdcoincide (Match.java:139),leagueShard= region.tracked: boolean— invariantfalse -> trueonly viaTracker(docs/architecture/README.md:106),MatchService.insertcreates onlytracked=false(MatchService.java:126).patchMajorderived fromPatchUtils.patchMajor(patch)(Match.java:75).
match_events
{
"_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
{
"_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.
{
"_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
{
"_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 perRecordMetric+region+Filter.canonical(), fieldrecords: List<ProfileRecord>withpuuid,metricValue,gameId.champion_builds:_id=champion + genericKey(Filter.genericKey:300), fieldbuilds: List<Build>.champion_stats:_id=champion + genericKey, fieldstats: ChampionStatistics.competitive:_id=Binary16B = first 16 bytes ofSHA256(puuid)(MongoDB.COMPETITIVE_ID_BYTES:103), fieldspuuid,region,queue,rank,mmr,lastUpdate, access viaMessageDigest.getInstance("SHA-256")(MongoDB.java:16).
champions_indexable / profiles_indexable
// 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:
| Collection | Keys | Unique | Purpose | Query that uses IXSCAN |
|---|---|---|---|---|
summoner | { _id: 1 } | yes (PK) | lookup by puuid | findSummoner(puuid), findProfileProjection |
summoner | { riotSearch: 1, region: 1 } | no | prefix autocomplete search | findSummonerSearch (Filters.regex("riotSearch", prefix) + region) |
summoner | { riotId: 1, region: 1 } | no (case-insensitive via regex) | lookup by Riot ID | findPuuid(riotId, shard) |
summoner | { userId: 1 } | no | Discord account link | findAccountsByUserId |
summoner | { "ranks.RANKED_SOLO_5X5.tier": 1 } | no | leaderboard rank filter | LeaderboardService aggregation |
match | { _id: 1 } | yes | detail | findMatch(fullGameId) |
match | { "participants.puuid": 1, timeStart: -1 } | no | match history per puuid | getMatches, findMatchResults (elemMatch puuid + sort timeStart) |
match | { "participants.puuid": 1, region: 1, queue: 1, timeStart: -1 } | no | history with queue filter | buildMatchFilter(puuid, shard, filter) |
match | { patchMajor: 1 } | no | champion tier-list per patch | findChampionRoleGames, findChampionIndexables |
match | { patchMajor: 1, "participants.champion": 1 } | no | champion win query | findChampionWins |
match_events | { _id: 1 } | yes | join with match | attachEvents |
profile_statistics | { _id: 1 } | yes | puuid:filterKey | findProfileStatistics(puuid, filter) |
profile_activity | { _id: 1 } | yes | puuid:filterKey | getActivity |
profile_matchups | { _id: 1 } | yes | puuid:stateKey | getMatchups |
champion_stats | { _id: 1 } | yes | champion:genericKey | ChampionService.get |
champion_builds | { _id: 1 } | yes | as above | ChampionService.getBuild |
competitive | { _id: 1 } | yes (Binary 16B) | lookup by puuid hash | findCompetitive(puuid) via SHA256 |
leaderboard_aggregates | { _id: 1 } | yes | aggregates per segment | LeaderboardService |
IXSCAN: every query above must show
IXSCANinexplain("executionStats"), neverCOLLSCAN. Verify withdb.match.explain("executionStats").find({"participants.puuid":"abc"}).sort({timeStart:-1})—winningPlan.stagemust beIXSCANorFETCHonIXSCAN.
Explain — IXSCAN
// 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):
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 showexpireAfterSeconds. Retention is logical (filters bytimeStart/Filter.canonical()), not physical. - No sharding —
sh.status()must not showshardedforbeebot.*. Scalability via indexes and projections (Projections.include) to limitbsonBytes.
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()ifinstant=true. upsert()/update()/updateNow()→flush(upsert)→NoSqlEntityExecutor.execute(collectionName(), entityId(), operations, persistenceFilters, upsert).snapshotValues()overridden bySummoner/Matchfor initial state on insert.collectionName()andentityId()abstract — onlysummonerandmatchimplement them.
NoSqlEntityExecutor
NoSqlEntityExecutor (src/main/java/com/safjnest/nosql/NoSqlEntityExecutor.java:5):
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):
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).
