forked from votrongdao/FlowX
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathBulkJobStore.cs
More file actions
402 lines (340 loc) · 15.9 KB
/
Copy pathBulkJobStore.cs
File metadata and controls
402 lines (340 loc) · 15.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
using Npgsql;
using NpgsqlTypes;
namespace Crm;
/// <summary>
/// The bulk job queue: what was submitted, how far it got, and what it refused.
/// </summary>
/// <remarks>
/// <para>
/// <strong>The claim is a short transaction, not one held across the work.</strong> The connector
/// sweep holds its claim for the length of its batch because sending is the work and it must not
/// happen twice. A job's work is writing records whose ids are derived, so writing them twice is
/// a no-op — which buys the ability to claim in milliseconds and do the work outside any lock. A
/// sweeper that dies mid-chunk leaves the job <c>Pending</c> and another finishes it.
/// </para>
/// <para>
/// <strong>Progress is written with the chunk that made it.</strong> <c>processed</c> is durable,
/// so resuming means continuing rather than starting again — which is the difference between a job
/// that must succeed in one attempt and one that only has to succeed eventually.
/// </para>
/// </remarks>
public sealed class BulkJobStore
{
private const string InsertJob = """
INSERT INTO bulk_job (
job_id, tenant_id, kind, object_id, status, total, scopes, request, created_at)
VALUES (@id, @tenant, @kind, @object, 'Pending', @total, @scopes, @request::jsonb, @now)
""";
// FOR UPDATE SKIP LOCKED inside the subquery, so two sweepers take different jobs rather than
// both taking the oldest. The attempt is counted at the claim and not at the failure: a
// sweeper that dies leaves no record of having tried, and a job nothing can process would
// otherwise be claimed forever.
private const string ClaimJob = """
UPDATE bulk_job SET attempts = attempts + 1
WHERE job_id = (
SELECT job_id FROM bulk_job
WHERE status = 'Pending' AND attempts < @maxAttempts
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED)
RETURNING job_id, kind, object_id, total, processed, failed, scopes, request::text
""";
private const string AbandonExhausted = """
UPDATE bulk_job SET status = 'Failed', finished_at = @now
WHERE status = 'Pending' AND attempts >= @maxAttempts
""";
// `attempts = 0` because the counter is about a chunk that cannot be made to work, not about
// how long the job has been running. A job of a hundred chunks would otherwise be abandoned
// for having been unlucky five times across an hour of successful work.
private const string AdvanceJob = """
UPDATE bulk_job
SET processed = @processed,
failed = @failed,
attempts = 0,
result = coalesce(@result::jsonb, result),
status = CASE WHEN @processed >= total THEN 'Succeeded' ELSE 'Pending' END,
finished_at = CASE WHEN @processed >= total THEN @now END
WHERE job_id = @id
""";
// DO NOTHING, because a chunk re-run after a crash reports the same refusals again and a row
// that failed twice did not fail twice.
private const string InsertError = """
INSERT INTO bulk_job_error (job_id, tenant_id, ordinal, message)
VALUES (@id, @tenant, @ordinal, @message)
ON CONFLICT (job_id, ordinal) DO NOTHING
""";
private const string ReadJobById = """
SELECT kind, status, total, processed, failed, result::text
FROM bulk_job WHERE job_id = @id
""";
private const string ReadJobErrors = """
SELECT ordinal, message FROM bulk_job_error WHERE job_id = @id ORDER BY ordinal
""";
private readonly NpgsqlDataSource _source;
/// <summary>Builds the store over the application's data source.</summary>
/// <param name="source">The pool <c>AddFlowXPostgres</c> built.</param>
/// <exception cref="ArgumentNullException"><paramref name="source"/> is null.</exception>
public BulkJobStore(NpgsqlDataSource source)
{
ArgumentNullException.ThrowIfNull(source);
_source = source;
}
/// <summary>Records a submitted job.</summary>
/// <param name="tenantId">The caller's tenant.</param>
/// <param name="jobId">What the job is to be called.</param>
/// <param name="kind"><c>Import</c> or <c>Export</c>.</param>
/// <param name="target">Which object it is about.</param>
/// <param name="total">How much work it is.</param>
/// <param name="scopes">What the submitter held.</param>
/// <param name="request">The rows, or the criteria.</param>
/// <param name="now">When it was submitted.</param>
/// <param name="cancellationToken">Cancels the call.</param>
public async ValueTask SubmitAsync(
string? tenantId,
Guid jobId,
string kind,
Guid target,
int total,
IReadOnlyList<string> scopes,
string request,
DateTimeOffset now,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(scopes);
var connection = await OpenAsync(tenantId, cancellationToken).ConfigureAwait(false);
await using var closing = connection.ConfigureAwait(false);
var command = connection.CreateCommand();
await using var closingCommand = command.ConfigureAwait(false);
command.CommandText = InsertJob;
Add(command, "id", NpgsqlDbType.Uuid, jobId);
Add(command, "tenant", NpgsqlDbType.Text, tenantId ?? string.Empty);
Add(command, "kind", NpgsqlDbType.Text, kind);
Add(command, "object", NpgsqlDbType.Uuid, target);
Add(command, "total", NpgsqlDbType.Integer, total);
command.Parameters.Add(new NpgsqlParameter<string[]>("scopes", [.. scopes]));
Add(command, "request", NpgsqlDbType.Text, request);
Add(command, "now", NpgsqlDbType.TimestampTz, now);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>Gives up on jobs that have failed too many times.</summary>
/// <param name="tenantId">The tenant being swept.</param>
/// <param name="maxAttempts">How many failures is too many.</param>
/// <param name="now">When the sweep ran.</param>
/// <param name="cancellationToken">Cancels the call.</param>
/// <returns>How many jobs were given up on.</returns>
public async ValueTask<int> AbandonAsync(
string? tenantId,
int maxAttempts,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var connection = await OpenAsync(tenantId, cancellationToken).ConfigureAwait(false);
await using var closing = connection.ConfigureAwait(false);
var command = connection.CreateCommand();
await using var closingCommand = command.ConfigureAwait(false);
command.CommandText = AbandonExhausted;
Add(command, "maxAttempts", NpgsqlDbType.Integer, maxAttempts);
Add(command, "now", NpgsqlDbType.TimestampTz, now);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>Takes the oldest job nothing else is working on.</summary>
/// <param name="tenantId">The tenant being swept.</param>
/// <param name="maxAttempts">Jobs at or past this are left for <see cref="AbandonAsync"/>.</param>
/// <param name="cancellationToken">Cancels the call.</param>
/// <returns>The job, or null when there is nothing to do.</returns>
public async ValueTask<ClaimedJob?> ClaimAsync(
string? tenantId,
int maxAttempts,
CancellationToken cancellationToken)
{
var connection = await OpenAsync(tenantId, cancellationToken).ConfigureAwait(false);
await using var closing = connection.ConfigureAwait(false);
var command = connection.CreateCommand();
await using var closingCommand = command.ConfigureAwait(false);
command.CommandText = ClaimJob;
Add(command, "maxAttempts", NpgsqlDbType.Integer, maxAttempts);
var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await using var closingReader = reader.ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
var scopes = await reader
.GetFieldValueAsync<string[]>(6, cancellationToken)
.ConfigureAwait(false);
return new ClaimedJob(
reader.GetGuid(0),
reader.GetString(1),
reader.GetGuid(2),
reader.GetInt32(3),
reader.GetInt32(4),
reader.GetInt32(5),
scopes,
reader.GetString(7));
}
/// <summary>Records the progress a chunk made.</summary>
/// <param name="tenantId">The tenant being swept.</param>
/// <param name="jobId">Which job.</param>
/// <param name="processed">How much is now dealt with.</param>
/// <param name="failed">How many rows have been refused in total.</param>
/// <param name="result">An export's document, or null to leave what is there.</param>
/// <param name="now">When the chunk finished.</param>
/// <param name="cancellationToken">Cancels the call.</param>
public async ValueTask AdvanceAsync(
string? tenantId,
Guid jobId,
int processed,
int failed,
string? result,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var connection = await OpenAsync(tenantId, cancellationToken).ConfigureAwait(false);
await using var closing = connection.ConfigureAwait(false);
var command = connection.CreateCommand();
await using var closingCommand = command.ConfigureAwait(false);
command.CommandText = AdvanceJob;
Add(command, "id", NpgsqlDbType.Uuid, jobId);
Add(command, "processed", NpgsqlDbType.Integer, processed);
Add(command, "failed", NpgsqlDbType.Integer, failed);
Add(command, "now", NpgsqlDbType.TimestampTz, now);
command.Parameters.Add(new NpgsqlParameter("result", NpgsqlDbType.Text)
{
Value = (object?)result ?? DBNull.Value,
});
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>Records which rows a chunk refused.</summary>
/// <param name="tenantId">The tenant being swept.</param>
/// <param name="jobId">Which job.</param>
/// <param name="errors">The refusals, by ordinal.</param>
/// <param name="cancellationToken">Cancels the call.</param>
/// <exception cref="ArgumentNullException"><paramref name="errors"/> is null.</exception>
public async ValueTask RecordErrorsAsync(
string? tenantId,
Guid jobId,
IReadOnlyList<JobRowError> errors,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(errors);
if (errors.Count == 0)
{
return;
}
var connection = await OpenAsync(tenantId, cancellationToken).ConfigureAwait(false);
await using var closing = connection.ConfigureAwait(false);
foreach (var error in errors)
{
var command = connection.CreateCommand();
await using var closingCommand = command.ConfigureAwait(false);
command.CommandText = InsertError;
Add(command, "id", NpgsqlDbType.Uuid, jobId);
Add(command, "tenant", NpgsqlDbType.Text, tenantId ?? string.Empty);
Add(command, "ordinal", NpgsqlDbType.Integer, error.Ordinal);
Add(command, "message", NpgsqlDbType.Text, error.Message);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <summary>Reads a job and what it refused.</summary>
/// <param name="tenantId">The caller's tenant.</param>
/// <param name="jobId">Which job.</param>
/// <param name="cancellationToken">Cancels the call.</param>
/// <returns>The job, or null when this tenant has no such job.</returns>
public async ValueTask<StoredJob?> ReadAsync(
string? tenantId,
Guid jobId,
CancellationToken cancellationToken)
{
var connection = await OpenAsync(tenantId, cancellationToken).ConfigureAwait(false);
await using var closing = connection.ConfigureAwait(false);
StoredJob? job;
var command = connection.CreateCommand();
await using var closingCommand = command.ConfigureAwait(false);
command.CommandText = ReadJobById;
Add(command, "id", NpgsqlDbType.Uuid, jobId);
var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await using (reader.ConfigureAwait(false))
{
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
job = new StoredJob(
reader.GetString(0),
reader.GetString(1),
reader.GetInt32(2),
reader.GetInt32(3),
reader.GetInt32(4),
await reader.IsDBNullAsync(5, cancellationToken).ConfigureAwait(false)
? null
: reader.GetString(5),
[]);
}
var errorsCommand = connection.CreateCommand();
await using var closingErrors = errorsCommand.ConfigureAwait(false);
errorsCommand.CommandText = ReadJobErrors;
Add(errorsCommand, "id", NpgsqlDbType.Uuid, jobId);
var errorReader = await errorsCommand
.ExecuteReaderAsync(cancellationToken)
.ConfigureAwait(false);
await using var closingErrorReader = errorReader.ConfigureAwait(false);
var errors = new List<JobRowError>();
while (await errorReader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
errors.Add(new JobRowError(errorReader.GetInt32(0), errorReader.GetString(1)));
}
return job with { Errors = errors };
}
private static void Add(NpgsqlCommand command, string name, NpgsqlDbType type, object value) =>
command.Parameters.Add(new NpgsqlParameter(name, type) { Value = value });
private async ValueTask<NpgsqlConnection> OpenAsync(
string? tenantId,
CancellationToken cancellationToken)
{
var connection = await _source.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
try
{
await CrmTenantScope.ApplyAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
}
catch
{
await connection.DisposeAsync().ConfigureAwait(false);
throw;
}
return connection;
}
}
/// <summary>A job the sweep has taken.</summary>
/// <param name="JobId">Which job.</param>
/// <param name="Kind"><c>Import</c> or <c>Export</c>.</param>
/// <param name="Target">Which object it is about.</param>
/// <param name="Total">How much work it is.</param>
/// <param name="Processed">How much was already dealt with, by an earlier pass.</param>
/// <param name="Failed">How many rows have been refused so far.</param>
/// <param name="Scopes">What the submitter held, and what the sweep decides on.</param>
/// <param name="Request">The rows, or the criteria.</param>
public sealed record ClaimedJob(
Guid JobId,
string Kind,
Guid Target,
int Total,
int Processed,
int Failed,
IReadOnlyList<string> Scopes,
string Request);
/// <summary>A job as stored, for a caller asking after it.</summary>
/// <param name="Kind"><c>Import</c> or <c>Export</c>.</param>
/// <param name="Status">Where it has got to.</param>
/// <param name="Total">How much work it is.</param>
/// <param name="Processed">How much is dealt with.</param>
/// <param name="Failed">How many rows were refused.</param>
/// <param name="Result">An export's document, or null.</param>
/// <param name="Errors">Which rows were refused.</param>
public sealed record StoredJob(
string Kind,
string Status,
int Total,
int Processed,
int Failed,
string? Result,
IReadOnlyList<JobRowError> Errors);