fix(14): WR-06 never file a second G15Office task on a job retry
A retry after a failed attachment or a failed CompleteJob created another task for the same report. The task id is now stored as soon as the task is created, a retry with a stored id only re-attempts the attachment, a sent submission with a task id is skipped, and a CompleteJob failure is logged instead of returned.
This commit is contained in:
@@ -274,17 +274,49 @@ func TestSyncG15Office(t *testing.T) {
|
||||
failing(t, parityClient(), roundTrip(func(*http.Request) (*http.Response, error) { return nil, errors.New("dial failed") }), "g15office create task failed: connection error.")
|
||||
})
|
||||
t.Run("attach-failure", func(t *testing.T) {
|
||||
calls := 0
|
||||
// The task id is kept from the first attempt, so the retry only
|
||||
// re-attempts the attachment and files no second task (WR-06).
|
||||
tasks, attaches := 0, 0
|
||||
attachStatus := 413
|
||||
rt := roundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if strings.HasSuffix(r.URL.Path, "/tasks") {
|
||||
tasks++
|
||||
return jsonAnswer(201, `{"data":{"id":"tskX"}}`)(r)
|
||||
}
|
||||
return jsonAnswer(413, `{"error":true,"message":"File too large"}`)(r)
|
||||
attaches++
|
||||
if !strings.Contains(r.URL.Path, "/tasks/tskX/") {
|
||||
t.Errorf("attachment to %s", r.URL.Path)
|
||||
}
|
||||
if attachStatus != 201 {
|
||||
return jsonAnswer(attachStatus, `{"error":true,"message":"File too large"}`)(r)
|
||||
}
|
||||
return jsonAnswer(201, `{"data":{"id":"att"}}`)(r)
|
||||
})
|
||||
failing(t, parityClient(), rt, "g15office attach file failed: File too large")
|
||||
if calls != 2 {
|
||||
t.Fatalf("%d requests", calls)
|
||||
sub := recordedSubmission(t, app, gdb, bucket)
|
||||
deps := classes.SyncDeps{DB: gdb, Bucket: bucket, Client: parityClient()}
|
||||
ctx := fetchguard.WithTransport(t.Context(), rt)
|
||||
const wantErr = "g15office attach file failed: File too large"
|
||||
if err := classes.SyncG15Office(ctx, deps, classes.SyncG15OfficeArgs{SubmissionID: sub.ID}); err == nil || err.Error() != wantErr {
|
||||
t.Fatalf("error %v, want %q", err, wantErr)
|
||||
}
|
||||
got := reload(t, gdb, sub.ID)
|
||||
if got.Status != models.StatusFailed || got.G15OfficeError == nil || *got.G15OfficeError != wantErr || got.G15OfficeTaskID == nil || *got.G15OfficeTaskID != "tskX" {
|
||||
t.Fatalf("submission %+v", got)
|
||||
}
|
||||
attachStatus = 201
|
||||
if err := classes.SyncG15Office(ctx, deps, classes.SyncG15OfficeArgs{SubmissionID: sub.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = reload(t, gdb, sub.ID)
|
||||
if got.Status != models.StatusSent || got.G15OfficeError != nil || got.G15OfficeTaskID == nil || *got.G15OfficeTaskID != "tskX" {
|
||||
t.Fatalf("retried submission %+v", got)
|
||||
}
|
||||
// A sent submission with its task is never filed again.
|
||||
if err := classes.SyncG15Office(ctx, deps, classes.SyncG15OfficeArgs{SubmissionID: sub.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tasks != 1 || attaches != 2 {
|
||||
t.Fatalf("%d task and %d attachment requests", tasks, attaches)
|
||||
}
|
||||
})
|
||||
t.Run("http-base-url-is-refused", func(t *testing.T) {
|
||||
|
||||
@@ -48,6 +48,11 @@ type SyncDeps struct {
|
||||
// error and returns the error, so the job is retried. A submission that is
|
||||
// gone is skipped. An unconfigured client fails before any request, so
|
||||
// nothing leaves the application.
|
||||
//
|
||||
// Unlike PHP, a retry never files a second task: a submission already sent
|
||||
// with a task id is skipped, the task id is stored as soon as CreateTask
|
||||
// returns it, and a retry of a submission that has one only re-attempts
|
||||
// the screenshot attachment.
|
||||
func SyncG15Office(ctx context.Context, deps SyncDeps, args SyncG15OfficeArgs) error {
|
||||
if deps.DB == nil {
|
||||
return errors.New("feedback: database is not configured")
|
||||
@@ -61,6 +66,9 @@ func SyncG15Office(ctx context.Context, deps SyncDeps, args SyncG15OfficeArgs) e
|
||||
return nil
|
||||
}
|
||||
sub := rows[0]
|
||||
if sub.Status == models.StatusSent && hasTaskID(&sub) {
|
||||
return nil
|
||||
}
|
||||
id, err := fileTask(ctx, deps, &sub)
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
@@ -76,7 +84,32 @@ func SyncG15Office(ctx context.Context, deps SyncDeps, args SyncG15OfficeArgs) e
|
||||
}).Error
|
||||
}
|
||||
|
||||
// hasTaskID reports whether an earlier attempt already filed the task.
|
||||
func hasTaskID(sub *models.Submission) bool {
|
||||
return sub.G15OfficeTaskID != nil && *sub.G15OfficeTaskID != ""
|
||||
}
|
||||
|
||||
func fileTask(ctx context.Context, deps SyncDeps, sub *models.Submission) (string, error) {
|
||||
var id string
|
||||
if hasTaskID(sub) {
|
||||
id = *sub.G15OfficeTaskID
|
||||
} else {
|
||||
created, err := createTask(ctx, deps, sub)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id = created
|
||||
}
|
||||
if err := attachScreenshot(ctx, deps, sub, id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// createTask files the submission as a new G15Office task and stores the
|
||||
// task id at once, so a later failure (the attachment) is retried against
|
||||
// this task rather than filing another.
|
||||
func createTask(ctx context.Context, deps SyncDeps, sub *models.Submission) (string, error) {
|
||||
settings, err := models.Instance(ctx, deps.DB)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -101,27 +134,37 @@ func fileTask(ctx context.Context, deps SyncDeps, sub *models.Submission) (strin
|
||||
if id == "" {
|
||||
return "", errors.New("g15office did not return a task id.")
|
||||
}
|
||||
file, err := ScreenshotOf(ctx, deps.DB, sub.ID)
|
||||
if err != nil {
|
||||
if err := deps.DB.WithContext(ctx).Model(&models.Submission{}).Where("id = ?", sub.ID).Updates(map[string]any{
|
||||
"g15office_task_id": id, "updated_at": time.Now(),
|
||||
}).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
if file != nil {
|
||||
if deps.Bucket == nil {
|
||||
return "", errors.New("feedback: upload bucket is not configured")
|
||||
}
|
||||
r, err := deps.Bucket.NewReader(ctx, attach.BlobKey(file.DiskName), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = deps.Client.AttachFile(ctx, id, file.FileName, file.ContentType, r)
|
||||
_ = r.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
sub.G15OfficeTaskID = &id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// attachScreenshot uploads the submission's screenshot, when it has one,
|
||||
// to the task.
|
||||
func attachScreenshot(ctx context.Context, deps SyncDeps, sub *models.Submission, id string) error {
|
||||
file, err := ScreenshotOf(ctx, deps.DB, sub.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if file == nil {
|
||||
return nil
|
||||
}
|
||||
if deps.Bucket == nil {
|
||||
return errors.New("feedback: upload bucket is not configured")
|
||||
}
|
||||
r, err := deps.Bucket.NewReader(ctx, attach.BlobKey(file.DiskName), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = deps.Client.AttachFile(ctx, id, file.FileName, file.ContentType, r)
|
||||
_ = r.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
// whitespaceRun is PCRE's \s+ without the u flag: ASCII space, tab, LF, VT, FF
|
||||
// and CR (Go's \s leaves out VT).
|
||||
var whitespaceRun = regexp.MustCompile(`[\t\n\v\f\r ]+`)
|
||||
|
||||
Reference in New Issue
Block a user