1313 *
1414 * Portions Copyright 2014 The Apache Software Foundation
1515 * Copyright 2015-2016 ForgeRock AS.
16- * Portions Copyright 2023-2025 3A Systems, LLC
16+ * Portions Copyright 2023-2026 3A Systems, LLC
1717 */
1818package org .opends .server .backends .pluggable ;
1919
102102import org .opends .server .backends .pluggable .DN2ID .TreeVisitor ;
103103import org .opends .server .backends .pluggable .ImportLDIFReader .EntryInformation ;
104104import org .opends .server .backends .pluggable .OnDiskMergeImporter .BufferPool .MemoryBuffer ;
105+ import org .opends .server .backends .pluggable .spi .AccessMode ;
105106import org .opends .server .backends .pluggable .spi .Cursor ;
106107import org .opends .server .backends .pluggable .spi .Importer ;
107108import org .opends .server .backends .pluggable .spi .ReadOperation ;
118119import org .opends .server .types .DirectoryException ;
119120import org .opends .server .types .Entry ;
120121import org .opends .server .types .InitializationException ;
122+ import org .opends .server .types .ExistingFileBehavior ;
123+ import org .opends .server .types .LDIFExportConfig ;
121124import org .opends .server .types .LDIFImportConfig ;
122125import org .opends .server .types .LDIFImportResult ;
123126
@@ -156,6 +159,8 @@ static class StrategyImpl implements ImportStrategy
156159
157160 private static final String PHASE1_IMPORTER_THREAD_NAME = "PHASE1-IMPORTER-%d" ;
158161
162+ private static final String PHASE1_MIGRATOR_THREAD_NAME = "PHASE1-MIGRATOR-%d" ;
163+
159164 private static final String PHASE2_IMPORTER_THREAD_NAME = "PHASE2-IMPORTER-%d" ;
160165
161166 private static final String SORTER_THREAD_NAME = "PHASE1-SORTER-%d" ;
@@ -190,6 +195,23 @@ public LDIFImportResult importLDIF(LDIFImportConfig importConfig)
190195 {
191196 logger .info (NOTE_IMPORT_STARTING , DirectoryServer .getVersionString (), BUILD_ID , REVISION );
192197
198+ // A partial import (include and/or exclude branches) only replaces the
199+ // imported branches: existing entries outside of the imported scope must
200+ // be preserved. Snapshot them into temporary LDIF files before the
201+ // import deletes the entry containers, so that they can be fed through
202+ // the import pipeline together with the user-provided LDIF file.
203+ final List <File > migrationFiles ;
204+ try
205+ {
206+ migrationFiles = exportEntriesToPreserve (importConfig );
207+ }
208+ catch (Exception e )
209+ {
210+ throw new ExecutionException (e );
211+ }
212+
213+ try
214+ {
193215 final long startTime = System .currentTimeMillis ();
194216 final int maxThreadCount = importConfig .getThreadCount () == 0
195217 ? getDefaultNumberOfThread ()
@@ -212,7 +234,34 @@ public LDIFImportResult importLDIF(LDIFImportConfig importConfig)
212234 final AbstractTwoPhaseImportStrategy importStrategy =
213235 new ExternalSortAndImportStrategy (entryContainers , dbStorage , tempDir , bufferPool , sorter );
214236 importer = new OnDiskMergeImporter (PHASE2_IMPORTER_THREAD_NAME , importStrategy );
215- importer .doImport (source );
237+ if (migrationFiles .isEmpty ())
238+ {
239+ importer .doImport (source );
240+ }
241+ else
242+ {
243+ final List <String > migrationPaths = new ArrayList <>(migrationFiles .size ());
244+ for (File migrationFile : migrationFiles )
245+ {
246+ migrationPaths .add (migrationFile .getAbsolutePath ());
247+ }
248+ final LDIFImportConfig migrationConfig = new LDIFImportConfig (migrationPaths );
249+ try
250+ {
251+ // Entries come straight from this backend: no filtering and no
252+ // schema validation must be applied to them.
253+ migrationConfig .setValidateSchema (false );
254+ try (final LDIFReaderSource migrationSource =
255+ new LDIFReaderSource (rootContainer , migrationConfig , PHASE1_MIGRATOR_THREAD_NAME , threadCount ))
256+ {
257+ importer .doImport (new CompositeSource (Arrays .asList ((Source ) migrationSource , source )));
258+ }
259+ }
260+ finally
261+ {
262+ migrationConfig .close ();
263+ }
264+ }
216265 }
217266 finally
218267 {
@@ -246,6 +295,150 @@ public LDIFImportResult importLDIF(LDIFImportConfig importConfig)
246295 {
247296 throw new ExecutionException (e );
248297 }
298+ }
299+ finally
300+ {
301+ for (File migrationFile : migrationFiles )
302+ {
303+ migrationFile .delete ();
304+ }
305+ }
306+ }
307+
308+ /**
309+ * Exports the entries which must survive a partial import to temporary
310+ * LDIF files, mimicking the migration performed by the previous generation
311+ * importer: when include and/or exclude branches are configured, only the
312+ * imported branches are replaced, so existing entries outside of the
313+ * include branches, as well as existing entries below the exclude branches
314+ * of an imported branch, are re-imported from the snapshot files.
315+ *
316+ * @return the temporary LDIF files containing the entries to preserve
317+ */
318+ private List <File > exportEntriesToPreserve (LDIFImportConfig importConfig ) throws Exception
319+ {
320+ if (importConfig .clearBackend ())
321+ {
322+ return Collections .emptyList ();
323+ }
324+ final Set <DN > includeBranches = importConfig .getIncludeBranches ();
325+ final Set <DN > excludeBranches = importConfig .getExcludeBranches ();
326+ if (includeBranches .isEmpty () && excludeBranches .isEmpty ())
327+ {
328+ return Collections .emptyList ();
329+ }
330+
331+ final List <File > migrationFiles = new ArrayList <>();
332+ boolean storageOpen = false ;
333+ try
334+ {
335+ for (EntryContainer entryContainer : rootContainer .getEntryContainers ())
336+ {
337+ final DN baseDN = entryContainer .getBaseDN ();
338+ if (excludeBranches .contains (baseDN ))
339+ {
340+ // This entire base DN is excluded: the import does not touch it.
341+ continue ;
342+ }
343+
344+ final List <DN > includesUnderBase = new ArrayList <>();
345+ for (DN includeBranch : includeBranches )
346+ {
347+ if (includeBranch .isSubordinateOrEqualTo (baseDN ))
348+ {
349+ includesUnderBase .add (includeBranch );
350+ }
351+ }
352+ if (!includeBranches .isEmpty () && includesUnderBase .isEmpty ())
353+ {
354+ // Nothing is imported under this base DN: the import does not touch it.
355+ continue ;
356+ }
357+
358+ // Everything under this base DN which is not below an include branch
359+ // is replaced by the import, unless the base DN itself is included.
360+ final boolean wholeBaseReplaced = includesUnderBase .isEmpty () || includesUnderBase .contains (baseDN );
361+ if (!wholeBaseReplaced )
362+ {
363+ if (!storageOpen )
364+ {
365+ rootContainer .getStorage ().open (AccessMode .READ_ONLY );
366+ storageOpen = true ;
367+ }
368+ logger .info (NOTE_IMPORT_MIGRATION_START , "existing" , baseDN );
369+ migrationFiles .add (
370+ exportBranches (Collections .singletonList (baseDN ), new ArrayList <>(includesUnderBase )));
371+ }
372+
373+ // Existing entries below exclude branches located inside the
374+ // replaced scope must be preserved as well.
375+ final List <DN > excludedToPreserve = new ArrayList <>();
376+ for (DN excludeBranch : excludeBranches )
377+ {
378+ if (excludeBranch .isSubordinateOrEqualTo (baseDN )
379+ && (wholeBaseReplaced || isUnderAnyOf (excludeBranch , includesUnderBase )))
380+ {
381+ excludedToPreserve .add (excludeBranch );
382+ }
383+ }
384+ if (!excludedToPreserve .isEmpty ())
385+ {
386+ if (!storageOpen )
387+ {
388+ rootContainer .getStorage ().open (AccessMode .READ_ONLY );
389+ storageOpen = true ;
390+ }
391+ logger .info (NOTE_IMPORT_MIGRATION_START , "excluded" , baseDN );
392+ migrationFiles .add (exportBranches (excludedToPreserve , Collections .<DN > emptyList ()));
393+ }
394+ }
395+ return migrationFiles ;
396+ }
397+ catch (Exception e )
398+ {
399+ for (File migrationFile : migrationFiles )
400+ {
401+ migrationFile .delete ();
402+ }
403+ throw e ;
404+ }
405+ finally
406+ {
407+ if (storageOpen )
408+ {
409+ rootContainer .getStorage ().close ();
410+ }
411+ }
412+ }
413+
414+ private static boolean isUnderAnyOf (DN dn , List <DN > branches )
415+ {
416+ for (DN branch : branches )
417+ {
418+ if (dn .isSubordinateOrEqualTo (branch ))
419+ {
420+ return true ;
421+ }
422+ }
423+ return false ;
424+ }
425+
426+ private File exportBranches (List <DN > includeBranches , List <DN > excludeBranches ) throws Exception
427+ {
428+ final File migrationFile = File .createTempFile ("import-migration-" , ".ldif" );
429+ final LDIFExportConfig exportConfig =
430+ new LDIFExportConfig (migrationFile .getAbsolutePath (), ExistingFileBehavior .OVERWRITE );
431+ exportConfig .setIncludeBranches (includeBranches );
432+ exportConfig .setExcludeBranches (excludeBranches );
433+ try
434+ {
435+ new ExportJob (exportConfig ).exportLDIF (rootContainer );
436+ }
437+ finally
438+ {
439+ exportConfig .close ();
440+ }
441+ return migrationFile ;
249442 }
250443
251444 private static int getDefaultNumberOfThread ()
@@ -644,6 +837,45 @@ interface EntryProcessor
644837 boolean isCancelled ();
645838 }
646839
840+ /** Chains {@link Source}s, processing them sequentially. */
841+ private static final class CompositeSource implements Source
842+ {
843+ private final List <Source > sources ;
844+
845+ CompositeSource (final List <Source > sources )
846+ {
847+ this .sources = sources ;
848+ }
849+
850+ @ Override
851+ public void processAllEntries (EntryProcessor processor ) throws InterruptedException , ExecutionException
852+ {
853+ for (Source source : sources )
854+ {
855+ source .processAllEntries (processor );
856+ }
857+ }
858+
859+ @ Override
860+ public boolean isCancelled ()
861+ {
862+ for (Source source : sources )
863+ {
864+ if (source .isCancelled ())
865+ {
866+ return true ;
867+ }
868+ }
869+ return false ;
870+ }
871+
872+ @ Override
873+ public void close ()
874+ {
875+ // The chained sources are closed by their respective owners.
876+ }
877+ }
878+
647879 /** Extract LDAP {@link Entry}s from an LDIF file. */
648880 private static final class LDIFReaderSource implements Source
649881 {
0 commit comments