From e39ac8b4c7c48f24ec051893394e8e745f75fb90 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:10:12 -0400 Subject: [PATCH 1/6] perf(api): refresh cached skins in the background after a day A cached skin was used for 3 hours; after that, the next request waited for Mojang's session server before it could draw a head or a render. Every player seen again after 3 hours cost a Mojang call, and the session server rate limits by address. A cached skin is now always served at once. Once it's a day old, the request that finds it starts a refresh in the background, and the next request gets the new skin. Requests that arrive while a refresh is running share it, so a stale skin costs one call however many heads are drawn. A failed refresh keeps the cached skin and is tried again after an hour. It only moves the skin's refresh time, so it can't write an older copy back over anything saved meanwhile, such as the usernameToLower backfill. A player with no cached skin still waits for Mojang, as before. This drops getSkin's fallback to the cached skin when the session server fails, along with its check that a skin found by name belongs to the uuid Mojang gave for that name. No request path falls back anymore: a cached skin is served before Mojang is asked, and its refresh asks for the skin's own uuid. save() replaces the document with the skin's own uuid, so it can't hit the duplicate key error (E11000) that the check guarded against. After a rename, a lookup by the name can still serve the previous owner's skin until that copy is refreshed; from then on the name finds the new owner. --- apps/api/src/skin/skin.service.ts | 40 ++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/apps/api/src/skin/skin.service.ts b/apps/api/src/skin/skin.service.ts index d47cf9f14..ddfc01ec9 100644 --- a/apps/api/src/skin/skin.service.ts +++ b/apps/api/src/skin/skin.service.ts @@ -18,8 +18,17 @@ import { getMinecraftTexturePath } from "@statsify/assets"; import { renderSkin } from "@statsify/skin-renderer"; import type { ReturnModelType } from "@typegoose/typegoose"; +/** How long a cached skin is used before it's refreshed. Players rarely change skins. */ +const REFRESH_AFTER = 1000 * 60 * 60 * 24; + +/** After a failed refresh (Mojang down or rate limiting), the cached skin is kept and the refresh tried again later. */ +const RETRY_AFTER = 1000 * 60 * 60; + @Injectable() export class SkinService { + /** Refreshes in flight, so many requests for one stale skin make a single Mojang call. */ + private readonly refreshing = new Map>(); + public constructor( private readonly httpService: HttpService, @InjectModel(Skin) private readonly skinModel: ReturnModelType @@ -64,23 +73,36 @@ export class SkinService { .lean() .exec(); - if (cachedSkin && Date.now() < cachedSkin.expiresAt) { + // A cached skin is served at once, even when it's due a refresh; the refresh happens in the background. + if (cachedSkin) { + if (Date.now() >= cachedSkin.expiresAt) this.refresh(cachedSkin); return cachedSkin; } const uuid = isUsername ? await this.getUuid(tag) : tag; + const skin = await this.requestSkin(uuid); - const skin = await this.requestSkin(uuid).catch((error) => { - // A skin found by name may belong to whoever had the name before - if (cachedSkin?.uuid === uuid) return cachedSkin; - throw error; - }); + return this.save(skin, REFRESH_AFTER); + } + + private refresh(cachedSkin: Skin) { + const { uuid } = cachedSkin; + if (this.refreshing.has(uuid)) return; - // Cache for 3 hours - skin.expiresAt = Date.now() + (1000 * 60 * 60 * 3); + const refresh = this.requestSkin(uuid) + .then((skin) => this.save(skin, REFRESH_AFTER)) + // Only the refresh time changes: writing back the copy read earlier would undo anything saved since + .catch(() => this.skinModel.updateOne({ uuid }, { expiresAt: Date.now() + RETRY_AFTER }).exec()) + // The database failed too; the next request tries again. + .catch(() => undefined) + .finally(() => this.refreshing.delete(uuid)); - await this.skinModel.replaceOne({ uuid }, skin, { upsert: true }).lean().exec(); + this.refreshing.set(uuid, refresh); + } + private async save(skin: Skin, refreshAfter: number): Promise { + skin.expiresAt = Date.now() + refreshAfter; + await this.skinModel.replaceOne({ uuid: skin.uuid }, skin, { upsert: true }).lean().exec(); return skin; } From 64cf9527c7a39ba42d79e24a6175006ce2680616 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:30:05 -0400 Subject: [PATCH 2/6] refactor(api): track in-flight skin refreshes by uuid in a set --- apps/api/src/skin/skin.service.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/api/src/skin/skin.service.ts b/apps/api/src/skin/skin.service.ts index ddfc01ec9..80747470b 100644 --- a/apps/api/src/skin/skin.service.ts +++ b/apps/api/src/skin/skin.service.ts @@ -27,7 +27,7 @@ const RETRY_AFTER = 1000 * 60 * 60; @Injectable() export class SkinService { /** Refreshes in flight, so many requests for one stale skin make a single Mojang call. */ - private readonly refreshing = new Map>(); + private readonly refreshing = new Set(); public constructor( private readonly httpService: HttpService, @@ -75,7 +75,7 @@ export class SkinService { // A cached skin is served at once, even when it's due a refresh; the refresh happens in the background. if (cachedSkin) { - if (Date.now() >= cachedSkin.expiresAt) this.refresh(cachedSkin); + if (Date.now() >= cachedSkin.expiresAt) this.refresh(cachedSkin.uuid); return cachedSkin; } @@ -85,19 +85,16 @@ export class SkinService { return this.save(skin, REFRESH_AFTER); } - private refresh(cachedSkin: Skin) { - const { uuid } = cachedSkin; + private refresh(uuid: string) { if (this.refreshing.has(uuid)) return; + this.refreshing.add(uuid); - const refresh = this.requestSkin(uuid) + this.requestSkin(uuid) .then((skin) => this.save(skin, REFRESH_AFTER)) - // Only the refresh time changes: writing back the copy read earlier would undo anything saved since .catch(() => this.skinModel.updateOne({ uuid }, { expiresAt: Date.now() + RETRY_AFTER }).exec()) // The database failed too; the next request tries again. .catch(() => undefined) .finally(() => this.refreshing.delete(uuid)); - - this.refreshing.set(uuid, refresh); } private async save(skin: Skin, refreshAfter: number): Promise { From ae2265e93f05618358d2614599ace2f8bd248b0a Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:30:13 -0400 Subject: [PATCH 3/6] refactor(api): drop the skin save's refresh time parameter --- apps/api/src/skin/skin.service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/src/skin/skin.service.ts b/apps/api/src/skin/skin.service.ts index 80747470b..1dfcc3ce7 100644 --- a/apps/api/src/skin/skin.service.ts +++ b/apps/api/src/skin/skin.service.ts @@ -82,7 +82,7 @@ export class SkinService { const uuid = isUsername ? await this.getUuid(tag) : tag; const skin = await this.requestSkin(uuid); - return this.save(skin, REFRESH_AFTER); + return this.save(skin); } private refresh(uuid: string) { @@ -90,15 +90,15 @@ export class SkinService { this.refreshing.add(uuid); this.requestSkin(uuid) - .then((skin) => this.save(skin, REFRESH_AFTER)) + .then((skin) => this.save(skin)) .catch(() => this.skinModel.updateOne({ uuid }, { expiresAt: Date.now() + RETRY_AFTER }).exec()) // The database failed too; the next request tries again. .catch(() => undefined) .finally(() => this.refreshing.delete(uuid)); } - private async save(skin: Skin, refreshAfter: number): Promise { - skin.expiresAt = Date.now() + refreshAfter; + private async save(skin: Skin): Promise { + skin.expiresAt = Date.now() + REFRESH_AFTER; await this.skinModel.replaceOne({ uuid: skin.uuid }, skin, { upsert: true }).lean().exec(); return skin; } From acc29fb87e670c8f1b91bf2e098cb85c5e34af35 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:30:24 -0400 Subject: [PATCH 4/6] refactor(api): trim the skin refresh comments --- apps/api/src/skin/skin.service.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/api/src/skin/skin.service.ts b/apps/api/src/skin/skin.service.ts index 1dfcc3ce7..50c5bb812 100644 --- a/apps/api/src/skin/skin.service.ts +++ b/apps/api/src/skin/skin.service.ts @@ -18,15 +18,11 @@ import { getMinecraftTexturePath } from "@statsify/assets"; import { renderSkin } from "@statsify/skin-renderer"; import type { ReturnModelType } from "@typegoose/typegoose"; -/** How long a cached skin is used before it's refreshed. Players rarely change skins. */ const REFRESH_AFTER = 1000 * 60 * 60 * 24; - -/** After a failed refresh (Mojang down or rate limiting), the cached skin is kept and the refresh tried again later. */ const RETRY_AFTER = 1000 * 60 * 60; @Injectable() export class SkinService { - /** Refreshes in flight, so many requests for one stale skin make a single Mojang call. */ private readonly refreshing = new Set(); public constructor( @@ -73,7 +69,6 @@ export class SkinService { .lean() .exec(); - // A cached skin is served at once, even when it's due a refresh; the refresh happens in the background. if (cachedSkin) { if (Date.now() >= cachedSkin.expiresAt) this.refresh(cachedSkin.uuid); return cachedSkin; @@ -92,7 +87,7 @@ export class SkinService { this.requestSkin(uuid) .then((skin) => this.save(skin)) .catch(() => this.skinModel.updateOne({ uuid }, { expiresAt: Date.now() + RETRY_AFTER }).exec()) - // The database failed too; the next request tries again. + // The database failed too, so the next request tries again .catch(() => undefined) .finally(() => this.refreshing.delete(uuid)); } From 97a17cc83c5c17f4a69e344209315ddd80b49550 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 5 Nov 2026 03:19:19 -0500 Subject: [PATCH 5/6] refactor(api): carry #1011's shorter rename comment --- apps/api/src/skin/skin.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/skin/skin.service.ts b/apps/api/src/skin/skin.service.ts index 50c5bb812..941812052 100644 --- a/apps/api/src/skin/skin.service.ts +++ b/apps/api/src/skin/skin.service.ts @@ -64,7 +64,7 @@ export class SkinService { .findOne() .where(isUsername ? "usernameToLower" : "uuid") .equals(tag) - // A name can be on more than one skin after a rename, so take the most recently refreshed + // Two skins can share a name after a rename .sort({ expiresAt: -1 }) .lean() .exec(); From d24659805435b6899668abaea44526040dac4bb2 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 5 Nov 2026 03:22:41 -0500 Subject: [PATCH 6/6] refactor(api): drop the skin refresh retry comment --- apps/api/src/skin/skin.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/src/skin/skin.service.ts b/apps/api/src/skin/skin.service.ts index 941812052..4b15586a4 100644 --- a/apps/api/src/skin/skin.service.ts +++ b/apps/api/src/skin/skin.service.ts @@ -87,7 +87,6 @@ export class SkinService { this.requestSkin(uuid) .then((skin) => this.save(skin)) .catch(() => this.skinModel.updateOne({ uuid }, { expiresAt: Date.now() + RETRY_AFTER }).exec()) - // The database failed too, so the next request tries again .catch(() => undefined) .finally(() => this.refreshing.delete(uuid)); }