Skip to content

Commit 6bc4c47

Browse files
committed
use pause and resume content import
1 parent 4ad6e2b commit 6bc4c47

16 files changed

Lines changed: 617 additions & 134 deletions

File tree

src/Abstractions/CrestApps.OrchardCore.ContentTransfer.Abstractions/ContentTransferEntry.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,14 @@ public sealed class ContentTransferEntry : Entity
6969

7070
public enum ContentTransferEntryStatus
7171
{
72-
New,
73-
Processing,
74-
Completed,
75-
CompletedWithErrors,
76-
Canceled,
77-
CanceledWithImportedRecords,
78-
Failed,
72+
New = 0,
73+
Processing = 1,
74+
Completed = 2,
75+
CompletedWithErrors = 3,
76+
Failed = 6,
77+
Pending = 7,
78+
Paused = 8,
79+
Deleting = 9,
7980
}
8081

8182
public enum ContentTransferDirection
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
namespace CrestApps.OrchardCore.ContentTransfer;
2+
3+
/// <summary>
4+
/// Provides helpers for interpreting import-specific content transfer entry statuses.
5+
/// </summary>
6+
public static class ContentTransferEntryStatusExtensions
7+
{
8+
/// <summary>
9+
/// Determines whether the status represents a queued import waiting to start.
10+
/// </summary>
11+
/// <param name="status">The status to evaluate.</param>
12+
/// <returns><c>true</c> when the import is pending; otherwise, <c>false</c>.</returns>
13+
public static bool IsPendingImport(this ContentTransferEntryStatus status)
14+
=> status == ContentTransferEntryStatus.Pending
15+
|| status == ContentTransferEntryStatus.New;
16+
17+
/// <summary>
18+
/// Determines whether the status represents a paused import.
19+
/// </summary>
20+
/// <param name="status">The status to evaluate.</param>
21+
/// <returns><c>true</c> when the import is paused; otherwise, <c>false</c>.</returns>
22+
public static bool IsPausedImport(this ContentTransferEntryStatus status)
23+
=> status == ContentTransferEntryStatus.Paused;
24+
25+
/// <summary>
26+
/// Determines whether the status can be resumed for import processing.
27+
/// </summary>
28+
/// <param name="status">The status to evaluate.</param>
29+
/// <returns><c>true</c> when the import can be resumed; otherwise, <c>false</c>.</returns>
30+
public static bool CanResumeImport(this ContentTransferEntryStatus status)
31+
=> status.IsPendingImport()
32+
|| status.IsPausedImport()
33+
|| status == ContentTransferEntryStatus.Failed;
34+
35+
/// <summary>
36+
/// Determines whether the status should stop import processing.
37+
/// </summary>
38+
/// <param name="status">The status to evaluate.</param>
39+
/// <returns><c>true</c> when background import processing should stop; otherwise, <c>false</c>.</returns>
40+
public static bool ShouldStopImport(this ContentTransferEntryStatus status)
41+
=> status.IsPausedImport()
42+
|| status == ContentTransferEntryStatus.Deleting;
43+
44+
/// <summary>
45+
/// Maps import statuses to the normalized status used by the admin UI.
46+
/// </summary>
47+
/// <param name="status">The status to normalize.</param>
48+
/// <returns>The normalized import status for display and filtering.</returns>
49+
public static ContentTransferEntryStatus NormalizeImportStatus(this ContentTransferEntryStatus status)
50+
{
51+
if (status.IsPendingImport())
52+
{
53+
return ContentTransferEntryStatus.Pending;
54+
}
55+
56+
if (status.IsPausedImport())
57+
{
58+
return ContentTransferEntryStatus.Paused;
59+
}
60+
61+
return status;
62+
}
63+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
namespace CrestApps.OrchardCore.ContentTransfer;
2+
3+
/// <summary>
4+
/// Manages content transfer entry lifecycle operations that must be coordinated with background work.
5+
/// </summary>
6+
public interface IContentTransferEntryManager
7+
{
8+
/// <summary>
9+
/// Pauses an in-progress import entry so it can be resumed later.
10+
/// </summary>
11+
/// <param name="entryId">The entry identifier.</param>
12+
/// <param name="cancellationToken">A cancellation token.</param>
13+
Task PauseImportAsync(string entryId, CancellationToken cancellationToken = default);
14+
15+
/// <summary>
16+
/// Marks an import entry as processing so background work can resume it.
17+
/// </summary>
18+
/// <param name="entryId">The entry identifier.</param>
19+
/// <param name="cancellationToken">A cancellation token.</param>
20+
Task ResumeImportAsync(string entryId, CancellationToken cancellationToken = default);
21+
22+
/// <summary>
23+
/// Marks an entry as deleting before background cleanup begins.
24+
/// </summary>
25+
/// <param name="entryId">The entry identifier.</param>
26+
/// <param name="cancellationToken">A cancellation token.</param>
27+
Task MarkAsDeletingAsync(string entryId, CancellationToken cancellationToken = default);
28+
29+
/// <summary>
30+
/// Deletes an entry and its stored file while coordinating with any active background processing lock.
31+
/// </summary>
32+
/// <param name="entryId">The entry identifier.</param>
33+
/// <param name="cancellationToken">A cancellation token.</param>
34+
Task DeleteAsync(string entryId, CancellationToken cancellationToken = default);
35+
}

src/Core/CrestApps.OrchardCore.ContentTransfer.Core/DefaultContentTransferEntryAdminListFilterProvider.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ public void Build(QueryEngineBuilder<ContentTransferEntry> builder)
1515
{
1616
if (Enum.TryParse<ContentTransferEntryStatus>(val, true, out var status))
1717
{
18+
if (status == ContentTransferEntryStatus.Pending)
19+
{
20+
return new ValueTask<IQuery<ContentTransferEntry>>(query.With<ContentTransferEntryIndex>(x =>
21+
x.Status == ContentTransferEntryStatus.Pending
22+
|| x.Status == ContentTransferEntryStatus.New));
23+
}
24+
25+
if (status == ContentTransferEntryStatus.Paused)
26+
{
27+
return new ValueTask<IQuery<ContentTransferEntry>>(query.With<ContentTransferEntryIndex>(x =>
28+
x.Status == ContentTransferEntryStatus.Paused));
29+
}
30+
1831
return new ValueTask<IQuery<ContentTransferEntry>>(query.With<ContentTransferEntryIndex>(x => x.Status == status));
1932
}
2033

src/CrestApps.Docs/docs/changelog/v2.0.0.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ Large parts of the reusable AI infrastructure are no longer implemented only ins
113113
- Content Transfer content-type settings now default **Allow Bulk Import** and **Allow Bulk Export** to enabled, so content types participate by default and can explicitly opt out by turning either setting off
114114
- Content Transfer now keeps CSV support in the base `CrestApps.OrchardCore.ContentTransfer` feature and moves optional `.xlsx` support into `CrestApps.OrchardCore.ContentTransfer.OpenXml`, with the import and export UI showing only the file extensions enabled by the current tenant feature set
115115
- Content Transfer now welds lazily created parts onto the parent content item during column discovery, import, and export so `.xlsx` exports no longer fail with a `System.Text.Json` node-cycle exception when a content item does not already materialize one of its configured parts
116+
- Content Transfer imports now commit inline status changes before their deferred background jobs run, use **Pending**, **Paused**, and **Deleting** import states in the admin list, surface **Resume import** and **Pause import** actions instead of the earlier cancel/process wording, and migrate older canceled import rows to the paused state
116117
- Omnichannel contact import and export now map the first contact-method bag entries to workbook `Email`, `Cell Phone`, and `Phone` columns, also read and write `DoNotCall`, `DoNotCallUtc`, `DoNotSms`, `DoNotSmsUtc`, `DoNotEmail`, `DoNotEmailUtc`, `DoNotChat`, and `DoNotChatUtc` on `OmnichannelContactPart`, advertise `true` and `false` for the `DoNot*` boolean columns, default duplicate-phone filtering to enabled, add skipped duplicate rows to the error export with a reason, batch-check duplicate phone numbers against both the import file and the existing Orchard contact records, fall back to legacy stored phone values when older tenants have not yet backfilled normalized-phone indexes, normalize non-E.164 import numbers before handing them to DNC registry providers, and can enforce national do-not-call registry checks through **Settings** -> **Import Content Settings**
117118
- Omnichannel contact and communication-preference content-item indexes now subscribe to Orchard's default content-item collection instead of the custom omnichannel document collection, so publishing or updating a contact reliably refreshes its index rows
118119
- Omnichannel contact definitions now enforce a fixed `ContactMethods` bag for content types that attach `OmnichannelContactPart`, while `OmnichannelContactPart` itself owns the contact-level Do-Not-Call, Do-Not-SMS, Do-Not-Email, and Do-Not-Chat compliance flags so omnichannel indexing, campaign actions, and activity loading all use one C#-controlled contact model

src/CrestApps.Docs/docs/modules/content-transfer.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,12 @@ Use **Content** -> **Bulk Import** to upload a transfer file for a content type.
4141
2. Download the template if you need the expected column layout.
4242
3. Upload one of the enabled file formats shown in the UI.
4343
4. Choose whether the imported items should stay as the latest draft or be published immediately.
44-
5. The import is queued and processed in the background.
44+
5. The import is queued with a **Pending** status and processed in the background.
4545

4646
Validation runs through `IContentManager.ValidateAsync()`. Failed rows are tracked, and rejected rows can be downloaded again in the same file format as the original import as long as that format feature is still enabled.
4747

48+
Queued imports now follow the same background-job pattern used by the local DNC list importer. The admin list updates the status inline before work starts or stops, so entries can move through **Pending**, **Processing**, **Paused**, **Deleting**, **Completed**, **Completed with errors**, and **Failed** states without briefly showing stale values. While an import is running, the action menu offers **Pause import**. Paused, failed, pending, and stalled imports show **Resume import** so the background job can continue from the last saved batch.
49+
4850
For Omnichannel contacts, the import UI can also expose duplicate-phone filtering, a lead-country selector for phone normalization, and national do-not-call registry checks. Duplicate-phone filtering is enabled by default, skipped duplicate rows are recorded in the error export with the reason, and duplicate detection checks both the current import batch and existing contact phone numbers already stored in Orchard before the batch commits. When a row includes an existing `ContentItemId`, duplicate detection now treats matching phone numbers on that same content item as an update instead of a conflict. The database lookup also falls back to older stored phone values that predate the normalized-phone index columns, so re-importing the same contact list is still rejected while older tenants finish reindexing. See [DNC Registry](./dnc-registry) for registry configuration and global enforcement.
4951

5052
Bulk imports now default to saving drafts only. Enable **Publish imported content** when the imported items should be published immediately after create or update. When a row includes an existing `ContentItemId`, the import updates a new latest version of that item and then either keeps that version as a draft or publishes it based on the checkbox. For versionable content types, exports still include `ContentItemVersionId` for reference, but imports now ignore that value entirely.

src/Modules/CrestApps.OrchardCore.ContentTransfer/BackgroundTasks/ExportFilesBackgroundTask.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ private static async Task SaveEntryWithErrorAsync(ISession session, IClock clock
253253
await session.SaveChangesAsync();
254254
}
255255

256-
private static string GetExportLockKey(string entryId)
256+
internal static string GetExportLockKey(string entryId)
257257
=> $"ContentsTransfer_Export_{entryId}";
258258

259259
private static IQuery<ContentItem> BuildExportQuery(

src/Modules/CrestApps.OrchardCore.ContentTransfer/BackgroundTasks/ImportFilesBackgroundTask.cs

Lines changed: 42 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ internal static async Task ProcessEntriesAsync(IServiceProvider serviceProvider,
3636
var distributedLock = serviceProvider.GetRequiredService<IDistributedLock>();
3737

3838
var entries = await session.Query<ContentTransferEntry, ContentTransferEntryIndex>(x =>
39-
(x.Status == ContentTransferEntryStatus.New || x.Status == ContentTransferEntryStatus.Processing)
39+
(x.Status == ContentTransferEntryStatus.New
40+
|| x.Status == ContentTransferEntryStatus.Pending
41+
|| x.Status == ContentTransferEntryStatus.Processing)
4042
&& x.Direction == ContentTransferDirection.Import
4143
&& (entryId == null || x.EntryId == entryId))
4244
.OrderBy(x => x.CreatedUtc)
@@ -78,7 +80,7 @@ private static async Task ProcessEntryAsync(IServiceProvider serviceProvider, st
7880
var rowFilters = serviceProvider.GetServices<IContentImportRowFilter>();
7981
var entry = await session.Query<ContentTransferEntry, ContentTransferEntryIndex>(x => x.EntryId == entryId).FirstOrDefaultAsync(cancellationToken);
8082

81-
if (entry == null || entry.Status == ContentTransferEntryStatus.Canceled || entry.Status == ContentTransferEntryStatus.CanceledWithImportedRecords)
83+
if (entry == null || entry.Status.ShouldStopImport())
8284
{
8385
return;
8486
}
@@ -103,13 +105,25 @@ private static async Task ProcessEntryAsync(IServiceProvider serviceProvider, st
103105
? ContentImportOptions.DefaultImportBatchSize
104106
: contentImportOptions.ImportBatchSize;
105107

108+
var progressPart = entry.GetOrCreate<ImportFileProcessStatsPart>();
109+
progressPart.Errors ??= [];
110+
progressPart.ErrorMessages ??= [];
111+
112+
var isResuming = progressPart.CurrentRow > 0;
113+
106114
entry.Status = ContentTransferEntryStatus.Processing;
107115
entry.Error = null;
108116
entry.CompletedUtc = null;
109117

110-
var progressPart = entry.GetOrCreate<ImportFileProcessStatsPart>();
111-
progressPart.Errors ??= [];
112-
progressPart.ErrorMessages ??= [];
118+
if (!isResuming)
119+
{
120+
progressPart.CurrentRow = 0;
121+
progressPart.TotalProcessed = 0;
122+
progressPart.ImportedCount = 0;
123+
progressPart.Errors.Clear();
124+
progressPart.ErrorMessages.Clear();
125+
}
126+
113127
entry.Put(progressPart);
114128

115129
session.Save(entry);
@@ -141,7 +155,7 @@ private static async Task ProcessEntryAsync(IServiceProvider serviceProvider, st
141155
}
142156
}
143157

144-
await ProcessFileInBatchesAsync(
158+
var completed = await ProcessFileInBatchesAsync(
145159
serviceProvider,
146160
fileStream,
147161
formatProvider,
@@ -155,24 +169,18 @@ await ProcessFileInBatchesAsync(
155169
clock,
156170
batchSize,
157171
cancellationToken);
172+
173+
if (!completed)
174+
{
175+
return;
176+
}
158177
}
159178
catch (Exception ex)
160179
{
161180
await SaveEntryWithErrorAsync(session, clock, entry, localizer["Error processing file: {0}", ex.Message], cancellationToken);
162181
return;
163182
}
164183

165-
if (await IsImportCanceledAsync(serviceProvider, entry.EntryId, cancellationToken))
166-
{
167-
entry.Status = GetCanceledStatus(progressPart);
168-
entry.ProcessSaveUtc = clock.UtcNow;
169-
entry.CompletedUtc = clock.UtcNow;
170-
entry.Put(progressPart);
171-
session.Save(entry);
172-
await session.SaveChangesAsync(cancellationToken);
173-
return;
174-
}
175-
176184
var nowUtc = clock.UtcNow;
177185
entry.ProcessSaveUtc = nowUtc;
178186
entry.CompletedUtc = nowUtc;
@@ -185,7 +193,7 @@ await ProcessFileInBatchesAsync(
185193
await session.SaveChangesAsync(cancellationToken);
186194
}
187195

188-
private static async Task ProcessFileInBatchesAsync(
196+
private static async Task<bool> ProcessFileInBatchesAsync(
189197
IServiceProvider serviceProvider,
190198
Stream stream,
191199
IContentTransferFileFormatProvider formatProvider,
@@ -203,7 +211,7 @@ private static async Task ProcessFileInBatchesAsync(
203211
using var reader = formatProvider.CreateReader(stream);
204212

205213
var columnNames = reader.GetColumnNames();
206-
var dataTable = new DataTable();
214+
using var dataTable = new DataTable();
207215

208216
foreach (var columnName in columnNames)
209217
{
@@ -226,6 +234,7 @@ private static async Task ProcessFileInBatchesAsync(
226234

227235
var rowIndex = 1;
228236
var hasFilters = activeRowFilters.Count > 0;
237+
var importInterrupted = false;
229238

230239
foreach (var rowValues in reader.ReadRows())
231240
{
@@ -234,8 +243,9 @@ private static async Task ProcessFileInBatchesAsync(
234243
break;
235244
}
236245

237-
if (await IsImportCanceledAsync(serviceProvider, entry.EntryId, cancellationToken))
246+
if (await ShouldStopImportAsync(serviceProvider, entry.EntryId, cancellationToken))
238247
{
248+
importInterrupted = true;
239249
break;
240250
}
241251

@@ -328,14 +338,18 @@ await ProcessBatchAsync(
328338
clock,
329339
cancellationToken);
330340
newRecords.Clear();
331-
newRecords.Clear();
332341
existingRows.Clear();
333342
dataTable.Rows.Clear();
334343
}
335344

336345
rowIndex++;
337346
}
338347

348+
if (importInterrupted || cancellationToken.IsCancellationRequested)
349+
{
350+
return false;
351+
}
352+
339353
if (newRecords.Count + existingRows.Count > 0)
340354
{
341355
await ProcessBatchAsync(
@@ -353,7 +367,7 @@ await ProcessBatchAsync(
353367
cancellationToken);
354368
}
355369

356-
dataTable.Dispose();
370+
return true;
357371
}
358372

359373
private static async Task ProcessBatchAsync(
@@ -377,7 +391,7 @@ private static async Task ProcessBatchAsync(
377391

378392
foreach (var existingRow in existingRows)
379393
{
380-
if (await IsImportCanceledAsync(serviceProvider, entry.EntryId, cancellationToken))
394+
if (await ShouldStopImportAsync(serviceProvider, entry.EntryId, cancellationToken))
381395
{
382396
return;
383397
}
@@ -406,7 +420,7 @@ await ProcessRowAsync(
406420

407421
foreach (var record in newRecords)
408422
{
409-
if (await IsImportCanceledAsync(serviceProvider, entry.EntryId, cancellationToken))
423+
if (await ShouldStopImportAsync(serviceProvider, entry.EntryId, cancellationToken))
410424
{
411425
return;
412426
}
@@ -543,27 +557,22 @@ private static async Task SaveEntryWithErrorAsync(ISession session, IClock clock
543557
{
544558
entry.Status = ContentTransferEntryStatus.Failed;
545559
entry.Error = error;
560+
entry.ProcessSaveUtc = clock.UtcNow;
546561
entry.CompletedUtc = clock.UtcNow;
547562

548563
session.Save(entry);
549564
await session.SaveChangesAsync(cancellationToken);
550565
}
551566

552-
private static string GetImportLockKey(string entryId)
567+
internal static string GetImportLockKey(string entryId)
553568
=> $"ContentsTransfer_Import_{entryId}";
554569

555-
private static async Task<bool> IsImportCanceledAsync(IServiceProvider serviceProvider, string entryId, CancellationToken cancellationToken)
570+
private static async Task<bool> ShouldStopImportAsync(IServiceProvider serviceProvider, string entryId, CancellationToken cancellationToken)
556571
{
557572
await using var scope = serviceProvider.CreateAsyncScope();
558573
var session = scope.ServiceProvider.GetRequiredService<ISession>();
559574
var currentEntry = await session.Query<ContentTransferEntry, ContentTransferEntryIndex>(x => x.EntryId == entryId).FirstOrDefaultAsync(cancellationToken);
560575

561-
return currentEntry?.Status == ContentTransferEntryStatus.Canceled
562-
|| currentEntry?.Status == ContentTransferEntryStatus.CanceledWithImportedRecords;
576+
return currentEntry?.Status.ShouldStopImport() == true;
563577
}
564-
565-
private static ContentTransferEntryStatus GetCanceledStatus(ImportFileProcessStatsPart progressPart)
566-
=> (progressPart?.ImportedCount ?? 0) > 0
567-
? ContentTransferEntryStatus.CanceledWithImportedRecords
568-
: ContentTransferEntryStatus.Canceled;
569578
}

0 commit comments

Comments
 (0)