fix(windows-node): 复用上传完成标记临时文件
This commit is contained in:
parent
b929cee832
commit
ef5428c3b7
|
|
@ -22,7 +22,7 @@
|
||||||
|
|
||||||
### 2026-08-14
|
### 2026-08-14
|
||||||
|
|
||||||
- **08-14 / Unreleased / Windows Node 输出上传收敛与诊断**:恢复中的成功任务优先向云端重放完成确认,已发布结果不再重新打开可能被 Origin 占用的 OPJU;文件共享冲突按 0.5/1/2/5 秒有界退避,本地完成标记独立重试,并按 Job 写入带阶段、产物、重试次数和 HRESULT 的 1 MiB 轮转诊断日志。同步修正本机更新时间早于接收时间的展示边界;相关 45 项专项 unittest、.NET build 与 diff 检查通过,未写入生产 DB。
|
- **08-14 / Unreleased / Windows Node 输出上传收敛与诊断**:恢复中的成功任务优先向云端重放完成确认,已发布结果不再重新打开可能被 Origin 占用的 OPJU;文件共享冲突按 0.5/1/2/5 秒有界退避,本地完成标记使用可跨心跳复用的固定 pending 文件,只重试重命名以避开系统程序对每个新文件的重复扫描,并按 Job 写入带阶段、产物、重试次数和 HRESULT 的 1 MiB 轮转诊断日志。同步修正本机更新时间早于接收时间的展示边界;相关 45 项专项 unittest、.NET build 与 diff 检查通过,未写入生产 DB。
|
||||||
|
|
||||||
- **08-14 / Unreleased / Windows Node 本机任务监控 + Origin 多输入多输出 v2**:配置窗口新增只读本机任务列表与详情,每秒从 `%ProgramData%/Zcbot/WindowsNode/jobs` 合并 request/state/terminal/upload-complete,展示标题、全部输入、阶段、执行耗时、时间、Job ID 与错误,重启后可恢复且不查询云端未派发 Job。按用户明确授权直接切换到 `origin.plot@v2`:通用 `inputs[] + operation + outputs[]` 支持 1–16 个输入、跨输入系列和多个显式产物,Node 下载到 keyed 目录,Origin Worker 建立多工作表后合并绘图;本地阶段统一为 `downloading_inputs/software_running`,不兼容 v1 请求或旧本地任务。相关专项 71 项 unittest、Python/JavaScript 语法、Ruff 致命规则、.NET build 与 diff 检查通过;完整测试 593 项中非数据库测试通过,3 个数据库集成模块因显式测试库未迁移、缺少 `users` 表而未通过,未连接或写入生产 DB。
|
- **08-14 / Unreleased / Windows Node 本机任务监控 + Origin 多输入多输出 v2**:配置窗口新增只读本机任务列表与详情,每秒从 `%ProgramData%/Zcbot/WindowsNode/jobs` 合并 request/state/terminal/upload-complete,展示标题、全部输入、阶段、执行耗时、时间、Job ID 与错误,重启后可恢复且不查询云端未派发 Job。按用户明确授权直接切换到 `origin.plot@v2`:通用 `inputs[] + operation + outputs[]` 支持 1–16 个输入、跨输入系列和多个显式产物,Node 下载到 keyed 目录,Origin Worker 建立多工作表后合并绘图;本地阶段统一为 `downloading_inputs/software_running`,不兼容 v1 请求或旧本地任务。相关专项 71 项 unittest、Python/JavaScript 语法、Ruff 致命规则、.NET build 与 diff 检查通过;完整测试 593 项中非数据库测试通过,3 个数据库集成模块因显式测试库未迁移、缺少 `users` 表而未通过,未连接或写入生产 DB。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,11 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
||||||
self.assertIn('"node-output-upload.log"', uploader)
|
self.assertIn('"node-output-upload.log"', uploader)
|
||||||
self.assertIn("MaxDiagnosticLogBytes", uploader)
|
self.assertIn("MaxDiagnosticLogBytes", uploader)
|
||||||
self.assertIn("outputUploader.RecordDeferred(current, exception)", connection)
|
self.assertIn("outputUploader.RecordDeferred(current, exception)", connection)
|
||||||
|
self.assertIn('completionPath + ".pending"', uploader)
|
||||||
|
self.assertIn("PreparePendingCompletion(pendingPath, responseBody)", uploader)
|
||||||
|
self.assertIn("File.Move(pendingPath, completionPath, overwrite: false)", uploader)
|
||||||
|
self.assertIn("operation=move_pending", uploader)
|
||||||
|
self.assertNotIn('".tmp-" + Guid.NewGuid()', uploader)
|
||||||
self.assertNotIn("Process.Start", uploader)
|
self.assertNotIn("Process.Start", uploader)
|
||||||
|
|
||||||
def test_local_job_monitor_never_shows_update_before_acceptance(self) -> None:
|
def test_local_job_monitor_never_shows_update_before_acceptance(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -180,25 +180,85 @@ internal sealed class JobOutputUploader(NodeConfig config)
|
||||||
string completionPath,
|
string completionPath,
|
||||||
byte[] responseBody)
|
byte[] responseBody)
|
||||||
{
|
{
|
||||||
|
var jobDirectory = Path.GetDirectoryName(completionPath)!;
|
||||||
|
if (Directory.Exists(completionPath))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"本地完成标记路径被同名目录占用:upload-complete.json。");
|
||||||
|
}
|
||||||
|
if (File.Exists(completionPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var pendingPath = completionPath + ".pending";
|
||||||
|
PreparePendingCompletion(pendingPath, responseBody);
|
||||||
for (var attempt = 0; ; attempt++)
|
for (var attempt = 0; ; attempt++)
|
||||||
{
|
{
|
||||||
|
if (File.Exists(completionPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
AtomicWrite(completionPath, responseBody);
|
File.Move(pendingPath, completionPath, overwrite: false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
catch (IOException exception) when (
|
catch (IOException exception) when (
|
||||||
IsSharingViolation(exception) && attempt < SharingViolationBackoff.Length)
|
IsSharingViolation(exception) && attempt < SharingViolationBackoff.Length)
|
||||||
{
|
{
|
||||||
var delay = SharingViolationBackoff[attempt];
|
var delay = SharingViolationBackoff[attempt];
|
||||||
Log(Path.GetDirectoryName(completionPath)!, "WARN",
|
Log(jobDirectory, "WARN",
|
||||||
$"Completion marker busy job={job.JobId:D} "
|
$"Completion marker busy job={job.JobId:D} "
|
||||||
|
+ "operation=move_pending "
|
||||||
+ $"attempt={attempt + 1} retry_ms={(int)delay.TotalMilliseconds} "
|
+ $"attempt={attempt + 1} retry_ms={(int)delay.TotalMilliseconds} "
|
||||||
+ $"hresult=0x{exception.HResult:X8}", error: true);
|
+ $"hresult=0x{exception.HResult:X8}", error: true);
|
||||||
await Task.Delay(delay);
|
await Task.Delay(delay);
|
||||||
}
|
}
|
||||||
|
catch (IOException exception) when (IsSharingViolation(exception))
|
||||||
|
{
|
||||||
|
Log(jobDirectory, "WARN",
|
||||||
|
$"Completion marker busy job={job.JobId:D} "
|
||||||
|
+ "operation=move_pending retries=exhausted "
|
||||||
|
+ $"hresult=0x{exception.HResult:X8}", error: true);
|
||||||
|
throw new IOException(
|
||||||
|
"本地完成标记 upload-complete.json 在重命名阶段持续被占用"
|
||||||
|
+ $"(HRESULT=0x{exception.HResult:X8})。",
|
||||||
|
exception);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PreparePendingCompletion(string pendingPath, byte[] responseBody)
|
||||||
|
{
|
||||||
|
if (File.Exists(pendingPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var pending = JsonDocument.Parse(File.ReadAllBytes(pendingPath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (IOException exception) when (IsSharingViolation(exception))
|
||||||
|
{
|
||||||
|
// 上一轮已经完整落盘但暂时被系统程序扫描时,保留同一个 pending
|
||||||
|
// 文件;重试重命名不会再次触发“新文件关闭后立即扫描”。
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
using var response = JsonDocument.Parse(responseBody);
|
||||||
|
var content = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
completed_at = DateTimeOffset.UtcNow,
|
||||||
|
response = response.RootElement,
|
||||||
|
}, JsonOptions);
|
||||||
|
using var stream = new FileStream(
|
||||||
|
pendingPath, FileMode.Create, FileAccess.Write, FileShare.None,
|
||||||
|
4096, FileOptions.WriteThrough);
|
||||||
|
stream.Write(content);
|
||||||
|
stream.Flush(flushToDisk: true);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsSharingViolation(IOException exception) =>
|
private static bool IsSharingViolation(IOException exception) =>
|
||||||
(exception.HResult & 0xFFFF) is 32 or 33;
|
(exception.HResult & 0xFFFF) is 32 or 33;
|
||||||
|
|
@ -243,27 +303,4 @@ internal sealed class JobOutputUploader(NodeConfig config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AtomicWrite(string path, byte[] responseBody)
|
|
||||||
{
|
|
||||||
using var response = JsonDocument.Parse(responseBody);
|
|
||||||
var content = JsonSerializer.SerializeToUtf8Bytes(new
|
|
||||||
{
|
|
||||||
completed_at = DateTimeOffset.UtcNow,
|
|
||||||
response = response.RootElement,
|
|
||||||
}, JsonOptions);
|
|
||||||
var temporary = path + ".tmp-" + Guid.NewGuid().ToString("N");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var stream = new FileStream(
|
|
||||||
temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
|
||||||
4096, FileOptions.WriteThrough);
|
|
||||||
stream.Write(content);
|
|
||||||
stream.Flush(flushToDisk: true);
|
|
||||||
File.Move(temporary, path, overwrite: false);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (File.Exists(temporary)) File.Delete(temporary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue