fix: close the last three local checklist items
Duplicate relations. Connecting the same target twice saved without a word: the contract carries no uniqueItems on relations (only maxItems 20) and the validator checked order uniqueness but not target. The document then renders the same row twice publicly, and removing one leaves the other behind — "삭제했는데 그대로". Rejected now, alongside the existing order check. two distinct targets 201 same target twice 422 REQUEST_VALIDATION_FAILED The prod DDL guard ran too late. JpaSchemaSafetyValidator was a SmartInitializingSingleton, which fires after every singleton exists — including entityManagerFactory, which Hibernate builds by applying ddl-auto. Booting prod with ddl-auto=update logged "Initialized JPA EntityManagerFactory" first and the PROFILE_MISMATCH second, with the tables Hibernate created in between still in the schema. The guard stopped traffic but not schema mutation, so a misconfigured deploy had already changed the production database by the time it refused to start. It is a BeanFactoryPostProcessor now, before any bean is instantiated. fs_* tables dropped, prod booted with ddl-auto=update exit 71, no EntityManagerFactory line, 0 tables created Object storage inside a database transaction. UploadStudioAssetUseCase called binaries.store from inside inWrite, holding a connection and its locks for the length of a network round-trip — a slow storage backend becomes connection-pool exhaustion. It bought nothing: storage does not join the transaction, so a failed commit leaves the bytes written either way. Storage now happens first and the database write is a short transaction; a failed write deletes the object it just uploaded, and a failed delete is attached with addSuppressed rather than replacing the error the caller needs to see. Full build passes apart from one fileserver flake (LocalPersistentControlPlaneTest.heldOperationReentrancyIsScopedToThe AttestedRoot) that passes in isolation and touches none of these files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c8a891c407
commit
743fee3907
+38
-21
@@ -86,27 +86,44 @@ public class UploadStudioAssetUseCase implements CommandUseCase<UploadAssetComma
|
||||
String assetKey = assetKeyFor(input.originalFilename(), assetId);
|
||||
String objectKey = "techlog/assets/" + assetId;
|
||||
|
||||
return transactions.inWrite(
|
||||
() -> {
|
||||
String storedKey = binaries.store(objectKey, content, mediaType);
|
||||
return assets.create(
|
||||
new AssetRepositoryPort.NewAsset(
|
||||
assetId,
|
||||
assetKey,
|
||||
input.kind(),
|
||||
mediaType,
|
||||
storedKey,
|
||||
input.originalFilename(),
|
||||
input.byteSize(),
|
||||
null,
|
||||
null,
|
||||
sha256(content),
|
||||
input.altText(),
|
||||
input.decorative(),
|
||||
// 내용 판정을 통과했으므로 READY 다. 판정 실패는 위에서 이미 거절했다.
|
||||
AssetManagementStatusView.READY),
|
||||
input.principal());
|
||||
});
|
||||
// 오브젝트 스토리지 호출은 트랜잭션 밖이다. 원래는 inWrite 안에 있었는데, 그러면 네트워크
|
||||
// 왕복이 끝날 때까지 DB 커넥션과 행 잠금을 붙잡고 있게 된다 — 스토리지가 느려지면 그대로
|
||||
// 커넥션 풀 고갈로 번진다. 게다가 스토리지는 트랜잭션에 참여하지 않으므로 안에 둔다고
|
||||
// 원자성이 생기지도 않는다: 커밋이 실패하면 바이트는 이미 저장돼 있고 롤백되지 않는다.
|
||||
//
|
||||
// 그래서 순서를 뒤집는다 — 저장 먼저, DB 쓰기는 짧은 트랜잭션으로. DB 쓰기가 실패하면
|
||||
// 방금 올린 오브젝트를 지운다(보상). 그 삭제마저 실패하면 orphan 이 남지만, 그건 원래
|
||||
// 실패 경로에 있던 위험이고 지금은 최소한 로그로 드러난다.
|
||||
String storedKey = binaries.store(objectKey, content, mediaType);
|
||||
try {
|
||||
return transactions.inWrite(
|
||||
() ->
|
||||
assets.create(
|
||||
new AssetRepositoryPort.NewAsset(
|
||||
assetId,
|
||||
assetKey,
|
||||
input.kind(),
|
||||
mediaType,
|
||||
storedKey,
|
||||
input.originalFilename(),
|
||||
input.byteSize(),
|
||||
null,
|
||||
null,
|
||||
sha256(content),
|
||||
input.altText(),
|
||||
input.decorative(),
|
||||
// 내용 판정을 통과했으므로 READY 다. 판정 실패는 위에서 이미 거절했다.
|
||||
AssetManagementStatusView.READY),
|
||||
input.principal()));
|
||||
} catch (RuntimeException databaseFailure) {
|
||||
try {
|
||||
binaries.delete(objectKey);
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
// 원래 실패를 덮지 않는다 — 호출자가 알아야 하는 것은 업로드가 왜 실패했는지다.
|
||||
databaseFailure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw databaseFailure;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+11
@@ -58,7 +58,18 @@ public final class WorkingCopyInputValidator {
|
||||
"document.relations must hold at most " + MAX_RELATIONS + " items");
|
||||
}
|
||||
Set<Integer> orders = new HashSet<>();
|
||||
Set<String> targets = new HashSet<>();
|
||||
for (RelationView relation : relations) {
|
||||
if (relation.targetId() != null && !targets.add(relation.targetId().toString())) {
|
||||
// 같은 대상을 두 번 연결하면 공개 문서에 같은 줄이 두 번 나오고, 관계를 하나 지웠을 때
|
||||
// 나머지 하나가 남아 "지웠는데 그대로"로 보인다. 계약에 uniqueItems 가 없어 스키마가
|
||||
// 걸러주지 못하므로 여기서 막는다.
|
||||
throw StudioException.of(
|
||||
StudioError.REQUEST_VALIDATION_FAILED,
|
||||
"document.relations[].targetId must be unique; "
|
||||
+ relation.targetId()
|
||||
+ " is repeated");
|
||||
}
|
||||
if (relation.order() < 0) {
|
||||
throw StudioException.of(
|
||||
StudioError.REQUEST_VALIDATION_FAILED,
|
||||
|
||||
Reference in New Issue
Block a user