invalidate asset cache on metadata changes and refresh asset details

This commit is contained in:
bQUARKz 2026-03-19 07:20:29 +00:00
parent a7710be847
commit d773e8684b
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
8 changed files with 182 additions and 11 deletions

View File

@ -4,12 +4,16 @@ import java.util.*;
public record PackerAssetCacheEntry(
int assetId,
String contractFingerprint,
List<PackerFileCacheEntry> files) {
public PackerAssetCacheEntry {
if (assetId <= 0) {
throw new IllegalArgumentException("assetId must be positive");
}
contractFingerprint = contractFingerprint == null || contractFingerprint.isBlank()
? null
: contractFingerprint.trim();
files = List.copyOf(Objects.requireNonNull(files, "files"));
}

View File

@ -59,7 +59,7 @@ public final class FileSystemPackerCacheRepository {
}
validateAssetId(asset.assetId);
validateDuplicateRelativePaths(asset.assetId, files);
assets.add(new PackerAssetCacheEntry(asset.assetId, files));
assets.add(new PackerAssetCacheEntry(asset.assetId, asset.contractFingerprint, files));
}
}
validateDuplicateAssetIds(assets);
@ -84,6 +84,7 @@ public final class FileSystemPackerCacheRepository {
document.assets = safeState.assets().stream()
.map(asset -> new CacheAssetDocument(
asset.assetId(),
asset.contractFingerprint(),
asset.files().stream()
.map(file -> new CacheFileDocument(
file.relativePath(),
@ -150,6 +151,7 @@ public final class FileSystemPackerCacheRepository {
@JsonIgnoreProperties(ignoreUnknown = true)
private record CacheAssetDocument(
@JsonProperty("asset_id") int assetId,
@JsonProperty("contract_fingerprint") String contractFingerprint,
@JsonProperty("files") List<CacheFileDocument> files) {
}

View File

@ -0,0 +1,34 @@
package p.packer.repositories;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
public final class PackerContractFingerprint {
private PackerContractFingerprint() {
}
public static String metadataFingerprint(Map<String, String> metadata) {
final var normalized = new TreeMap<String, String>(String.CASE_INSENSITIVE_ORDER);
Objects.requireNonNull(metadata, "metadata").forEach((key, value) -> {
if (key == null || key.isBlank()) {
return;
}
normalized.put(key.trim(), value == null ? "" : value);
});
final String canonical = normalized.entrySet().stream()
.map(entry -> entry.getKey() + "=" + entry.getValue())
.reduce((left, right) -> left + "\n" + right)
.orElse("");
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 contract fingerprint is unavailable", exception);
}
}
}

View File

@ -25,6 +25,9 @@ public final class PackerRuntimeAssetMaterializer {
final Optional<PackerRegistryEntry> safeRegistryEntry = Objects.requireNonNull(registryEntry, "registryEntry");
final PackerAssetDeclarationParseResult safeParseResult = Objects.requireNonNull(parseResult, "parseResult");
final Optional<PackerAssetCacheEntry> safePriorAssetCache = Objects.requireNonNull(priorAssetCache, "priorAssetCache");
final String currentContractFingerprint = safeParseResult.valid()
? PackerContractFingerprint.metadataFingerprint(safeParseResult.declaration().outputMetadata())
: null;
if (safeRegistryEntry.isEmpty() || !safeParseResult.valid()) {
return new PackerRuntimeAssetMaterialization(
@ -42,7 +45,7 @@ public final class PackerRuntimeAssetMaterializer {
final PackerWalkResult walkResult = assetWalker.walk(
normalizedAssetRoot,
safeParseResult.declaration(),
safePriorAssetCache);
reusablePriorAssetCache(safePriorAssetCache, currentContractFingerprint));
final List<PackerRuntimeWalkFile> buildCandidateFiles = walkResult.probeResults().stream()
.map(probeResult -> toRuntimeWalkFile(normalizedAssetRoot, probeResult))
.sorted(Comparator.comparing(PackerRuntimeWalkFile::relativePath, String.CASE_INSENSITIVE_ORDER))
@ -63,6 +66,7 @@ public final class PackerRuntimeAssetMaterializer {
walkResult.diagnostics());
final PackerAssetCacheEntry assetCacheEntry = new PackerAssetCacheEntry(
safeRegistryEntry.get().assetId(),
currentContractFingerprint,
buildCandidateFiles.stream()
.map(file -> new PackerFileCacheEntry(
file.relativePath(),
@ -75,6 +79,19 @@ public final class PackerRuntimeAssetMaterializer {
return new PackerRuntimeAssetMaterialization(runtimeAsset, Optional.of(assetCacheEntry));
}
private Optional<PackerAssetCacheEntry> reusablePriorAssetCache(
Optional<PackerAssetCacheEntry> priorAssetCache,
String currentContractFingerprint) {
if (priorAssetCache.isEmpty()) {
return Optional.empty();
}
final String cachedContractFingerprint = priorAssetCache.get().contractFingerprint();
if (cachedContractFingerprint == null || !cachedContractFingerprint.equals(currentContractFingerprint)) {
return Optional.empty();
}
return priorAssetCache;
}
private List<String> listAvailableFiles(Path assetRoot) {
try (var paths = Files.list(assetRoot)
.filter(Files::isRegularFile)

View File

@ -39,7 +39,7 @@ final class FileSystemPackerCacheRepositoryTest {
final var state = new PackerWorkspaceCacheState(
PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION,
List.of(
new PackerAssetCacheEntry(3, List.of(
new PackerAssetCacheEntry(3, "contract-3", List.of(
new PackerFileCacheEntry(
"tiles/ui.png",
"image/png",
@ -54,7 +54,7 @@ final class FileSystemPackerCacheRepositoryTest {
84L,
"def456",
Map.of()))),
new PackerAssetCacheEntry(1, List.of(
new PackerAssetCacheEntry(1, "contract-1", List.of(
new PackerFileCacheEntry(
"audio/click.wav",
"audio/wav",
@ -67,6 +67,7 @@ final class FileSystemPackerCacheRepositoryTest {
final var loaded = repository.load(project);
assertEquals(List.of(1, 3), loaded.assets().stream().map(PackerAssetCacheEntry::assetId).toList());
assertEquals("contract-3", loaded.findAsset(3).orElseThrow().contractFingerprint());
assertTrue(loaded.findAsset(3).flatMap(asset -> asset.findFile("tiles/ui.png")).isPresent());
assertEquals(128L, loaded.findAsset(3).orElseThrow().findFile("tiles/ui.png").orElseThrow().size());
}
@ -105,8 +106,8 @@ final class FileSystemPackerCacheRepositoryTest {
final var state = new PackerWorkspaceCacheState(
PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION,
List.of(
new PackerAssetCacheEntry(7, List.of()),
new PackerAssetCacheEntry(9, List.of())));
new PackerAssetCacheEntry(7, "contract-7", List.of()),
new PackerAssetCacheEntry(9, "contract-9", List.of())));
assertTrue(state.findAsset(7).isPresent());
assertTrue(state.findAsset(8).isEmpty());
@ -118,7 +119,7 @@ final class FileSystemPackerCacheRepositoryTest {
final var project = project(tempDir.resolve("project"));
final var state = new PackerWorkspaceCacheState(
PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION,
List.of(new PackerAssetCacheEntry(1, List.of(
List.of(new PackerAssetCacheEntry(1, "contract-1", List.of(
new PackerFileCacheEntry(
"tiles/ui.png",
"image/png",
@ -133,6 +134,7 @@ final class FileSystemPackerCacheRepositoryTest {
assertFalse(json.contains("diagnostic"));
assertFalse(json.contains("diagnostics"));
assertTrue(json.contains("\"asset_id\""));
assertTrue(json.contains("\"contract_fingerprint\""));
assertTrue(json.contains("\"metadata\""));
}

View File

@ -28,7 +28,7 @@ final class PackerAbstractBankWalkerTest {
final var assetRoot = Files.createDirectories(tempDir.resolve("asset"));
final var filePath = writeText(assetRoot.resolve("entry.txt"), "hello world");
final long lastModified = Files.getLastModifiedTime(filePath).toMillis();
final var priorCache = new PackerAssetCacheEntry(1, List.of(
final var priorCache = new PackerAssetCacheEntry(1, "contract", List.of(
new PackerFileCacheEntry("entry.txt", "text/plain", 1L, lastModified, "cached", Map.of("source", "cache"))));
final var result = walker.walk(assetRoot, "requirements", java.util.Optional.of(priorCache));
@ -44,7 +44,7 @@ final class PackerAbstractBankWalkerTest {
final var assetRoot = Files.createDirectories(tempDir.resolve("asset"));
final var filePath = writeText(assetRoot.resolve("entry.txt"), "hello world");
final long currentLastModified = Files.getLastModifiedTime(filePath).toMillis();
final var priorCache = new PackerAssetCacheEntry(1, List.of(
final var priorCache = new PackerAssetCacheEntry(1, "contract", List.of(
new PackerFileCacheEntry(
"entry.txt",
"text/plain",
@ -67,7 +67,7 @@ final class PackerAbstractBankWalkerTest {
final var filePath = writeText(assetRoot.resolve("entry.txt"), "hello world");
final long lastModified = Files.getLastModifiedTime(filePath).toMillis();
final String fingerprint = sha256(Files.readAllBytes(filePath));
final var priorCache = new PackerAssetCacheEntry(1, List.of(
final var priorCache = new PackerAssetCacheEntry(1, "contract", List.of(
new PackerFileCacheEntry(
"entry.txt",
"text/plain",
@ -89,7 +89,7 @@ final class PackerAbstractBankWalkerTest {
final var assetRoot = Files.createDirectories(tempDir.resolve("asset"));
final var filePath = writeText(assetRoot.resolve("entry.txt"), "hello world");
final long lastModified = Files.getLastModifiedTime(filePath).toMillis();
final var priorCache = new PackerAssetCacheEntry(1, List.of(
final var priorCache = new PackerAssetCacheEntry(1, "contract", List.of(
new PackerFileCacheEntry(
"entry.txt",
"text/plain",

View File

@ -0,0 +1,104 @@
package p.packer.repositories;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.packer.messages.assets.AssetFamilyCatalog;
import p.packer.messages.assets.OutputCodecCatalog;
import p.packer.messages.assets.OutputFormatCatalog;
import p.packer.models.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
final class PackerRuntimeAssetMaterializerTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@TempDir
Path tempDir;
@Test
void reusesPriorAssetCacheOnlyWhenContractFingerprintMatches() throws Exception {
final var walker = new TrackingWalker();
final var materializer = new PackerRuntimeAssetMaterializer(walker);
final Path assetRoot = Files.createDirectories(tempDir.resolve("asset"));
final Path manifestPath = assetRoot.resolve("asset.json");
Files.writeString(manifestPath, "{}");
final PackerAssetDeclaration declaration = declaration(Map.of("tile_size", "32x32"));
final PackerAssetDeclarationParseResult parseResult = new PackerAssetDeclarationParseResult(declaration, List.of());
final String fingerprint = PackerContractFingerprint.metadataFingerprint(declaration.outputMetadata());
final Optional<PackerAssetCacheEntry> priorAssetCache = Optional.of(new PackerAssetCacheEntry(1, fingerprint, List.of()));
materializer.materialize(
assetRoot,
manifestPath,
Optional.of(new PackerRegistryEntry(1, "uuid", "asset", true)),
parseResult,
priorAssetCache);
assertTrue(walker.lastPriorAssetCache().isPresent());
}
@Test
void invalidatesPriorAssetCacheWhenContractFingerprintChanges() throws Exception {
final var walker = new TrackingWalker();
final var materializer = new PackerRuntimeAssetMaterializer(walker);
final Path assetRoot = Files.createDirectories(tempDir.resolve("asset"));
final Path manifestPath = assetRoot.resolve("asset.json");
Files.writeString(manifestPath, "{}");
materializer.materialize(
assetRoot,
manifestPath,
Optional.of(new PackerRegistryEntry(1, "uuid", "asset", true)),
new PackerAssetDeclarationParseResult(declaration(Map.of("tile_size", "32x32")), List.of()),
Optional.of(new PackerAssetCacheEntry(
1,
PackerContractFingerprint.metadataFingerprint(Map.of("tile_size", "16x16")),
List.of())));
assertTrue(walker.lastPriorAssetCache().isEmpty());
}
private static PackerAssetDeclaration declaration(Map<String, String> metadata) {
return new PackerAssetDeclaration(
1,
"uuid",
"asset",
AssetFamilyCatalog.TILE_BANK,
Map.of(),
List.of(),
OutputFormatCatalog.TILES_INDEXED_V1,
OutputCodecCatalog.NONE,
metadata,
true);
}
private static final class TrackingWalker extends PackerAssetWalker {
private Optional<PackerAssetCacheEntry> lastPriorAssetCache = Optional.empty();
private TrackingWalker() {
super(MAPPER);
}
@Override
public PackerWalkResult walk(
Path assetRoot,
PackerAssetDeclaration declaration,
Optional<PackerAssetCacheEntry> priorAssetCache) {
lastPriorAssetCache = Objects.requireNonNull(priorAssetCache, "priorAssetCache");
return PackerWalkResult.EMPTY;
}
private Optional<PackerAssetCacheEntry> lastPriorAssetCache() {
return lastPriorAssetCache;
}
}
}

View File

@ -104,6 +104,14 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
@Override
public void registerEventSubscriptions() {
eventBindings.listen(workspaceBus, StudioAssetsRefreshRequestedEvent.class).handle(event -> {
if (event.preferredAssetReference() != null) {
loadSelection(event.preferredAssetReference());
return;
}
if (event.deepSync() && viewState.selectedAssetReference() != null) {
loadSelection(viewState.selectedAssetReference());
return;
}
if (event.preferredAssetReference() == null) {
clearSelection();
}