videoworkflow-parse-cleanup.mjs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. const PARSE_HOST = normalizeParseHost(process.env.SMOKE_PARSE_HOST || 'https://server.fmode.cn');
  4. const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster';
  5. const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || '';
  6. const CONFIRM_TEXT = 'DELETE_VIDEO_WORKFLOW_TEST_DATA';
  7. const CONFIRM = readArg('--confirm') || process.env.VIDEO_WORKFLOW_CLEANUP_CONFIRM || '';
  8. const EXECUTE = CONFIRM === CONFIRM_TEXT;
  9. const CONCURRENCY = clamp(Number(readArg('--concurrency') || process.env.VIDEO_WORKFLOW_CLEANUP_CONCURRENCY || 2), 1, 4);
  10. const OUTPUT_DIR = process.env.VIDEO_WORKFLOW_CLEANUP_OUTPUT_DIR
  11. ? path.resolve(process.env.VIDEO_WORKFLOW_CLEANUP_OUTPUT_DIR)
  12. : path.join(process.cwd(), 'tmp');
  13. const PROJECT_KEY = 'video-workflow';
  14. const ALLOWED_CLASSES = [
  15. 'VideoWorkflowEntity',
  16. 'VideoWorkflowAudit',
  17. 'VideoWorkflowMigration',
  18. 'VideoWorkflowFileAsset',
  19. ];
  20. const requestedClasses = parseRequestedClasses(readArg('--classes') || process.env.VIDEO_WORKFLOW_CLEANUP_CLASSES || '');
  21. const targetClasses = requestedClasses.length ? requestedClasses : ALLOWED_CLASSES;
  22. if (!SESSION_TOKEN) {
  23. console.error('Missing SMOKE_SESSION_TOKEN. Refusing to inspect or delete Parse data.');
  24. process.exit(1);
  25. }
  26. const invalidClasses = targetClasses.filter((className) => !ALLOWED_CLASSES.includes(className));
  27. if (invalidClasses.length) {
  28. console.error(`Refusing to continue. Classes outside allowlist: ${invalidClasses.join(', ')}`);
  29. console.error(`Allowed classes: ${ALLOWED_CLASSES.join(', ')}`);
  30. process.exit(1);
  31. }
  32. const startedAt = new Date().toISOString();
  33. const me = await parseRequest('GET', '/users/me');
  34. const ownerId = me?.objectId || '';
  35. if (!ownerId) {
  36. console.error('Could not resolve current Parse user from SMOKE_SESSION_TOKEN.');
  37. process.exit(1);
  38. }
  39. const rowsByClass = {};
  40. for (const className of targetClasses) {
  41. rowsByClass[className] = await listRows(className, ownerId);
  42. }
  43. const allTargets = Object.entries(rowsByClass).flatMap(([className, rows]) =>
  44. rows.map((row) => ({
  45. className,
  46. objectId: row.objectId,
  47. ownerId: row.ownerId || '',
  48. projectKey: row.projectKey || '',
  49. entityType: row.entityType || '',
  50. entityId: row.entityId || '',
  51. status: row.status || '',
  52. createdAt: row.createdAt || '',
  53. updatedAt: row.updatedAt || '',
  54. })),
  55. );
  56. console.log(`VideoWorkflow Parse cleanup generatedAt=${startedAt}`);
  57. console.log(`mode=${EXECUTE ? 'EXECUTE_DELETE' : 'DRY_RUN_ONLY'}`);
  58. console.log(`ownerId=${ownerId}`);
  59. console.log(`classes=${targetClasses.join(', ')}`);
  60. console.log('');
  61. console.log('Rows by class:');
  62. for (const className of targetClasses) {
  63. console.log(`- ${className}: ${rowsByClass[className].length}`);
  64. }
  65. console.log('');
  66. console.log(`Total target rows: ${allTargets.length}`);
  67. let deleteResults = [];
  68. if (!EXECUTE) {
  69. console.log('');
  70. console.log('No data was deleted.');
  71. console.log(`To execute physical delete, rerun with: --confirm ${CONFIRM_TEXT}`);
  72. } else {
  73. deleteResults = await deleteTargets(allTargets);
  74. }
  75. const completedAt = new Date().toISOString();
  76. const failures = deleteResults.filter((item) => !item.ok);
  77. const report = {
  78. startedAt,
  79. completedAt,
  80. mode: EXECUTE ? 'execute' : 'dry-run',
  81. ownerId,
  82. allowedClasses: ALLOWED_CLASSES,
  83. targetClasses,
  84. totalTargets: allTargets.length,
  85. countsByClass: Object.fromEntries(targetClasses.map((className) => [className, rowsByClass[className].length])),
  86. deleteAttempted: deleteResults.length,
  87. deleteSuccess: deleteResults.filter((item) => item.ok).length,
  88. deleteFailures: failures.length,
  89. targets: allTargets,
  90. deleteResults,
  91. };
  92. fs.mkdirSync(OUTPUT_DIR, { recursive: true });
  93. const reportPath = path.join(
  94. OUTPUT_DIR,
  95. `videoworkflow-parse-cleanup-${EXECUTE ? 'delete' : 'dry-run'}-${safeTimestamp(completedAt)}.json`,
  96. );
  97. fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
  98. console.log('');
  99. console.log(`Cleanup ${EXECUTE ? 'execute' : 'dry-run'} complete.`);
  100. console.log(`Report: ${reportPath}`);
  101. if (failures.length) process.exit(1);
  102. async function listRows(className, ownerId) {
  103. const output = [];
  104. const pageSize = 1000;
  105. for (let skip = 0; ; skip += pageSize) {
  106. const query = new URLSearchParams();
  107. query.set('where', JSON.stringify({ projectKey: PROJECT_KEY, ownerId }));
  108. query.set('limit', String(pageSize));
  109. query.set('skip', String(skip));
  110. query.set('order', 'createdAt');
  111. query.set('keys', 'objectId,projectKey,ownerId,entityType,entityId,status,createdAt,updatedAt');
  112. const data = await parseRequest('GET', `/classes/${encodeURIComponent(className)}?${query.toString()}`);
  113. const rows = Array.isArray(data.results) ? data.results : [];
  114. output.push(...rows);
  115. if (rows.length < pageSize) break;
  116. }
  117. return output;
  118. }
  119. async function deleteTargets(targets) {
  120. const results = [];
  121. let index = 0;
  122. await Promise.all(Array.from({ length: CONCURRENCY }, async () => {
  123. while (index < targets.length) {
  124. const currentIndex = index;
  125. index += 1;
  126. const target = targets[currentIndex];
  127. results[currentIndex] = await deleteTarget(target);
  128. if ((currentIndex + 1) % 100 === 0 || currentIndex + 1 === targets.length) {
  129. console.log(`delete progress ${currentIndex + 1}/${targets.length}`);
  130. }
  131. }
  132. }));
  133. const failures = results.filter((item) => !item.ok);
  134. console.log('');
  135. console.log(`Delete complete. success=${results.length - failures.length} failures=${failures.length}`);
  136. return results;
  137. }
  138. async function deleteTarget(target) {
  139. try {
  140. await parseRequest('DELETE', `/classes/${encodeURIComponent(target.className)}/${encodeURIComponent(target.objectId)}`);
  141. return { ...target, ok: true, error: '' };
  142. } catch (error) {
  143. return { ...target, ok: false, error: error?.message || 'delete failed' };
  144. }
  145. }
  146. async function parseRequest(method, parsePath) {
  147. const headers = {
  148. Accept: 'application/json',
  149. 'X-Parse-Application-Id': APP_ID,
  150. 'X-Parse-Session-Token': SESSION_TOKEN,
  151. };
  152. let lastError = null;
  153. for (let attempt = 1; attempt <= 4; attempt += 1) {
  154. try {
  155. const response = await fetch(`${PARSE_HOST}/parse${parsePath}`, { method, headers });
  156. const data = await response.json().catch(() => ({}));
  157. if (!response.ok || data.error) {
  158. const error = new Error(data.error || data.message || `Parse ${method} ${parsePath} failed`);
  159. error.status = response.status || 500;
  160. error.detail = data;
  161. if (attempt < 4 && isRetryableError(error)) {
  162. await sleep(500 * attempt);
  163. continue;
  164. }
  165. throw error;
  166. }
  167. return data;
  168. } catch (error) {
  169. lastError = error;
  170. if (attempt < 4 && isRetryableError(error)) {
  171. await sleep(500 * attempt);
  172. continue;
  173. }
  174. throw error;
  175. }
  176. }
  177. throw lastError || new Error(`Parse ${method} ${parsePath} failed`);
  178. }
  179. function parseRequestedClasses(value) {
  180. return String(value || '')
  181. .split(',')
  182. .map((item) => item.trim())
  183. .filter(Boolean);
  184. }
  185. function readArg(name) {
  186. const index = process.argv.indexOf(name);
  187. return index >= 0 ? process.argv[index + 1] || '' : '';
  188. }
  189. function normalizeParseHost(value) {
  190. return String(value || 'https://server.fmode.cn').replace(/\/+$/, '').replace(/\/parse$/i, '');
  191. }
  192. function isRetryableError(error) {
  193. const status = Number(error?.status || 0);
  194. const message = `${error?.message || ''} ${error?.cause?.code || ''} ${error?.detail?.error || ''}`;
  195. return status >= 500 || /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message);
  196. }
  197. function clamp(value, min, max) {
  198. if (!Number.isFinite(value)) return min;
  199. return Math.max(min, Math.min(max, Math.floor(value)));
  200. }
  201. function sleep(ms) {
  202. return new Promise((resolve) => setTimeout(resolve, ms));
  203. }
  204. function safeTimestamp(value) {
  205. return String(value).replace(/[:.]/g, '-');
  206. }