Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -122,24 +122,37 @@ open class DocumentOpenSearchReindexService(
try {
while (!cancelRequested) {
val cursor = lastId
val batch = txTemplate.execute {
fetchBatch(scope, cursor, pageSize).also { entityManager.clear() }
} ?: break
if (batch.isEmpty()) break

val docs = batch.mapNotNull { jpaDoc ->
try {
converter.toOsDocument(jpaDoc)
} catch (e: Exception) {
skipped++
logger.warn(e) { "Failed to convert document — skipping" }
// Convert inside the read transaction, while the entities are still attached: toOsDocument
// serializes lazy associations (e.g. the caseTags @ManyToMany) that would otherwise throw a
// LazyInitializationException once the persistence context is cleared and the transaction closes.
// Only the OpenSearch bulk write below happens outside the transaction.
val page = txTemplate.execute {

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.

not the right PR but lgtm

val batch = fetchBatch(scope, cursor, pageSize)
if (batch.isEmpty()) {
null
} else {
val docs = batch.mapNotNull { jpaDoc ->
try {
converter.toOsDocument(jpaDoc)
} catch (e: Exception) {
skipped++
logger.warn(e) { "Failed to convert document — skipping" }
null
}
}
// Advance the cursor past every fetched row (not just the converted ones) so a document
// that fails conversion is skipped for good rather than re-fetched on the next iteration.
val lastInBatch = batch.last().id().id
entityManager.clear()
ConvertedPage(docs, lastInBatch)
}
}
skipped += docs.chunked(JsonSchemaDocumentOsConverter.BULK_CHUNK_SIZE).sumOf { converter.indexChunk(it) }
} ?: break

processed += docs.size
lastId = batch.last().id().id
skipped += page.documents.chunked(JsonSchemaDocumentOsConverter.BULK_CHUNK_SIZE)
.sumOf { converter.indexChunk(it) }

processed += page.documents.size
lastId = page.lastId
runService.recordProgress(runId, lastId, processed, skipped)
}

Expand Down Expand Up @@ -319,6 +332,9 @@ open class DocumentOpenSearchReindexService(
}
}

/** A converted page of documents plus the primary key of the last fetched row (the next keyset cursor). */
private data class ConvertedPage(val documents: List<JsonSchemaDocumentOsDocument>, val lastId: UUID)

companion object {
private val logger = KotlinLogging.logger {}
private val OS_DATE_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import org.hibernate.Hibernate;
import org.hibernate.annotations.BatchSize;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.Type;
import org.slf4j.Logger;
Expand Down Expand Up @@ -131,7 +133,8 @@ public class JsonSchemaDocument extends AbstractAggregateRoot<JsonSchemaDocument
@JoinColumn(name = "internal_case_status_key", referencedColumnName = "internal_case_status_key")
private InternalCaseStatus internalStatus;

@ManyToMany(fetch = FetchType.EAGER)
@ManyToMany
@BatchSize(size = 100)
@JoinTable(
name = "case_tag_link",
joinColumns = @JoinColumn(name = "json_schema_document_id", referencedColumnName = "json_schema_document_id"),
Expand Down Expand Up @@ -375,6 +378,18 @@ public void removeCaseTag(CaseTag caseTag) {
this.caseTags.remove(caseTag);
}

/**
* Forces the lazily-loaded {@code caseTags} collection to be initialized.
*
* <p>Since {@code caseTags} is fetched lazily, it must be initialized while the Hibernate session is still
* open (i.e. within the transactional read path) to avoid a {@link org.hibernate.LazyInitializationException}
* when the document is later serialized outside of a session (open-in-view is disabled). The {@code @BatchSize}
* on the collection ensures that initializing a page of documents is batched into few queries instead of N+1.</p>
*/
public void initializeCaseTags() {
Hibernate.initialize(caseTags);
}

@Override
public JsonSchemaDocumentId id() {
return id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,11 @@ private Page<JsonSchemaDocument> search(QueryWhereBuilder queryWhereBuilder, Pag
}

List<JsonSchemaDocument> documents = typedQuery.getResultList();

// Initialize the lazy caseTags collections within the transaction (batched via @BatchSize)
// so they are available when the documents are serialized outside of the session.
documents.forEach(JsonSchemaDocument::initializeCaseTags);

outboxService.send(() ->
new DocumentsListed(
objectMapper.valueToTree(documents)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ public Optional<JsonSchemaDocument> findBy(
)
);

// Initialize the lazy caseTags collection within the transaction so it is available
// when the document is serialized outside of the session (open-in-view is disabled).
document.initializeCaseTags();

outboxService.send(() ->
new DocumentViewed(
document.id().toString(),
Expand Down Expand Up @@ -241,6 +245,10 @@ public Page<JsonSchemaDocument> getAllByDocumentDefinitionName(
Page<JsonSchemaDocument> documentPage = documentRepository.findAll(
spec.and(byDocumentDefinitionIdName(definitionName)), pageable);

// Initialize the lazy caseTags collections within the transaction (batched via @BatchSize)
// so they are available when the documents are serialized outside of the session.
documentPage.forEach(JsonSchemaDocument::initializeCaseTags);

outboxService.send(() ->
new DocumentsListed(
objectMapper.valueToTree(documentPage.getContent())
Expand Down Expand Up @@ -268,6 +276,10 @@ public Page<JsonSchemaDocument> getAll(Pageable pageable) {
));
Page<JsonSchemaDocument> documentPage = documentRepository.findAll(spec, pageable);

// Initialize the lazy caseTags collections within the transaction (batched via @BatchSize)
// so they are available when the documents are serialized outside of the session.
documentPage.forEach(JsonSchemaDocument::initializeCaseTags);

outboxService.send(() ->
new DocumentsListed(
objectMapper.valueToTree(documentPage.getContent())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/*
* Copyright 2015-2026 Ritense BV, the Netherlands.
*
* Licensed under EUPL, Version 1.2 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.ritense.document.web.rest;

import static com.ritense.authorization.AuthorizationContext.runWithoutAuthorization;
import static org.hamcrest.Matchers.hasItem;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.ritense.BaseIntegrationTest;
import com.ritense.document.domain.CaseTagColor;
import com.ritense.document.domain.impl.JsonDocumentContent;
import com.ritense.document.domain.impl.JsonSchemaDocument;
import com.ritense.document.domain.impl.request.NewDocumentRequest;
import com.ritense.document.service.CaseTagService;
import com.ritense.document.service.impl.SearchRequest;
import com.ritense.document.web.rest.dto.CaseTagCreateRequestDto;
import com.ritense.document.web.rest.impl.JsonSchemaDocumentResource;
import com.ritense.document.web.rest.impl.JsonSchemaDocumentSearchResource;
import com.ritense.outbox.OutboxService;
import com.ritense.valtimo.contract.case_.CaseDefinitionId;
import java.util.UUID;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.web.PageableHandlerMethodArgumentResolver;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

/**
* Regression test for the lazy {@code JsonSchemaDocument.caseTags} collection.
*
* <p>{@code caseTags} is fetched lazily and is exposed as a {@code @JsonProperty} on the {@code Document}
* interface, so it is serialized in the document REST responses. Because {@code spring.jpa.open-in-view} is
* disabled, no Hibernate session is open while the MVC layer serializes the response — the read paths must
* therefore initialize {@code caseTags} within their transaction, or serialization fails with a
* {@link org.hibernate.LazyInitializationException}.</p>
*
* <p>Two things make this a real guard for the read-path initialization:
* <ul>
* <li>The test is intentionally <b>NOT</b> {@code @Transactional}: a test-managed transaction would keep
* the session open during serialization and hide exactly the failure we are guarding against.</li>
* <li>{@link OutboxService} is replaced with a plain mock, so the outbox's in-transaction
* {@code valueToTree(document)} side effect (which would otherwise initialize {@code caseTags} for us)
* never runs. The read-path initialization is then the only thing that loads {@code caseTags}.</li>
* </ul>
* With the eager fetch removed, this test fails unless the read paths explicitly initialize the collection.</p>
*/
class JsonSchemaDocumentCaseTagsSerializationIntegrationTest extends BaseIntegrationTest {

private static final String USER_EMAIL = "user@valtimo.nl";
private static final CaseDefinitionId CASE_DEFINITION_ID = CaseDefinitionId.of("house", "1.1.0");

// Plain mock (not the parent's spy): its send() does nothing, so the outbox never initializes caseTags.
@MockitoBean
private OutboxService outboxService;

@Autowired
private ObjectMapper objectMapper;

@Autowired
private CaseTagService caseTagService;

private MockMvc documentMockMvc;
private MockMvc searchMockMvc;

private JsonSchemaDocument document;
private String tagKey;

@BeforeEach
void setUp() {
tagKey = "regression-tag-" + UUID.randomUUID();

runWithoutAuthorization(() -> caseTagService.create(
CASE_DEFINITION_ID,
new CaseTagCreateRequestDto(tagKey, "Regression Tag", CaseTagColor.MAGENTA)
));

var definition = definition();
var content = new JsonDocumentContent("{\"street\": \"Regression case tags street\"}");
document = runWithoutAuthorization(() ->
documentService.createDocument(
new NewDocumentRequest(
definition.id().name(),
definition.id().caseDefinitionId().getKey(),
definition.id().caseDefinitionId().getVersionTag().getVersion(),
content.asJson()
)
).resultingDocument().orElseThrow()
);

runWithoutAuthorization(() -> {
documentService.addCaseTag(document.id(), tagKey);
return null;
});

documentMockMvc = MockMvcBuilders
.standaloneSetup(new JsonSchemaDocumentResource(documentService))
.setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver())
.setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper))
.build();

searchMockMvc = MockMvcBuilders
.standaloneSetup(new JsonSchemaDocumentSearchResource(documentSearchService))
.setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver())
.setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper))
.build();
}

@AfterEach
void tearDown() {
// Non-transactional test: clean up the committed document and tag so other tests are unaffected.
runWithoutAuthorization(() -> {
if (document != null) {
try {
documentService.deleteDocument(document.id());
} catch (RuntimeException e) {
documentRepository.deleteById(document.id());
}
}
if (tagKey != null) {
try {
caseTagService.delete(CASE_DEFINITION_ID, tagKey);
} catch (RuntimeException ignored) {
// best-effort cleanup
}
}
return null;
});
}

@Test
@WithMockUser(username = USER_EMAIL, authorities = {FULL_ACCESS_ROLE})
void shouldSerializeCaseTagsWhenGettingDocumentOutsideOfSession() throws Exception {
documentMockMvc.perform(get("/api/v1/document/{documentId}", document.id().getId().toString()))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.caseTags").isArray())
.andExpect(jsonPath("$.caseTags.length()").value(1))
.andExpect(jsonPath("$.caseTags[0].key").value(tagKey))
.andExpect(jsonPath("$.caseTags[0].title").value("Regression Tag"))
.andExpect(jsonPath("$.caseTags[0].color").value("MAGENTA"))
.andExpect(jsonPath("$.caseTags[0].caseDefinitionKey").value("house"));
}

@Test
@WithMockUser(username = USER_EMAIL, authorities = {FULL_ACCESS_ROLE})
void shouldSerializeCaseTagsWhenSearchingDocumentsOutsideOfSession() throws Exception {
var searchRequest = new SearchRequest();
searchRequest.setDocumentDefinitionName("house");

searchMockMvc.perform(
post("/api/v1/document-search")
.param("size", "2000")
.content(objectMapper.writeValueAsBytes(searchRequest))
.contentType(MediaType.APPLICATION_JSON_VALUE))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[*].caseTags[*].key", hasItem(tagKey)));
}
}
5 changes: 5 additions & 0 deletions documentation/release-notes/13.x.x/13.38.0/README.md

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.

13.38.0 has been release already

Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@

## Enhancements

* **Faster case lists and searches**

Case lists and document searches now load only the page of cases being shown, instead of fetching all matching
cases at once.

* **Case start menu updates automatically when process availability changes**

The start menu on the case detail page now keeps its list of startable supporting processes in sync
Expand Down
Loading