Skip to content
Open
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
4 changes: 2 additions & 2 deletions docker/docker-compose.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ services:
- VITE_API_URL=${BACKEND_URI:-http://localhost:3123}
- VITE_APP_URL=${FRONTEND_URI:-http://localhost:5173}
- VITE_INACTIVITY_LIMIT=1
- VITE_DEBUG_RECAPP=1 # change to 0 for production
- VITE_DEBUG_RECAPP=0 # change to 1 for diagnostic builds
environment:
- FRONTEND_URI=${FRONTEND_URI}
- BACKEND_URI=${BACKEND_URI}
- VITE_DEBUG_RECAPP=1 # change to 0 for production
- VITE_DEBUG_RECAPP=0 # change to 1 for diagnostic builds
ports:
- "5173:80"
depends_on:
Expand Down
6 changes: 4 additions & 2 deletions packages/backend/src/actors/QuizActor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,10 +284,12 @@ export class QuizActor extends SubscribableActor<Quiz, QuizActorMessage, ResultT
},
GetUserRun: async ({ studentId, quizId }) => {
const db = await this.connector.db();
const mbRun = maybe(await db.collection<QuizRun>("quizruns").findOne({ studentId, quizId }));
const found = await db.collection<QuizRun>("quizruns").findOne({ studentId, quizId });
const mbRun = maybe(found);
this.logger.debug(
`GETUSERRUN studentId=${String(studentId)} quizId=${String(quizId)} ` +
`runPresent=${mbRun ? "maybe" : "none"}`
`runPresent=${found ? "yes" : "no"} ` +
`runUid=${found?.uid ?? "-"} counter=${found?.counter ?? "-"}`
);
return mbRun.match(identity, () => new Error("No run for user"));
},
Expand Down
115 changes: 73 additions & 42 deletions packages/backend/src/actors/QuizRunActor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { create } from "mutative";
import { identity, pick } from "rambda";
import { logger } from "../logger";
import { v4 } from "uuid";
import { maybe } from "tsmonads";

type State = {
cache: Map<Id, QuizRun>;
Expand Down Expand Up @@ -53,6 +52,25 @@ export class QuizRunActor extends SubscribableActor<QuizRun, QuizRunActorMessage
super(name, system, "quizruns");
}

public override async beforeStart(): Promise<void> {
try {
const db = await this.connector.db();
await db
.collection<QuizRun>(this.collectionName)
.createIndex({ studentId: 1, quizId: 1 }, { unique: true, name: "studentId_quizId_unique" });
} catch (e) {
// Pre-existing duplicate (studentId, quizId) docs from the prior race condition
// will block this index creation. Continue without the index — the atomic upsert
// is still narrower than the previous read-then-create. Deduplicate and re-deploy
// to gain the strict guarantee.
this.logger.warn(
`QUIZRUNACTOR could not create unique index on (studentId, quizId): ${
e instanceof Error ? e.message : String(e)
}`
);
}
}

public async receive(from: ActorRef, message: QuizRunActorMessage): Promise<ResultType> {
const [clientUserRole, clientUserId] = await this.determineRole(from);
if (typeof message === "string" && message === "SHUTDOWN") {
Expand All @@ -67,48 +85,52 @@ export class QuizRunActor extends SubscribableActor<QuizRun, QuizRunActorMessage
try {
return await QuizRunActorMessages.match<Promise<ResultType>>(message, {
GetForUser: async ({ studentId, questions }) => {
if (questions.length === 0) return undefined as any;
const db = await this.connector.db();
const mbRunId = maybe(
await db
.collection<QuizRun>(this.collectionName)
.findOne({ studentId, quizId: this.uid }, { uid: 1, _id: 0 } as any)
);
const result = mbRunId.match<Promise<QuizRun | Error>>(
async runId => {
const run = await this.getEntity(runId.uid);
// console.log("Found existing run", run);
this.logger.debug(`QUIZRUNACTOR found existing run present=${run ? "maybe" : "none"}`);
return run.match<QuizRun | Error>(identity, () => new Error());
},
async () => {
if (questions.length === 0) return undefined as any;
const run: QuizRun = {
uid: v4() as Id,
studentId,
quizId: this.uid,
counter: 0,
questions,
answers: [],
created: toTimestamp(),
updated: toTimestamp(),
correct: [],
wrong: [],
};
await this.storeEntity(run);
for (const [subscriber, subscription] of this.state.collectionSubscribers) {
this.send(
subscriber,
new QuizRunUpdateMessage(
subscription.properties.length > 0 ? pick(subscription.properties, run) : run
)
);
}
// console.log("Created new run", run);
this.logger.info(`QUIZRUNACTOR created new run`);
return run;
const candidate: QuizRun = {
uid: v4() as Id,
studentId,
quizId: this.uid,
counter: 0,
questions,
answers: [],
created: toTimestamp(),
updated: toTimestamp(),
correct: [],
wrong: [],
};
// Atomic upsert. Replaces the previous findOne + conditional insert,
// which allowed concurrent GetForUser calls to each create sibling
// run documents (Question Reset / Repetition glitch root cause).
const stored = (await db
.collection<QuizRun>(this.collectionName)
.findOneAndUpdate(
{ studentId, quizId: this.uid },
{ $setOnInsert: candidate },
{ upsert: true, returnDocument: "after" }
)) as unknown as QuizRun | null;
if (!stored) {
return new Error("Failed to upsert quiz run");
}
if (stored.uid === candidate.uid) {
// We just inserted — notify collection subscribers using the
// in-memory candidate object (avoids leaking MongoDB's _id field).
for (const [subscriber, subscription] of this.state.collectionSubscribers) {
this.send(
subscriber,
new QuizRunUpdateMessage(
subscription.properties.length > 0
? pick(subscription.properties, candidate)
: candidate
)
);
}
);
return result;
this.logger.info(`QUIZRUNACTOR created new run`);
return candidate;
}
const existing = await this.getEntity(stored.uid);
this.logger.debug(`QUIZRUNACTOR returning existing run`);
return existing.match<QuizRun | Error>(identity, () => new Error());
},
Update: async run => {
const existingRun = await this.getEntity(run.uid);
Expand Down Expand Up @@ -140,7 +162,16 @@ export class QuizRunActor extends SubscribableActor<QuizRun, QuizRunActorMessage
Clear: async () => {
const db = await this.connector.db();
const result = await db.collection<QuizRun>(this.collectionName).deleteMany({ quizId: this.uid });
logger.warn(JSON.stringify(result));
const runSubscriberCount = Array.from(this.state.subscribers.values())
.reduce((acc, set) => acc + set.size, 0);
const collectionSubscriberCount = this.state.collectionSubscribers.size;
logger.warn(
`QUIZRUNACTOR_CLEAR quizId=${String(this.uid)} ` +
`deletedCount=${result.deletedCount} ` +
`runSubscribers=${runSubscriberCount} ` +
`collectionSubscribers=${collectionSubscriberCount} ` +
`from=${String((from as any)?.name ?? from)}`
);
this.state.cache = new Map();
this.state.subscribers.forEach(subscriberSet =>
subscriberSet.forEach(subscriber => this.send(subscriber, new QuizRunDeletedMessage()))
Expand Down
5 changes: 4 additions & 1 deletion packages/backend/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ export const bearerValid = async (idTokenString: string): Promise<Id> => {
.orUndefined();
try {
if (userId) {
await system.ask(createActorUri("SessionStore"), SessionStoreMessages.GetSessionForUserId(userId));
const result = await system.ask(createActorUri("SessionStore"), SessionStoreMessages.GetSessionForUserId(userId));
if (result instanceof Error) {
return Promise.reject(new Error("Session expired"));
}
return Promise.resolve(userId);
} else {
return Promise.reject(new Error("Unknown user"));
Expand Down
16 changes: 16 additions & 0 deletions packages/frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ COPY packages/models/tsconfig*.json ./packages/models/

RUN npm ci

# Surface actor-crash context. ts-actors swallows the underlying Error in
# its supervisor warning; this rewrites the catch block to include the
# full stack inline. Kept on for production so backend log inspection
# stays useful. Browser-side noise stays minimal — only the warn line is
# affected; no extra console.error per crash.
RUN node -e " \
const fs = require('fs'); \
const p = '/app/node_modules/ts-actors/lib/src/ActorSystem.js'; \
const s = fs.readFileSync(p, 'utf8'); \
const oldLine = 'this.logger.warn(\`Unhandled exception in \${target.name}, applying strategy \${target.strategy}\`);'; \
const newLine = 'this.logger.warn(\`Unhandled exception in \${target.name}, applying strategy \${target.strategy}: \${(e && e.stack) ? e.stack : String(e)}\`);'; \
if (!s.includes(oldLine)) { console.error('PATCH FAILED: target line not found in', p); process.exit(1); } \
fs.writeFileSync(p, s.replace(oldLine, newLine)); \
console.log('Patched ts-actors ActorSystem.js to surface error stacks'); \
"

COPY packages/frontend ./packages/frontend
COPY packages/models ./packages/models

Expand Down
Loading
Loading