Architecture deep-dive · 2026-05-15
Three-service pipeline: bot aggregation → compressed parsing → OpenSearch blue/green indexing
The inventory pipeline spans three services across two repos. tradeit-inventory-server (sinvbot + cinvbot) aggregates ~400 trade-bot inventories from Redis into a single merged payload and handles on-demand user Steam inventory fetches. tradeit-inventory-parser polls that payload via HTTP, parses its compressed single-letter keys into OpenSearch documents, and performs a blue/green alias swap every 5 minutes per game lane. tradeit-backend (out of scope here) serves the live alias to the site.
| App | PM2 mode | Instances | Port | EC2 host (prod) |
|---|---|---|---|---|
tradeit-site-inventory (sinvbot) | fork | 1 | 3000 | ec2-63-33-0-232 (eu-west-1) |
tradeit-user-inventory (cinvbot) | cluster | 4 | 3001 | ec2-63-33-0-232 (eu-west-1) |
Container: Docker --network host · Max-memory restart: 10 GB · Staging: ec2-63-32-135-112
NODE_APP_INSTANCE=0 is the sole Bull queue consumer (inventory_server_ip). Other instances only serve HTTP. cinvbot.ts:1551–1557
| Setting | Value |
|---|---|
| PM2 mode | cluster (pinned to 1 instance) |
| Script | dist/main.js |
| Node heap | 2 600 MB (--max-old-space-size=2600) |
| Container memory | 2.9 GB — heap cap chosen so GC fires before Docker OOM-kills |
| Port | 3000 (HTTP; TLS at proxy) |
| Trigger | External cron server hits HTTP endpoints; service is stateless between calls |
graph LR
classDef gap fill:#2a1218,stroke:#f43f5e,stroke-width:2px,color:#fecdd3
classDef warn fill:#2a1f0e,stroke:#f59e0b,stroke-width:2px,color:#fde68a
classDef ok fill:#0e2a1e,stroke:#10b981,stroke-width:2px,color:#a7f3d0
classDef hi fill:#1e2438,stroke:#a855f7,stroke-width:2px,color:#e9d5ff
subgraph "tradeit-tradebot-server"
BOT["~400 bots\nwrite loop"]
end
subgraph "tradeit-inventory-server"
SINV["sinvbot\nport 3000"]
CINV["cinvbot\nport 3001"]
end
subgraph "tradeit-inventory-parser"
CTRL["InventoryController\nGET /index/:gameId"]
SVC["InventoryService\nparseData()"]
OS_SVC["OpenSearchService\nbulk + alias swap"]
end
subgraph "External marketplaces"
HALO["HaloSkins API\ncs2dt.com /v2"]
UU["Uuskins API\n/v1/market/items"]
end
REDIS[("Redis\nElastiCache")]
MYSQL[("MySQL 8\nAurora")]
OS[("OpenSearch\nalias: inventory_730...")]
STEAM["Steam\nCommunity API"]
SCRAPER["ScraperAPI\nproxy"]
CF["Cloudflare\ncache purge"]
SLACK["Slack\n#tradeit-builds"]
BOT -- "sinv:{n}:compressed" --> REDIS
REDIS -- "batch mGet sinvKeys" --> SINV
MYSQL -- "items / item_prices\nsale_offers" --> SINV
SINV -- "sinv, globalItemInfo\nupdatedPrices, stickerPrices" --> REDIS
SINV -- "GET /sinv-merged/:gameId" --> CTRL
HALO --> CTRL
UU --> CTRL
MYSQL -- "collections, item_types\nconfigurations, sale_offers" --> SVC
REDIS -- "indexLaneLock:{gameId}\nstickerPrices, charmPrices" --> CTRL
CTRL --> SVC
SVC --> OS_SVC
OS_SVC -- "bulk index + alias swap" --> OS
OS_SVC --> CF
OS_SVC --> SLACK
STEAM --> CINV
SCRAPER --> CINV
MYSQL -- "bot_users, collections\nitem_types, configurations" --> CINV
REDIS -- "updatedPrices\nrate-limit keys" --> CINV
class OS_SVC ok
class HALO,UU hi
class CINV,STEAM,SCRAPER warn
| Dir | Surface | Description |
|---|---|---|
| in | Redis sinv:{botIndex}:compressed | Compressed bot inventory from tradebot-server |
| in | MySQL items, item_prices | Price + stock enrichment |
| in | MySQL sale_offers | P2P offer prices and owners |
| in | MySQL configurations | Runtime config |
| out | GET /sinv/:gameid | Game-specific inventory; auth-token gated |
| out | GET /sinv-merged/:gameid | Merged ctx 2+16 inventory (CS2 protected items) |
| out | GET /compressedstatic | Full filtered site inventory |
| out | GET /cached/:gameid | Cached game inventory |
| out | Redis sinv, sinvItemCounts | Aggregated site inventory + counts |
| out | Redis globalItemInfo, globalItemInfoById | Item info maps |
| out | Redis updatedPrices, stickerPrices, charmPrices | Price caches consumed by cinvbot/parser |
| Dir | Surface | Description |
|---|---|---|
| in | HTTP user requests | /cinv/professional, /cinv/cached, /cinv/search |
| in | Steam Community API | User inventory JSON (direct + ScraperAPI fallback) |
| in | Redis updatedPrices | Polled every 10 s from sinvbot |
| out | Bull queue inventory_server_ip | IP rotation requests; consumer on instance 0 only |
| out | Bull queue tradebotQueue:{botId} | Per-bot trade actions (0..MAX_BOT_INDEX) |
| Dir | Surface | Description |
|---|---|---|
| in | GET /index/:gameId | Cron-triggered; one per game lane |
| in | GET /index/:gameId/:marketplace | Marketplace-specific (haloskins, uuskins) |
| in | sinvbot GET /sinv-merged/:gameId | Compressed site inventory payload |
| in | HaloSkins API v2/items/market/price | Marketplace pricing + item list |
| in | Uuskins API /v1/market/items | Uuskins item inventory (RSA-signed) |
| out | OpenSearch bulk index | Inventory + group + sticker docs |
| out | OpenSearch alias swap | Atomic cutover to new generation |
| out | Redis indexLaneLock:{gameId}:{laneKey} | Lock write + renewal every 450 s |
| out | Cloudflare cache purge | Post-index CDN invalidation |
| out | Slack alerts | Slow-run (>3.5 min) and error alerts |
| Key pattern | TTL | Written by | Read by | Notes |
|---|---|---|---|---|
sinv:{botIndex}:compressed | none | tradebot-server | sinvbot | One key per bot; batch mGet'd |
sinv | none | sinvbot:305 | inventory-parser, tradeit-backend | Full site inventory |
sinvItemCounts | none | sinvbot:300 | tradeit-backend | Item count by group |
globalItemInfo | none | sinvbot:966 | tradeit-backend | Item info map by name |
globalItemInfoById | none | sinvbot:971 | tradeit-backend | Item info map by id |
unStoreInPeriod:{item_id} | none | sinvbot:977 | tradeit-backend | Per-item un-store counter |
priceupdate | none | sinvbot:1103 | cinvbot:86 | Epoch ms of last price refresh |
updatedPrices | none | sinvbot:1108 | cinvbot:99, parser | All current prices JSON |
stickerPrices | none | sinvbot:1114 | cinvbot, parser | Sticker price map |
charmPrices | none | sinvbot:1119 | cinvbot, parser | Charm price map |
itemsMeta | none | sinvbot:1205 | tradeit-backend | Colors + URL slugs |
indexLaneLock:{gameId}:{laneKey} | 900 s | parser controller:135 | parser controller | UUID value; compare-and-delete EVAL |
steamLimitedIp:{ip} | implicit | cinvbot:242 | cinvbot:223 | Per-IP rate-limit flag |
userInvRequesting:{steamId} | — | cinvbot | cinvbot:505 | In-flight dedup flag |
ud:{token} | 48 h | tradeit-backend | cinvbot:543 | Session user data |
{gameId}_inventory_{steamId} | 3 d | cinvbot | cinvbot | Cached user inventory |
inventory_server_ip:{requestId} | 10 s | cinvbot consumer:1539 | cinvbot producer | Bull job result handoff |
| Table | Operations | Used by | Notes |
|---|---|---|---|
configurations | SELECT by key | sinvbot:255, cinvbot:269, parser | Runtime config via ConfigurationService |
items | SELECT (prices, meta, float ranges) | sinvbot:1009–1012, parser | Large table; hot on item_id |
item_prices | SELECT (stock, stable_price, bot_price, popularity) | sinvbot:934 | Joined to items |
sale_offers | SELECT (asset_id, price, owner) | sinvbot:823–824, parser | P2P offers for price overlay |
bot_users | SELECT steam_trade_url | cinvbot:283 | Bot trade URL lookup |
csgo_collections | SELECT * | cinvbot:296, parser | Collection name map; loaded on start |
item_types | SELECT by app_id | cinvbot:302, parser | Type names by game; loaded on start |
items_meta (+ joins) | SELECT JOIN | sinvbot:1173–1178 | Color + URL slug enrichment; joins items_meta_items, csgo_skin_colors |
TypeORM synchronize: false in inventory-parser — migrations are manual. Driver: mysql2 3.12.0.
| Alias family | Pattern | Source |
|---|---|---|
| Site inventory | inventory_{gameId} | sinvbot (tradeit bots) |
| Internal / HaloSkins | inventory_internal_{gameId} | HaloSkins marketplace |
| Group (site) | group_{gameId} | Derived from site inventory |
| Group (internal) | group_internal_{gameId} | Derived from HaloSkins inventory |
| Stickers | stickers_{marketplaceKey} | CS2 sticker extraction only |
Each alias points to one of two generations (incremental integer suffix, e.g., _0 / _1, colloquially called "rick/morty"). Per cycle:
openSearch.service.ts:398)setRefreshInterval(-1), line 403)replaceAliasForIndex, line 414)| Parameter | Value | Location |
|---|---|---|
MAX_DOCS_PER_BULK | 2 500 | openSearch.service.ts:18 |
MAX_BULK_PAYLOAD_BYTES | 8 MB | openSearch.service.ts:19 |
MAX_BULK_RETRIES | 3 | openSearch.service.ts:20 |
| Retryable HTTP codes | 429, 502, 503, 504 | openSearch.service.ts:21 |
| Retry backoff | 250 ms × 2^attempt | openSearch.service.ts:335–365 |
| Request timeout | 120 s | openSearch.service.ts:323 |
| Bulk concurrency | 1 (hotfix #117, was 2) | openSearch.service.ts (getBulkConcurrency) |
| Halt threshold | >30% permanent failures | openSearch.service.ts:430 |
getBulkConcurrency() is forced to 1 (sequential) to relieve 100% CPU on OpenSearch data nodes. Code statically reads 2 — runtime behavior differs. Will revert once PR #114 (Lane Scheduler + Backpressure) merges.
OPENSEARCH_COORDINATION_NODE_URL — query path. Mutual TLS (base64 CA/cert/key).
OPENSEARCH_INGEST_NODE_URL — write path, separate node. keepAlive: false to prevent socket listener buildup on batch writes.
| Endpoint | Frequency | Offset | Lane |
|---|---|---|---|
GET /index/730 | every 5 min | :00 | CS2 site inventory |
GET /index/570 | every 5 min | :02 | Dota 2 (not user-visible) |
GET /index/252490 | every 5 min | :03 | Rust |
GET /index/753 | every 5 min | :03 | Steam |
GET /index/440 | every 5 min | :04 | TF2 |
| Timer | Interval | Action |
|---|---|---|
saveItemInfoToRedis | 60 000 ms | Re-aggregate bot inventories; write Redis sinv, globalItemInfo, etc. (sinvbot.ts:230) |
loadUpdatedPrices | 60 000 ms | Reload prices from MySQL; write updatedPrices, stickerPrices, charmPrices (sinvbot.ts:234) |
| Timer | Interval | Action |
|---|---|---|
| Price poll | 10 000 ms | Read updatedPrices from Redis (cinvbot.ts:118) |
| Image loader | 3 600 000 ms (1 h) | Batch-load item images on startup and hourly |
| Queue | Producer | Consumer | Purpose |
|---|---|---|---|
inventory_server_ip | sendToIpQueueAndWait() | cinvbot instance 0 only (cinvbot.ts:1533) | IP rotation — returns next available outbound IPs for Steam API calls; result stored at {requestId} Redis key with 10 s TTL |
tradebotQueue:{botId} | cinvbot | tradeit-tradebot-server | Per-bot trade operations (0..MAX_BOT_INDEX) |
SET indexLaneLock:{gameId}:{laneKey} {uuid} EX 900 (inventory.controller.ts:135)EVAL compareAndDeleteScript — only deletes if UUID matches (prevents stale release)| Enum key | DB key string | Description |
|---|---|---|
TradeLockBaseFee | tradeLockBaseFee | Base fee for trade-locked items |
TradeLockChangeOverTime | tradeLockChangeOverTime | Time-decay factor for trade lock fee |
EnableTradeLocked | enableTradeLocked | Feature flag: include trade-locked items in index |
StoreDiscountPercent | storeDiscountPercent | Discount applied to store prices |
UserStorePercent | userStorePercent | User cut of store sale |
HaloSkinStoreEnabled | haloSkinStoreEnabled | HaloSkins store mode toggle |
HaloSkinMarkupStorePercent | haloSkinMarkupStorePercent | Store markup % for HaloSkins items |
HaloSkinMarkupTradePercent | haloSkinMarkupTradePercent | Trade markup % for HaloSkins items |
HaloSkinMaxPerItem | haloSkinMaxPerItem | Max stock per item from HaloSkins |
HaloSkinMaxPerBestPriceItem | haloSkinMaxPerBestPriceItem | Max stock for best-price items |
HaloSkinEnableBestPriceItem | haloSkinEnableBestPriceItem | Toggle best-price item logic |
HaloSkinMinPrice | haloSkinMinPrice | Min price filter for HaloSkins items |
HaloSkinMaxPrice | haloSkinMaxPrice | Max price filter for HaloSkins items |
HaloStablePriceMultiplierCheck | haloStablePriceMultiplierCheck | Stable-price volatility multiplier guard |
Source: src/models/enums/ConfigurationKey.ts
| Var | Service | Description |
|---|---|---|
MAX_BOT_INDEX | sinvbot, cinvbot | How many bot Redis keys to iterate; mismatched value silently misses bots |
TRADEIT_ENV | cinvbot, parser | staging / production — affects bot routing |
NODE_APP_INSTANCE | cinvbot | PM2 instance ID; 0 = Bull queue consumer |
SCRAPER_API_KEY | cinvbot | ScraperAPI proxy key |
HALO_SKIN_APP_KEY | parser | HaloSkins API auth key |
HALO_SKIN_BASE_URL | parser | HaloSkins API base: https://openapi.cs2dt.com/ |
OPENSEARCH_INGEST_NODE_URL | parser | Write path (separate ingest node) |
OPENSEARCH_COORDINATION_NODE_URL | parser | Query path (coordination node) |
inventory.controller.ts:30openSearch.service.ts:20openSearch.service.ts:430inventory.controller.ts:98Trigger: Cron fires while previous cycle holds indexLaneLock.
Blast radius: One cycle skipped; inventory ages by one 5-min window.
Recovery: Automatic — next tick wins the lock. Signal: 409 CONFLICT logged; no Slack alert (intentional).
Trigger: sinvbot crash / OOM / network partition.
Blast radius: GET /sinv-merged/:gameId fails; full lane aborted; OS index not refreshed.
Recovery: PM2 auto-restart; next parser cycle retries. Signal: Slack exception alert (inventory.controller.ts:105).
Trigger: 429 write rejection or 502–504 from ingest node.
Blast radius: Partial index write; alias swap deferred.
Recovery: Exponential backoff (250 ms × 2^attempt, 3 retries). If >30% docs permanently fail → Slack alert + halt; old alias remains live as safe fallback.
Guard: isLoadingSiteInventory boolean flag in sinvbot — second concurrent load returns 429 (sinvbot.ts:196, 281).
Blast radius: None — second call dropped cleanly.
Recovery: Round-robin IP rotation via inventory_server_ip Bull queue; ScraperAPI proxy fallback (cinvbot.ts:625).
Signal: Per-IP steamLimitedIp:{ip} Redis flag; viewable via GET /cinv/viewIpCount.
Trigger: Large inventory cycle exceeds 2.6 GB Node heap.
Guard: GC fires before Docker OOM-kills (2.9 GB container limit).
Recovery: PM2 restarts; next cron cycle re-indexes. No Cronitor monitor wired.
Trigger: Running deploy.sh from a non-interactive shell (CI, /deploy skill, sh -c).
Root cause: Background SSH subshell has no wait; SIGHUP'd when parent exits.
Workaround: Deploy only from an interactive terminal until PR #118 merges.
@zengamingx/logger (pino-based) in inventory-server; NestJS ClassWithLogger mixin in parser. no-console enforced; structured JSON to stdout.inventory.controller.ts:98); bulk failure alert with stack (openSearch.service.ts:383); exception alert with instance info (inventory.controller.ts:105).| Component | File:line | Notes |
|---|---|---|
| sinvbot entry | sinvbot.ts:1 | Express app, port 3000 |
| Site inventory load trigger | sinvbot.ts:188 | GET /internal/load-site-inventory |
| Concurrent-load guard | sinvbot.ts:196, 281 | isLoadingSiteInventory boolean |
| Redis sinv write | sinvbot.ts:305 | Aggregated site inventory |
| Redis sinvItemCounts write | sinvbot.ts:300 | Item counts |
| Redis globalItemInfo write | sinvbot.ts:966 | Item info map |
| Redis priceupdate / updatedPrices / stickerPrices / charmPrices | sinvbot.ts:1103–1119 | Price cache writes |
| Redis itemsMeta write | sinvbot.ts:1205 | Colors + URL slugs |
| MySQL items query | sinvbot.ts:1009–1012 | Prices, float ranges |
| MySQL items_meta JOIN | sinvbot.ts:1173–1178 | Colors + slugs enrichment |
| saveItemInfoToRedis timer | sinvbot.ts:230 | 60 000 ms interval |
| Compressed property interface (ItemD) | util.ts:89–103 | Single-letter key map (d, p, e, f, etc.) |
| Asset property interface | util.ts:104–115 | Per-instance data (float, trade lock) |
| cinvbot entry | cinvbot.ts:1 | Express app, port 3001 |
| Price poll timer | cinvbot.ts:118 | 10 000 ms interval |
| userInvRequesting dedup | cinvbot.ts:505 | In-flight dedup flag per steamId |
| Steam API call | cinvbot.ts:~590 | Direct + ScraperAPI fallback at :625 |
| IP queue producer | cinvbot.ts:1533 | inventory_server_ip queue |
| IP queue consumer guard | cinvbot.ts:1551–1557 | NODE_APP_INSTANCE === 0 only |
| Component | File:line | Notes |
|---|---|---|
| Entry point | src/main.ts:1 | NestJS bootstrap, port 3000 |
| Root module | src/app.module.ts | TypeORM, ConfigModule, InventoryIndexModule |
| Index controller | src/modules/inventoryIndex/inventory.controller.ts:56 | GET /index/:gameId |
| Marketplace index route | src/modules/inventoryIndex/inventory.controller.ts:107 | GET /index/:gameId/:marketplace |
| Lane lock acquire | src/modules/inventoryIndex/inventory.controller.ts:135 | SET ... EX 900 + compare-and-delete EVAL |
| Lane lock TTL constant | src/modules/inventoryIndex/inventory.controller.ts:30 | laneLockTtlSeconds = 900 |
| Slow-run Slack alert | src/modules/inventoryIndex/inventory.controller.ts:98 | >3.5 min threshold |
| Exception Slack alert | src/modules/inventoryIndex/inventory.controller.ts:105 | Full stack + instance info |
| parseData method | src/modules/inventoryIndex/inventory.service.ts | Main parse pipeline |
| Compressed key parsing | src/modules/inventoryIndex/inventory.service.ts:101–119 | d, e, p, q, hi, ws, cs, vis, etc. |
| ConfigurationKey enum | src/models/enums/ConfigurationKey.ts | 14 DB-backed config keys |
| GameId enum | src/models/enums/GameId.ts | 730, 570, 252490, 440, 753 |
| OpenSearch service | src/common/modules/opensearch/openSearch.service.ts | Bulk + alias swap |
| Bulk pipeline | src/common/modules/opensearch/openSearch.service.ts:292–393 | prep → chunk → submit → retry |
| Alias swap | src/common/modules/opensearch/openSearch.service.ts:414 | replaceAliasForIndex — atomic |
| Concurrency hotfix | src/common/modules/opensearch/openSearch.service.ts | getBulkConcurrency() forced to 1 |
| Halt threshold | src/common/modules/opensearch/openSearch.service.ts:430 | >30% failures → halt + Slack |
| OpenSearch clients | src/common/modules/opensearch/openSearch.manager.ts | Coordination + ingest nodes, mutual TLS |
| HaloSkins service | src/modules/inventoryIndex/haloSkin.service.ts | cs2dt.com /v2 API integration |
| Uuskins service | src/modules/inventoryIndex/uuskin.service.ts | RSA-signed /v1/market/items API |
| Module registry | src/modules/inventoryIndex/inventoryIndex.module.ts | All imports + providers |
[OPEN: Uuskins production status] — Is Uuskins lane live in production or only HaloSkins? uuskin.service.ts registered but production config not confirmed.[OPEN: Alias generation naming] — Wiki/CLAUDE.md say "rick/morty" but code shows incremental integer suffix (_0, _1). Verify with GET _cat/aliases in production.[OPEN: PR #118 deploy.sh fix] — Non-interactive deploy still hangs. Track merge; update CLAUDE.md once landed.[OPEN: 570 lane scheduling] — Dota 2 cron still runs every 5 min despite not being user-visible. Evaluate removing to reduce OpenSearch ingest load.[OPEN: Cronitor gap] — inventory-parser has no Cronitor monitor. Missed cycles only surfaced by Slack. Consider adding per-lane heartbeats.[OPEN: Cloudflare purge details] — CloudflareModule imported but target zones/paths not captured in code spelunk. Verify what exactly gets invalidated post-index.Generated 2026-05-15 · tradeit.gg engineering · repos: tradeit-inventory-server, tradeit-inventory-parser