Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Archive games after 24h in GCS and restore automatically #172

Merged
merged 2 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 88 additions & 1 deletion functions/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { initializeApp } from "firebase-admin/app";
import { getAuth } from "firebase-admin/auth";
import { type DataSnapshot, getDatabase } from "firebase-admin/database";
import { getStorage } from "firebase-admin/storage";
import * as functions from "firebase-functions/v1";
import Stripe from "stripe";

import { GameMode, findSet, generateDeck, replayEvents } from "./game";
import { databaseIterator, gzip } from "./utils";

initializeApp(); // Sets the default Firebase app.

Expand Down Expand Up @@ -228,7 +230,7 @@ export const createGame = functions.https.onCall(async (data, context) => {

const userId = context.auth.uid;

const oneHourAgo = Date.now() - 3600000;
const oneHourAgo = Date.now() - 3600 * 1000;
const recentGameIds = await getDatabase()
.ref(`userGames/${userId}`)
.orderByValue()
Expand Down Expand Up @@ -416,3 +418,88 @@ export const handleStripe = functions.https.onRequest(async (req, res) => {

res.status(200).end();
});

/**
* Archive a game state from RTDB to GCS, reducing the storage tier.
* Returns whether the state was found in the database.
*/
async function archiveGameState(gameId: string): Promise<boolean> {
const snap = await getDatabase().ref(`gameData/${gameId}`).get();
if (!snap.exists()) {
return false; // Game state is not present in the database, maybe racy?
}

const jsonBlob = JSON.stringify(snap.val());
const gzippedBlob = await gzip.compress(jsonBlob);

await getStorage()
.bucket()
.file(`gameData/${gameId}.json.gz`)
.save(gzippedBlob);

// After archiving, we remove the game state from the database.
await getDatabase().ref(`gameData/${gameId}`).remove();
return true;
}

/** Restore a game state in GCS to RTDB so it can be read from the client. */
async function restoreGameState(gameId: string): Promise<boolean> {
const file = getStorage().bucket().file(`gameData/${gameId}.json.gz`);
let gzippedBlob: Buffer;
try {
[gzippedBlob] = await file.download();
} catch (error: any) {
// File was not present.
if (error.code === 404) return false;
throw error;
}
const jsonBlob = await gzip.decompress(gzippedBlob);

const gameData = JSON.parse(jsonBlob.toString());
await getDatabase().ref(`gameData/${gameId}`).set(gameData);
return true;
}

/** Try to fetch a game state that's not present, restoring if needed. */
export const fetchStaleGame = functions.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError(
"failed-precondition",
"The function must be called while authenticated.",
);
}

const gameId = data.gameId;
if (
!(typeof gameId === "string") ||
gameId.length === 0 ||
gameId.length > MAX_GAME_ID_LENGTH
) {
throw new functions.https.HttpsError(
"invalid-argument",
"The function must be called with argument `gameId` to be fetched at `/games/:gameId`.",
);
}

try {
const restored = await restoreGameState(gameId);
return { restored };
} catch (error: any) {
throw new functions.https.HttpsError("internal", error.message);
}
});

/** Archive stale game states to GCS for cost savings. */
export const archiveStaleGames = functions.pubsub
.schedule("every 1 hours")
.onRun(async (context) => {
const cutoff = Date.now() - 86400 * 1000; // 24 hours ago

for await (const [gameId] of databaseIterator("gameData")) {
const game = await getDatabase().ref(`games/${gameId}`).get();
if (game.val().createdAt < cutoff) {
console.log(`Archiving stale game state for ${gameId}`);
await archiveGameState(gameId);
}
}
});
59 changes: 59 additions & 0 deletions functions/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { type DataSnapshot, getDatabase } from "firebase-admin/database";
import * as zlib from "node:zlib";

/** Promises API wrapper around the 'zlib' Node library. */
export const gzip = {
compress(
input: string | Buffer | ArrayBuffer | Uint8Array | DataView,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
zlib.gzip(input, (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
},
decompress(input: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
zlib.gunzip(input, (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
},
};

/**
* Iterate through a reference in the database, returning the children ordered
* by key in batches.
*/
export async function* databaseIterator(
path: string,
batchSize = 1000,
): AsyncGenerator<[string, DataSnapshot]> {
let lastKey = null;
while (true) {
const snap = lastKey
? await getDatabase()
.ref(path)
.orderByKey()
.startAfter(lastKey)
.limitToFirst(batchSize)
.get()
: await getDatabase()
.ref(path)
.orderByKey()
.limitToFirst(batchSize)
.get();
if (!snap.exists()) return;

const childKeys: string[] = [];
snap.forEach((child) => {
childKeys.push(child.key);
lastKey = child.key;
});
for (const key of childKeys) {
yield [key, snap.child(key)];
}
}
}
24 changes: 18 additions & 6 deletions rundev.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,31 @@ const pubsub = new PubSub({
projectId: "setwithfriends-dev",
});

const pubsubInterval = setInterval(async () => {
await pubsub
.topic("firebase-schedule-clearConnections")
.publishMessage({ json: {} });
}, 60 * 1000); // every minute
// This is a workaround for the Pub/Sub emulator not supporting scheduled functions.
// https://github.com/firebase/firebase-tools/issues/2034
const pubsubIntervals = [
setInterval(async () => {
await pubsub
.topic("firebase-schedule-clearConnections")
.publishMessage({ json: {} });
}, 60 * 1000), // every minute

setInterval(async () => {
await pubsub
.topic("firebase-schedule-archiveStaleGames")
.publishMessage({ json: {} });
}, 3600 * 1000), // every hour
];

let shutdownCalled = false;

async function shutdown() {
if (shutdownCalled) return;
shutdownCalled = true;

clearInterval(pubsubInterval);
for (const interval of pubsubIntervals) {
clearInterval(interval);
}

const waitForChild = (p) => new Promise((resolve) => p.on("exit", resolve));
await Promise.all([
Expand Down
1 change: 1 addition & 0 deletions scripts/src/calcStats.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Note: This is deprecated.
import { getDatabase } from "firebase-admin/database";

const batchSize = 1000;
Expand Down
1 change: 1 addition & 0 deletions scripts/src/fixGames.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Note: This is deprecated.
import assert from "assert";
import { getDatabase } from "firebase-admin/database";

Expand Down
1 change: 1 addition & 0 deletions src/firebase.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@ const functions = firebase.functions();
export const createGame = functions.httpsCallable("createGame");
export const customerPortal = functions.httpsCallable("customerPortal");
export const finishGame = functions.httpsCallable("finishGame");
export const fetchStaleGame = functions.httpsCallable("fetchStaleGame");

export default firebase;
31 changes: 29 additions & 2 deletions src/pages/GamePage.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import Loading from "../components/Loading";
import SnackContent from "../components/SnackContent";
import User from "../components/User";
import { SettingsContext, UserContext } from "../context";
import firebase, { createGame, finishGame } from "../firebase";
import firebase, { createGame, fetchStaleGame, finishGame } from "../firebase";
import useFirebaseRef from "../hooks/useFirebaseRef";
import {
checkSet,
Expand Down Expand Up @@ -81,6 +81,7 @@ function GamePage() {
const [selected, setSelected] = useState([]);
const [snack, setSnack] = useState({ open: false });
const [numHints, setNumHints] = useState(0);
const [fetchingStaleGame, setFetchingStaleGame] = useState("not-stale");

const [game, loadingGame] = useFirebaseRef(`games/${gameId}`);
const [gameData, loadingGameData] = useFirebaseRef(`gameData/${gameId}`);
Expand Down Expand Up @@ -128,9 +129,35 @@ function GamePage() {
}
});

// Try to fetch the game from cloud storage, if archived due to being stale.
useEffect(() => {
if (!loadingGame && !loadingGameData) {
if (game && gameData) {
if (fetchingStaleGame !== "not-stale") {
setFetchingStaleGame("not-stale");
}
} else {
if (fetchingStaleGame === "not-stale") {
setFetchingStaleGame("fetching");
(async () => {
// On success, the database should automatically update with game state.
const { restored } = await fetchStaleGame({ gameId });
if (!restored) {
setFetchingStaleGame("failed");
}
})();
}
}
}
}, [loadingGame, loadingGameData, game, gameData, fetchingStaleGame, gameId]);

if (redirect) return <Navigate push to={redirect} />;

if (loadingGame || loadingGameData) {
if (
loadingGame ||
loadingGameData ||
((!game || !gameData) && fetchingStaleGame !== "failed")
) {
return <LoadingPage />;
}

Expand Down
Loading