Yield bounded catch-up batches behind per-user readiness queues
This commit is contained in:
@@ -13,7 +13,45 @@ export type Habit = typeof habits.$inferSelect;
|
||||
export type Revision = typeof habitRevisions.$inferSelect;
|
||||
export type HabitDay = typeof habitDays.$inferSelect;
|
||||
|
||||
// Each database has its own per-user readiness queue. No transaction spans a yield.
|
||||
const preparationQueues = new WeakMap<AppDatabase, Map<string, Promise<void>>>();
|
||||
export class HabitService {
|
||||
static async withPrepared<T>(db: AppDatabase, user: PublicUser, now: () => number, run: (service: HabitService) => T | Promise<T>): Promise<T> {
|
||||
let queues = preparationQueues.get(db);
|
||||
if (!queues) { queues = new Map(); preparationQueues.set(db, queues); }
|
||||
const previous = queues.get(user.id);
|
||||
let release!: () => void;
|
||||
const ticket = new Promise<void>(resolve => { release = resolve; });
|
||||
queues.set(user.id, ticket);
|
||||
if (previous) await previous;
|
||||
try {
|
||||
let sliceStarted = performance.now();
|
||||
while (true) {
|
||||
// Re-read timezone, date and owned records after every scheduling yield.
|
||||
const service = new HabitService(db, user, now());
|
||||
const habit = service.pending()[0];
|
||||
if (!habit) {
|
||||
service.sync();
|
||||
return await run(service);
|
||||
}
|
||||
const start = habit.materializedThrough ? addDays(habit.materializedThrough, 1) : habit.createdDate;
|
||||
const through = [addDays(start, 7), service.today].sort()[0]!;
|
||||
db.transaction(() => service.materialize(habit, through));
|
||||
if (performance.now() - sliceStarted >= 8) {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
sliceStarted = performance.now();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
if (queues.get(user.id) === ticket) queues.delete(user.id);
|
||||
}
|
||||
}
|
||||
/** Ready read snapshot. Use withPrepared for read-modify-write handlers. */
|
||||
static prepare(db: AppDatabase, user: PublicUser, now: () => number) {
|
||||
return HabitService.withPrepared(db, user, now, service => service);
|
||||
}
|
||||
|
||||
readonly today: string;
|
||||
private ownedCache = new Map<string, Habit>();
|
||||
private revisionCache = new Map<string, Revision[]>();
|
||||
@@ -25,7 +63,9 @@ export class HabitService {
|
||||
private countCache = new Map<string, Map<string, { value: number; carriedFrom: string | null }>>();
|
||||
constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) {
|
||||
// A different request may update the timezone while this request's body streams in.
|
||||
const timezone = db.select({ timezone: users.timezone }).from(users).where(eq(users.id, user.id)).get()!.timezone;
|
||||
const currentUser = db.select({ timezone: users.timezone }).from(users).where(eq(users.id, user.id)).get();
|
||||
if (!currentUser) throw missing();
|
||||
const timezone = currentUser.timezone;
|
||||
this.user = { ...user, timezone };
|
||||
// Travel west must never reopen or rewrite earlier requirements.
|
||||
const latest = db.select({ date: habits.materializedThrough }).from(habits).where(eq(habits.userId, user.id)).orderBy(desc(habits.materializedThrough)).get()?.date;
|
||||
@@ -221,11 +261,12 @@ export class HabitService {
|
||||
.innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id))
|
||||
.where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date)))
|
||||
.orderBy(desc(habitDays.id)).all().map(row => row.occurrence) : [];
|
||||
for (const task of revision.config.tasks.filter(t => scheduled(t.schedule, date))) {
|
||||
const occurrences = revision.config.tasks.filter(t => scheduled(t.schedule, date)).map(task => {
|
||||
const old = previous.find(t => t.taskId === task.id);
|
||||
this.db.insert(taskOccurrences).values({ id: crypto.randomUUID(), dayId: day.id, taskId: task.id, name: task.name,
|
||||
done: old?.done ?? false, expiredAt: old ? old.expiredAt : day.endsAt <= this.now ? day.endsAt : null, closedAt: old?.closedAt ?? (day.endsAt <= this.now ? day.endsAt : null), updatedAt: old?.updatedAt ?? this.now }).run();
|
||||
}
|
||||
return { id: crypto.randomUUID(), dayId: day.id, taskId: task.id, name: task.name,
|
||||
done: old?.done ?? false, expiredAt: old ? old.expiredAt : day.endsAt <= this.now ? day.endsAt : null, closedAt: old?.closedAt ?? (day.endsAt <= this.now ? day.endsAt : null), updatedAt: old?.updatedAt ?? this.now };
|
||||
});
|
||||
if (occurrences.length) this.db.insert(taskOccurrences).values(occurrences).run();
|
||||
}
|
||||
}
|
||||
date = addDays(date, 1);
|
||||
@@ -240,7 +281,7 @@ export class HabitService {
|
||||
const rows = select().where(and(eq(habitDays.habitId, id), gte(habitDays.date, range.from), lte(habitDays.date, range.to), latest)).orderBy(asc(habitDays.date)).all();
|
||||
// Only carry-enabled ranges need a prefix. Walk backwards in bounded pages
|
||||
// until a logged value or requirements reset establishes the starting state.
|
||||
const first = rows[0];
|
||||
const first = rows.find(({day, config}) => config.method !== 'count' || config.archived || scheduled(config.schedule, day.date));
|
||||
if (first?.config.method === 'count' && first.config.carryPartialProgress && !first.day.countSet) {
|
||||
let before = range.from;
|
||||
const prefix: typeof rows = [];
|
||||
|
||||
Reference in New Issue
Block a user