View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.helper;
17  
18  import java.util.ArrayList;
19  import java.util.List;
20  import java.util.Map;
21  
22  import org.apache.logging.log4j.LogManager;
23  import org.apache.logging.log4j.Logger;
24  import org.apache.lucene.search.TotalHits;
25  import org.codelibs.fess.mylasta.direction.FessConfig;
26  import org.codelibs.fess.opensearch.client.SearchEngineClient;
27  import org.codelibs.fess.opensearch.client.SearchEngineClientException;
28  import org.codelibs.fess.thumbnail.ThumbnailManager;
29  import org.codelibs.fess.util.ComponentUtil;
30  import org.codelibs.fess.util.DocList;
31  import org.codelibs.fess.util.MemoryUtil;
32  import org.opensearch.action.admin.indices.refresh.RefreshResponse;
33  import org.opensearch.action.bulk.BulkItemResponse;
34  import org.opensearch.action.bulk.BulkItemResponse.Failure;
35  import org.opensearch.action.bulk.BulkResponse;
36  import org.opensearch.action.search.SearchResponse;
37  import org.opensearch.index.query.QueryBuilder;
38  import org.opensearch.index.query.QueryBuilders;
39  
40  /**
41   * Helper class for indexing operations in the Fess search engine.
42   * This class provides functionality for sending documents to the search engine,
43   * managing document lifecycle operations (create, update, delete), and handling
44   * thumbnail processing during indexing.
45   *
46   * <p>The IndexingHelper manages bulk operations, handles retries on failures,
47   * and provides various query-based operations for document management.
48   * It also integrates with the thumbnail generation system and handles
49   * the cleanup of old documents during updates.</p>
50   */
51  public class IndexingHelper {
52      /** Logger for this class */
53      private static final Logger logger = LogManager.getLogger(IndexingHelper.class);
54  
55      /** Maximum number of retry attempts for failed operations */
56      protected int maxRetryCount = 5;
57  
58      /** Default number of rows to process in a single batch */
59      protected int defaultRowSize = 100;
60  
61      /**
62       * Default constructor for indexing helper.
63       * Creates a new instance with default values.
64       */
65      public IndexingHelper() {
66          // Default constructor
67      }
68  
69      /** Interval between requests in milliseconds */
70      protected long requestInterval = 500;
71  
72      /**
73       * Sends a list of documents to the search engine for indexing.
74       * This method handles thumbnail processing, deletes old documents with the same URL,
75       * and performs bulk indexing operations with proper error handling.
76       *
77       * @param searchEngineClient the search engine client to use for indexing
78       * @param docList the list of documents to be indexed
79       * @throws SearchEngineClientException if the bulk indexing operation fails
80       */
81      public void sendDocuments(final SearchEngineClient searchEngineClient, final DocList docList) {
82          if (docList.isEmpty()) {
83              return;
84          }
85          final FessConfig fessConfig = ComponentUtil.getFessConfig();
86          final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
87          final long execTime = systemHelper.getCurrentTimeAsLong();
88          if (logger.isDebugEnabled()) {
89              logger.debug("Sending {} documents to a server.", docList.size());
90          }
91          try {
92              if (fessConfig.isThumbnailCrawlerEnabled()) {
93                  final ThumbnailManager thumbnailManager = ComponentUtil.getThumbnailManager();
94                  final String thumbnailField = fessConfig.getIndexFieldThumbnail();
95                  docList.stream().forEach(doc -> {
96                      if (!thumbnailManager.offer(doc)) {
97                          if (logger.isDebugEnabled()) {
98                              logger.debug("Removing {}={} from doc[{}]", thumbnailField, doc.get(thumbnailField),
99                                      doc.get(fessConfig.getIndexFieldUrl()));
100                         }
101                         doc.remove(thumbnailField);
102                     }
103                 });
104             }
105             final CrawlingConfigHelper crawlingConfigHelper = ComponentUtil.getCrawlingConfigHelper();
106             synchronized (searchEngineClient) {
107                 final long deletedDocCount = deleteOldDocuments(searchEngineClient, docList);
108                 if (logger.isDebugEnabled()) {
109                     logger.debug("Deleted {} stale documents", deletedDocCount);
110                 }
111                 final BulkResponse response =
112                         searchEngineClient.addAll(fessConfig.getIndexDocumentUpdateIndex(), docList, (doc, builder) -> {
113                             final String configId = (String) doc.get(fessConfig.getIndexFieldConfigId());
114                             crawlingConfigHelper.getPipeline(configId).ifPresent(s -> builder.setPipeline(s));
115                         });
116                 if (response.hasFailures()) {
117                     if (logger.isDebugEnabled()) {
118                         final BulkItemResponse[] items = response.getItems();
119                         if (docList.size() == items.length) {
120                             for (int i = 0; i < docList.size(); i++) {
121                                 final BulkItemResponse resp = items[i];
122                                 if (resp.isFailed() && resp.getFailure() != null) {
123                                     final Map<String, Object> req = docList.get(i);
124                                     final Failure failure = resp.getFailure();
125                                     logger.debug("Failed Request: {}\n=>{}", req, failure.getMessage());
126                                 }
127                             }
128                         }
129                     }
130                     throw new SearchEngineClientException(response.buildFailureMessage());
131                 }
132             }
133             if (logger.isInfoEnabled()) {
134                 if (docList.getContentSize() > 0) {
135                     logger.info("Sent {} documents (process={}ms, send={}ms, size={}, {})", docList.size(), docList.getProcessingTime(),
136                             systemHelper.getCurrentTimeAsLong() - execTime, MemoryUtil.byteCountToDisplaySize(docList.getContentSize()),
137                             MemoryUtil.getMemoryUsageLog());
138                 } else {
139                     logger.info("Sent {} documents (send={}ms, {})", docList.size(), systemHelper.getCurrentTimeAsLong() - execTime,
140                             MemoryUtil.getMemoryUsageLog());
141                 }
142             }
143         } finally {
144             docList.clear();
145         }
146     }
147 
148     /**
149      * Deletes old documents that have the same URL but different document IDs
150      * as the documents in the provided list. This prevents duplicate documents
151      * from accumulating in the index.
152      *
153      * @param searchEngineClient the search engine client to use for deletion
154      * @param docList the list of new documents to check against
155      * @return the number of old documents that were deleted
156      */
157     protected long deleteOldDocuments(final SearchEngineClient searchEngineClient, final DocList docList) {
158         final FessConfig fessConfig = ComponentUtil.getFessConfig();
159 
160         final List<String> docIdList = new ArrayList<>();
161         for (final Map<String, Object> inputDoc : docList) {
162             final Object idValue = inputDoc.get(fessConfig.getIndexFieldId());
163             if (idValue == null) {
164                 continue;
165             }
166 
167             final Object configIdValue = inputDoc.get(fessConfig.getIndexFieldConfigId());
168             if (configIdValue == null) {
169                 continue;
170             }
171 
172             final QueryBuilder queryBuilder = QueryBuilders.boolQuery()
173                     .must(QueryBuilders.termQuery(fessConfig.getIndexFieldUrl(), inputDoc.get(fessConfig.getIndexFieldUrl())))
174                     .filter(QueryBuilders.termQuery(fessConfig.getIndexFieldConfigId(), configIdValue));
175 
176             final List<Map<String, Object>> docs = getDocumentListByQuery(searchEngineClient, queryBuilder,
177                     new String[] { fessConfig.getIndexFieldId(), fessConfig.getIndexFieldDocId() });
178             for (final Map<String, Object> doc : docs) {
179                 final Object oldIdValue = doc.get(fessConfig.getIndexFieldId());
180                 if (oldIdValue != null && !idValue.equals(oldIdValue)) {
181                     final Object oldDocIdValue = doc.get(fessConfig.getIndexFieldDocId());
182                     if (oldDocIdValue != null) {
183                         docIdList.add(oldDocIdValue.toString());
184                     }
185                 }
186             }
187             if (logger.isDebugEnabled()) {
188                 logger.debug("{} => {}", queryBuilder, docs);
189             }
190         }
191         if (!docIdList.isEmpty()) {
192             return deleteDocumentByQuery(searchEngineClient, fessConfig.getIndexDocumentUpdateIndex(),
193                     QueryBuilders.termsQuery(fessConfig.getIndexFieldDocId(), docIdList.stream().toArray(n -> new String[n])));
194         }
195         return 0L;
196     }
197 
198     /**
199      * Updates a specific field of a document in the search index.
200      *
201      * @param searchEngineClient the search engine client to use for the update
202      * @param id the document ID to update
203      * @param field the field name to update
204      * @param value the new value for the field
205      * @return true if the update was successful, false otherwise
206      */
207     public boolean updateDocument(final SearchEngineClient searchEngineClient, final String id, final String field, final Object value) {
208         final FessConfig fessConfig = ComponentUtil.getFessConfig();
209         return searchEngineClient.update(fessConfig.getIndexDocumentUpdateIndex(), id, field, value);
210     }
211 
212     /**
213      * Deletes a document from the search index by its ID.
214      *
215      * @param searchEngineClient the search engine client to use for deletion
216      * @param id the document ID to delete
217      * @return true if the deletion was successful, false otherwise
218      */
219     public boolean deleteDocument(final SearchEngineClient searchEngineClient, final String id) {
220         final FessConfig fessConfig = ComponentUtil.getFessConfig();
221         return searchEngineClient.delete(fessConfig.getIndexDocumentUpdateIndex(), id);
222     }
223 
224     /**
225      * Deletes all documents that match the specified URL.
226      *
227      * @param searchEngineClient the search engine client to use for deletion
228      * @param url the URL to match for document deletion
229      * @return the number of documents that were deleted
230      */
231     public long deleteDocumentByUrl(final SearchEngineClient searchEngineClient, final String url) {
232         final FessConfig fessConfig = ComponentUtil.getFessConfig();
233         return deleteDocumentByQuery(searchEngineClient, fessConfig.getIndexDocumentUpdateIndex(),
234                 QueryBuilders.termQuery(fessConfig.getIndexFieldUrl(), url));
235     }
236 
237     /**
238      * Deletes all documents that match the specified document IDs.
239      *
240      * @param searchEngineClient the search engine client to use for deletion
241      * @param docIdList the list of document IDs to delete
242      * @return the number of documents that were deleted
243      */
244     public long deleteDocumentsByDocId(final SearchEngineClient searchEngineClient, final List<String> docIdList) {
245         final FessConfig fessConfig = ComponentUtil.getFessConfig();
246         return deleteDocumentByQuery(searchEngineClient, fessConfig.getIndexDocumentUpdateIndex(),
247                 QueryBuilders.termsQuery(fessConfig.getIndexFieldDocId(), docIdList.stream().toArray(n -> new String[n])));
248     }
249 
250     /**
251      * Deletes all documents that match the specified query from the default update index.
252      *
253      * @param searchEngineClient the search engine client to use for deletion
254      * @param queryBuilder the query to match documents for deletion
255      * @return the number of documents that were deleted
256      */
257     public long deleteDocumentByQuery(final SearchEngineClient searchEngineClient, final QueryBuilder queryBuilder) {
258         final FessConfig fessConfig = ComponentUtil.getFessConfig();
259         return deleteDocumentByQuery(searchEngineClient, fessConfig.getIndexDocumentUpdateIndex(), queryBuilder);
260     }
261 
262     /**
263      * Deletes all documents that match the specified query from the specified index.
264      *
265      * @param searchEngineClient the search engine client to use for deletion
266      * @param index the index name to delete documents from
267      * @param queryBuilder the query to match documents for deletion
268      * @return the number of documents that were deleted
269      */
270     protected long deleteDocumentByQuery(final SearchEngineClient searchEngineClient, final String index, final QueryBuilder queryBuilder) {
271         return searchEngineClient.deleteByQuery(index, queryBuilder);
272     }
273 
274     /**
275      * Retrieves a document from the search index by its ID.
276      *
277      * @param searchEngineClient the search engine client to use for retrieval
278      * @param id the document ID to retrieve
279      * @param fields the fields to include in the response (null for all fields)
280      * @return the document as a map of field names to values, or null if not found
281      */
282     public Map<String, Object> getDocument(final SearchEngineClient searchEngineClient, final String id, final String[] fields) {
283         final FessConfig fessConfig = ComponentUtil.getFessConfig();
284         return searchEngineClient.getDocument(fessConfig.getIndexDocumentUpdateIndex(), builder -> {
285             builder.setQuery(QueryBuilders.idsQuery().addIds(id));
286             builder.setFetchSource(fields, null);
287             return true;
288         }).orElse(null);
289     }
290 
291     /**
292      * Retrieves a list of documents whose IDs start with the specified prefix.
293      *
294      * @param searchEngineClient the search engine client to use for retrieval
295      * @param id the ID prefix to match documents
296      * @param fields the fields to include in the response (null for all fields)
297      * @return a list of documents that match the prefix
298      */
299     public List<Map<String, Object>> getDocumentListByPrefixId(final SearchEngineClient searchEngineClient, final String id,
300             final String[] fields) {
301         final FessConfig fessConfig = ComponentUtil.getFessConfig();
302         final QueryBuilder queryBuilder = QueryBuilders.prefixQuery(fessConfig.getIndexFieldId(), id);
303         return getDocumentListByQuery(searchEngineClient, queryBuilder, fields);
304     }
305 
306     /**
307      * Deletes all child documents that belong to the specified parent document.
308      *
309      * @param searchEngineClient the search engine client to use for deletion
310      * @param id the parent document ID whose children should be deleted
311      * @return the number of child documents that were deleted
312      */
313     public long deleteChildDocument(final SearchEngineClient searchEngineClient, final String id) {
314         final FessConfig fessConfig = ComponentUtil.getFessConfig();
315         return searchEngineClient.deleteByQuery(fessConfig.getIndexDocumentUpdateIndex(),
316                 QueryBuilders.termQuery(fessConfig.getIndexFieldParentId(), id));
317     }
318 
319     /**
320      * Retrieves all child documents that belong to the specified parent document.
321      *
322      * @param searchEngineClient the search engine client to use for retrieval
323      * @param id the parent document ID whose children should be retrieved
324      * @param fields the fields to include in the response (null for all fields)
325      * @return a list of child documents
326      */
327     public List<Map<String, Object>> getChildDocumentList(final SearchEngineClient searchEngineClient, final String id,
328             final String[] fields) {
329         final FessConfig fessConfig = ComponentUtil.getFessConfig();
330         final QueryBuilder queryBuilder = QueryBuilders.termQuery(fessConfig.getIndexFieldParentId(), id);
331         return getDocumentListByQuery(searchEngineClient, queryBuilder, fields);
332     }
333 
334     /**
335      * Retrieves a list of documents that match the specified query.
336      * This method handles large result sets by using scroll search when necessary
337      * and enforces maximum document size limits.
338      *
339      * @param searchEngineClient the search engine client to use for retrieval
340      * @param queryBuilder the query to match documents
341      * @param fields the fields to include in the response (null for all fields)
342      * @return a list of documents that match the query
343      */
344     protected List<Map<String, Object>> getDocumentListByQuery(final SearchEngineClient searchEngineClient, final QueryBuilder queryBuilder,
345             final String[] fields) {
346         final FessConfig fessConfig = ComponentUtil.getFessConfig();
347 
348         final long numFound = getDocumentSizeByQuery(searchEngineClient, queryBuilder, fessConfig);
349         final long maxSearchDocSize = fessConfig.getIndexerMaxSearchDocSizeAsInteger().longValue();
350         final boolean exceeded = numFound > maxSearchDocSize;
351         if (exceeded) {
352             logger.warn("Max search document size exceeded: found={}, limit={}. query={}", numFound,
353                     fessConfig.getIndexerMaxSearchDocSize(), queryBuilder);
354         }
355 
356         if (numFound > fessConfig.getIndexerMaxResultWindowSizeAsInteger().longValue()) {
357             final List<Map<String, Object>> entityList = new ArrayList<>(Long.valueOf(numFound).intValue());
358             searchEngineClient.scrollSearch(fessConfig.getIndexDocumentUpdateIndex(), requestBuilder -> {
359                 requestBuilder.setQuery(queryBuilder).setSize((int) numFound);
360                 if (fields != null) {
361                     requestBuilder.setFetchSource(fields, null);
362                 }
363                 return true;
364             }, entity -> {
365                 entityList.add(entity);
366                 return entityList.size() <= (exceeded ? maxSearchDocSize : numFound);
367             });
368             return entityList;
369         }
370         return searchEngineClient.getDocumentList(fessConfig.getIndexDocumentUpdateIndex(), requestBuilder -> {
371             requestBuilder.setQuery(queryBuilder).setSize((int) numFound);
372             if (fields != null) {
373                 requestBuilder.setFetchSource(fields, null);
374             }
375             return true;
376         });
377     }
378 
379     /**
380      * Gets the total number of documents that match the specified query.
381      *
382      * @param searchEngineClient the search engine client to use for the count
383      * @param queryBuilder the query to count documents for
384      * @param fessConfig the Fess configuration
385      * @return the number of documents that match the query
386      */
387     protected long getDocumentSizeByQuery(final SearchEngineClient searchEngineClient, final QueryBuilder queryBuilder,
388             final FessConfig fessConfig) {
389         final SearchResponse countResponse = searchEngineClient.prepareSearch(fessConfig.getIndexDocumentUpdateIndex())
390                 .setQuery(queryBuilder)
391                 .setSize(0)
392                 .setTrackTotalHits(true)
393                 .execute()
394                 .actionGet(fessConfig.getIndexSearchTimeout());
395         final TotalHits totalHits = countResponse.getHits().getTotalHits();
396         if (totalHits != null) {
397             return totalHits.value();
398         }
399         return 0;
400     }
401 
402     /**
403      * Deletes all documents associated with the specified session ID.
404      *
405      * @param sessionId the session ID to delete documents for
406      * @return the number of documents that were deleted
407      */
408     public long deleteBySessionId(final String sessionId) {
409         final SearchEngineClient searchEngineClient = ComponentUtil.getSearchEngineClient();
410         final FessConfig fessConfig = ComponentUtil.getFessConfig();
411         final String index = fessConfig.getIndexDocumentUpdateIndex();
412         return deleteBySessionId(searchEngineClient, index, sessionId);
413     }
414 
415     /**
416      * Deletes all documents associated with the specified session ID from the given index.
417      *
418      * @param searchEngineClient the search engine client to use for deletion
419      * @param index the index name to delete documents from
420      * @param sessionId the session ID to delete documents for
421      * @return the number of documents that were deleted
422      */
423     public long deleteBySessionId(final SearchEngineClient searchEngineClient, final String index, final String sessionId) {
424         final FessConfig fessConfig = ComponentUtil.getFessConfig();
425         final QueryBuilder queryBuilder = QueryBuilders.termQuery(fessConfig.getIndexFieldSegment(), sessionId);
426         return deleteByQueryBuilder(searchEngineClient, index, queryBuilder);
427     }
428 
429     /**
430      * Deletes all documents associated with the specified configuration ID.
431      *
432      * @param configId the configuration ID to delete documents for
433      * @return the number of documents that were deleted
434      */
435     public long deleteByConfigId(final String configId) {
436         final SearchEngineClient searchEngineClient = ComponentUtil.getSearchEngineClient();
437         final FessConfig fessConfig = ComponentUtil.getFessConfig();
438         final String index = fessConfig.getIndexDocumentUpdateIndex();
439         return deleteByConfigId(searchEngineClient, index, configId);
440     }
441 
442     /**
443      * Deletes all documents associated with the specified configuration ID from the given index.
444      *
445      * @param searchEngineClient the search engine client to use for deletion
446      * @param index the index name to delete documents from
447      * @param configId the configuration ID to delete documents for
448      * @return the number of documents that were deleted
449      */
450     public long deleteByConfigId(final SearchEngineClient searchEngineClient, final String index, final String configId) {
451         final FessConfig fessConfig = ComponentUtil.getFessConfig();
452         final QueryBuilder queryBuilder = QueryBuilders.termQuery(fessConfig.getIndexFieldConfigId(), configId);
453         return deleteByQueryBuilder(searchEngineClient, index, queryBuilder);
454     }
455 
456     /**
457      * Deletes all documents associated with the specified virtual host.
458      *
459      * @param virtualHost the virtual host to delete documents for
460      * @return the number of documents that were deleted
461      */
462     public long deleteByVirtualHost(final String virtualHost) {
463         final SearchEngineClient searchEngineClient = ComponentUtil.getSearchEngineClient();
464         final FessConfig fessConfig = ComponentUtil.getFessConfig();
465         final String index = fessConfig.getIndexDocumentUpdateIndex();
466         return deleteByVirtualHost(searchEngineClient, index, virtualHost);
467     }
468 
469     /**
470      * Deletes all documents associated with the specified virtual host from the given index.
471      *
472      * @param searchEngineClient the search engine client to use for deletion
473      * @param index the index name to delete documents from
474      * @param virtualHost the virtual host to delete documents for
475      * @return the number of documents that were deleted
476      */
477     public long deleteByVirtualHost(final SearchEngineClient searchEngineClient, final String index, final String virtualHost) {
478         final FessConfig fessConfig = ComponentUtil.getFessConfig();
479         final QueryBuilder queryBuilder = QueryBuilders.termQuery(fessConfig.getIndexFieldVirtualHost(), virtualHost);
480         return deleteByQueryBuilder(searchEngineClient, index, queryBuilder);
481     }
482 
483     /**
484      * Deletes documents using the specified query builder and refreshes the index.
485      * This method first refreshes the index, then performs the deletion, and logs the result.
486      *
487      * @param searchEngineClient the search engine client to use for deletion
488      * @param index the index name to delete documents from
489      * @param queryBuilder the query to match documents for deletion
490      * @return the number of documents that were deleted
491      */
492     protected long deleteByQueryBuilder(final SearchEngineClient searchEngineClient, final String index, final QueryBuilder queryBuilder) {
493         refreshIndex(searchEngineClient, index);
494         final long numOfDeleted = searchEngineClient.deleteByQuery(index, queryBuilder);
495         if (logger.isDebugEnabled()) {
496             logger.debug("Deleted {} stale documents.", numOfDeleted);
497         }
498         return numOfDeleted;
499     }
500 
501     /**
502      * Refreshes the specified index to make recent changes visible for search.
503      * This operation ensures that all pending writes are persisted and searchable.
504      *
505      * @param searchEngineClient the search engine client to use for refresh
506      * @param index the index name to refresh
507      * @return the refresh status code
508      */
509     protected int refreshIndex(final SearchEngineClient searchEngineClient, final String index) {
510         final RefreshResponse response = searchEngineClient.admin().indices().prepareRefresh(index).execute().actionGet();
511         if (logger.isDebugEnabled()) {
512             logger.debug("[{}] refresh status: {} ({}/{}/{})", index, response.getStatus(), response.getTotalShards(),
513                     response.getSuccessfulShards(), response.getFailedShards());
514         }
515         return response.getStatus().getStatus();
516     }
517 
518     /**
519      * Calculates the memory size of a document data map.
520      * This is useful for monitoring memory usage during indexing operations.
521      *
522      * @param dataMap the document data as a map of field names to values
523      * @return the estimated memory size in bytes
524      */
525     public long calculateDocumentSize(final Map<String, Object> dataMap) {
526         return MemoryUtil.sizeOf(dataMap);
527     }
528 
529     /**
530      * Sets the maximum number of retry attempts for failed operations.
531      *
532      * @param maxRetryCount the maximum retry count
533      */
534     public void setMaxRetryCount(final int maxRetryCount) {
535         this.maxRetryCount = maxRetryCount;
536     }
537 
538     /**
539      * Sets the default number of rows to process in a single batch.
540      *
541      * @param defaultRowSize the default row size
542      */
543     public void setDefaultRowSize(final int defaultRowSize) {
544         this.defaultRowSize = defaultRowSize;
545     }
546 
547     /**
548      * Sets the interval between requests in milliseconds.
549      *
550      * @param requestInterval the request interval in milliseconds
551      */
552     public void setRequestInterval(final long requestInterval) {
553         this.requestInterval = requestInterval;
554     }
555 
556 }