job-store.mjs 3.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. import { mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises';
  2. import { join } from 'node:path';
  3. export class JobStore {
  4. constructor(directory, retentionMs) {
  5. this.directory = directory;
  6. this.retentionMs = retentionMs;
  7. this.jobs = new Map();
  8. this.idempotency = new Map();
  9. }
  10. async init() {
  11. await mkdir(this.directory, { recursive: true });
  12. for (const name of await readdir(this.directory)) {
  13. if (!name.endsWith('.json')) continue;
  14. try {
  15. const job = JSON.parse(await readFile(join(this.directory, name), 'utf8'));
  16. if (!job?.id) continue;
  17. this.jobs.set(job.id, job);
  18. if (job.idempotencyKey) {
  19. const current = this.get(this.idempotency.get(job.idempotencyKey));
  20. if (!current || Date.parse(job.createdAt || '') >= Date.parse(current.createdAt || '')) {
  21. this.idempotency.set(job.idempotencyKey, job.id);
  22. }
  23. }
  24. } catch {
  25. // 损坏的单个临时任务不会阻止服务启动。
  26. }
  27. }
  28. await this.cleanupExpired();
  29. }
  30. get(id) {
  31. return this.jobs.get(id) || null;
  32. }
  33. findByIdempotency(key) {
  34. const id = this.idempotency.get(key);
  35. return id ? this.get(id) : null;
  36. }
  37. listRecoverable() {
  38. return [...this.jobs.values()].filter(job => ['queued', 'running'].includes(job.status));
  39. }
  40. listByOwner(ownerKey) {
  41. return [...this.jobs.values()].filter(job => job.ownerKey === ownerKey);
  42. }
  43. async create(job) {
  44. this.jobs.set(job.id, job);
  45. if (job.idempotencyKey) this.idempotency.set(job.idempotencyKey, job.id);
  46. await this.persist(job);
  47. return job;
  48. }
  49. async update(id, patch) {
  50. const current = this.get(id);
  51. if (!current) return null;
  52. const next = {
  53. ...current,
  54. ...patch,
  55. updatedAt: new Date().toISOString(),
  56. };
  57. this.jobs.set(id, next);
  58. await this.persist(next);
  59. return next;
  60. }
  61. async persist(job) {
  62. const finalPath = join(this.directory, `${job.id}.json`);
  63. const temporaryPath = join(this.directory, `.${job.id}.${process.pid}.tmp`);
  64. await writeFile(temporaryPath, JSON.stringify(job), { mode: 0o600 });
  65. await rename(temporaryPath, finalPath);
  66. }
  67. async cleanupExpired(now = Date.now()) {
  68. for (const job of [...this.jobs.values()]) {
  69. const expiresAt = Date.parse(job.expiresAt || '');
  70. if (!Number.isFinite(expiresAt) || expiresAt > now) continue;
  71. this.jobs.delete(job.id);
  72. if (job.idempotencyKey && this.idempotency.get(job.idempotencyKey) === job.id) {
  73. this.idempotency.delete(job.idempotencyKey);
  74. }
  75. await unlink(join(this.directory, `${job.id}.json`)).catch(() => undefined);
  76. }
  77. }
  78. }
  79. export function publicJob(job) {
  80. return {
  81. id: job.id,
  82. status: job.status,
  83. stage: job.stage,
  84. progress: job.progress,
  85. createdAt: job.createdAt,
  86. updatedAt: job.updatedAt,
  87. heartbeatAt: job.heartbeatAt || null,
  88. expiresAt: job.expiresAt,
  89. result: job.status === 'completed' ? job.result : undefined,
  90. error: job.status === 'failed' ? job.error : undefined,
  91. };
  92. }