package jnpf.audit.sdk; import jnpf.util.JsonUtil; import lombok.extern.slf4j.Slf4j; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.UUID; import java.util.concurrent.atomic.AtomicLong; import jnpf.audit.model.AuditEventDTO; /** * 本地兜底 spool 写入器(Task 7 Step 3):三级降级最后一级。 * *

按日追加一行 JSON 到 {@code ${audit.spool-dir}/audit-events-yyyyMMdd.jsonl}; * IO 失败仅 {@code log.error} + 丢弃计数,绝不向业务抛异常——审计落盘失败不能反噬业务。 * *

并发安全(Codex 批次二复验 #1,双层锁协议终局修复):{@link #write} 的 * "open(append)→写一行→flush→close" 整段搬进与 {@link AuditSpoolReplayer} 共享的 * {@link AuditSpoolLock} 临界区(进程内 monitor + 跨进程 {@code spool.lock} FileLock)。因此 * append 与重放器的"认领 rename"严格互斥——彻底消除"writer 经已打开 FD 向被改名 inode 写入、 * 事件随 {@code .replaying} 被删而永久丢失"的窗口竞态(原 60 秒静默窗口仅活跃度启发式,不足以保证正确性, * 现降级为纯优化)。{@link Files#write} 单次调用内即开-写-关(写入量 <0.5 QPS,每次开关流性能无虞), * 锁内只做这一快操作;重放器把活动文件 rename 走后,下一次 write 以 {@link StandardOpenOption#CREATE} * 在原名新建文件继续追加,天然不写回旧 inode(终验四轮③)。 * *

降级隔离(Codex 批次二三轮 #A:消灭共享名,杜绝降级路径 rename 竞态):跨进程 FileLock * 一时拿不到(超时/中断/不可用/已销毁)时,{@link AuditSpoolLock#lockAndRun(AuditSpoolLock.IoRunnable, AuditSpoolLock.IoRunnable)} * 不再让本次写入去追加共享活动名(那样会与其它进程的"认领 rename+删除"重演 #1 丢事件时序:无锁 * open 活动文件 → 他进程 rename+读 → 本进程向被改名 inode 写 → 他进程删 → 事件丢失),而是改走 * "应急段"路径:把该行写入唯一名文件(一事件一文件,仅降级场景、低 QPS 可接受),由重放器锁内认领 * 消费——宁多几个文件也不丢事件。进程内 monitor 全程持有(同 JVM 串行不变)。 * *

写后原子发布(Codex 四轮复验 Critical#1,消灭跨进程半写窗口):上一版把应急段直接用可扫描的 * {@code .retry} 名一步 {@link StandardOpenOption#CREATE_NEW} 建文件+写入——但"目录项创建可见"与 * "内容写完 close"之间存在窗口,另一进程的 {@link AuditSpoolReplayer} 按文件名扫描/认领可能落进这个 * 窗口:读到空文件 → 收尾把它当处理完删掉(token 丢失)而本进程仍持已打开 FD 向已被 unlink 的 inode * 追加 → 事件静默丢失;或读到半行 → 前缀被当坏行隔离进 {@code .bad},行内容本不完整却被判定"已处理"。 * 现改为先写后发布两步,rename 的原子性物理消灭这个窗口: *

    *
  1. 写入唯一名 {@code <活动名>..retry.writing}({@link StandardOpenOption#CREATE_NEW}, * {@link Files#write} 单次调用内写完 flush+close)——此时文件名不在重放器扫描集 * ({@code .retry}/{@code .jsonl})内,任何进程都不会认领它;
  2. *
  3. {@link Files#move} + {@link StandardCopyOption#ATOMIC_MOVE} 原子改名发布为 * {@code <活动名>..retry}——rename 是文件系统原子操作,{@code .retry} 这个可扫描名 * 从"不存在"直接跳到"内容完整存在",不存在中间态,跨进程认领窗口物理消失。
  4. *
* rename 因源文件已不存在而失败({@link NoSuchFileException})时,说明本次写入在上面第①步 * {@link Files#write} 调用内部被阻塞过久(磁盘/网络存储 IO 卡顿),导致 * {@link AuditSpoolReplayer#recoverOrphanedWriting()} 误判其超龄并把源文件隔离为 {@code .stale}—— * 此为 Codex 五轮修复的自愈场景,见下一段。其它 rename 失败(磁盘满、权限突变等极端 IO 异常)时仍只 * {@code log.error}(脱敏,仅记文件名与 {@code clientEventId},不记事件正文)、保留 {@code .writing} * 文件不删、不重试——内容已完整落盘,不计入 {@link #discardCount}(不算事件丢失);交由 * {@link AuditSpoolReplayer} 的孤儿回收兜底(mtime 超阈值后锁内重验+隔离为 {@code .stale},等待人工 * 核查补录,见其类 Javadoc)。 * *

Codex 五轮修复(自愈重发:源被隔离/挪走时重试一次):{@code .writing} 源文件被 * {@link AuditSpoolReplayer#recoverOrphanedWriting()} 隔离为 {@code .stale} 后,本次 rename 会抛 * {@link NoSuchFileException}——此时事件内容并未真的丢失(已完整落在 {@code .stale} 里,等待人工 * 核查),但本次投递尚未自动完成。为了不让审计事件仅仅因为一次极端的 IO 阻塞就必须依赖人工补录, * {@link #write} 在此情形下只重试一次:换一个新 {@code UUID} 重新走一遍「写 {@code .writing} * → move 发布为 {@code .retry}」两步协议(见 {@link #publishOnceOrRetryAfterQuarantine})。重试 * 成功——事件已正常投递到重放通道,{@code .stale} 里的旧副本至多在人工核查补录时造成一次重复,落库按 * {@code clientEventId} 幂等吸收,无害;重试仍以 {@code NoSuchFileException} 失败(理论上极罕见,需 * 连续两次不同 UUID 的文件都被隔离)则不再重试,{@code log.error}(含 {@code clientEventId})交由 * 人工核查 {@code .stale} 隔离区,原样保留其中已经写好的完整内容。 */ @Slf4j public class AuditSpoolWriter { private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyyMMdd"); private final File spoolDir; private final AuditSpoolLock spoolLock; private final AtomicLong discardCount = new AtomicLong(); public AuditSpoolWriter(String spoolDir, AuditSpoolLock spoolLock) { this.spoolDir = new File(spoolDir); this.spoolLock = spoolLock; } public void write(AuditEventDTO event) { try { String fileName = "audit-events-" + LocalDate.now().format(DAY) + ".jsonl"; Path path = new File(spoolDir, fileName).toPath(); byte[] bytes = (JsonUtil.getObjectToString(event) + "\n") // jsonl 规范用 \n .getBytes(StandardCharsets.UTF_8); // Codex 批次二复验 #1:持锁路径 open-append-flush-close 搬进共享临界区,与 replayer 的认领 rename 互斥 spoolLock.lockAndRun( // lockedAction:持有跨进程锁 → 正常追加共享活动文件 () -> { if (!spoolDir.exists()) { Files.createDirectories(spoolDir.toPath()); } Files.write(path, bytes, StandardOpenOption.CREATE, StandardOpenOption.APPEND); }, // degradedAction(Codex 批次二三轮 #A + 四轮 Critical#1 + 五轮自愈):未持锁 → 写后 // 原子发布唯一名 .retry 应急段,消灭共享名/杜绝 rename 竞态、消灭跨进程半写窗口(见类 // Javadoc「写后原子发布」段);发布源被 AuditSpoolReplayer 孤儿隔离时自愈重试一次 // (见类 Javadoc「Codex 五轮修复(自愈重发)」段)。 () -> { if (!spoolDir.exists()) { Files.createDirectories(spoolDir.toPath()); } publishOnceOrRetryAfterQuarantine(fileName, bytes, event); }); } catch (Throwable t) { long n = discardCount.incrementAndGet(); log.error("AUDIT-SPOOL-LOST clientEventId={} 落盘失败,累计丢弃={}", event == null ? null : event.getClientEventId(), n, t); } } /** * degradedAction 主体:写后原子发布两步协议(写唯一名 {@code .writing} → move 发布为 * {@code .retry}),源被孤儿隔离({@link NoSuchFileException})时自愈只重试一次——见类 * Javadoc「写后原子发布」与「Codex 五轮修复(自愈重发)」两段的完整论证。 * * @throws IOException 非隔离原因的 rename 失败(磁盘满等)已在方法内 log.error 吸收并返回, * 仅 {@code Files.write} 本身失败(如磁盘已满导致写不进去)会外抛, * 交由 {@link #write} 的外层 catch 计入 {@link #discardCount} */ private void publishOnceOrRetryAfterQuarantine(String fileName, byte[] bytes, AuditEventDTO event) throws IOException { boolean retried = false; while (true) { String uuid = UUID.randomUUID().toString(); Path writing = new File(spoolDir, fileName + "." + uuid + ".retry.writing").toPath(); Path published = new File(spoolDir, fileName + "." + uuid + ".retry").toPath(); // 第一步:写唯一名 .writing 临时文件,CREATE_NEW+单次 write 调用内写完 flush+close Files.write(writing, bytes, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); try { // 第二步:原子改名发布为 .retry——rename 原子性使可扫描名下只可能出现完整内容 Files.move(writing, published, StandardCopyOption.ATOMIC_MOVE); return; // 发布成功 } catch (NoSuchFileException sourceGone) { // 源已被 AuditSpoolReplayer#recoverOrphanedWriting() 隔离为 .stale(本次写入曾在 // Files.write 内被阻塞超过孤儿回收阈值)——内容未丢,只是滞留隔离区。只重试一次: // 换新 UUID 重写一份再发布;.stale 里的旧副本至多造成人工补录时的重复(幂等吸收) if (retried) { log.error("AUDIT-SPOOL-RETRY-PUBLISH-FAIL file={} clientEventId={} " + "源文件已被隔离且重试后仍失败,需人工核查 .stale 隔离区补录", writing.getFileName(), event == null ? null : event.getClientEventId(), sourceGone); return; } retried = true; log.warn("[audit] degraded 发布源文件被隔离(写入曾阻塞超过孤儿回收阈值),自愈重写一次: " + "file={} clientEventId={}", writing.getFileName(), event == null ? null : event.getClientEventId()); // 继续循环:新 UUID 重写一份再尝试发布 } catch (IOException renameFail) { // 其它极端 IO 异常(磁盘满/权限突变等):内容已完整落在 .writing(上一步写入已成功), // 不算事件丢失——不计入 discardCount,保留 .writing 交孤儿回收兜底 log.error("AUDIT-SPOOL-RETRY-PUBLISH-FAIL file={} clientEventId={} " + "原子发布失败,内容已保留于 .writing 待孤儿回收", writing.getFileName(), event == null ? null : event.getClientEventId(), renameFail); return; } } } /** 累计因落盘 IO 失败而丢弃的事件数(观测用)。 */ public long discardCount() { return discardCount.get(); } }