implements PR-021

This commit is contained in:
bQUARKz 2026-03-16 08:02:37 +00:00
parent 4278d045c2
commit 223f9a0f87
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
10 changed files with 293 additions and 9 deletions

View File

@ -0,0 +1,77 @@
# PR-21 Point In-Memory Snapshot Updates After Write Commit
Domain Owner: `docs/packer`
Cross-Domain Impact: `docs/studio`
## Briefing
The current write path still rebuilds the whole project runtime snapshot after a successful point write such as `REGISTER`.
That is too expensive for the runtime model we want.
Studio refresh on top of an already-updated in-memory snapshot is acceptable. Full filesystem rescan inside the packer after every point write is not.
This PR replaces the normal post-write `full refresh` path with point snapshot updates in memory, while keeping full reload available only as an explicit recovery fallback.
## Objective
Remove whole-project runtime rescan from the normal point write path and replace it with point in-memory snapshot updates after durable commit.
## Dependencies
- [`./PR-14-project-runtime-core-snapshot-model-and-lifecycle.md`](./PR-14-project-runtime-core-snapshot-model-and-lifecycle.md)
- [`./PR-15-snapshot-backed-asset-query-services.md`](./PR-15-snapshot-backed-asset-query-services.md)
- [`./PR-16-write-lane-command-completion-and-used-write-services.md`](./PR-16-write-lane-command-completion-and-used-write-services.md)
- [`./PR-20-asset-action-capabilities-and-register-first-delivery.md`](./PR-20-asset-action-capabilities-and-register-first-delivery.md)
## Scope
- add explicit point snapshot update support to the runtime registry
- keep the write lane as the only owner of point runtime mutation after durable commit
- replace post-write `runtimeRegistry.refresh(project)` in the active write path with point snapshot patching
- deliver the first point patch for `REGISTER`
- keep Studio refresh behavior unchanged
## Non-Goals
- no background divergence detection
- no reconcile loop
- no change to Studio refresh semantics
- no broad event model redesign
- no speculative patch implementation for unused write actions
## Execution Method
1. Extend the runtime registry so it can update a loaded project snapshot by applying a point patch function inside the packer-owned write flow.
2. Keep full snapshot reload available as recovery fallback, but do not use it in the normal successful point write path.
3. Model a point patch for `REGISTER` that:
- appends the new registry entry to the in-memory registry view
- updates the matching runtime asset from unregistered to registered
- preserves the rest of the snapshot untouched
4. Apply the patch only after the durable disk commit succeeds.
5. Keep read services unchanged from the Studio point of view; they should continue reading from the runtime snapshot.
6. Keep Studio free to refresh list/details after write completion, because those reads now hit the already-updated in-memory snapshot.
7. Add regression coverage proving that `REGISTER` no longer depends on whole-project rescan to become visible to subsequent reads.
## Acceptance Criteria
- successful point writes no longer trigger whole-project runtime rescan in the normal path
- the runtime registry supports point in-memory snapshot updates after durable commit
- `REGISTER` updates the loaded project snapshot in place
- subsequent `listAssets`, `getAssetDetails`, and `getAssetActions` read the updated state from memory
- Studio refresh after `REGISTER` remains valid and does not require frontend-local state patching
- full reload remains available only as fallback or explicit recovery path
## Validation
- packer tests for point snapshot patching on `REGISTER`
- packer tests proving read-after-write coherence without full runtime rebuild
- regression tests for fallback safety when point patching cannot be applied
- Studio smoke validation confirming existing refresh behavior still works on top of the updated runtime snapshot
## Affected Artifacts
- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/**`
- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/**`
- `prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/**`
- `prometeu-studio/src/main/java/p/studio/**`

View File

@ -80,6 +80,7 @@ The current production track for the standalone `prometeu-packer` project is:
18. [`PR-18-legacy-service-retirement-and-regression-hardening.md`](./PR-18-legacy-service-retirement-and-regression-hardening.md)
19. [`PR-19-api-surface-audit-model-separation-and-public-read-dtos.md`](./PR-19-api-surface-audit-model-separation-and-public-read-dtos.md)
20. [`PR-20-asset-action-capabilities-and-register-first-delivery.md`](./PR-20-asset-action-capabilities-and-register-first-delivery.md)
21. [`PR-21-point-in-memory-snapshot-updates-after-write-commit.md`](./PR-21-point-in-memory-snapshot-updates-after-write-commit.md)
Current wave discipline from `PR-11` onward:
@ -90,4 +91,4 @@ Current wave discipline from `PR-11` onward:
Recommended dependency chain:
`PR-01 -> PR-02 -> PR-03 -> PR-04 -> PR-05 -> PR-06 -> PR-07 -> PR-08 -> PR-09 -> PR-10 -> PR-11 -> PR-12 -> PR-13 -> PR-14 -> PR-15 -> PR-16 -> PR-17 -> PR-18 -> PR-19 -> PR-20`
`PR-01 -> PR-02 -> PR-03 -> PR-04 -> PR-05 -> PR-06 -> PR-07 -> PR-08 -> PR-09 -> PR-10 -> PR-11 -> PR-12 -> PR-13 -> PR-14 -> PR-15 -> PR-16 -> PR-17 -> PR-18 -> PR-19 -> PR-20 -> PR-21`

View File

@ -32,11 +32,13 @@ public final class Packer implements Closeable {
runtimeRegistry,
assetReferenceResolver,
workspaceFoundation.lookup());
final PackerRuntimePatchService runtimePatchService = new PackerRuntimePatchService(declarationParser);
final PackerProjectWriteCoordinator writeCoordinator = new PackerProjectWriteCoordinator();
return new Packer(new FileSystemPackerWorkspaceService(
workspaceFoundation,
assetDetailsService,
assetActionReadService,
runtimePatchService,
runtimeRegistry,
writeCoordinator,
resolvedEventSink), runtimeRegistry, writeCoordinator);

View File

@ -47,6 +47,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
private final PackerWorkspaceFoundation workspaceFoundation;
private final PackerAssetDetailsService detailsService;
private final PackerAssetActionReadService actionReadService;
private final PackerRuntimePatchService runtimePatchService;
private final PackerRuntimeRegistry runtimeRegistry;
private final PackerProjectWriteCoordinator writeCoordinator;
private final PackerEventSink eventSink;
@ -55,12 +56,14 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
PackerWorkspaceFoundation workspaceFoundation,
PackerAssetDetailsService detailsService,
PackerAssetActionReadService actionReadService,
PackerRuntimePatchService runtimePatchService,
PackerRuntimeRegistry runtimeRegistry,
PackerProjectWriteCoordinator writeCoordinator,
PackerEventSink eventSink) {
this.workspaceFoundation = Objects.requireNonNull(workspaceFoundation, "workspaceFoundation");
this.detailsService = Objects.requireNonNull(detailsService, "detailsService");
this.actionReadService = Objects.requireNonNull(actionReadService, "actionReadService");
this.runtimePatchService = Objects.requireNonNull(runtimePatchService, "runtimePatchService");
this.runtimeRegistry = Objects.requireNonNull(runtimeRegistry, "runtimeRegistry");
this.writeCoordinator = Objects.requireNonNull(writeCoordinator, "writeCoordinator");
this.eventSink = Objects.requireNonNull(eventSink, "eventSink");
@ -218,7 +221,13 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
writeManifest(manifestPath, request, entry.assetUuid());
final PackerRegistryState updated = workspaceFoundation.appendAllocatedEntry(registry, entry);
workspaceFoundation.saveRegistry(project, updated);
runtimeRegistry.refresh(project);
runtimeRegistry.update(project, (snapshot, generation) -> runtimePatchService.afterCreateAsset(
snapshot,
generation,
updated,
entry,
assetRoot,
manifestPath));
final CreateAssetResult result = new CreateAssetResult(
PackerOperationStatus.SUCCESS,
"Asset created: " + relativeAssetRoot,
@ -262,7 +271,12 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
declaration.assetUuid());
final PackerRegistryState updated = workspaceFoundation.appendAllocatedEntry(registry, entry);
workspaceFoundation.saveRegistry(project, updated);
runtimeRegistry.refresh(project);
runtimeRegistry.update(project, (currentSnapshot, generation) -> runtimePatchService.afterRegisterAsset(
currentSnapshot,
generation,
updated,
entry,
assetRoot));
final RegisterAssetResult result = new RegisterAssetResult(
PackerOperationStatus.SUCCESS,
"Asset registered: " + relativeAssetRoot,

View File

@ -6,6 +6,7 @@ import p.packer.models.PackerRuntimeSnapshot;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
public final class PackerProjectRuntime {
private final PackerProjectContext project;
@ -31,6 +32,13 @@ public final class PackerProjectRuntime {
snapshot.set(Objects.requireNonNull(nextSnapshot, "nextSnapshot"));
}
public PackerRuntimeSnapshot updateSnapshot(UnaryOperator<PackerRuntimeSnapshot> updater) {
ensureActive();
return snapshot.updateAndGet(current -> Objects.requireNonNull(
Objects.requireNonNull(updater, "updater").apply(current),
"updatedSnapshot"));
}
public boolean disposed() {
return disposed.get();
}

View File

@ -19,7 +19,7 @@ import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public final class PackerRuntimeLoader {
public final class PackerRuntimeLoader implements PackerRuntimeSnapshotLoader {
private final PackerWorkspaceFoundation workspaceFoundation;
private final PackerAssetDeclarationParser parser;
@ -30,6 +30,7 @@ public final class PackerRuntimeLoader {
this.parser = Objects.requireNonNull(parser, "parser");
}
@Override
public PackerRuntimeSnapshot load(PackerProjectContext project, long generation) {
final PackerProjectContext safeProject = Objects.requireNonNull(project, "project");
workspaceFoundation.initWorkspace(new InitWorkspaceRequest(safeProject));

View File

@ -0,0 +1,68 @@
package p.packer.services;
import p.packer.models.PackerAssetDeclarationParseResult;
import p.packer.models.PackerRegistryEntry;
import p.packer.models.PackerRegistryState;
import p.packer.models.PackerRuntimeAsset;
import p.packer.models.PackerRuntimeSnapshot;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public final class PackerRuntimePatchService {
private final PackerAssetDeclarationParser declarationParser;
public PackerRuntimePatchService(PackerAssetDeclarationParser declarationParser) {
this.declarationParser = Objects.requireNonNull(declarationParser, "declarationParser");
}
public PackerRuntimeSnapshot afterCreateAsset(
PackerRuntimeSnapshot snapshot,
long generation,
PackerRegistryState updatedRegistry,
PackerRegistryEntry entry,
Path assetRoot,
Path manifestPath) {
final PackerAssetDeclarationParseResult parsed = declarationParser.parse(manifestPath);
final List<PackerRuntimeAsset> updatedAssets = new ArrayList<>(snapshot.assets().stream()
.filter(candidate -> !candidate.assetRoot().equals(assetRoot.toAbsolutePath().normalize()))
.toList());
updatedAssets.add(new PackerRuntimeAsset(
assetRoot,
manifestPath,
Optional.of(entry),
parsed));
updatedAssets.sort(Comparator.comparing(asset -> asset.assetRoot().toString(), String.CASE_INSENSITIVE_ORDER));
return new PackerRuntimeSnapshot(generation, updatedRegistry, updatedAssets);
}
public PackerRuntimeSnapshot afterRegisterAsset(
PackerRuntimeSnapshot snapshot,
long generation,
PackerRegistryState updatedRegistry,
PackerRegistryEntry entry,
Path assetRoot) {
final List<PackerRuntimeAsset> updatedAssets = new ArrayList<>();
boolean patched = false;
for (PackerRuntimeAsset asset : snapshot.assets()) {
if (asset.assetRoot().equals(assetRoot.toAbsolutePath().normalize())) {
updatedAssets.add(new PackerRuntimeAsset(
asset.assetRoot(),
asset.manifestPath(),
Optional.of(entry),
asset.parsedDeclaration()));
patched = true;
} else {
updatedAssets.add(asset);
}
}
if (!patched) {
throw new IllegalStateException("Unable to patch runtime snapshot for unregistered asset: " + assetRoot);
}
return new PackerRuntimeSnapshot(generation, updatedRegistry, updatedAssets);
}
}

View File

@ -1,8 +1,10 @@
package p.packer.services;
import p.packer.messages.PackerProjectContext;
import p.packer.models.PackerRuntimeSnapshot;
import java.nio.file.Path;
import java.util.function.BiFunction;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@ -10,11 +12,11 @@ import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicLong;
public final class PackerRuntimeRegistry {
private final PackerRuntimeLoader loader;
private final PackerRuntimeSnapshotLoader loader;
private final ConcurrentMap<ProjectKey, PackerProjectRuntime> runtimes = new ConcurrentHashMap<>();
private final AtomicLong nextGeneration = new AtomicLong(1L);
public PackerRuntimeRegistry(PackerRuntimeLoader loader) {
public PackerRuntimeRegistry(PackerRuntimeSnapshotLoader loader) {
this.loader = Objects.requireNonNull(loader, "loader");
}
@ -46,6 +48,27 @@ public final class PackerRuntimeRegistry {
});
}
public PackerProjectRuntime update(
PackerProjectContext project,
BiFunction<PackerRuntimeSnapshot, Long, PackerRuntimeSnapshot> updater) {
final PackerProjectContext safeProject = Objects.requireNonNull(project, "project");
final BiFunction<PackerRuntimeSnapshot, Long, PackerRuntimeSnapshot> safeUpdater =
Objects.requireNonNull(updater, "updater");
final ProjectKey key = ProjectKey.from(safeProject);
return runtimes.compute(key, (ignored, current) -> {
if (current == null || current.disposed()) {
return new PackerProjectRuntime(safeProject, loader.load(safeProject, nextGeneration.getAndIncrement()));
}
try {
final long generation = nextGeneration.getAndIncrement();
current.updateSnapshot(snapshot -> safeUpdater.apply(snapshot, generation));
} catch (RuntimeException exception) {
current.replaceSnapshot(loader.load(safeProject, nextGeneration.getAndIncrement()));
}
return current;
});
}
public void dispose(PackerProjectContext project) {
final PackerProjectRuntime removed = runtimes.remove(ProjectKey.from(Objects.requireNonNull(project, "project")));
if (removed != null) {

View File

@ -0,0 +1,9 @@
package p.packer.services;
import p.packer.messages.PackerProjectContext;
import p.packer.models.PackerRuntimeSnapshot;
@FunctionalInterface
public interface PackerRuntimeSnapshotLoader {
PackerRuntimeSnapshot load(PackerProjectContext project, long generation);
}

View File

@ -147,6 +147,32 @@ final class FileSystemPackerWorkspaceServiceTest {
assertTrue(detailsResult.diagnostics().isEmpty());
}
@Test
void createAssetPatchesLoadedSnapshotWithoutWholeProjectReload() throws Exception {
final Path projectRoot = tempDir.resolve("created-no-reload");
final CountingLoader loader = countingLoader();
final FileSystemPackerWorkspaceService service = service(ignored -> { }, loader);
service.listAssets(new ListAssetsRequest(project(projectRoot)));
assertEquals(1, loader.loadCount());
final CreateAssetResult createResult = service.createAsset(new CreateAssetRequest(
project(projectRoot),
"ui/new-atlas",
"new_atlas",
AssetFamilyCatalog.IMAGE_BANK,
OutputFormatCatalog.TILES_INDEXED_V1,
OutputCodecCatalog.NONE,
true));
assertEquals(PackerOperationStatus.SUCCESS, createResult.status());
assertEquals(1, loader.loadCount());
final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), createResult.assetReference()));
assertEquals(PackerOperationStatus.SUCCESS, detailsResult.status());
assertEquals(1, loader.loadCount());
}
@Test
void exposesRegisterActionForValidUnregisteredAsset() throws Exception {
final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("orphan-actions"));
@ -201,6 +227,29 @@ final class FileSystemPackerWorkspaceServiceTest {
assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.ACTION_APPLIED));
}
@Test
void registerAssetPatchesLoadedSnapshotWithoutWholeProjectReload() throws Exception {
final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("register-no-reload"));
final CountingLoader loader = countingLoader();
final FileSystemPackerWorkspaceService service = service(ignored -> { }, loader);
service.listAssets(new ListAssetsRequest(project(projectRoot)));
assertEquals(1, loader.loadCount());
final RegisterAssetResult registerResult = service.registerAsset(new RegisterAssetRequest(
project(projectRoot),
AssetReference.forRelativeAssetRoot("orphans/ui_sounds")));
assertEquals(PackerOperationStatus.SUCCESS, registerResult.status());
assertEquals(1, loader.loadCount());
final var actionsResult = service.getAssetActions(new GetAssetActionsRequest(
project(projectRoot),
registerResult.assetReference()));
assertTrue(actionsResult.actions().isEmpty());
assertEquals(1, loader.loadCount());
}
@Test
void blocksRegisterActionForInvalidDeclaration() throws Exception {
final Path projectRoot = copyFixture("workspaces/invalid-missing-fields", tempDir.resolve("invalid-actions"));
@ -341,27 +390,59 @@ final class FileSystemPackerWorkspaceServiceTest {
}
private FileSystemPackerWorkspaceService service() {
return service(ignored -> {
});
return service(ignored -> { });
}
private FileSystemPackerWorkspaceService service(p.packer.events.PackerEventSink eventSink) {
return service(eventSink, countingLoader());
}
private FileSystemPackerWorkspaceService service(
p.packer.events.PackerEventSink eventSink,
PackerRuntimeSnapshotLoader loader) {
final var foundation = new p.packer.services.PackerWorkspaceFoundation();
final var parser = new p.packer.services.PackerAssetDeclarationParser();
final var runtimeRegistry = new p.packer.services.PackerRuntimeRegistry(new p.packer.services.PackerRuntimeLoader(foundation, parser));
final var runtimeRegistry = new p.packer.services.PackerRuntimeRegistry(loader);
final var resolver = new p.packer.services.PackerAssetReferenceResolver(foundation.lookup());
final var detailsService = new p.packer.services.PackerAssetDetailsService(runtimeRegistry, resolver);
final var actionReadService = new p.packer.services.PackerAssetActionReadService(runtimeRegistry, resolver, foundation.lookup());
final var runtimePatchService = new p.packer.services.PackerRuntimePatchService(parser);
final var writeCoordinator = new p.packer.services.PackerProjectWriteCoordinator();
return new FileSystemPackerWorkspaceService(
foundation,
detailsService,
actionReadService,
runtimePatchService,
runtimeRegistry,
writeCoordinator,
eventSink);
}
private CountingLoader countingLoader() {
final var foundation = new p.packer.services.PackerWorkspaceFoundation();
final var parser = new p.packer.services.PackerAssetDeclarationParser();
return new CountingLoader(new p.packer.services.PackerRuntimeLoader(foundation, parser));
}
private static final class CountingLoader implements PackerRuntimeSnapshotLoader {
private final PackerRuntimeSnapshotLoader delegate;
private int loadCount;
private CountingLoader(PackerRuntimeSnapshotLoader delegate) {
this.delegate = delegate;
}
@Override
public synchronized p.packer.models.PackerRuntimeSnapshot load(PackerProjectContext project, long generation) {
loadCount += 1;
return delegate.load(project, generation);
}
private synchronized int loadCount() {
return loadCount;
}
}
private Path copyFixture(String relativePath, Path targetRoot) throws Exception {
final Path sourceRoot = PackerFixtureLocator.fixtureRoot(relativePath);
try (var stream = Files.walk(sourceRoot)) {