fix: synchronize release notes after update restart
Release / release (push) Canceled after 0s

This commit is contained in:
Qiufeng
2026-08-19 09:04:33 +08:00
parent 6b04d8dd0a
commit 1d45be4e97
7 changed files with 181 additions and 36 deletions
@@ -34,6 +34,7 @@ import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger;
@@ -47,6 +48,7 @@ public class SystemUpdateApplicationService {
private static final Logger log = LoggerFactory.getLogger(SystemUpdateApplicationService.class);
private static final int MAX_MANIFEST_BYTES = 64 * 1024;
private static final int MAX_RELEASE_METADATA_BYTES = 16 * 1024;
private static final int MAX_RELEASE_API_BYTES = 256 * 1024;
private static final int MAX_STATUS_BYTES = 64 * 1024;
private static final int MAX_EVENT_LOG_BYTES = 256 * 1024;
@@ -418,15 +420,71 @@ public class SystemUpdateApplicationService {
}
private UpdateStatus readStatus() {
UpdateStatus resolved;
if (!effectiveEnabled()) {
return new UpdateStatus("DISABLED", "在线更新未配置", null, null);
resolved = new UpdateStatus("DISABLED", "在线更新未配置", null, null);
} else {
UpdateStatus persisted = readPersistedStatus();
if (persisted != null && BUSY_STATES.contains(persisted.state())) {
resolved = persisted;
} else if (persisted != null && "FAILED".equals(persisted.state()) && hasProcessingRequest()) {
resolved = persisted;
} else {
UpdateStatus queued = pendingStatus();
resolved = queued != null
? queued
: (persisted == null ? new UpdateStatus("IDLE", "尚未执行在线更新", null, null) : persisted);
}
}
return enrichWithEmbeddedReleaseMetadata(resolved);
}
/**
* The updater which performed an older release switch may not have known about release notes yet.
* New artifacts therefore carry the signed notes inside the release directory as well. Reading that
* immutable, artifact-hashed file lets the first application process after an upgrade reconstruct the
* terminal audit snapshot without a network request or a database migration.
*/
private UpdateStatus enrichWithEmbeddedReleaseMetadata(UpdateStatus status) {
if (status == null) return null;
ReleaseMetadata metadata = readEmbeddedReleaseMetadata();
if (metadata == null) return status;
String current = currentVersion();
boolean matchesCurrent = metadata.version().equals(current);
boolean matchesTarget = status.targetVersion() != null && metadata.version().equals(status.targetVersion());
if (!matchesCurrent && !matchesTarget) return status;
String notes = status.releaseNotes() == null || status.releaseNotes().isBlank()
? metadata.releaseNotes() : status.releaseNotes();
Instant publishedAt = status.publishedAt() == null ? metadata.publishedAt() : status.publishedAt();
if (notes.equals(status.releaseNotes()) && Objects.equals(publishedAt, status.publishedAt())) return status;
return new UpdateStatus(status.state(), status.message(), status.targetVersion(), status.updatedAt(),
status.downloadedBytes(), status.totalBytes(), status.bytesPerSecond(), status.downloadPercent(),
status.restartExpectedSeconds(), status.requestId(), status.action(), notes, publishedAt);
}
private ReleaseMetadata readEmbeddedReleaseMetadata() {
Path versionFile = properties.currentVersionFile();
if (versionFile == null) return null;
Path releaseDirectory = versionFile.toAbsolutePath().normalize().getParent();
if (releaseDirectory == null) return null;
Path metadataFile = releaseDirectory.resolve("release-metadata.json").normalize();
if (!metadataFile.startsWith(releaseDirectory)
|| !Files.isRegularFile(metadataFile, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(metadataFile)) {
return null;
}
try {
if (Files.size(metadataFile) > MAX_RELEASE_METADATA_BYTES) return null;
JsonNode root = objectMapper.readTree(Files.readAllBytes(metadataFile));
String version = blank(root.path("version").asText(null));
String notes = boundedText(root, "releaseNotes", 4000);
Instant publishedAt = parseInstant(root.path("publishedAt").asText(null));
if (version == null || !VERSION.matcher(version).matches() || notes == null) return null;
return new ReleaseMetadata(version, notes, publishedAt);
} catch (IOException | RuntimeException exception) {
log.debug("嵌入式 Release 更新日志读取失败:{}", metadataFile, exception);
return null;
}
UpdateStatus persisted = readPersistedStatus();
if (persisted != null && BUSY_STATES.contains(persisted.state())) return persisted;
if (persisted != null && "FAILED".equals(persisted.state()) && hasProcessingRequest()) return persisted;
UpdateStatus queued = pendingStatus();
if (queued != null) return queued;
return persisted == null ? new UpdateStatus("IDLE", "尚未执行在线更新", null, null) : persisted;
}
private UpdateStatus readPersistedStatus() {
@@ -702,6 +760,9 @@ public class SystemUpdateApplicationService {
String releaseNotes) {
}
private record ReleaseMetadata(String version, String releaseNotes, Instant publishedAt) {
}
private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt,
Long downloadedBytes, Long totalBytes, Long bytesPerSecond,
Integer downloadPercent, Integer restartExpectedSeconds,
@@ -336,8 +336,44 @@ class SystemUpdateApplicationServiceTest {
"Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z"), null, null);
}
@Test
void restoresEmbeddedReleaseNotesAfterLegacyUpdaterSwitch() throws Exception {
Path inbox = Files.createDirectory(tempDir.resolve("embedded-notes-inbox"));
Path versionFile = tempDir.resolve("current/VERSION");
Files.createDirectories(versionFile.getParent());
Files.writeString(versionFile, "1.0.0-preview.47\n");
Files.writeString(versionFile.resolveSibling("release-metadata.json"), """
{"version":"1.0.0-preview.47","publishedAt":"2026-08-19T00:28:41.701Z",
"releaseNotes":"从已签名制品恢复的更新日志"}
""");
Path statusFile = tempDir.resolve("embedded-notes-status.json");
Files.writeString(statusFile, """
{"state":"SUCCEEDED","message":"Release 1.0.0-preview.47 is running",
"targetVersion":"1.0.0-preview.47","updatedAt":"2026-08-19T00:30:00Z"}
""");
AuditService auditService = mock(AuditService.class);
when(auditService.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.47",
"Release 1.0.0-preview.47 is running", java.time.Instant.parse("2026-08-19T00:30:00Z"),
"从已签名制品恢复的更新日志", java.time.Instant.parse("2026-08-19T00:28:41.701Z")))
.thenReturn("SYSUPD:embedded-notes");
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService, versionFile);
var status = service.status();
assertEquals("从已签名制品恢复的更新日志", status.releaseNotes());
assertEquals(java.time.Instant.parse("2026-08-19T00:28:41.701Z"), status.publishedAt());
verify(auditService).recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.47",
"Release 1.0.0-preview.47 is running", java.time.Instant.parse("2026-08-19T00:30:00Z"),
"从已签名制品恢复的更新日志", java.time.Instant.parse("2026-08-19T00:28:41.701Z"));
}
private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService) {
SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.43", null,
return serviceForStatus(inbox, statusFile, auditService, null);
}
private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService,
Path versionFile) {
SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.43", versionFile,
"https://release.fixture.invalid/", null, null, inbox.resolve("request.json"), statusFile,
Duration.ofSeconds(2), Duration.ofSeconds(2), true);
AuthorizationService authorization = mock(AuthorizationService.class);