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:
DongHyeonka
2026-08-20 10:36:47 +09:00
co-authored by Claude Opus 5
parent c8a891c407
commit 743fee3907
4 changed files with 69 additions and 25 deletions
@@ -3,14 +3,16 @@ package dev.caskeleton.bootstrap.runtime;
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
import java.util.Locale;
import java.util.Set;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.core.env.Environment;
/**
* Prevents Hibernate from becoming a production schema writer. Flyway owns the physical schema;
* production may only disable Hibernate DDL or validate the schema.
*/
public class JpaSchemaSafetyValidator implements SmartInitializingSingleton {
public class JpaSchemaSafetyValidator implements BeanFactoryPostProcessor {
static final String DDL_AUTO_KEY = "spring.jpa.hibernate.ddl-auto";
static final String DDL_AUTO_ENV_KEY = "APP_DATASOURCE_DDL_AUTO";
@@ -24,8 +26,18 @@ public class JpaSchemaSafetyValidator implements SmartInitializingSingleton {
this.environment = environment;
}
/**
* {@code BeanFactoryPostProcessor} 이지 {@code SmartInitializingSingleton} 이 아닌 이유: 후자는 모든 싱글턴이
* 만들어진 <em>뒤</em>에 돈다. {@code entityManagerFactory} 도 그 싱글턴 중 하나이고, Hibernate 는 그것을 만들면서 {@code
* ddl-auto} 를 이미 적용한다 — 실측으로 확인했다: {@code ddl-auto=update} 로 prod 를 띄우면 로그에 "Initialized JPA
* EntityManagerFactory" 가 먼저, 그 다음에 이 가드의 PROFILE_MISMATCH 가 찍히고, 스키마에는 그 사이에 만들어진 테이블이 남는다.
*
* <p>즉 가드가 트래픽은 막았지만 스키마 변조는 못 막고 있었다. 잘못 설정된 배포가 운영 DB 를 이미 바꿔 놓고 실패하는 셈이라, 검사를 빈 인스턴스화 이전으로
* 옮긴다.
*/
@Override
public void afterSingletonsInstantiated() {
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory)
throws BeansException {
if (!isProdActive()) {
return;
}
@@ -29,8 +29,12 @@ public class RuntimeSafetyConfig {
return new OpenInViewSafetyValidator(environment);
}
/**
* {@code static} 이어야 한다 — {@code BeanFactoryPostProcessor} 는 다른 빈보다 먼저 만들어지므로, 인스턴스 메서드로 두면 이 설정
* 클래스 전체가 too-early 로 초기화되어 경고가 뜬다.
*/
@Bean
JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) {
static JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) {
return new JpaSchemaSafetyValidator(environment);
}
@@ -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;
}
}
/**
@@ -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,