request-scheduler.ts 5.2 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. import { Observable, Subscription, defer, finalize, of, shareReplay, tap, throwError } from 'rxjs';
  2. export interface RequestPolicy<T> {
  3. read: boolean;
  4. key?: string;
  5. heavy?: boolean;
  6. background?: boolean;
  7. valid?: () => boolean;
  8. cacheMs?: number | ((value: T) => number);
  9. refresh?: boolean;
  10. cacheable?: (value: T) => boolean;
  11. }
  12. interface Ticket { read: boolean; heavy: boolean; background: boolean; start: () => void; }
  13. export function stableRequestJson(value: unknown): string {
  14. if (Array.isArray(value)) return '[' + value.map(stableRequestJson).join(',') + ']';
  15. if (value && typeof value === 'object') return '{' + Object.entries(value).filter(([,v]) => v !== undefined).sort(([a],[b]) => a.localeCompare(b)).map(([k,v]) => JSON.stringify(k)+':'+stableRequestJson(v)).join(',') + '}';
  16. return JSON.stringify(value) ?? 'null';
  17. }
  18. /** One scheduler per app: all gateway calls share capacity, including retries. */
  19. export class RequestScheduler {
  20. private active = 0;
  21. private reads = 0;
  22. private heavy = 0;
  23. private revision = 0;
  24. private queue: Ticket[] = [];
  25. private pending = new Map<string, Observable<unknown>>();
  26. private cache = new Map<string, { value: unknown; expires: number }>();
  27. private readonly now: () => number;
  28. private readonly queueTimeoutMs: number;
  29. private completed = 0;
  30. private cancelled = 0;
  31. private coalesced = 0;
  32. constructor(options: { now?: () => number; queueTimeoutMs?: number } = {}) {
  33. this.now = options.now ?? Date.now;
  34. this.queueTimeoutMs = options.queueTimeoutMs ?? 10_000;
  35. }
  36. invalidate(): void { this.revision++; this.cache.clear(); this.pending.clear(); }
  37. snapshot(): { active: number; queued: number; completed: number; cancelled: number; coalesced: number } {
  38. return { active: this.active, queued: this.queue.length, completed: this.completed, cancelled: this.cancelled, coalesced: this.coalesced };
  39. }
  40. schedule<T>(run: () => Observable<T>, policy: RequestPolicy<T>): Observable<T> {
  41. return defer(() => {
  42. if (policy.valid && !policy.valid()) return throwError(() => new Error('会话已变化,请重新查询'));
  43. const key = policy.read ? policy.key : undefined;
  44. const cached = key && !policy.refresh ? this.cache.get(key) : undefined;
  45. if (cached && cached.expires > this.now()) return of(cached.value as T);
  46. const pending = key ? this.pending.get(key) : undefined;
  47. if (pending) { this.coalesced++; return pending as Observable<T>; }
  48. const revision = this.revision;
  49. const request = new Observable<T>(subscriber => {
  50. let started = false, ended = false, sub: Subscription | undefined;
  51. let timer: ReturnType<typeof setTimeout> | undefined;
  52. const ticket: Ticket = { read: policy.read, heavy: !!policy.heavy, background: !!policy.background, start: () => {
  53. started = true; if (timer) clearTimeout(timer);
  54. this.active++; if (ticket.read) this.reads++; if (ticket.heavy) this.heavy++;
  55. if (policy.valid && !policy.valid()) { subscriber.error(new Error('会话已变化,请重新查询')); return; }
  56. sub = defer(run).subscribe({ next: value => {
  57. if (policy.valid && !policy.valid()) { subscriber.error(new Error('会话已变化,请重新查询')); return; }
  58. if (!ended) { ended = true; this.completed++; }
  59. subscriber.next(value);
  60. }, error: error => subscriber.error(error), complete: () => { if (!ended) { ended = true; this.completed++; } subscriber.complete(); } });
  61. } };
  62. if (this.queue.length >= 64) subscriber.error(new Error('请求排队过多,请稍后重试'));
  63. else {
  64. this.queue.push(ticket);
  65. timer = setTimeout(() => subscriber.error(new Error('请求排队暂未完成,请稍后重试')), this.queueTimeoutMs);
  66. this.drain();
  67. }
  68. return () => {
  69. if (timer) clearTimeout(timer);
  70. this.queue = this.queue.filter(item => item !== ticket);
  71. sub?.unsubscribe();
  72. if (started) { this.active--; if (ticket.read) this.reads--; if (ticket.heavy) this.heavy--; }
  73. if (!ended) this.cancelled++;
  74. this.drain();
  75. };
  76. }).pipe(tap(value => {
  77. const cacheMs = typeof policy.cacheMs === 'function' ? policy.cacheMs(value) : policy.cacheMs;
  78. if (key && cacheMs && cacheMs > 0 && policy.cacheable?.(value) && revision === this.revision) {
  79. // Bounded cache, timestamps originate at the response, never at a cache hit.
  80. if (this.cache.size >= 100) this.cache.delete(this.cache.keys().next().value!);
  81. this.cache.set(key, { value, expires: this.now() + Math.min(30_000, cacheMs) });
  82. }
  83. }), finalize(() => { if (key && this.pending.get(key) === shared) this.pending.delete(key); }));
  84. const shared = request.pipe(shareReplay({ bufferSize: 1, refCount: true }));
  85. if (key) this.pending.set(key, shared);
  86. return shared;
  87. });
  88. }
  89. private drain(): void {
  90. while (this.active < 4) {
  91. const candidates = this.queue.filter(t => (!t.read || this.reads < 3) && (!t.heavy || this.heavy < 1));
  92. const next = candidates.find(t => !t.read) ?? candidates.find(t => !t.background) ?? candidates[0];
  93. if (!next) return;
  94. this.queue.splice(this.queue.indexOf(next), 1); next.start();
  95. }
  96. }
  97. }