Skip to content

fix: Index no page of a site users don't navigate, and create each init plugin's metadata on its own EXO-91183 - #6245

Merged
boubaker merged 2 commits into
feature/maintenancefrom
fix/EXO-91183-search-and-metadata-start
Oct 21, 2026
Merged

boubaker merged 2 commits into
feature/maintenancefrom
fix/EXO-91183-search-and-metadata-start

Conversation

@boubaker

@boubaker boubaker commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Symptom:

  • Search showed the content blocks of the pages of a space template, and of other sites users don't navigate (portal templates, user sites), to every user the page grants access to, with a link that leads nowhere. They were indexed on a full reindex, on every save of such a page through Layout, and on a space template's "Restore default pages", which refreshes the index of every page of the template with fix: Refuse to hand on the layout of a site that couldn't be read, and a site restore refreshes its pages' search index EXO-91183 layout#581.
  • With fix: Restoring the built-in sites can leave the main sites with empty pages EXO-91183 portal#1338, a method whose transaction was marked rollback-only fails instead of returning. A metadata init plugin whose metadata can't be stored at startup would then fail the start of MetadataServiceImpl, and with it the portal container start. Before, the outcome depended on the database. On MySQL/MariaDB (identity ids, the INSERT runs at persist), the start logged a WARN, and the metadata of every plugin of that start were silently rolled back. On the databases whose ids come from a sequence, the INSERT waits for the flush: it failed at the commit of the start's transaction, failing the container start with no WARN, or, when a later plugin's lookup flushed it first, the start logged a WARN under that later plugin's metadata (its lookup threw the flush failure as IllegalStateException: Error retrieving value with key …), and the whole start was silently rolled back.

Cause:

  • PageContentIndexingConnector builds no bare page document for a page of a site users don't navigate (isIndexableSite: portal and group sites), but built its content block documents, and listed them for a full reindex. PageContentBlockIndexingListener reindexed them on layout.page.updated.
  • MetadataServiceImpl#start created the metadata of every plugin in one @ExoTransactional transaction, and caught each plugin's failure to go on.

Fix:

  • PageContentIndexingConnector builds no document, block or bare, for a page of a site users don't navigate, and a full reindex doesn't list the content blocks of such a page.
  • When such a page is updated through Layout (layout.page.updated, layout.page.permissions.updated), PageContentBlockIndexingListener removes every document the index holds for it, so the blocks indexed before leave search.
  • MetadataServiceImpl#start creates each plugin's metadata in a transaction of its own: a plugin that fails is logged (WARN), and the metadata of the others are created.
  • Pinned by PageContentIndexingConnectorTest (a block of a template page builds no document; a full reindex lists none), PageContentBlockIndexingListenerTest (a template page update unindexes its documents and reindexes none) and MetadataServiceTest#testStartCreatesTheMetadataOfTheOtherPluginsWhenOneFails (a real container: a metadata whose name is longer than its column, then a valid one). Each pin was mutation-verified on the committed tree.

Upgrade impact:

  • The content blocks of the pages of space templates, portal templates and user sites (dashboards) leave search: on the next full reindex, or when such a page is next updated through Layout. The bare page documents of those sites were already not indexed.
  • At startup, the metadata of the init plugins are created one plugin at a time: a plugin that fails is logged under its own name, no longer rolls back the metadata of the others, and, on the databases whose ids come from a sequence, no longer fails the container start.

Known limits:

  • A page of such a site saved outside Layout (a configuration re-import, for instance) builds no document, and the indexing framework skips a missing document instead of removing it: the blocks indexed before stay until the next full reindex, or the next update of the page through Layout.

Release order: merge it before, or with, Meeds-io/portal#1338 and Meeds-io/layout#581. The metadata fix is needed by Meeds-io/portal#1338, which would otherwise turn a failing metadata init plugin into a failed start. The search fix is needed by Meeds-io/layout#581, whose space template restore would otherwise index the template's content blocks.

Tests: mvn -B -ntp -o clean verify javadoc:javadoc -Pcoverage -pl component/core at c2106c8844: BUILD SUCCESS, 1357 tests, 0 failures, 0 errors (MetadataServiceTest 32, PageContentIndexingConnectorTest 33, PageContentBlockIndexingListenerTest 11), against the local 7.3.x-maintenance-SNAPSHOT of Meeds-io/portal#1338, which carries its RollbackException.

Knowledge: Meeds-io/eng-standards#409, #560, #586, #587

This change is classified N1 (proposed, recall-first) — the JPA transaction boundary over the kernel DAO writes of a component's start, whose failure fails the portal container start with Meeds-io/portal#1338, and which permission-bearing page documents search exposes. Its approver must be an Archi/Dev who knows it is N1, not an approval on AI review alone; author ≠ approver.

🤖 Generated with Claude Code

boubaker and others added 2 commits October 20, 2026 07:39
…'t indexed EXO-91183

A page of a site template, a user site or another site users don't
navigate got no page document in search, but its content blocks were
indexed: on a full reindex, and whenever the page was saved or restored
through Layout. Search shows a block to every user its page grants
access to.

- No document is built for a page of a site users don't navigate,
  whether a content block or the page itself.
- A full reindex doesn't list the content blocks of such a page.
- When such a page is updated through Layout, the documents the index
  holds for it are removed. A page saved outside Layout keeps them until
  the next full reindex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a of the others from being created EXO-91183

The metadata of every init plugin was created at startup in one
transaction. A plugin whose metadata couldn't be stored was logged, and
the metadata of every plugin was rolled back with it. Now that a method
whose transaction was marked rollback-only fails instead of returning
(Meeds-io/portal#1338), that start would fail the portal container start.

- The metadata of each plugin is created in a transaction of its own: a
  plugin that fails is logged, and the metadata of the others are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@boubaker

Copy link
Copy Markdown
Member Author

Self-review close-out — rounds 7 to 10

This PR was opened after round 8. Its two fixes came out of rounds 7 and 8, so its table covers every round it was in. The last round found nothing left to fix in it. DC rows come from the delivery-checks pass run before each round.

Status table

Round # Sev Finding Status
7 1 🟡 With Meeds-io/portal#1338, a failed metadata write inside MetadataServiceImpl#start (@ExoTransactional, failures caught per plugin) fails the container start ✅ Fixed c2106c8844: start loses the annotation, each plugin's creation is its own transaction; pinned by MetadataServiceTest#testStartCreatesTheMetadataOfTheOtherPluginsWhenOneFails
7 DC 🟡 Content blocks of pages of sites users don't navigate (templates, user sites) were indexed, and stayed after a restore ✅ Fixed fd64e98811: no document is built for them, a full reindex doesn't list them, the Layout update listener removes their documents
8 2 🟢 Release order of the bodies left out that Meeds-io/layout#581 needs this search fix ✅ Fixed (body)
8 3 🟢 Javadocs scoped the site rule to bare page documents, one line over-long ✅ Fixed fd64e98811 (reworded, rewrapped)
8 DC 🟢 Claims scoped to Layout updates, refresh timing ✅ Fixed (body)
8 R7 #6 🟢 Result line needed its SHA ✅ Fixed (body)
9 DC 🟡 Body's before-state of the metadata start depended on the database and was stated for one ✅ Fixed (body)
10 1 🟡 "The metadata cache logged an ERROR" can't happen: the cache rethrows IllegalStateException and the warning comes from MetadataServiceImpl#start under the later plugin ✅ Fixed (body, wording verified in the source)

Verified conform

The last rounds checked the body's before-state against the code and the libraries: Hibernate 7.4.5, PortableSequenceGenerator, portal's FutureCache, the identity-id and sequence-id cases. They found the corrected text holds. They also verified that the connector's build guard is the single path for every create and update, that the full-reindex filter runs before skip and limit, that the listener sweep runs after the draft check, that the event names match Layout's constants, that each plugin's creation runs in its own transaction in the test, and that the test counts match the evidence. No product text cites a role or a ruling as a reason.

Tests

mvn -B -ntp -o clean verify javadoc:javadoc -Pcoverage -pl component/core at c2106c8844: BUILD SUCCESS, 1357 tests, 0 failures, 0 errors (MetadataServiceTest 32, PageContentIndexingConnectorTest 33, PageContentBlockIndexingListenerTest 11), against the local portal snapshot carrying the RollbackException of Meeds-io/portal#1338.

Classification

This change is classified N1 (proposed, recall-first) at c2106c8844 (JPA transaction boundary over the kernel DAO writes of a component's start, whose failure fails the container start with Meeds-io/portal#1338; and which permission-bearing page documents search exposes) — its approver must be an Archi/Dev who knows it is N1, not an approval on AI review alone; author ≠ approver.

Open items for a human

🤖 Generated with Claude Code

@ahamdi ahamdi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — Round #1


🟡 Medium — The Knowledge: line misses the corpus PR that this fix makes stale

The body has Knowledge: Meeds-io/eng-standards#586, #587. Both PRs carry the search half of the change only, and neither mentions MetadataServiceImpl#start. Draft Meeds-io/eng-standards#560, under the same transaction topic, lists it as exposed:

- `social` `MetadataServiceImpl#start` (`MetadataServiceImpl.java:685-700`) catches `Exception` around each plugin's metadata read and creation, logs a WARN and goes on. Exposed: one failed creation rolls back every plugin's.

Once this PR lands, that line is wrong: each plugin's creation commits on its own. It also cites :685-700, while start() is at :683-698 at this head.

Fix: update #560's line and its permalink range to this PR's state: no longer exposed, fixed by #6245. Add #560 to the Knowledge: line, since it is the corpus PR that carries the metadata half of the update.


Verified conform

  • Metadata start, transaction scope. Removing @ExoTransactional from start() is safe. The Kernel's LifecycleVisitor.start wraps each Startable#start in RequestLifeCycle.begin(container) (startTransaction=true; checked in the 7.3.x-maintenance exo.kernel.container jar). That binds EntityManagerService's EntityManager with no transaction, so each MetadataDAO#create begins and ends its own @ExoTransactional transaction, and the reads share that EntityManager. testStartCreatesTheMetadataOfTheOtherPluginsWhenOneFails runs under the same wiring, through AbstractCoreTest#setUp's begin(), so the pin is representative.
  • Connector. The site guard sits in PageContentIndexingConnector#build, after the draft check and before the block/bare dispatch, so create and update both return null for block and bare documents alike. #queueLiveBlockDocuments is only reached from createPageDocument, behind that guard. getAllBlockIds filters on isIndexablePageReference before skip and limit, and an unparseable reference keeps its old behaviour (listed, then resolved by build).
  • Listener. PageContentBlockIndexingListener#onEvent checks the site after the draft check and after the page lookup. For such a page it reindexes nothing and unindexes everything findIndexedDocumentIds returns. It does this on both layout.page.updated and layout.page.permissions.updated (literals unchanged). PageSavedIndexingListener keeps PAGE_UPDATED → reindex, and create stays on build(id, true) (pitfall page-index-converges-only-through-events).
  • Body claims. The known limit holds: a save outside Layout yields a null document, which the indexing framework skips. The upgrade impact holds for portal, group, template and user sites. The release order matches Meeds-io/portal#1338 and Meeds-io/layout#581, both open on feature/maintenance. #586 and #587 are drafts, as they should be until the code reaches develop.
  • Tests. The three new connector and listener tests are JUnit 4 classes, already registered in the module's suites. Each asserts the behaviour named in the body.

The PR does several things well. The site rule now lives in one guard on the path every create and update takes, and getAllBlockIds applies the same predicate before paging, so a full reindex and a single update cannot disagree. The listener removes the documents already indexed for a template page instead of only stopping new ones. The metadata fix is the smallest change that gives each plugin its own transaction, and it is pinned through a real container with a real column limit.

This change is classified N1: a transaction boundary over kernel DAO writes at container start, and the documents search exposes under page permissions. It must be validated by an Architect or Developer who knows it is N1, not auto-merged on AI review alone; author ≠ approver.

🤖 Generated with Claude Code

@boubaker

Copy link
Copy Markdown
Member Author

Round #1 🟡 (Knowledge: line): fixed.

No code change: the head is still c2106c8844.

@boubaker
boubaker requested a review from ahamdi October 21, 2026 13:13

@ahamdi ahamdi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — Round #2 (final)

The head is still c2106c8844, the commit Round #1 reviewed, so this round covers only the body change.

Round # Sev Finding Status
1 1 🟡 The Knowledge: line misses Meeds-io/eng-standards#560, which this fix makes stale ✅ Fixed: verified in the live body and in #560 at its head 7b4c85c817

Verified conform

  • Body. The body now ends with Knowledge: Meeds-io/eng-standards#409, #560, #586, #587. All four PRs are open drafts, which is right while the code has not reached develop.
  • #560 carries the metadata half. At its head, the pitfall an-entitymanager-exception-caught-inside-an-exotransactional-transaction-rolls-it-back-without-a-word.md gives MetadataServiceImpl#start (MetadataServiceImpl.java:683-698) as not exposed. It explains why: each plugin's metadata is created in the transaction its DAO create begins and commits. Its Verified stamp leaves the social develop SHA as TBD until this code lands there. The develop state (@ExoTransactional, :685-700 at 3b9d6c6e1a) is kept only as history in the change fragment. The cited range matches start() at this PR's head, and so does the WARN (:695-697).

All findings from previous rounds are resolved. Nothing is outstanding on the AI review side.

This change is classified N1: a transaction boundary over kernel DAO writes at container start, and the documents search exposes under page permissions. It must be validated by an Architect or Developer who knows it is N1, not auto-merged on AI review alone; author ≠ approver.

🤖 Generated with Claude Code

@boubaker
boubaker requested a review from ahamdi October 21, 2026 15:28
boubaker added a commit to Meeds-io/portal that referenced this pull request Oct 21, 2026
… pages EXO-91183 (#1338)

**Symptom 1**: after the built-in sites (`global`, `dw`, `myworkspace`)
are restored with their layout, a displayed site's pages can render
empty: the meta site was left with an empty layout.

**Symptom 2**: an overwrite restore of a site defined by several
configurations (`global` has six: multifactor-authentication, agenda,
documents, processes, sites, digital-workplace) logs, for every second
configuration (reproduced on a local server):

```
NewPortalConfig error: Error while committing the transaction [org.hibernate.TransientPropertyValueException: Persistent instance of 'org.exoplatform.portal.jdbc.entity.NodeEntity' references an unsaved transient instance of 'org.exoplatform.portal.jdbc.entity.PageEntity' (persist the transient instance before flushing) [org.exoplatform.portal.jdbc.entity.NodeEntity.page -> org.exoplatform.portal.jdbc.entity.PageEntity]]
Can't find Window with ids: ...
```

and leaves pages missing, pages whose layout names deleted windows, and
the `dw` site's People and Spaces nodes without a page.

**Cause 1**: `SiteStorageImpl.save` replaced a site's layout one kernel
DAO call at a time, each `@ExoTransactional` call committing on its own.
A concurrent reader could read the site row while its root container was
already deleted and get, and cache, an empty layout.
`UserPortalConfigService.restoreSite` then read the site again after the
import and saved that empty layout back over the restored one.

**Cause 2**: a restore imported each configuration of the site in turn
with the requested mode. In overwrite mode, each configuration deleted
every page of the site, the pages the previous configurations had just
imported included, and re-created its own pages with new identifiers:
the nodes of other sites that reference them lost their page
(`FK_PORTAL_NODE_PAGE` is `ON DELETE SET NULL`). Its page deletion was
refused at commit while the page windows were already deleted in their
own transactions. In overwrite and restore-defaults mode, each
configuration also deleted the nodes the previous ones had imported
under the nodes it defines. The startup import did the same for a
configuration in overwrite mode, at every start.

**Fix**:
- `@ContainerTransactional(atomic = true)` runs the method in one JPA
transaction, committed when it returns and rolled back when it throws;
when a failed call marked it rollback-only, a method that returned
throws `RollbackException`. Kernel EntityManager writes join it. When
the method begins it, Spring transactional methods and Spring Data
repositories join it too, and their callbacks run with its outcome.
`SiteStorageImpl` `create(PortalData)`, `save(PortalData)` and
`remove(SiteKey)` use it.
- The site, layout, page, navigation and description caches evict again
once the transaction completes (`TransactionCallbacks.afterCompletion`),
after invalidating the loads in flight; `FutureCache.clearFutures`
invalidates every load it removes.
- A site read whose root container is missing is read again once
(`SiteDAO#refresh`), then flagged `layoutUnresolved` with an empty
layout; a save of it keeps the stored layout.
- `restoreSite` applies the previous site's settings to the imported
config before its save, instead of saving the site a second time.
- A site restore (`UserPortalConfigService#restoreSite`), and the
startup import of a site that one of its configurations overwrites,
import the site from all its configurations
(`NewPortalConfigListener#importSitePages`, `#importSiteNavigation`): an
overwrite deletes the pages that no configuration defines, then the
pages of every configuration are imported in place; the navigation of
every configuration is imported without deleting any node, then
`NavigationOrphanRemover` deletes the nodes that none of them defines,
as the mode does (every such node in overwrite mode, the nodes nested in
a default node in restore-defaults mode, a node only in the path of a
fragment attachment point keeping its children). Nothing is deleted when
the pages or the navigation file of a configuration can't be parsed; a
file a configuration can't open counts as defining none. All pages are
imported before any navigation.
- A restore imports the pages of every configuration of the site,
whatever was imported before: the check of
`NewPortalConfigListener#initPageDB` (`createdOwners`, filled by the
startup import and by a site-layout restore) no longer applies to it.
- Each orphan page is removed on its own: a page that can't be removed,
as one removed since the orphans were selected, is logged (WARN) and the
next ones are removed.
- `PageStorageImpl` deletes a page, and replaces a page layout, in one
transaction, and broadcasts the page event once that write returns.
- A page delete, a page layout save, a page clone, a site save and a
site removal read their row again (`PageDAO#refresh`,
`SiteDAO#refresh`), and work from the layout that row holds. Before,
they worked from the page or site as this request first read it, for
instance through a node load, which reads the pages its nodes reference;
with Meeds-io/layout#581, a site restore reads the site's navigation
before it imports the pages. A layout another request saved since is now
removed with the page or site, replaced by a layout save, and copied by
a clone.
- `PageEntity` is `@DynamicUpdate`. A page row has two writers, its
properties save and its layout save, and no version: an update now
writes only the columns it changed, so a properties save no longer
writes back the page body it read. It reads its row again too, so each
property it saves is compared with the row as stored.
- A failing atomic call that joined an active kernel transaction marks
it rollback-only and logs a WARN naming the method.
- `ExoTransactionalAspect` rolls back and throws `RollbackException`
when the method it began a transaction for returns over a rollback-only
transaction, as `ContainerTransactionalAspect` already did for its own
transactions. A method that throws keeps its error, unless ending the
transaction fails, as before. The EntityManager it started is closed
whatever the method's outcome.
- At startup, a configuration whose site type or import mode is unknown
is left out of the overwritten sites; on a site no other configuration
overwrites, its own import logs the error as before.
- Pinned by `ContainerTransactionalAspectTest`,
`ExoTransactionalAspectTest`, `ContainerTransactionalRepositoryTest` (a
real Spring Data repository), `TransactionCallbacksTest`,
`FutureCacheTest`, `TestDataStorage` (a layout write and a page delete
failing midway through the woven advice, a node update evicting the node
cache after the commit), `SiteDAOTest`, `TestUserPortalConfigService`,
`TestRestoreSiteConfigurations` in the new `InitContainer10TestSuite` (a
restore and a startup import of sites defined by several configurations,
a node of another site keeping its page, a configuration of an unknown
site type, a pages restore of a site this startup didn't import, an
orphan page the request has read and another request saves a layout on
during the restore), `TestDataStorage` (a page layout save, a page
properties save, a page clone, a site save and a site removal after
another request saved a layout on the page or site the request had read,
an update of the page properties that leaves the layout column another
request wrote, and a properties save that writes back a property another
request changed since the request read the page),
`NewPortalConfigListenerTest` (an orphan page that can't be removed),
`TestNavigationFragmentImporterRestoreDefaults` (now run, in
`InitContainer1TestSuite`), `TestSiteDataImportOverwrite` and
`TestImportNavigationOverwrite`.

**Upgrade impact**:
- A page delete, a page layout or properties save, a page clone, and a
site save or removal read their row once more (a SELECT by id).
- A site whose stored body names a missing root container is kept, read
with an empty layout flagged unresolved (WARN) and cached so; a save of
it keeps the stored layout (WARN). A restore with layout, or a save that
sets a layout, repairs it.
- `restoreSite` makes one save per configuration that declares the site:
`PORTAL_CONFIG_UPDATED`, or `PORTAL_CONFIG_CREATED` for a site the
import creates, and none when the import skips the site.
- A write failure now rolls back the whole site create, save or remove.
Inside an enclosing kernel transaction, a failed site create, save or
remove, page delete or page layout save marks that transaction
rollback-only and logs a WARN naming the method.
- **An `@ExoTransactional` method that began the transaction and returns
over it marked rollback-only now throws
`jakarta.persistence.RollbackException`** after the rollback, instead of
returning with none of its writes committed. A method that joined an
active transaction doesn't throw: the method that began it does. The
transaction is marked by an atomic call above, or by a failing JPA
operation (every converted `PersistenceException` but
`NoResultException`, `NonUniqueResultException`, `LockTimeoutException`
and `QueryTimeoutException`). So any such method that catches the
failure and returns now fails visibly. The callers checked:
- `JPASettingServiceImpl#set` catches every exception of its body: a
`SettingService#set` call made outside a transaction now throws where it
logged an ERROR.
- social `LayoutUpgradePlugin#updateLayout`: the exception leaves
`processUpgrade`, the upgrades after it don't run, and
`UpgradeProductService` doesn't record the plugin as done. The whole
plugin runs again at every start, re-applying the upgrades before the
failing one, until that one passes. Before, the failing upgrade's writes
were rolled back and the plugin was recorded as done.
- social `MetadataServiceImpl#start`: a failing metadata init plugin
would fail the start of the portal container; Meeds-io/social#6245
creates each plugin's metadata in its own transaction.
- social `SocialUserEventListenerImpl#postDelete` throws, `UserDAOImpl`
logs it (WARN), and the user is deleted as before.
- gamification `ProgramSpaceListener` and commons
`JPAQueueMessageImpl#send` keep their outcome: the exception is dropped
by an unread future, or logged by the job (ERROR), and the writes are
rolled back as before.
- A kernel component whose `start()` makes such a call outside a
transaction fails its start (`ContainerException`), and the portal
container is left partly started.
- An overwrite restore with pages keeps the pages and nodes of every
configuration of the site; the pages it imports keep their identifier,
so the nodes of other sites keep their page. The pages and nodes that no
configuration defines are deleted once, after every import for the
nodes.
- A restore imports the site layout of every configuration, then their
pages, then their navigation: a node that references a page of a later
configuration of the site is linked to it.
- A restore-defaults restore with navigation keeps the nodes of every
configuration, whatever their order; a node nested in one configuration
that another one attaches a fragment to keeps its custom children.
- The search index of a restored site's pages is refreshed by
Meeds-io/layout#581. With this PR alone, an overwrite restore updates
the pages in place with the portal's page update event only: their
indexed blocks keep their content and permissions until the page is next
saved through Layout, or the next full reindex, as the restore-defaults
restore already did.
- When the navigation, or the pages, file of one configuration can't be
parsed, a restore deletes no node, or no page, of the site. A file a
configuration can't open counts as defining none: the pages and nodes
only it defines are deleted as orphans, as the previous whole-site
removal deleted every page.
- A pages restore without the site layout ("Restore pages") restores the
pages of a site that neither this start nor a site-layout restore since
then imported (after a restart, a site no configuration overrides);
before, it restored only its navigation. A REST restore with layout and
pages whose site layout import fails now still imports the pages.
- **Startup contract change**: when one configuration of a site is in
overwrite mode (an extension, or a property such as
`exo.agenda.portalConfig.metadata.importmode=overwrite`), every start
that runs that configuration (its `override` true, or a first start, or
a reimport) imports the pages and the navigation of all the
configurations of the site, whatever their own mode and `override`, then
deletes the pages and nodes that none of them defines. The pages created
in the UI in that site are among them, as before. So are the nodes
created in the UI under a node that one of its configurations defines or
attaches a fragment to, the root included when one attaches there, as
one configuration of every shipped site does; before, a start deleted
only those under the attachment points of the overwriting
configuration's own fragments. Each configuration's pages are saved from
its files in place (layout, state and permissions replaced), and its
navigation merged. An extension in overwrite mode no longer deletes the
pages and nodes the other configurations of the site define: they are
kept and the extension's own are merged over them. On a first start, or
a reimport, with an overwrite configuration, the result is the union of
the configurations, where the last overwrite used to win.
- The shipped configurations (meeds sites, digital-workplace, agenda,
documents, processes, multifactor-authentication) default to insert or
conserve: the change applies to a deployment that sets an overwrite
mode.
- A configuration of that site whose `pages.xml` or `navigation.xml`
can't be parsed: no page, or no node, is deleted.
- A configuration of that site with an unknown import mode: its pages
and navigation are imported in merge mode; its site layout import still
logs the mode.
  - The site layout import, and the shipped defaults, are unchanged.
- A page delete or a page layout save that fails midway rolls back its
windows and permissions; before, the page kept a layout naming deleted
windows (`Can't find Window with ids`).

**Known limits**:
- `PAGE_DESTROYED` is broadcast before the commit when the page removal
joins a site removal (`SiteStorageImpl#remove`); no production listener.
- A node write drops every navigation and node load in flight, for all
sites, before evicting; the after-completion repeat is pinned, the
in-flight invalidation is not.
- A page delete, layout save or clone, and a site save or removal read
their row without a lock: a layout another request commits between that
read and the write is missed.
- `PAGE_REMOVED` carries the page as `LayoutService#remove(PageKey)`
gets it, from the page cache or from the request's first read of the
page: it may lack a layout saved since, which the delete removes.
- A user site's pages are never deleted by the orphan removal, as the
page import never did (not reachable today: no user-type configuration
ships).
- The template clone (`savePortalFromTemplate`) keeps the unresolved
flag, and `create` ignores it.
- The in-flight load pin covers the site cache; the `clearFutures` race
fix is not pinned.
- The banner write of the `PortalConfig` overloads runs before, outside
the transaction.
- A reader that straddles the commit still misses containers: a missing
root is re-read once, then flagged; a missing nested container is
skipped.
- Spring: a Spring write inside an atomic method joined to an outer
non-atomic kernel transaction keeps its own transaction. With a Spring
synchronization already active and no EntityManager of its own, the
atomic transaction collects none of the Spring callbacks. A Spring
`@CacheEvict` evicts at once, before the commit. A Spring call made
while the commit flushes (an entity listener) does not join.

**Follow-ups**: the per-session `UIPortal` cache is not refreshed on a
meta-site layout change; `global` is displayed on community-dev but not
in the packaged configuration; nested container misses; an optional
guard in the QA restore hook (meeds-qa-ui#339); a Spring join inside a
non-atomic kernel transaction; the web-conferencing pins of task 91299
(exoplatform/web-conferencing#421).

**Release order**: Meeds-io/social#6245 before this PR, or with it:
without it, a failing metadata init plugin fails the start. This PR
before the `Meeds-io/layout` PR of EXO-91183, which refuses to hand on
an unresolved site's layout and compiles against
`PortalConfig#isLayoutUnresolved`.

**Tests**: at the head `65f8e9a240`, `mvn -B -ntp -o
-Dcheckstyle.skip=true -pl component/portal
-Dtest='TestDataStorage,TestModelStorage,TestPageService,TestUserPortalConfigService,TestRestoreSiteConfigurations,TestSiteDataImport*,TestImportNavigation*,TestImportFragment*,TestNavigationFragmentImporterRestoreDefaults,NewPortalConfigListenerTest'
-Dsurefire.failIfNoSpecifiedTests=false -Djacoco.skip=true test`: 18
classes, 148 tests, 0 failures, 0 errors. The full suite (`mvn -B -ntp
clean verify javadoc:javadoc -Pcoverage`) wasn't run at this head.

Knowledge: Meeds-io/eng-standards#409, #560, #586, #587

This change is classified N1 — Kernel ↔ Spring bridge (the atomic
`@ContainerTransactional` transaction that Spring transactional methods
and Spring Data repositories join), the outcome of every
`@ExoTransactional` method, the JPA transaction boundaries over the site
and page storage writes, the access and edit permissions a restored site
keeps, and the irreversible deletion of pages and navigation nodes by a
restore and by the startup import. Its approver must be an Archi/Dev who
knows it is N1, not an approval on AI review alone; author ≠ approver.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@boubaker
boubaker merged commit eb10a86 into feature/maintenance Oct 21, 2026
18 of 19 checks passed
@boubaker
boubaker deleted the fix/EXO-91183-search-and-metadata-start branch October 21, 2026 15:53
boubaker added a commit to Meeds-io/layout that referenced this pull request Oct 21, 2026
…d a site restore refreshes its pages' search index EXO-91183 (#581)

**Symptom**: when the layout of a site couldn't be read, portal reads it
with an empty layout (Meeds-io/portal#1338). Opening that site in the
layout editor, creating a site from it as a template, or saving it as a
site template would copy the empty layout over a real one. Today,
creating the draft fails only by accident: a 500 from a
NullPointerException in `LayoutModel`.

**Cause**: `SiteLayoutService` `getSiteLayout`, `createDraftSite` and
`createSite`, and `SiteTemplateService.saveAsSiteTemplate`, hand on the
layout they read without checking that it was read.

**Fix**:
- These four methods throw `SiteLayoutUnresolvedException` for a site
read with `PortalConfig#isLayoutUnresolved`, before anything is written,
through one check (`SiteLayoutUnresolvedException#checkResolved`).
`createSite` checks the template it copies; the other three check the
site itself.
- `SiteLayoutRest` and `SiteTemplateRest` answer that refusal with 409
Conflict, on five endpoints: `GET` and `PUT` of `sites/layout` (the
update returns the site layout read again), `POST sites` (a site created
from a template), `POST sites/draft`, and the site template created from
a site. The layout editor and the site form show their error message,
and the site template drawer stays open. The other failures of these
requests keep their outcome.
- Tests in `SiteLayoutServiceTest`, `SiteTemplateServiceTest`,
`SiteLayoutRestTest` and `SiteTemplateRestTest`:
- the refusal, and that nothing is written: no draft removed, no site or
template created;
  - the resolved path;
- the 409 for the new exception, and no 409 for another
`IllegalStateException`.

**Search index after a site restore**: a restore saves the pages and
rebuilds the navigation of the site without the events of the editors.
Since Meeds-io/portal#1338 saves the pages in place, the portal's page
update event alone reaches the index, and it only queues the blocks the
index doesn't hold yet. So a block the restore removed stayed in search,
a block whose content it changed kept its old document, and a page whose
last node it removed kept its documents.
- After a restore of pages or navigation,
`SiteLayoutService#restoreSite` compares the pages a node of the site
leads to before and after the restore:
- a page a node of the site leads to after the restore gets
`layout.page.updated`, so its blocks and its path are reindexed, as
after a page save; for a page of a site users don't navigate (a site
template, a user site), Meeds-io/social#6245 removes its documents
instead;
- a page whose last node in the site was removed gets
`layout.page.unreachable`, so its documents are unindexed, as after a
node deletion;
- a page no node of the site leads to, before or after the restore
(reached, if at all, from another site, where the index resolves no path
for it either), gets `layout.page.updated` when the restore covers the
pages, and nothing otherwise.
- A failure to read the navigation, before or after the restore, logs a
WARN and leaves the restore done.
- `NavigationLayoutService#getReachablePages` holds the walk that the
node deletion already used.
- Tests in `SiteLayoutServiceTest` (each case above, a navigation-only
restore, both failures) and `NavigationLayoutServiceTest`.
- Cost: two loads of the site's navigation tree, and one event per page
of the site, per restore of pages or navigation.

**Known limits**:
- A restore checks which pages are still reached in the restored site's
navigation only: a page whose last node in its own site the restore
removes is unindexed, even when a node of another site still leads to
it, as after a node deletion.
- `SiteTemplateService.createSiteTemplate` copies the `basic` template
through portal's template clone, which keeps the flag.
- The site template export (`SiteTemplateDatabindPlugin`) writes the
empty layout into the archive.
- The site template drawer shows no message when its save fails.

**Release order**: merge after Meeds-io/portal#1338 and its published
`7.3.x-maintenance` snapshot. This PR compiles against
`PortalConfig#isLayoutUnresolved`, so its CI fails until then. Merge it
with or after Meeds-io/social#6245: without it, a space template's
"Restore default pages" indexes the template's content blocks.

**Tests**:
- `mvn -B -ntp -o clean verify javadoc:javadoc -Pcoverage -pl
layout-service` at `01d1b2bd`: BUILD SUCCESS, 388 tests, 0 failures, 0
errors.
- It compiled against a local `7.3.x-maintenance-SNAPSHOT` of
Meeds-io/portal#1338. No signature of #1338's `component/api` changed
since its first pushed commit: its later commits change only
`ExoTransactional`'s Javadoc there.

Knowledge: Meeds-io/eng-standards#409, #560, #586, #587

This change is classified N2 — a REST contract change (409 when a site's
layout couldn't be read, on the site layout read and update, the site
and draft site creation, and the site template created from a site) and
a cross-domain seam (a site restore broadcasts the page updated and page
unreachable events that social's search indexing consumes to reindex or
unindex the site's pages). Its approver must be an Archi/Dev who knows
it is N2, not an approval on AI review alone. It requires
Meeds-io/portal#1338 (N1) to merge first.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
exo-swf pushed a commit that referenced this pull request Oct 22, 2026
…it plugin's metadata on its own EXO-91183 (#6245)

**Symptom**:
- Search showed the content blocks of the pages of a space template, and
of other sites users don't navigate (portal templates, user sites), to
every user the page grants access to, with a link that leads nowhere.
They were indexed on a full reindex, on every save of such a page
through Layout, and on a space template's "Restore default pages", which
refreshes the index of every page of the template with
Meeds-io/layout#581.
- With Meeds-io/portal#1338, a method whose transaction was marked
rollback-only fails instead of returning. A metadata init plugin whose
metadata can't be stored at startup would then fail the start of
`MetadataServiceImpl`, and with it the portal container start. Before,
the outcome depended on the database. On MySQL/MariaDB (identity ids,
the INSERT runs at persist), the start logged a WARN, and the metadata
of every plugin of that start were silently rolled back. On the
databases whose ids come from a sequence, the INSERT waits for the
flush: it failed at the commit of the start's transaction, failing the
container start with no WARN, or, when a later plugin's lookup flushed
it first, the start logged a WARN under that later plugin's metadata
(its lookup threw the flush failure as `IllegalStateException: Error
retrieving value with key …`), and the whole start was silently rolled
back.

**Cause**:
- `PageContentIndexingConnector` builds no bare page document for a page
of a site users don't navigate (`isIndexableSite`: portal and group
sites), but built its content block documents, and listed them for a
full reindex. `PageContentBlockIndexingListener` reindexed them on
`layout.page.updated`.
- `MetadataServiceImpl#start` created the metadata of every plugin in
one `@ExoTransactional` transaction, and caught each plugin's failure to
go on.

**Fix**:
- `PageContentIndexingConnector` builds no document, block or bare, for
a page of a site users don't navigate, and a full reindex doesn't list
the content blocks of such a page.
- When such a page is updated through Layout (`layout.page.updated`,
`layout.page.permissions.updated`), `PageContentBlockIndexingListener`
removes every document the index holds for it, so the blocks indexed
before leave search.
- `MetadataServiceImpl#start` creates each plugin's metadata in a
transaction of its own: a plugin that fails is logged (WARN), and the
metadata of the others are created.
- Pinned by `PageContentIndexingConnectorTest` (a block of a template
page builds no document; a full reindex lists none),
`PageContentBlockIndexingListenerTest` (a template page update unindexes
its documents and reindexes none) and
`MetadataServiceTest#testStartCreatesTheMetadataOfTheOtherPluginsWhenOneFails`
(a real container: a metadata whose name is longer than its column, then
a valid one). Each pin was mutation-verified on the committed tree.

**Upgrade impact**:
- The content blocks of the pages of space templates, portal templates
and user sites (dashboards) leave search: on the next full reindex, or
when such a page is next updated through Layout. The bare page documents
of those sites were already not indexed.
- At startup, the metadata of the init plugins are created one plugin at
a time: a plugin that fails is logged under its own name, no longer
rolls back the metadata of the others, and, on the databases whose ids
come from a sequence, no longer fails the container start.

**Known limits**:
- A page of such a site saved outside Layout (a configuration re-import,
for instance) builds no document, and the indexing framework skips a
missing document instead of removing it: the blocks indexed before stay
until the next full reindex, or the next update of the page through
Layout.

**Release order**: merge it before, or with, Meeds-io/portal#1338 and
Meeds-io/layout#581. The metadata fix is needed by Meeds-io/portal#1338,
which would otherwise turn a failing metadata init plugin into a failed
start. The search fix is needed by Meeds-io/layout#581, whose space
template restore would otherwise index the template's content blocks.

**Tests**: `mvn -B -ntp -o clean verify javadoc:javadoc -Pcoverage -pl
component/core` at `c2106c8844`: BUILD SUCCESS, 1357 tests, 0 failures,
0 errors (`MetadataServiceTest` 32, `PageContentIndexingConnectorTest`
33, `PageContentBlockIndexingListenerTest` 11), against the local
`7.3.x-maintenance-SNAPSHOT` of Meeds-io/portal#1338, which carries its
`RollbackException`.

Knowledge: Meeds-io/eng-standards#409, #560, #586, #587

This change is classified N1 (proposed, recall-first) — the JPA
transaction boundary over the kernel DAO writes of a component's start,
whose failure fails the portal container start with
Meeds-io/portal#1338, and which permission-bearing page documents search
exposes. Its approver must be an Archi/Dev who knows it is N1, not an
approval on AI review alone; author ≠ approver.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
exo-swf pushed a commit that referenced this pull request Oct 23, 2026
…it plugin's metadata on its own EXO-91183 (#6245)

**Symptom**:
- Search showed the content blocks of the pages of a space template, and
of other sites users don't navigate (portal templates, user sites), to
every user the page grants access to, with a link that leads nowhere.
They were indexed on a full reindex, on every save of such a page
through Layout, and on a space template's "Restore default pages", which
refreshes the index of every page of the template with
Meeds-io/layout#581.
- With Meeds-io/portal#1338, a method whose transaction was marked
rollback-only fails instead of returning. A metadata init plugin whose
metadata can't be stored at startup would then fail the start of
`MetadataServiceImpl`, and with it the portal container start. Before,
the outcome depended on the database. On MySQL/MariaDB (identity ids,
the INSERT runs at persist), the start logged a WARN, and the metadata
of every plugin of that start were silently rolled back. On the
databases whose ids come from a sequence, the INSERT waits for the
flush: it failed at the commit of the start's transaction, failing the
container start with no WARN, or, when a later plugin's lookup flushed
it first, the start logged a WARN under that later plugin's metadata
(its lookup threw the flush failure as `IllegalStateException: Error
retrieving value with key …`), and the whole start was silently rolled
back.

**Cause**:
- `PageContentIndexingConnector` builds no bare page document for a page
of a site users don't navigate (`isIndexableSite`: portal and group
sites), but built its content block documents, and listed them for a
full reindex. `PageContentBlockIndexingListener` reindexed them on
`layout.page.updated`.
- `MetadataServiceImpl#start` created the metadata of every plugin in
one `@ExoTransactional` transaction, and caught each plugin's failure to
go on.

**Fix**:
- `PageContentIndexingConnector` builds no document, block or bare, for
a page of a site users don't navigate, and a full reindex doesn't list
the content blocks of such a page.
- When such a page is updated through Layout (`layout.page.updated`,
`layout.page.permissions.updated`), `PageContentBlockIndexingListener`
removes every document the index holds for it, so the blocks indexed
before leave search.
- `MetadataServiceImpl#start` creates each plugin's metadata in a
transaction of its own: a plugin that fails is logged (WARN), and the
metadata of the others are created.
- Pinned by `PageContentIndexingConnectorTest` (a block of a template
page builds no document; a full reindex lists none),
`PageContentBlockIndexingListenerTest` (a template page update unindexes
its documents and reindexes none) and
`MetadataServiceTest#testStartCreatesTheMetadataOfTheOtherPluginsWhenOneFails`
(a real container: a metadata whose name is longer than its column, then
a valid one). Each pin was mutation-verified on the committed tree.

**Upgrade impact**:
- The content blocks of the pages of space templates, portal templates
and user sites (dashboards) leave search: on the next full reindex, or
when such a page is next updated through Layout. The bare page documents
of those sites were already not indexed.
- At startup, the metadata of the init plugins are created one plugin at
a time: a plugin that fails is logged under its own name, no longer
rolls back the metadata of the others, and, on the databases whose ids
come from a sequence, no longer fails the container start.

**Known limits**:
- A page of such a site saved outside Layout (a configuration re-import,
for instance) builds no document, and the indexing framework skips a
missing document instead of removing it: the blocks indexed before stay
until the next full reindex, or the next update of the page through
Layout.

**Release order**: merge it before, or with, Meeds-io/portal#1338 and
Meeds-io/layout#581. The metadata fix is needed by Meeds-io/portal#1338,
which would otherwise turn a failing metadata init plugin into a failed
start. The search fix is needed by Meeds-io/layout#581, whose space
template restore would otherwise index the template's content blocks.

**Tests**: `mvn -B -ntp -o clean verify javadoc:javadoc -Pcoverage -pl
component/core` at `c2106c8844`: BUILD SUCCESS, 1357 tests, 0 failures,
0 errors (`MetadataServiceTest` 32, `PageContentIndexingConnectorTest`
33, `PageContentBlockIndexingListenerTest` 11), against the local
`7.3.x-maintenance-SNAPSHOT` of Meeds-io/portal#1338, which carries its
`RollbackException`.

Knowledge: Meeds-io/eng-standards#409, #560, #586, #587

This change is classified N1 (proposed, recall-first) — the JPA
transaction boundary over the kernel DAO writes of a component's start,
whose failure fails the portal container start with
Meeds-io/portal#1338, and which permission-bearing page documents search
exposes. Its approver must be an Archi/Dev who knows it is N1, not an
approval on AI review alone; author ≠ approver.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
boubaker added a commit to Meeds-io/portal that referenced this pull request Oct 27, 2026
… pages EXO-91183 (#1357)

**Symptom 1**: after the built-in sites (`global`, `dw`, `myworkspace`)
are restored with their layout, a displayed site's pages can render
empty: the meta site was left with an empty layout.

**Symptom 2**: an overwrite restore of a site defined by several
configurations (`global` has six: multifactor-authentication, agenda,
documents, processes, sites, digital-workplace) logs, for every second
configuration (reproduced on a local server):

```
NewPortalConfig error: Error while committing the transaction [org.hibernate.TransientPropertyValueException: Persistent instance of 'org.exoplatform.portal.jdbc.entity.NodeEntity' references an unsaved transient instance of 'org.exoplatform.portal.jdbc.entity.PageEntity' (persist the transient instance before flushing) [org.exoplatform.portal.jdbc.entity.NodeEntity.page -> org.exoplatform.portal.jdbc.entity.PageEntity]]
Can't find Window with ids: ...
```

and leaves pages missing, pages whose layout names deleted windows, and
the `dw` site's People and Spaces nodes without a page.

**Cause 1**: `SiteStorageImpl.save` replaced a site's layout one kernel
DAO call at a time, each `@ExoTransactional` call committing on its own.
A concurrent reader could read the site row while its root container was
already deleted and get, and cache, an empty layout.
`UserPortalConfigService.restoreSite` then read the site again after the
import and saved that empty layout back over the restored one.

**Cause 2**: a restore imported each configuration of the site in turn
with the requested mode. In overwrite mode, each configuration deleted
every page of the site, the pages the previous configurations had just
imported included, and re-created its own pages with new identifiers:
the nodes of other sites that reference them lost their page
(`FK_PORTAL_NODE_PAGE` is `ON DELETE SET NULL`). Its page deletion was
refused at commit while the page windows were already deleted in their
own transactions. In overwrite and restore-defaults mode, each
configuration also deleted the nodes the previous ones had imported
under the nodes it defines. The startup import did the same for a
configuration in overwrite mode, at every start.

**Fix**:
- `@ContainerTransactional(atomic = true)` runs the method in one JPA
transaction, committed when it returns and rolled back when it throws;
when a failed call marked it rollback-only, a method that returned
throws `RollbackException`. Kernel EntityManager writes join it. When
the method begins it, Spring transactional methods and Spring Data
repositories join it too, and their callbacks run with its outcome.
`SiteStorageImpl` `create(PortalData)`, `save(PortalData)` and
`remove(SiteKey)` use it.
- The site, layout, page, navigation and description caches evict again
once the transaction completes (`TransactionCallbacks.afterCompletion`),
after invalidating the loads in flight; `FutureCache.clearFutures`
invalidates every load it removes.
- A site read whose root container is missing is read again once
(`SiteDAO#refresh`), then flagged `layoutUnresolved` with an empty
layout; a save of it keeps the stored layout.
- `restoreSite` applies the previous site's settings to the imported
config before its save, instead of saving the site a second time.
- A site restore (`UserPortalConfigService#restoreSite`), and the
startup import of a site that one of its configurations overwrites,
import the site from all its configurations
(`NewPortalConfigListener#importSitePages`, `#importSiteNavigation`): an
overwrite deletes the pages that no configuration defines, then the
pages of every configuration are imported in place; the navigation of
every configuration is imported without deleting any node, then
`NavigationOrphanRemover` deletes the nodes that none of them defines,
as the mode does (every such node in overwrite mode, the nodes nested in
a default node in restore-defaults mode, a node only in the path of a
fragment attachment point keeping its children). Nothing is deleted when
the pages or the navigation file of a configuration can't be parsed; a
file a configuration can't open counts as defining none. All pages are
imported before any navigation.
- A restore imports the pages of every configuration of the site,
whatever was imported before: the check of
`NewPortalConfigListener#initPageDB` (`createdOwners`, filled by the
startup import and by a site-layout restore) no longer applies to it.
- Each orphan page is removed on its own: a page that can't be removed,
as one removed since the orphans were selected, is logged (WARN) and the
next ones are removed.
- `PageStorageImpl` deletes a page, and replaces a page layout, in one
transaction, and broadcasts the page event once that write returns.
- A page delete, a page layout save, a page clone, a site save and a
site removal read their row again (`PageDAO#refresh`,
`SiteDAO#refresh`), and work from the layout that row holds. Before,
they worked from the page or site as this request first read it, for
instance through a node load, which reads the pages its nodes reference;
with Meeds-io/layout#581, a site restore reads the site's navigation
before it imports the pages. A layout another request saved since is now
removed with the page or site, replaced by a layout save, and copied by
a clone.
- `PageEntity` is `@DynamicUpdate`. A page row has two writers, its
properties save and its layout save, and no version: an update now
writes only the columns it changed, so a properties save no longer
writes back the page body it read. It reads its row again too, so each
property it saves is compared with the row as stored.
- A failing atomic call that joined an active kernel transaction marks
it rollback-only and logs a WARN naming the method.
- `ExoTransactionalAspect` rolls back and throws `RollbackException`
when the method it began a transaction for returns over a rollback-only
transaction, as `ContainerTransactionalAspect` already did for its own
transactions. A method that throws keeps its error, unless ending the
transaction fails, as before. The EntityManager it started is closed
whatever the method's outcome.
- At startup, a configuration whose site type or import mode is unknown
is left out of the overwritten sites; on a site no other configuration
overwrites, its own import logs the error as before.
- Pinned by `ContainerTransactionalAspectTest`,
`ExoTransactionalAspectTest`, `ContainerTransactionalRepositoryTest` (a
real Spring Data repository), `TransactionCallbacksTest`,
`FutureCacheTest`, `TestDataStorage` (a layout write and a page delete
failing midway through the woven advice, a node update evicting the node
cache after the commit), `SiteDAOTest`, `TestUserPortalConfigService`,
`TestRestoreSiteConfigurations` in the new `InitContainer10TestSuite` (a
restore and a startup import of sites defined by several configurations,
a node of another site keeping its page, a configuration of an unknown
site type, a pages restore of a site this startup didn't import, an
orphan page the request has read and another request saves a layout on
during the restore), `TestDataStorage` (a page layout save, a page
properties save, a page clone, a site save and a site removal after
another request saved a layout on the page or site the request had read,
an update of the page properties that leaves the layout column another
request wrote, and a properties save that writes back a property another
request changed since the request read the page),
`NewPortalConfigListenerTest` (an orphan page that can't be removed),
`TestNavigationFragmentImporterRestoreDefaults` (now run, in
`InitContainer1TestSuite`), `TestSiteDataImportOverwrite` and
`TestImportNavigationOverwrite`.

**Upgrade impact**:
- A page delete, a page layout or properties save, a page clone, and a
site save or removal read their row once more (a SELECT by id).
- A site whose stored body names a missing root container is kept, read
with an empty layout flagged unresolved (WARN) and cached so; a save of
it keeps the stored layout (WARN). A restore with layout, or a save that
sets a layout, repairs it.
- `restoreSite` makes one save per configuration that declares the site:
`PORTAL_CONFIG_UPDATED`, or `PORTAL_CONFIG_CREATED` for a site the
import creates, and none when the import skips the site.
- A write failure now rolls back the whole site create, save or remove.
Inside an enclosing kernel transaction, a failed site create, save or
remove, page delete or page layout save marks that transaction
rollback-only and logs a WARN naming the method.
- **An `@ExoTransactional` method that began the transaction and returns
over it marked rollback-only now throws
`jakarta.persistence.RollbackException`** after the rollback, instead of
returning with none of its writes committed. A method that joined an
active transaction doesn't throw: the method that began it does. The
transaction is marked by an atomic call above, or by a failing JPA
operation (every converted `PersistenceException` but
`NoResultException`, `NonUniqueResultException`, `LockTimeoutException`
and `QueryTimeoutException`). So any such method that catches the
failure and returns now fails visibly. The callers checked:
- `JPASettingServiceImpl#set` catches every exception of its body: a
`SettingService#set` call made outside a transaction now throws where it
logged an ERROR.
- social `LayoutUpgradePlugin#updateLayout`: the exception leaves
`processUpgrade`, the upgrades after it don't run, and
`UpgradeProductService` doesn't record the plugin as done. The whole
plugin runs again at every start, re-applying the upgrades before the
failing one, until that one passes. Before, the failing upgrade's writes
were rolled back and the plugin was recorded as done.
- social `MetadataServiceImpl#start`: a failing metadata init plugin
would fail the start of the portal container; Meeds-io/social#6245
creates each plugin's metadata in its own transaction.
- social `SocialUserEventListenerImpl#postDelete` throws, `UserDAOImpl`
logs it (WARN), and the user is deleted as before.
- gamification `ProgramSpaceListener` and commons
`JPAQueueMessageImpl#send` keep their outcome: the exception is dropped
by an unread future, or logged by the job (ERROR), and the writes are
rolled back as before.
- A kernel component whose `start()` makes such a call outside a
transaction fails its start (`ContainerException`), and the portal
container is left partly started.
- An overwrite restore with pages keeps the pages and nodes of every
configuration of the site; the pages it imports keep their identifier,
so the nodes of other sites keep their page. The pages and nodes that no
configuration defines are deleted once, after every import for the
nodes.
- A restore imports the site layout of every configuration, then their
pages, then their navigation: a node that references a page of a later
configuration of the site is linked to it.
- A restore-defaults restore with navigation keeps the nodes of every
configuration, whatever their order; a node nested in one configuration
that another one attaches a fragment to keeps its custom children.
- The search index of a restored site's pages is refreshed by
Meeds-io/layout#581. With this PR alone, an overwrite restore updates
the pages in place with the portal's page update event only: their
indexed blocks keep their content and permissions until the page is next
saved through Layout, or the next full reindex, as the restore-defaults
restore already did.
- When the navigation, or the pages, file of one configuration can't be
parsed, a restore deletes no node, or no page, of the site. A file a
configuration can't open counts as defining none: the pages and nodes
only it defines are deleted as orphans, as the previous whole-site
removal deleted every page.
- A pages restore without the site layout ("Restore pages") restores the
pages of a site that neither this start nor a site-layout restore since
then imported (after a restart, a site no configuration overrides);
before, it restored only its navigation. A REST restore with layout and
pages whose site layout import fails now still imports the pages.
- **Startup contract change**: when one configuration of a site is in
overwrite mode (an extension, or a property such as
`exo.agenda.portalConfig.metadata.importmode=overwrite`), every start
that runs that configuration (its `override` true, or a first start, or
a reimport) imports the pages and the navigation of all the
configurations of the site, whatever their own mode and `override`, then
deletes the pages and nodes that none of them defines. The pages created
in the UI in that site are among them, as before. So are the nodes
created in the UI under a node that one of its configurations defines or
attaches a fragment to, the root included when one attaches there, as
one configuration of every shipped site does; before, a start deleted
only those under the attachment points of the overwriting
configuration's own fragments. Each configuration's pages are saved from
its files in place (layout, state and permissions replaced), and its
navigation merged. An extension in overwrite mode no longer deletes the
pages and nodes the other configurations of the site define: they are
kept and the extension's own are merged over them. On a first start, or
a reimport, with an overwrite configuration, the result is the union of
the configurations, where the last overwrite used to win.
- The shipped configurations (meeds sites, digital-workplace, agenda,
documents, processes, multifactor-authentication) default to insert or
conserve: the change applies to a deployment that sets an overwrite
mode.
- A configuration of that site whose `pages.xml` or `navigation.xml`
can't be parsed: no page, or no node, is deleted.
- A configuration of that site with an unknown import mode: its pages
and navigation are imported in merge mode; its site layout import still
logs the mode.
  - The site layout import, and the shipped defaults, are unchanged.
- A page delete or a page layout save that fails midway rolls back its
windows and permissions; before, the page kept a layout naming deleted
windows (`Can't find Window with ids`).

**Known limits**:
- `PAGE_DESTROYED` is broadcast before the commit when the page removal
joins a site removal (`SiteStorageImpl#remove`); no production listener.
- A node write drops every navigation and node load in flight, for all
sites, before evicting; the after-completion repeat is pinned, the
in-flight invalidation is not.
- A page delete, layout save or clone, and a site save or removal read
their row without a lock: a layout another request commits between that
read and the write is missed.
- `PAGE_REMOVED` carries the page as `LayoutService#remove(PageKey)`
gets it, from the page cache or from the request's first read of the
page: it may lack a layout saved since, which the delete removes.
- A user site's pages are never deleted by the orphan removal, as the
page import never did (not reachable today: no user-type configuration
ships).
- The template clone (`savePortalFromTemplate`) keeps the unresolved
flag, and `create` ignores it.
- The in-flight load pin covers the site cache; the `clearFutures` race
fix is not pinned.
- The banner write of the `PortalConfig` overloads runs before, outside
the transaction.
- A reader that straddles the commit still misses containers: a missing
root is re-read once, then flagged; a missing nested container is
skipped.
- Spring: a Spring write inside an atomic method joined to an outer
non-atomic kernel transaction keeps its own transaction. With a Spring
synchronization already active and no EntityManager of its own, the
atomic transaction collects none of the Spring callbacks. A Spring
`@CacheEvict` evicts at once, before the commit. A Spring call made
while the commit flushes (an entity listener) does not join.

**Follow-ups**: the per-session `UIPortal` cache is not refreshed on a
meta-site layout change; `global` is displayed on community-dev but not
in the packaged configuration; nested container misses; an optional
guard in the QA restore hook (meeds-qa-ui#339); a Spring join inside a
non-atomic kernel transaction; the web-conferencing pins of task 91299
(exoplatform/web-conferencing#421).

**Release order**: Meeds-io/social#6245 before this PR, or with it:
without it, a failing metadata init plugin fails the start. This PR
before the `Meeds-io/layout` PR of EXO-91183, which refuses to hand on
an unresolved site's layout and compiles against
`PortalConfig#isLayoutUnresolved`.

**Tests**: at the head `65f8e9a240`, `mvn -B -ntp -o
-Dcheckstyle.skip=true -pl component/portal
-Dtest='TestDataStorage,TestModelStorage,TestPageService,TestUserPortalConfigService,TestRestoreSiteConfigurations,TestSiteDataImport*,TestImportNavigation*,TestImportFragment*,TestNavigationFragmentImporterRestoreDefaults,NewPortalConfigListenerTest'
-Dsurefire.failIfNoSpecifiedTests=false -Djacoco.skip=true test`: 18
classes, 148 tests, 0 failures, 0 errors. The full suite (`mvn -B -ntp
clean verify javadoc:javadoc -Pcoverage`) wasn't run at this head.

Knowledge: Meeds-io/eng-standards#409, #560, #586, #587

This change is classified N1 — Kernel ↔ Spring bridge (the atomic
`@ContainerTransactional` transaction that Spring transactional methods
and Spring Data repositories join), the outcome of every
`@ExoTransactional` method, the JPA transaction boundaries over the site
and page storage writes, the access and edit permissions a restored site
keeps, and the irreversible deletion of pages and navigation nodes by a
restore and by the startup import. Its approver must be an Archi/Dev who
knows it is N1, not an approval on AI review alone; author ≠ approver.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
boubaker added a commit to Meeds-io/layout that referenced this pull request Oct 27, 2026
…d a site restore refreshes its pages' search index EXO-91183 (#603)

**Symptom**: when the layout of a site couldn't be read, portal reads it
with an empty layout (Meeds-io/portal#1338). Opening that site in the
layout editor, creating a site from it as a template, or saving it as a
site template would copy the empty layout over a real one. Today,
creating the draft fails only by accident: a 500 from a
NullPointerException in `LayoutModel`.

**Cause**: `SiteLayoutService` `getSiteLayout`, `createDraftSite` and
`createSite`, and `SiteTemplateService.saveAsSiteTemplate`, hand on the
layout they read without checking that it was read.

**Fix**:
- These four methods throw `SiteLayoutUnresolvedException` for a site
read with `PortalConfig#isLayoutUnresolved`, before anything is written,
through one check (`SiteLayoutUnresolvedException#checkResolved`).
`createSite` checks the template it copies; the other three check the
site itself.
- `SiteLayoutRest` and `SiteTemplateRest` answer that refusal with 409
Conflict, on five endpoints: `GET` and `PUT` of `sites/layout` (the
update returns the site layout read again), `POST sites` (a site created
from a template), `POST sites/draft`, and the site template created from
a site. The layout editor and the site form show their error message,
and the site template drawer stays open. The other failures of these
requests keep their outcome.
- Tests in `SiteLayoutServiceTest`, `SiteTemplateServiceTest`,
`SiteLayoutRestTest` and `SiteTemplateRestTest`:
- the refusal, and that nothing is written: no draft removed, no site or
template created;
  - the resolved path;
- the 409 for the new exception, and no 409 for another
`IllegalStateException`.

**Search index after a site restore**: a restore saves the pages and
rebuilds the navigation of the site without the events of the editors.
Since Meeds-io/portal#1338 saves the pages in place, the portal's page
update event alone reaches the index, and it only queues the blocks the
index doesn't hold yet. So a block the restore removed stayed in search,
a block whose content it changed kept its old document, and a page whose
last node it removed kept its documents.
- After a restore of pages or navigation,
`SiteLayoutService#restoreSite` compares the pages a node of the site
leads to before and after the restore:
- a page a node of the site leads to after the restore gets
`layout.page.updated`, so its blocks and its path are reindexed, as
after a page save; for a page of a site users don't navigate (a site
template, a user site), Meeds-io/social#6245 removes its documents
instead;
- a page whose last node in the site was removed gets
`layout.page.unreachable`, so its documents are unindexed, as after a
node deletion;
- a page no node of the site leads to, before or after the restore
(reached, if at all, from another site, where the index resolves no path
for it either), gets `layout.page.updated` when the restore covers the
pages, and nothing otherwise.
- A failure to read the navigation, before or after the restore, logs a
WARN and leaves the restore done.
- `NavigationLayoutService#getReachablePages` holds the walk that the
node deletion already used.
- Tests in `SiteLayoutServiceTest` (each case above, a navigation-only
restore, both failures) and `NavigationLayoutServiceTest`.
- Cost: two loads of the site's navigation tree, and one event per page
of the site, per restore of pages or navigation.

**Known limits**:
- A restore checks which pages are still reached in the restored site's
navigation only: a page whose last node in its own site the restore
removes is unindexed, even when a node of another site still leads to
it, as after a node deletion.
- `SiteTemplateService.createSiteTemplate` copies the `basic` template
through portal's template clone, which keeps the flag.
- The site template export (`SiteTemplateDatabindPlugin`) writes the
empty layout into the archive.
- The site template drawer shows no message when its save fails.

**Release order**: merge after Meeds-io/portal#1338 and its published
`7.3.x-maintenance` snapshot. This PR compiles against
`PortalConfig#isLayoutUnresolved`, so its CI fails until then. Merge it
with or after Meeds-io/social#6245: without it, a space template's
"Restore default pages" indexes the template's content blocks.

**Tests**:
- `mvn -B -ntp -o clean verify javadoc:javadoc -Pcoverage -pl
layout-service` at `01d1b2bd`: BUILD SUCCESS, 388 tests, 0 failures, 0
errors.
- It compiled against a local `7.3.x-maintenance-SNAPSHOT` of
Meeds-io/portal#1338. No signature of #1338's `component/api` changed
since its first pushed commit: its later commits change only
`ExoTransactional`'s Javadoc there.

Knowledge: Meeds-io/eng-standards#409, #560, #586, #587

This change is classified N2 — a REST contract change (409 when a site's
layout couldn't be read, on the site layout read and update, the site
and draft site creation, and the site template created from a site) and
a cross-domain seam (a site restore broadcasts the page updated and page
unreachable events that social's search indexing consumes to reindex or
unindex the site's pages). Its approver must be an Archi/Dev who knows
it is N2, not an approval on AI review alone. It requires
Meeds-io/portal#1338 (N1) to merge first.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants