task-delivery-demo.tsx 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. 'use client';
  2. import { useState } from 'react';
  3. import {
  4. ArrowRight,
  5. Check,
  6. RotateCcw,
  7. SkipForward,
  8. Unplug,
  9. Database,
  10. Network,
  11. Server,
  12. Code2,
  13. } from 'lucide-react';
  14. import { Button } from '@/components/ui/button';
  15. const states = {
  16. ready: {
  17. title: '等待请求',
  18. detail: '尚未创建任务。客户端准备提交一次笔记导出请求。',
  19. node: -1,
  20. deliveries: 0,
  21. writes: 0,
  22. },
  23. persisted: {
  24. title: '事务提交',
  25. detail: 'API 在同一数据库事务写入任务与 outbox 记录。任务 ID 为 job-42。',
  26. node: 1,
  27. deliveries: 0,
  28. writes: 0,
  29. },
  30. queued: {
  31. title: '消息已发布',
  32. detail: '发布器把 job-42 发送到队列。消息中的 ID 引用数据库任务记录。',
  33. node: 2,
  34. deliveries: 1,
  35. writes: 0,
  36. },
  37. running: {
  38. title: 'Worker 执行',
  39. detail:
  40. 'Worker 取得执行资格,使用 job-42 作为本次操作的去重键。消息尚未 ACK。',
  41. node: 3,
  42. deliveries: 1,
  43. writes: 0,
  44. },
  45. committed: {
  46. title: '业务事务已提交,尚未 ACK',
  47. detail: '结果和去重记录已原子提交。此时崩溃,队列仍可能再次投递消息。',
  48. node: 1,
  49. deliveries: 1,
  50. writes: 1,
  51. },
  52. crashed: {
  53. title: 'Worker 在 ACK 前退出',
  54. detail:
  55. '已提交的数据库结果仍存在。未确认的消息在队列恢复机制触发后重新投递。',
  56. node: 2,
  57. deliveries: 1,
  58. writes: 1,
  59. },
  60. redelivered: {
  61. title: '再次收到同一个任务',
  62. detail:
  63. '新的 Worker 检查 job-42 的已完成记录,返回已有结果,不再重复写入。',
  64. node: 3,
  65. deliveries: 2,
  66. writes: 1,
  67. },
  68. done: {
  69. title: '确认消息,处理完成',
  70. detail: '消息被 ACK。正常执行时投递一次;经过故障恢复时可能投递两次。',
  71. node: 3,
  72. deliveries: 1,
  73. writes: 1,
  74. },
  75. recovered: {
  76. title: '重复投递已处理',
  77. detail: '重复消息被 ACK。队列投递了两次,数据库副作用只提交一次。',
  78. node: 3,
  79. deliveries: 2,
  80. writes: 1,
  81. },
  82. };
  83. type State = keyof typeof states;
  84. const next: Partial<Record<State, State>> = {
  85. ready: 'persisted',
  86. persisted: 'queued',
  87. queued: 'running',
  88. running: 'committed',
  89. committed: 'done',
  90. crashed: 'redelivered',
  91. redelivered: 'recovered',
  92. };
  93. export function TaskDeliveryDemo() {
  94. const [state, setState] = useState<State>('ready');
  95. const s = states[state];
  96. return (
  97. <div className="demo delivery-demo">
  98. <div className="demo-top">
  99. <span className="demo-label">提交成功后,消息为什么还会重复?</span>
  100. <span className="simulation">状态模型演示 · 不连接真实服务</span>
  101. </div>
  102. <div className="delivery-nodes">
  103. {[
  104. { name: 'API', icon: Code2 },
  105. { name: 'MySQL', icon: Database },
  106. { name: '消息队列', icon: Network },
  107. { name: 'Worker', icon: Server },
  108. ].map((n, i) => (
  109. <div className={s.node === i ? 'current' : ''} key={n.name}>
  110. <n.icon size={22} />
  111. <span>{n.name}</span>
  112. {i < 3 && <ArrowRight size={13} />}
  113. </div>
  114. ))}
  115. </div>
  116. <div className="demo-result" aria-live="polite">
  117. <div>
  118. <h3>{s.title}</h3>
  119. <p>{s.detail}</p>
  120. </div>
  121. </div>
  122. <div className="delivery-counters">
  123. <span>
  124. 任务 ID <strong>job-42</strong>
  125. </span>
  126. <span>
  127. 消息投递 <strong>{s.deliveries}</strong>
  128. </span>
  129. <span>
  130. 业务写入 <strong>{s.writes}</strong>
  131. </span>
  132. </div>
  133. <div className="demo-controls">
  134. <Button
  135. variant="outline"
  136. disabled={!next[state]}
  137. onClick={() => setState(next[state]!)}
  138. >
  139. <SkipForward />
  140. {state === 'ready' ? '提交任务' : '推进一步'}
  141. </Button>
  142. <Button
  143. variant="ghost"
  144. disabled={state !== 'committed'}
  145. onClick={() => setState('crashed')}
  146. >
  147. <Unplug />
  148. 提交后崩溃
  149. </Button>
  150. <Button
  151. variant="ghost"
  152. onClick={() => setState('ready')}
  153. aria-label="重置任务投递演示"
  154. >
  155. <RotateCcw />
  156. </Button>
  157. {!next[state] && <Check size={16} />}
  158. </div>
  159. <p className="demo-footnote">
  160. 本例假设业务结果与去重记录处于同一个数据库事务,并有唯一约束。发送邮件、调用支付等外部副作用需要单独设计幂等协议。队列恢复时间取决于
  161. broker、ACK 和 visibility timeout 配置。
  162. </p>
  163. </div>
  164. );
  165. }