diff --git a/Firebase/firestore.index.json b/Firebase/firestore.index.json index bc9e032e..eeda2a59 100644 --- a/Firebase/firestore.index.json +++ b/Firebase/firestore.index.json @@ -11,6 +11,10 @@ { "fieldPath": "dueDate", "order": "ASCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" } ] }, @@ -573,5 +577,94 @@ ] } ], - "fieldOverrides": [] + "fieldOverrides": [ + { + "collectionGroup": "todoLists", + "fieldPath": "dueDate", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + }, + { + "collectionGroup": "todoLists", + "fieldPath": "isDeleted", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + }, + { + "collectionGroup": "notifications", + "fieldPath": "isDeleted", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + }, + { + "collectionGroup": "webPages", + "fieldPath": "isDeleted", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + } + ] } diff --git a/Firebase/functions/src/common/error.ts b/Firebase/functions/src/common/error.ts index 4cd84fac..ecdc0508 100644 --- a/Firebase/functions/src/common/error.ts +++ b/Firebase/functions/src/common/error.ts @@ -1,14 +1,4 @@ -export function normalizeError(error: unknown): Record { - const normalized = error as { - code?: unknown; - details?: unknown; - message?: unknown; - stack?: unknown; - }; - return { - code: normalized?.code ?? null, - details: normalized?.details ?? null, - message: normalized?.message ?? String(error), - stack: normalized?.stack ?? null - }; +export function toError(error: unknown): Error { + if (error instanceof Error) { return error; } + return new Error(String(error)); } diff --git a/Firebase/functions/src/fcm/notification.ts b/Firebase/functions/src/fcm/notification.ts index 99232ead..5afb24da 100644 --- a/Firebase/functions/src/fcm/notification.ts +++ b/Firebase/functions/src/fcm/notification.ts @@ -2,7 +2,7 @@ import { onTaskDispatched } from "firebase-functions/v2/tasks"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; import { formatDateKey, toDate } from "../common/date"; -import { normalizeError } from "../common/error"; +import { toError } from "../common/error"; import { FirestorePath } from "../common/firestorePath"; import { resolveTimeZone } from "./shared"; @@ -131,9 +131,8 @@ export const sendPushNotification = onTaskDispatched({ } } catch (error) { - logger.error("알림 발송 중 오류 발생", { - payload: req.data, - error: normalizeError(error) + logger.error("알림 발송 중 오류 발생", toError(error), { + payload: req.data }); } } diff --git a/Firebase/functions/src/fcm/schedule.ts b/Firebase/functions/src/fcm/schedule.ts index 54f45c50..4bdd52bf 100644 --- a/Firebase/functions/src/fcm/schedule.ts +++ b/Firebase/functions/src/fcm/schedule.ts @@ -3,7 +3,7 @@ import { getFunctions } from "firebase-admin/functions"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; import { addDays, getZonedParts, zonedDateTimeToUTC } from "../common/date"; -import { normalizeError } from "../common/error"; +import { toError } from "../common/error"; import { FirestorePath } from "../common/firestorePath"; import { resolveTimeZone } from "./shared"; @@ -27,9 +27,8 @@ export const scheduleTodoReminder = onSchedule({ try { usersSnapshot = await admin.firestore().collection("users").get(); } catch (error) { - logger.error("users 조회 실패", { - at: "collection(users).get()", - ...normalizeError(error) + logger.error("users 조회 실패", toError(error), { + at: "collection(users).get()" }); return; } @@ -42,10 +41,9 @@ export const scheduleTodoReminder = onSchedule({ .doc(FirestorePath.userData(userId, FirestorePath.UserDataDocument.settings)) .get(); } catch (error) { - logger.error("settings 조회 실패", { + logger.error("settings 조회 실패", toError(error), { userId, - at: "users/{uid}/userData/settings", - ...normalizeError(error) + at: "users/{uid}/userData/settings" }); continue; } @@ -93,13 +91,12 @@ export const scheduleTodoReminder = onSchedule({ .where("dueDate", "<", admin.firestore.Timestamp.fromDate(endUTC)) .get(); } catch (error) { - logger.error("todoLists 조회 실패", { + logger.error("todoLists 조회 실패", toError(error), { userId, at: "todoLists.where(dueDate>=start).where(dueDate { + const userId = event.params.userId; + const todoId = event.params.todoId; + + try { + await deleteByTodoId(userId, "notificationDispatches", todoId); + await deleteByTodoId(userId, "notifications", todoId); + } catch (error) { + logger.error( + "todo 삭제 후 notification 문서 정리 실패", + toError(error), + { + userId, + todoId, + collections: ["notificationDispatches", "notifications"] + } + ); + } + } +); + +// 지난 마감일 Todo 완료 시 재발송 방지 기록 정리 +export const removeCompletedTodoNotificationRecords = onDocumentUpdated({ + maxInstances: 1, + document: "users/{userId}/todoLists/{todoId}", + region: LOCATION + }, + async (event) => { + const beforeData = event.data?.before.data(); + const afterData = event.data?.after.data(); + const userId = event.params.userId; + const todoId = event.params.todoId; + + if (!beforeData || !afterData) { return; } + if (beforeData.isCompleted === true || afterData.isCompleted !== true) { return; } + + const dueDate = toDate(afterData.dueDate); + + if (!dueDate || Date.now() <= dueDate.getTime()) { return; } + + try { + await deleteByTodoId(userId, "notificationDispatches", todoId); + } catch (error) { + logger.error( + "완료된 todo의 notification record 정리 실패", + toError(error), + { + userId, + todoId, + collection: "notificationDispatches" + } + ); + } + } +); export const cleanupSoftDeletedNotifications = onSchedule({ maxInstances: 1, @@ -14,12 +81,20 @@ export const cleanupSoftDeletedNotifications = onSchedule({ }, async () => { try { + let lastDocument: + FirebaseFirestore.QueryDocumentSnapshot | undefined; + while (true) { - const snapshot = await admin.firestore() + let query = admin.firestore() .collectionGroup("notifications") .where("isDeleted", "==", true) + .orderBy(admin.firestore.FieldPath.documentId()) .limit(CLEANUP_BATCH_SIZE) - .get(); + if (lastDocument) { + query = query.startAfter(lastDocument); + } + + const snapshot = await query.get(); if (snapshot.empty) { return; } @@ -30,11 +105,139 @@ export const cleanupSoftDeletedNotifications = onSchedule({ await batch.commit(); if (snapshot.size < CLEANUP_BATCH_SIZE) { return; } + lastDocument = snapshot.docs[snapshot.docs.length - 1]; } } catch (error) { - logger.error("soft delete Notification cleanup 실패", { - error: normalizeError(error) + logger.error( + "soft delete Notification cleanup 실패", + toError(error), + { + collectionGroup: "notifications", + filter: "isDeleted == true", + orderBy: "documentId", + cleanupBatchSize: CLEANUP_BATCH_SIZE + } + ); + } + } +); + +// 더 이상 필요하지 않은 알림 발송 기록 정리 +export const cleanupNotificationDispatches = onSchedule({ + maxInstances: 1, + region: LOCATION, + schedule: "0 0 * * *", + timeZone: "UTC" + }, + async () => { + try { + await cleanupDispatchesByTodoQuery((lastDocument) => { + let query = admin.firestore() + .collectionGroup("todoLists") + .where("isCompleted", "==", true) + .where("dueDate", "<", admin.firestore.Timestamp.now()) + .orderBy("dueDate") + .orderBy(admin.firestore.FieldPath.documentId()) + .limit(QUERY_BATCH_SIZE); + + if (lastDocument) { + query = query.startAfter(lastDocument); + } + + return query; + }); + } catch (error) { + logger.error( + "지난 마감일의 완료된 todo notification record 정리 실패", + toError(error), + { + collectionGroup: "todoLists", + filter: "isCompleted == true && dueDate < now", + orderBy: ["dueDate", "documentId"], + queryBatchSize: QUERY_BATCH_SIZE + } + ); + } + + try { + await cleanupDispatchesByTodoQuery((lastDocument) => { + let query = admin.firestore() + .collectionGroup("todoLists") + .where("dueDate", "==", null) + .orderBy(admin.firestore.FieldPath.documentId()) + .limit(QUERY_BATCH_SIZE); + + if (lastDocument) { + query = query.startAfter(lastDocument); + } + + return query; }); + } catch (error) { + logger.error( + "마감일이 없는 todo notification record 정리 실패", + toError(error), + { + collectionGroup: "todoLists", + filter: "dueDate == null", + orderBy: "__name__", + queryBatchSize: QUERY_BATCH_SIZE + } + ); } } ); + +// Todo 조회 쿼리를 순회하며 연결된 알림 발송 기록을 정리 +async function cleanupDispatchesByTodoQuery( + makeQuery: ( + lastDocument?: + FirebaseFirestore.QueryDocumentSnapshot + ) => FirebaseFirestore.Query +): Promise { + let lastDocument: + FirebaseFirestore.QueryDocumentSnapshot | undefined; + + while (true) { + const snapshot = await makeQuery(lastDocument).get(); + if (snapshot.empty) { return; } + + for (const todoDoc of snapshot.docs) { + const userId = todoDoc.ref.parent.parent?.id; + if (!userId) { continue; } + + await deleteByTodoId(userId, "notificationDispatches", todoDoc.id); + } + + if (snapshot.size < QUERY_BATCH_SIZE) { return; } + lastDocument = snapshot.docs[snapshot.docs.length - 1]; + } +} + +// 특정 Todo 연결 문서의 배치 단위 전체 삭제 +async function deleteByTodoId( + userId: string, + collectionName: "notificationDispatches" | "notifications", + todoId: string +): Promise { + while (true) { + const collectionPath = collectionName === "notificationDispatches" ? + FirestorePath.notificationDispatches(userId) : + FirestorePath.notifications(userId); + const snapshot = await admin.firestore() + .collection(collectionPath) + .where("todoId", "==", todoId) + .limit(DELETE_BATCH_SIZE) + .get(); + + if (snapshot.empty) { return; } + + const batch = admin.firestore().batch(); + snapshot.docs.forEach((document) => { + batch.delete(document.ref); + }); + await batch.commit(); + + if (snapshot.size < DELETE_BATCH_SIZE) { return; } + } +} diff --git a/Firebase/functions/src/notification/deletion.ts b/Firebase/functions/src/notification/deletion.ts index ddaee980..529e1eb4 100644 --- a/Firebase/functions/src/notification/deletion.ts +++ b/Firebase/functions/src/notification/deletion.ts @@ -3,7 +3,7 @@ import { onTaskDispatched } from "firebase-functions/v2/tasks"; import { getFunctions } from "firebase-admin/functions"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; -import { normalizeError } from "../common/error"; +import { toError } from "../common/error"; import { FirestorePath } from "../common/firestorePath"; const LOCATION = "asia-northeast3"; @@ -62,10 +62,9 @@ export const requestPushNotificationDeletion = onCall({ }); } - logger.error("푸시 알림 삭제 요청 실패", { + logger.error("푸시 알림 삭제 요청 실패", toError(error), { userId, - notificationId, - error: normalizeError(error) + notificationId }); throw new HttpsError("internal", "푸시 알림 삭제 요청에 실패했습니다."); } @@ -103,10 +102,9 @@ export const undoPushNotificationDeletion = onCall({ }); } } catch (error) { - logger.error("푸시 알림 삭제 취소 실패", { + logger.error("푸시 알림 삭제 취소 실패", toError(error), { userId, - notificationId, - error: normalizeError(error) + notificationId }); throw new HttpsError("internal", "푸시 알림 삭제 취소에 실패했습니다."); } @@ -146,10 +144,9 @@ export const completePushNotificationDeletion = onTaskDispatched({ isDeleted: true }, { merge: true }); } catch (error) { - logger.error("푸시 알림 최종 삭제 실패", { + logger.error("푸시 알림 최종 삭제 실패", toError(error), { userId, - notificationId, - error: normalizeError(error) + notificationId }); throw error; } diff --git a/Firebase/functions/src/todo/cleanup.ts b/Firebase/functions/src/todo/cleanup.ts index ef0c971e..4525c5b4 100644 --- a/Firebase/functions/src/todo/cleanup.ts +++ b/Firebase/functions/src/todo/cleanup.ts @@ -1,69 +1,11 @@ -import { onDocumentDeleted, onDocumentUpdated } from "firebase-functions/v2/firestore"; import { onSchedule } from "firebase-functions/v2/scheduler"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; -import { toDate } from "../common/date"; -import { normalizeError } from "../common/error"; -import { FirestorePath } from "../common/firestorePath"; +import { toError } from "../common/error"; const LOCATION = "asia-northeast3"; -const DELETE_BATCH_SIZE = 200; const QUERY_BATCH_SIZE = 100; -// Todo 삭제 시 연결된 알림 문서와 발송 기록 문서의 동시 제거 -export const removeTodoNotificationDocuments = onDocumentDeleted({ - maxInstances: 1, - document: "users/{userId}/todoLists/{todoId}", - region: LOCATION - }, - async (event) => { - const userId = event.params.userId; - const todoId = event.params.todoId; - - try { - await deleteByTodoId(userId, "notificationDispatches", todoId); - await deleteByTodoId(userId, "notifications", todoId); - } catch (error) { - logger.error("todo 삭제 후 notification 문서 정리 실패", { - userId, - todoId, - error: normalizeError(error) - }); - } - } -); - -// 지난 마감일 Todo 완료 시 재발송 방지 기록 정리 -export const removeCompletedTodoNotificationRecords = onDocumentUpdated({ - maxInstances: 1, - document: "users/{userId}/todoLists/{todoId}", - region: LOCATION - }, - async (event) => { - const beforeData = event.data?.before.data(); - const afterData = event.data?.after.data(); - const userId = event.params.userId; - const todoId = event.params.todoId; - - if (!beforeData || !afterData) { return; } - if (beforeData.isCompleted === true || afterData.isCompleted !== true) { return; } - - const dueDate = toDate(afterData.dueDate); - - if (!dueDate || Date.now() <= dueDate.getTime()) { return; } - - try { - await deleteByTodoId(userId, "notificationDispatches", todoId); - } catch (error) { - logger.error("완료된 todo의 notification record 정리 실패", { - userId, - todoId, - error: normalizeError(error) - }); - } - } -); - // soft delete Todo 문서의 실제 삭제 export const cleanupSoftDeletedTodos = onSchedule({ maxInstances: 1, @@ -81,8 +23,7 @@ export const cleanupSoftDeletedTodos = onSchedule({ .collectionGroup("todoLists") .where("isDeleted", "==", true) .orderBy(admin.firestore.FieldPath.documentId()) - .limit(QUERY_BATCH_SIZE); - + .limit(QUERY_BATCH_SIZE) if (lastDocument) { query = query.startAfter(lastDocument); } @@ -100,114 +41,16 @@ export const cleanupSoftDeletedTodos = onSchedule({ lastDocument = snapshot.docs[snapshot.docs.length - 1]; } } catch (error) { - logger.error("soft delete Todo cleanup 실패", { - error: normalizeError(error) - }); - } - } -); - -// 사용되지 않는 알림 발송 기록의 주기적 정리 -export const cleanupUnusedTodoNotificationRecords = onSchedule({ - maxInstances: 1, - region: LOCATION, - schedule: "0 * * * *", - timeZone: "UTC" - }, - async () => { - try { - await cleanupDispatchesByTodoQuery((lastDocument) => { - let query = admin.firestore() - .collectionGroup("todoLists") - .where("isCompleted", "==", true) - .where("dueDate", "<", admin.firestore.Timestamp.now()) - .orderBy("dueDate") - .limit(QUERY_BATCH_SIZE); - - if (lastDocument) { - query = query.startAfter(lastDocument); - } - - return query; - }); - } catch (error) { - logger.error("지난 마감일의 완료된 todo notification record 정리 실패", { - error: normalizeError(error) - }); - } - - try { - await cleanupDispatchesByTodoQuery((lastDocument) => { - let query = admin.firestore() - .collectionGroup("todoLists") - .where("dueDate", "==", null) - .orderBy(admin.firestore.FieldPath.documentId()) - .limit(QUERY_BATCH_SIZE); - - if (lastDocument) { - query = query.startAfter(lastDocument); + logger.error( + "soft delete Todo cleanup 실패", + toError(error), + { + collectionGroup: "todoLists", + filter: "isDeleted == true", + orderBy: "documentId", + queryBatchSize: QUERY_BATCH_SIZE } - - return query; - }); - } catch (error) { - logger.error("마감일이 없는 todo notification record 정리 실패", { - error: normalizeError(error) - }); + ); } } ); - -// Todo 조회 쿼리를 순회하며 연결된 알림 발송 기록을 정리 -async function cleanupDispatchesByTodoQuery( - makeQuery: ( - lastDocument?: - FirebaseFirestore.QueryDocumentSnapshot - ) => FirebaseFirestore.Query -): Promise { - let lastDocument: - FirebaseFirestore.QueryDocumentSnapshot | undefined; - - while (true) { - const snapshot = await makeQuery(lastDocument).get(); - if (snapshot.empty) { return; } - - for (const todoDoc of snapshot.docs) { - const userId = todoDoc.ref.parent.parent?.id; - if (!userId) { continue; } - - await deleteByTodoId(userId, "notificationDispatches", todoDoc.id); - } - - if (snapshot.size < QUERY_BATCH_SIZE) { return; } - lastDocument = snapshot.docs[snapshot.docs.length - 1]; - } -} - -// 특정 Todo 연결 문서의 배치 단위 전체 삭제 -async function deleteByTodoId( - userId: string, - collectionName: "notificationDispatches" | "notifications", - todoId: string -): Promise { - while (true) { - const collectionPath = collectionName === "notificationDispatches" ? - FirestorePath.notificationDispatches(userId) : - FirestorePath.notifications(userId); - const snapshot = await admin.firestore() - .collection(collectionPath) - .where("todoId", "==", todoId) - .limit(DELETE_BATCH_SIZE) - .get(); - - if (snapshot.empty) { return; } - - const batch = admin.firestore().batch(); - snapshot.docs.forEach((document) => { - batch.delete(document.ref); - }); - await batch.commit(); - - if (snapshot.size < DELETE_BATCH_SIZE) { return; } - } -} diff --git a/Firebase/functions/src/todo/deletion.ts b/Firebase/functions/src/todo/deletion.ts index c66a57a3..99753ca3 100644 --- a/Firebase/functions/src/todo/deletion.ts +++ b/Firebase/functions/src/todo/deletion.ts @@ -4,7 +4,7 @@ import {getFunctions} from "firebase-admin/functions"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; import { FirestorePath } from "../common/firestorePath"; -import {normalizeError} from "../common/error"; +import {toError} from "../common/error"; const LOCATION = "asia-northeast3"; const DELETE_DELAY_SECONDS = 5; @@ -80,10 +80,9 @@ export const requestTodoDeletion = onCall({ } ); - logger.error("todo 삭제 요청 실패", { + logger.error("todo 삭제 요청 실패", toError(error), { userId, - todoId, - error: normalizeError(error) + todoId }); throw new HttpsError("internal", "Todo 삭제 요청에 실패했습니다."); } @@ -129,10 +128,9 @@ export const undoTodoDeletion = onCall({ } ); } catch (error) { - logger.error("todo 삭제 취소 실패", { + logger.error("todo 삭제 취소 실패", toError(error), { userId, - todoId, - error: normalizeError(error) + todoId }); throw new HttpsError("internal", "Todo 삭제 취소에 실패했습니다."); } @@ -181,10 +179,9 @@ export const completeTodoDeletion = onTaskDispatched({ } ); } catch (error) { - logger.error("todo 최종 soft delete 실패", { + logger.error("todo 최종 soft delete 실패", toError(error), { userId, - todoId, - error: normalizeError(error) + todoId }); throw error; } diff --git a/Firebase/functions/src/todo/update.ts b/Firebase/functions/src/todo/update.ts index e60555f8..b12f4e6d 100644 --- a/Firebase/functions/src/todo/update.ts +++ b/Firebase/functions/src/todo/update.ts @@ -1,7 +1,7 @@ import { onDocumentUpdated } from "firebase-functions/v2/firestore"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; -import { normalizeError } from "../common/error"; +import { toError } from "../common/error"; import { FirestorePath } from "../common/firestorePath"; const LOCATION = "asia-northeast3"; @@ -29,12 +29,11 @@ export const syncTodoNotificationCategory = onDocumentUpdated({ try { await updateNotifications(userId, todoId, afterCategory); } catch (error) { - logger.error("todo 카테고리 변경 후 알림 데이터 동기화 실패", { + logger.error("todo 카테고리 변경 후 알림 데이터 동기화 실패", toError(error), { userId, todoId, beforeCategory, - afterCategory, - error: normalizeError(error) + afterCategory }); throw error; } diff --git a/Firebase/functions/src/todoCategory/update.ts b/Firebase/functions/src/todoCategory/update.ts index 7944819e..9a673729 100644 --- a/Firebase/functions/src/todoCategory/update.ts +++ b/Firebase/functions/src/todoCategory/update.ts @@ -3,7 +3,7 @@ import { onTaskDispatched } from "firebase-functions/v2/tasks"; import { getFunctions } from "firebase-admin/functions"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; -import { normalizeError } from "../common/error"; +import { toError } from "../common/error"; import { FirestorePath } from "../common/firestorePath"; const LOCATION = "asia-northeast3"; @@ -56,10 +56,9 @@ export const requestMoveRemovedCategoryTodosToEtc = onDocumentUpdated({ } } } catch (error) { - logger.error("삭제된 사용자 카테고리 todo 정리 요청 실패", { + logger.error("삭제된 사용자 카테고리 todo 정리 요청 실패", toError(error), { userId, - removedIDs, - error: normalizeError(error) + removedIDs }); throw error; } @@ -83,11 +82,10 @@ export const completeMoveRemovedCategoryTodosToEtc = onTaskDispatched({ try { await updateTodos(userId, id); } catch (error) { - logger.error("삭제된 사용자 카테고리 todo 정리 실패", { + logger.error("삭제된 사용자 카테고리 todo 정리 실패", toError(error), { userId, id, - payload: request.data, - error: normalizeError(error) + payload: request.data }); throw error; } diff --git a/Firebase/functions/src/webPage/cleanup.ts b/Firebase/functions/src/webPage/cleanup.ts index 2cfc2483..5eaec4b9 100644 --- a/Firebase/functions/src/webPage/cleanup.ts +++ b/Firebase/functions/src/webPage/cleanup.ts @@ -1,7 +1,7 @@ import { onSchedule } from "firebase-functions/v2/scheduler"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; -import { normalizeError } from "../common/error"; +import { toError } from "../common/error"; const LOCATION = "asia-northeast3"; const CLEANUP_BATCH_SIZE = 200; @@ -14,12 +14,20 @@ export const cleanupSoftDeletedWebPages = onSchedule({ }, async () => { try { + let lastDocument: + FirebaseFirestore.QueryDocumentSnapshot | undefined; + while (true) { - const snapshot = await admin.firestore() + let query = admin.firestore() .collectionGroup("webPages") .where("isDeleted", "==", true) + .orderBy(admin.firestore.FieldPath.documentId()) .limit(CLEANUP_BATCH_SIZE) - .get(); + if (lastDocument) { + query = query.startAfter(lastDocument); + } + + const snapshot = await query.get(); if (snapshot.empty) { return; } @@ -30,10 +38,14 @@ export const cleanupSoftDeletedWebPages = onSchedule({ await batch.commit(); if (snapshot.size < CLEANUP_BATCH_SIZE) { return; } + lastDocument = snapshot.docs[snapshot.docs.length - 1]; } } catch (error) { - logger.error("soft delete WebPage cleanup 실패", { - error: normalizeError(error) + logger.error("soft delete WebPage cleanup 실패", toError(error), { + collectionGroup: "webPages", + filter: "isDeleted == true", + orderBy: "documentId", + cleanupBatchSize: CLEANUP_BATCH_SIZE }); } } diff --git a/Firebase/functions/src/webPage/deletion.ts b/Firebase/functions/src/webPage/deletion.ts index 17b01ffb..7a2c21d4 100644 --- a/Firebase/functions/src/webPage/deletion.ts +++ b/Firebase/functions/src/webPage/deletion.ts @@ -3,7 +3,7 @@ import { onTaskDispatched } from "firebase-functions/v2/tasks"; import { getFunctions } from "firebase-admin/functions"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; -import { normalizeError } from "../common/error"; +import { toError } from "../common/error"; import { FirestorePath } from "../common/firestorePath"; const LOCATION = "asia-northeast3"; @@ -67,10 +67,9 @@ export const requestWebPageDeletion = onCall({ }); } - logger.error("웹페이지 삭제 요청 실패", { + logger.error("웹페이지 삭제 요청 실패", toError(error), { userId, - urlString, - error: normalizeError(error) + urlString }); throw new HttpsError("internal", "웹페이지 삭제 요청에 실패했습니다."); } @@ -118,10 +117,9 @@ export const undoWebPageDeletion = onCall({ }); } } catch (error) { - logger.error("웹페이지 삭제 취소 실패", { + logger.error("웹페이지 삭제 취소 실패", toError(error), { userId, - urlString, - error: normalizeError(error) + urlString }); throw new HttpsError("internal", "웹페이지 삭제 취소에 실패했습니다."); } @@ -169,10 +167,9 @@ export const completeWebPageDeletion = onTaskDispatched({ isDeleted: true }, { merge: true }); } catch (error) { - logger.error("웹페이지 최종 soft delete 실패", { + logger.error("웹페이지 최종 soft delete 실패", toError(error), { userId, - urlString, - error: normalizeError(error) + urlString }); throw error; }