node-mongo-demo

node.js and mongodb demo

git clone https://9o.is/git/node-mongo-demo.git

commit a0ce89cf1a1e45e3ec2384710a282e57a919a08d
parent c15e716b21b98805655e2ec02114493c3f58eb44
Author: Jul <jul@9o.is>
Date:   Tue, 28 Jan 2025 09:55:41 -0500

add architecture document

Diffstat:
Aarchitecture.md | 35+++++++++++++++++++++++++++++++++++
1 file changed, 35 insertions(+), 0 deletions(-)

diff --git a/architecture.md b/architecture.md @@ -0,0 +1,35 @@ +# Lucky7 Backend Architecture Notes + +## Timer Design +- Implementing a timer in the backend adds complexity and requires state management, complicating horizontal scaling in the cloud. Additionally, persisting game records every 15 seconds consumes resources while players are idle. +- To mitigate this, it's more efficient to utilize the system clock as a timer in both the backend and frontend. Since a game starts every 15 seconds, we can determine the start time in advance with a starting timestamp of all games. The seconds remaining for the current game can be calculated at runtime using the current time and JavaScript's Date object. +- By sharing the starting timestamp with the client, both the client and server can maintain roughly synchronized timers. While network latency is a concern, it can be addressed by measuring request duration with the browser's Performance API. +- Another potential issue is the client's clock being out of sync with NTP servers. The server can send the remaining milliseconds until the next game starts, allowing the client to calculate the offset by comparing its time to the server's. This approach ensures accurate timer functionality regardless of the client's clock or timezone settings. +- Timer drift on the client's side due to device performance issues can be resolved by resetting the timer with the starting timestamp after each game, making drift imperceptible to players. + +## Session Model +- When a logged-in player initiates a Lucky7 game, a session is created that expires after one day (configurable). This session includes a createdAt timestamp, serving as the timer's starting point. +- Sessions are stored in the `lucky7sessions` MongoDB collection and are automatically removed by MongoDB based on a TTL of one day. +- The userId is a reference to the user's primary MongoDB _id. Since sessions are queried by the user ID from the auth token, we index this field and ensure its uniqueness due to the session TTL in MongoDB. +- To track games a player did not participate in, we can calculate results at runtime using time calculations, but we cannot delete session documents. Instead, we persist and index the expiresAt timestamp, adjusting queries to retrieve non-expired sessions. (Not implemented) + +## Bets Model +- When a bet is created, the `rollAt` field is populated using the timer calculations described earlier, allowing us to roll the dice and persist the result without concern for the exact timing of the dice roll, as long as results are redacted from the backend response. +- We utilize a cryptographically secure pseudorandom number generator (CSPRNG) for rolling the dice with Node.js crypto library, rather than a standard pseudorandom number generator (PRNG) like `Math.random()`. This choice provides advantages, especially in scenarios where unpredictability is essential. +- A composite key is created for the bet using userId and rollAt, ensuring uniqueness, and the index is maintained in descending order of the roll's timestamp to prioritize recent bets. +- We enable optimistic concurrency using the Mongoose library, which uses the versioning field `__v` to detect merge conflicts during bet updates, accommodating simultaneous requests. (However, the current implementation does not handle 409 errors.) + +## Bet Events +- For notifying players of game results, we utilize server-sent events with the HTTP content type `text/event-stream`, which is simpler than websockets or socket.io. This one-way connection allows the server to send messages, sufficient for notifications. +- An event stream is established at the endpoint `/api/lucky7/bets/events` to deliver streaming bet events to the authenticated player connected to that stream. +- In the backend, we subscribe to changes in the `lucky7bets` collection using MongoDB change streams and relay relevant changes to connected users. +- An additional timer is scheduled with `setTimeout` to notify users of the final result once the current game concludes. To support this feature, we maintain an in-memory state of connected users and their pending bets, while cleaning up data after the timeout triggers or when a player's connection closes. + +## Leaderboard +- We employ the MongoDB aggregate pipeline to compute the top 10 longest win streaks among players. +- This operation is resource-intensive as it requires scanning the entire `lucky7bets` collection. However, improvements can be made. First, we can implement caching; as a public endpoint, results can be cached globally with a CDN using HTTP headers, while as a private endpoint, we can utilize Redis with a cache-aside pattern. Second, to avoid full collection scans, we can store the timestamp of the last scan and current win streaks for each player, resuming scans from the last point. The next scan will focus on bets rolled after the indexed `rollAt` field. + +## Improvements +- To enhance performance, we can introduce Redis to cache current sessions, reducing the need to query the database when creating or updating bets. Reading from memory is approximately four times faster than from solid-state drives, with memory reading 1MB of sequential data taking about 250k ns compared to 1ms on SSDs. +- We can aim for a more RESTful API. Currently, the POST endpoints `/api/lucky7/bets` and `/api/lucky7/sessions` perform more than just resource creation. The sessions endpoint returns a 201 status if an existing session is found, and the bets endpoint updates the current game's existing bet. We can further enhance these endpoints by returning a 409 conflict status if a bet or session already exists, prompting the client to use GET or PATCH. +