Skip to content

registry: a deep registry write schedules a synchronization pass (#7303) - #7337

Merged
iliyan-velichkov merged 2 commits into
masterfrom
issue-7303-registry-watcher-forces-sync
Sep 11, 2026
Merged

registry: a deep registry write schedules a synchronization pass (#7303)#7337
iliyan-velichkov merged 2 commits into
masterfrom
issue-7303-registry-watcher-forces-sync

Conversation

@iliyan-velichkov

Copy link
Copy Markdown
Contributor

Cause

LocalRegistryWatcher watched /registry/public recursively, registered folders created after startup, and fanned every event out to List<LocalRegistryWatcherHandler> - an interface with zero implementations anywhere in the repository. Every instance therefore started a thread, opened a WatchService, walked the entire registry once and kept polling it, in order to hand each event to an empty list. Its configuration key, DIRIGIBLE_REGISTRY_LOCAL_IGNORED_FOLDERS, could not affect anything observable either.

Next to it sits the watcher everything depends on. SynchronizationWatcher decides whether a synchronization pass runs at all, and it registers the registry root non-recursively: a file written several folders deep produces no event, isSynchronizationNeeded() keeps answering false, and no pass is ever scheduled for it. A publish was covered (SynchronizationWatcherPublisherHandler.afterPublish forces one) and, since #7299, so is the external-folder copy - anything else that wrote the registry was invisible for the life of the process. That is the shape of #7192: a client-Java generation that lost the race against the copy stayed partial, controllers answering 404 with all their sources on disk.

The change

Option (2) of the issue. The dead SPI is dropped and the recursive watcher is given the one job it was sitting next to all along: it marks the registry modified on every create / modify / delete under /registry/public, so any deep writer becomes self-healing rather than only the two that remember to announce themselves.

  • LocalRegistryWatcherHandler and the six fan-out loops are gone, and so is the boot-time walk of the whole registry - a pass reconciles the entire registry, so a per-file notification is not needed and the walk existed only to produce one. The class is 177 lines shorter.
  • DIRIGIBLE_REGISTRY_LOCAL_IGNORED_FOLDERS now means something observable: those top-level folders are neither watched nor reported (asserted end to end, see below).
  • The macOS teardown machinery (closeOffThread, the stop-loop-then-close ordering) is untouched - RecursiveFolderWatcher.destroy() uses it and LocalRegistryWatcherShutdownTest still guards it.

Two details are load-bearing and are commented as such:

  • A newly created folder is registered before it is reported. A file written into it before the registration produces no event of its own, and is only covered because the pass the report schedules walks the subtree afterwards. Reporting first would leave exactly that window open.
  • An OVERFLOW now reports instead of being skipped. Events were dropped, so what changed is unknown - which is when a pass is most needed.

This does not retire RegistryMutationTracker. A pass is now scheduled while a multi-file write is still arriving, and only the bracket tells that pass to defer its cleanup instead of reaping artefacts whose sources have not landed yet. A component that writes the registry outside the publisher pipeline still brackets the write; what it no longer has to do is remember to announce it. .claude/docs/synchronizer-model.md says so, replacing the paragraph that described the gap.

Verification

  • New RegistryDeepWriteSyncIT (HTTP-only, untagged, so it runs in the PR smoke gate): writes a client-Java source several folders deep straight onto the registry's file system path - no publish, no forceProcessSynchronizers(), no external folder - and waits for the endpoint to serve it. Green in 38 s. It genuinely bites: re-run with DIRIGIBLE_REGISTRY_LOCAL_IGNORED_FOLDERS naming the test's project (which suppresses exactly this report) it times out on a 404 at 208 s, which is also the end-to-end proof that the ignore key works.
  • New LocalRegistryWatcherTest (4 cases): a file written deep schedules a pass; a folder created after startup is watched too and its content schedules a pass; a deletion schedules a pass; a change under an ignored top-level folder schedules nothing.
  • LocalRegistryWatcherShutdownTest (3) and RecursiveFolderWatcherTest (2) still green; components/core/core-registry, core-initializers, core-base unit suites green with clean.
  • Integration tests run by name, all green: ExternalRegistryFolderSyncIT, SynchronizerCleanupRaceIT, JavaEngineIT, CsvimIdentityRestartIT, RoleSynchronizerCleanupIT, IntentEngineIT (80), IntentEmissionCoverageIT, ModelGenerationIT, DependencyResolutionIT, PerspectiveGroupAggregationIT, ArtefactStatusEndpointIT.
  • mvn formatter:format on the changed modules, then mvn -T 1C formatter:validate with the formatter cache wiped: SUCCESS. Javadoc release profile on core-registry -am: no error:.

Not verified: the full UI / sample-project suite, and PostgreSQL. Both are worth watching on this PR specifically, because the issue's own reservation about this option was that it changes behaviour on every deployment and every integration test - passes are now scheduled that previously were not. The measured shape of that cost is one flag write per event and one pass per batch (force() sets an AtomicBoolean; the processor decides when to run), and the SynchronizationUtil.waitForStableSynchronization() quiet period already exists to absorb trailing watcher events.

Pre-existing flake noticed, not touched: RecursiveFolderWatcherTest.a_file_added_deep_in_the_tree_afterwards_schedules_another_pass fails intermittently (1 in ~3 full-module runs, green in isolation). registryChanged() also fires for the ENTRY_MODIFY on the containing directory, whose mtime moves when the new file is created, so the test's atLeast(2)).force() can return before second.txt has been copied and the Files.exists assertion that follows races. The production ordering is correct (copy, then report); the race is in the assertion. Left alone as out of scope - happy to fix it in a follow-up.

Fixes #7303

🤖 Generated with Claude Code

iliyan-velichkov and others added 2 commits September 11, 2026 15:17
LocalRegistryWatcher watched /registry/public recursively and handed every
event to List<LocalRegistryWatcherHandler>, an interface with zero
implementations - so each instance started a thread, opened a watch service,
walked the whole registry once and kept polling it, to notify nobody.

Next to it sits SynchronizationWatcher, which decides whether a pass runs at
all and registers the registry root NON-recursively: a file written several
folders deep produces no event, so nothing is ever scheduled for it. Only a
publish (SynchronizationWatcherPublisherHandler forces a pass) and the
external-folder copy (#7299) reported themselves; anything else was invisible
for the life of the process - the shape of #7192, a partial client-Java
generation that stayed installed with every source on disk.

So the SPI is dropped and the watcher is given the one job it was next to all
along: it marks the registry modified on every create/modify/delete under it,
which makes any deep writer self-healing. The six fan-out loops and the
boot-time walk go with the interface - a pass reconciles the whole registry,
so a per-file notification is not needed and the walk was only there to feed
one. DIRIGIBLE_REGISTRY_LOCAL_IGNORED_FOLDERS now means something observable:
those top-level folders are neither watched nor reported.

This does not retire RegistryMutationTracker. A pass is now scheduled while a
multi-file write is still arriving, and only the bracket tells that pass to
defer its cleanup instead of reaping artefacts whose sources have not landed
yet - a component that writes the registry outside the publisher pipeline
still brackets the write, it just no longer has to remember to announce it.

Two details that are load-bearing. A new folder is REGISTERED before it is
reported: a file written into it before the registration produces no event of
its own and is only covered because the pass the report schedules walks the
subtree afterwards. And an OVERFLOW now reports instead of being skipped -
events were dropped, so what changed is unknown, which is when a pass is most
needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ten (#7303)

How many events one write produces is the platform's business: inotify reports
a created file as ENTRY_CREATE and again as ENTRY_MODIFY, while the polling
watch service macOS falls back to reports it once. The assertions were written
against the macOS count, so on the Linux CI leg two forces met a verification
wanting exactly one and the two single-write cases failed with
TooManyActualInvocations after burning the full 60s timeout.

Marking the registry modified is idempotent, so the count carries nothing worth
pinning down - every assertion is now atLeastOnce(), and the steps of a sequence
are separated by clearing the recorded calls instead of by counting them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@iliyan-velichkov
iliyan-velichkov merged commit 1c69fa4 into master Sep 11, 2026
10 checks passed
@iliyan-velichkov
iliyan-velichkov deleted the issue-7303-registry-watcher-forces-sync branch September 11, 2026 14:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LocalRegistryWatcher watches the whole registry recursively and notifies nobody - LocalRegistryWatcherHandler has zero implementations

1 participant