diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ca0db1..596672fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- SQLite job completion, rescue, and full-update paths now serialize timestamps using River's standard millisecond format instead of relying on database driver serialization, preventing inconsistent timestamp representations from being persisted. [PR #1353](https://github.com/riverqueue/river/pull/1353) + ## [0.44.0] - 2026-08-18 ### Added diff --git a/riverdriver/riverdrivertest/job_update.go b/riverdriver/riverdrivertest/job_update.go index 955f775a..a8d7e9b5 100644 --- a/riverdriver/riverdrivertest/job_update.go +++ b/riverdriver/riverdrivertest/job_update.go @@ -37,6 +37,10 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT } } + // Deliberately includes sub-millisecond precision so tests can verify that + // drivers normalize timestamps to their declared precision. + precisionTestTime := time.Date(2025, 4, 30, 13, 26, 39, 123400000, time.UTC) + t.Run("JobCancel", func(t *testing.T) { t.Parallel() @@ -147,7 +151,7 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT exec, bundle := setup(ctx, t) - now := time.Now().UTC() + now := precisionTestTime job1 := testfactory.Job(ctx, t, exec, &testfactory.JobOpts{ Metadata: []byte(`{"river:rescue_count": 5, "something": "else"}`), @@ -187,15 +191,15 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT require.NoError(t, err) require.Equal(t, "message1", updatedJob1.Errors[0].Error) require.Nil(t, updatedJob1.FinalizedAt) - require.WithinDuration(t, now, updatedJob1.ScheduledAt, bundle.driver.TimePrecision()) + require.Equal(t, now.Truncate(bundle.driver.TimePrecision()), updatedJob1.ScheduledAt) require.Equal(t, rivertype.JobStateAvailable, updatedJob1.State) require.JSONEq(t, `{"river:rescue_count": 6, "something": "else"}`, string(updatedJob1.Metadata)) updatedJob2, err := exec.JobGetByID(ctx, &riverdriver.JobGetByIDParams{ID: job2.ID}) require.NoError(t, err) require.Equal(t, "message2", updatedJob2.Errors[0].Error) - require.WithinDuration(t, now, *updatedJob2.FinalizedAt, bundle.driver.TimePrecision()) - require.WithinDuration(t, now, updatedJob2.ScheduledAt, bundle.driver.TimePrecision()) + require.Equal(t, now.Truncate(bundle.driver.TimePrecision()), *updatedJob2.FinalizedAt) + require.Equal(t, now.Truncate(bundle.driver.TimePrecision()), updatedJob2.ScheduledAt) require.Equal(t, rivertype.JobStateDiscarded, updatedJob2.State) require.JSONEq(t, `{"river:rescue_count": 1}`, string(updatedJob2.Metadata)) }) @@ -585,9 +589,9 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT t.Run("CompletesARunningJob", func(t *testing.T) { t.Parallel() - exec, _ := setup(ctx, t) + exec, bundle := setup(ctx, t) - now := time.Now().UTC() + now := precisionTestTime job := testfactory.Job(ctx, t, exec, &testfactory.JobOpts{ State: ptrutil.Ptr(rivertype.JobStateRunning), @@ -598,7 +602,7 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT require.NoError(t, err) jobAfter := jobsAfter[0] require.Equal(t, rivertype.JobStateCompleted, jobAfter.State) - require.WithinDuration(t, now, *jobAfter.FinalizedAt, time.Microsecond) + require.Equal(t, now.Truncate(bundle.driver.TimePrecision()), *jobAfter.FinalizedAt) jobUpdated, err := exec.JobGetByID(ctx, &riverdriver.JobGetByIDParams{ID: job.ID, Schema: ""}) require.NoError(t, err) @@ -670,9 +674,9 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT t.Run("SetsARunningJobToRetryable", func(t *testing.T) { t.Parallel() - exec, _ := setup(ctx, t) + exec, bundle := setup(ctx, t) - now := time.Now().UTC() + now := precisionTestTime job := testfactory.Job(ctx, t, exec, &testfactory.JobOpts{ State: ptrutil.Ptr(rivertype.JobStateRunning), @@ -683,7 +687,7 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT require.NoError(t, err) jobAfter := jobsAfter[0] require.Equal(t, rivertype.JobStateRetryable, jobAfter.State) - require.WithinDuration(t, now, jobAfter.ScheduledAt, time.Microsecond) + require.Equal(t, now.Truncate(bundle.driver.TimePrecision()), jobAfter.ScheduledAt) jobUpdated, err := exec.JobGetByID(ctx, &riverdriver.JobGetByIDParams{ID: job.ID, Schema: ""}) require.NoError(t, err) @@ -701,7 +705,7 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT t.Run("SetsAnInterruptedRunningJobToAvailableWithUpdatedAttempt", func(t *testing.T) { t.Parallel() - exec, _ := setup(ctx, t) + exec, bundle := setup(ctx, t) now := time.Now().UTC() attempt := 2 @@ -720,7 +724,7 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT require.Equal(t, attempt, jobAfter.Attempt) require.Equal(t, rivertype.JobStateAvailable, jobAfter.State) require.Equal(t, 3, jobAfter.MaxAttempts) - require.WithinDuration(t, now, jobAfter.ScheduledAt, time.Microsecond) + require.WithinDuration(t, now, jobAfter.ScheduledAt, bundle.driver.TimePrecision()) jobUpdated, err := exec.JobGetByID(ctx, &riverdriver.JobGetByIDParams{ID: job.ID, Schema: ""}) require.NoError(t, err) @@ -841,7 +845,7 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT t.Run("CancelsARunningJob", func(t *testing.T) { t.Parallel() - exec, _ := setup(ctx, t) + exec, bundle := setup(ctx, t) now := time.Now().UTC() @@ -855,7 +859,7 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT require.NoError(t, err) jobAfter := jobsAfter[0] require.Equal(t, rivertype.JobStateCancelled, jobAfter.State) - require.WithinDuration(t, now, *jobAfter.FinalizedAt, time.Microsecond) + require.WithinDuration(t, now, *jobAfter.FinalizedAt, bundle.driver.TimePrecision()) jobUpdated, err := exec.JobGetByID(ctx, &riverdriver.JobGetByIDParams{ID: job.ID, Schema: ""}) require.NoError(t, err) @@ -870,7 +874,7 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT t.Run("DiscardsARunningJob", func(t *testing.T) { t.Parallel() - exec, _ := setup(ctx, t) + exec, bundle := setup(ctx, t) now := time.Now().UTC() @@ -884,7 +888,7 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT require.NoError(t, err) jobAfter := jobsAfter[0] require.Equal(t, rivertype.JobStateDiscarded, jobAfter.State) - require.WithinDuration(t, now, *jobAfter.FinalizedAt, time.Microsecond) + require.WithinDuration(t, now, *jobAfter.FinalizedAt, bundle.driver.TimePrecision()) require.Equal(t, "unique-key", string(jobAfter.UniqueKey)) require.Equal(t, rivertype.JobStates(), jobAfter.UniqueStates) @@ -900,7 +904,7 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT t.Run("SnoozesARunningJob_WithNoPreexistingMetadata", func(t *testing.T) { t.Parallel() - exec, _ := setup(ctx, t) + exec, bundle := setup(ctx, t) now := time.Now().UTC() snoozeUntil := now.Add(1 * time.Minute) @@ -918,7 +922,7 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT require.Equal(t, job.MaxAttempts, jobAfter.MaxAttempts) require.JSONEq(t, `{"snoozes": 1}`, string(jobAfter.Metadata)) require.Equal(t, rivertype.JobStateScheduled, jobAfter.State) - require.WithinDuration(t, snoozeUntil, jobAfter.ScheduledAt, time.Microsecond) + require.WithinDuration(t, snoozeUntil, jobAfter.ScheduledAt, bundle.driver.TimePrecision()) jobUpdated, err := exec.JobGetByID(ctx, &riverdriver.JobGetByIDParams{ID: job.ID, Schema: ""}) require.NoError(t, err) @@ -932,7 +936,7 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT t.Run("SnoozesARunningJob_WithPreexistingMetadata", func(t *testing.T) { t.Parallel() - exec, _ := setup(ctx, t) + exec, bundle := setup(ctx, t) now := time.Now().UTC() snoozeUntil := now.Add(1 * time.Minute) @@ -951,7 +955,7 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT require.Equal(t, job.MaxAttempts, jobAfter.MaxAttempts) require.JSONEq(t, `{"foo": "bar", "snoozes": 6}`, string(jobAfter.Metadata)) require.Equal(t, rivertype.JobStateScheduled, jobAfter.State) - require.WithinDuration(t, snoozeUntil, jobAfter.ScheduledAt, time.Microsecond) + require.WithinDuration(t, snoozeUntil, jobAfter.ScheduledAt, bundle.driver.TimePrecision()) jobUpdated, err := exec.JobGetByID(ctx, &riverdriver.JobGetByIDParams{ID: job.ID, Schema: ""}) require.NoError(t, err) @@ -966,7 +970,7 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT t.Run("JobSetStateIfRunningMany_MultipleJobsAtOnce", func(t *testing.T) { t.Parallel() - exec, _ := setup(ctx, t) + exec, bundle := setup(ctx, t) now := time.Now().UTC() future := now.Add(10 * time.Second) @@ -985,12 +989,12 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT require.Len(t, jobsAfter, 3) completedJob := jobsAfter[0] require.Equal(t, rivertype.JobStateCompleted, completedJob.State) - require.WithinDuration(t, now, *completedJob.FinalizedAt, time.Microsecond) + require.WithinDuration(t, now, *completedJob.FinalizedAt, bundle.driver.TimePrecision()) require.JSONEq(t, `{"a":"b"}`, string(completedJob.Metadata)) retryableJob := jobsAfter[1] require.Equal(t, rivertype.JobStateRetryable, retryableJob.State) - require.WithinDuration(t, future, retryableJob.ScheduledAt, time.Microsecond) + require.WithinDuration(t, future, retryableJob.ScheduledAt, bundle.driver.TimePrecision()) // validate error payload: require.Len(t, retryableJob.Errors, 1) require.Equal(t, now, retryableJob.Errors[0].At) @@ -1000,7 +1004,7 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT cancelledJob := jobsAfter[2] require.Equal(t, rivertype.JobStateCancelled, cancelledJob.State) - require.WithinDuration(t, now, *cancelledJob.FinalizedAt, time.Microsecond) + require.WithinDuration(t, now, *cancelledJob.FinalizedAt, bundle.driver.TimePrecision()) }) t.Run("JobUpdate", func(t *testing.T) { @@ -1051,7 +1055,7 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT job := testfactory.Job(ctx, t, exec, &testfactory.JobOpts{}) - now := time.Now().UTC() + now := precisionTestTime updatedJob, err := exec.JobUpdateFull(ctx, &riverdriver.JobUpdateFullParams{ ID: job.ID, @@ -1074,10 +1078,10 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT }) require.NoError(t, err) require.Equal(t, 7, updatedJob.Attempt) - require.WithinDuration(t, now, *updatedJob.AttemptedAt, bundle.driver.TimePrecision()) + require.Equal(t, now.Truncate(bundle.driver.TimePrecision()), *updatedJob.AttemptedAt) require.Equal(t, []string{"worker1"}, updatedJob.AttemptedBy) require.Equal(t, "message", updatedJob.Errors[0].Error) - require.WithinDuration(t, now, *updatedJob.FinalizedAt, bundle.driver.TimePrecision()) + require.Equal(t, now.Truncate(bundle.driver.TimePrecision()), *updatedJob.FinalizedAt) require.Equal(t, 99, updatedJob.MaxAttempts) require.JSONEq(t, `{"foo":"bar"}`, string(updatedJob.Metadata)) require.Equal(t, rivertype.JobStateDiscarded, updatedJob.State) diff --git a/riverdriver/riversqlite/internal/dbsqlc/river_job.sql b/riverdriver/riversqlite/internal/dbsqlc/river_job.sql index 3ba74a82..6f8bd9a4 100644 --- a/riverdriver/riversqlite/internal/dbsqlc/river_job.sql +++ b/riverdriver/riversqlite/internal/dbsqlc/river_job.sql @@ -489,7 +489,7 @@ UPDATE /* TEMPLATE: schema */river_job SET errors = jsonb(json_insert(json(coalesce(errors, jsonb('[]'))), '$[#]', json(@error))), finalized_at = cast(sqlc.narg('finalized_at') as text), - scheduled_at = @scheduled_at, + scheduled_at = cast(@scheduled_at AS text), metadata = jsonb_set( metadata, '$."river:rescue_count"', @@ -606,13 +606,13 @@ SET finalized_at = CASE WHEN /* should_cancel */((@state = 'available' OR @state = 'retryable' OR @state = 'scheduled') AND (metadata -> 'cancel_attempted_at') IS NOT NULL) THEN coalesce(cast(sqlc.narg('now') AS text), datetime('now', 'subsec')) WHEN cast(@finalized_at_do_update AS boolean) - THEN @finalized_at + THEN cast(sqlc.narg('finalized_at') AS text) ELSE finalized_at END, metadata = CASE WHEN cast(@metadata_do_merge AS boolean) THEN jsonb_patch(json(metadata), json(@metadata_updates)) ELSE metadata END, scheduled_at = CASE WHEN /* NOT should_cancel */(cast(@state AS text) <> 'available' AND @state <> 'retryable' AND @state <> 'scheduled' OR (metadata -> 'cancel_attempted_at') IS NULL) AND cast(@scheduled_at_do_update AS boolean) - THEN @scheduled_at + THEN cast(@scheduled_at AS text) ELSE scheduled_at END, state = CASE WHEN /* should_cancel */((@state = 'available' OR @state = 'retryable' OR @state = 'scheduled') AND (metadata -> 'cancel_attempted_at') IS NOT NULL) THEN 'cancelled' @@ -634,10 +634,10 @@ RETURNING *; UPDATE /* TEMPLATE: schema */river_job SET attempt = CASE WHEN cast(@attempt_do_update AS boolean) THEN @attempt ELSE attempt END, - attempted_at = CASE WHEN cast(@attempted_at_do_update AS boolean) THEN @attempted_at ELSE attempted_at END, + attempted_at = CASE WHEN cast(@attempted_at_do_update AS boolean) THEN cast(sqlc.narg('attempted_at') AS text) ELSE attempted_at END, attempted_by = CASE WHEN cast(@attempted_by_do_update AS boolean) THEN jsonb(@attempted_by) ELSE attempted_by END, errors = CASE WHEN cast(@errors_do_update AS boolean) THEN jsonb(@errors) ELSE errors END, - finalized_at = CASE WHEN cast(@finalized_at_do_update AS boolean) THEN @finalized_at ELSE finalized_at END, + finalized_at = CASE WHEN cast(@finalized_at_do_update AS boolean) THEN cast(sqlc.narg('finalized_at') AS text) ELSE finalized_at END, max_attempts = CASE WHEN cast(@max_attempts_do_update AS boolean) THEN @max_attempts ELSE max_attempts END, metadata = CASE WHEN cast(@metadata_do_update AS boolean) THEN jsonb(@metadata) ELSE metadata END, state = CASE WHEN cast(@state_do_update AS boolean) THEN @state ELSE state END diff --git a/riverdriver/riversqlite/internal/dbsqlc/river_job.sql.go b/riverdriver/riversqlite/internal/dbsqlc/river_job.sql.go index 1a8c2971..61ef76b9 100644 --- a/riverdriver/riversqlite/internal/dbsqlc/river_job.sql.go +++ b/riverdriver/riversqlite/internal/dbsqlc/river_job.sql.go @@ -9,7 +9,6 @@ import ( "context" "database/sql" "strings" - "time" ) const jobCancel = `-- name: JobCancel :one @@ -1245,7 +1244,7 @@ UPDATE /* TEMPLATE: schema */river_job SET errors = jsonb(json_insert(json(coalesce(errors, jsonb('[]'))), '$[#]', json(?1))), finalized_at = cast(?2 as text), - scheduled_at = ?3, + scheduled_at = cast(?3 AS text), metadata = jsonb_set( metadata, '$."river:rescue_count"', @@ -1264,7 +1263,7 @@ WHERE id = ?5 type JobRescueParams struct { Error interface{} FinalizedAt *string - ScheduledAt time.Time + ScheduledAt string State string ID int64 } @@ -1645,13 +1644,13 @@ SET finalized_at = CASE WHEN /* should_cancel */((?1 = 'available' OR ?1 = 'retryable' OR ?1 = 'scheduled') AND (metadata -> 'cancel_attempted_at') IS NOT NULL) THEN coalesce(cast(?6 AS text), datetime('now', 'subsec')) WHEN cast(?7 AS boolean) - THEN ?8 + THEN cast(?8 AS text) ELSE finalized_at END, metadata = CASE WHEN cast(?9 AS boolean) THEN jsonb_patch(json(metadata), json(?10)) ELSE metadata END, scheduled_at = CASE WHEN /* NOT should_cancel */(cast(?1 AS text) <> 'available' AND ?1 <> 'retryable' AND ?1 <> 'scheduled' OR (metadata -> 'cancel_attempted_at') IS NULL) AND cast(?11 AS boolean) - THEN ?12 + THEN cast(?12 AS text) ELSE scheduled_at END, state = CASE WHEN /* should_cancel */((?1 = 'available' OR ?1 = 'retryable' OR ?1 = 'scheduled') AND (metadata -> 'cancel_attempted_at') IS NOT NULL) THEN 'cancelled' @@ -1669,11 +1668,11 @@ type JobSetStateIfRunningParams struct { Error interface{} Now *string FinalizedAtDoUpdate bool - FinalizedAt *time.Time + FinalizedAt *string MetadataDoMerge bool MetadataUpdates interface{} ScheduledAtDoUpdate bool - ScheduledAt time.Time + ScheduledAt string ID int64 } @@ -1764,10 +1763,10 @@ const jobUpdateFull = `-- name: JobUpdateFull :one UPDATE /* TEMPLATE: schema */river_job SET attempt = CASE WHEN cast(?1 AS boolean) THEN ?2 ELSE attempt END, - attempted_at = CASE WHEN cast(?3 AS boolean) THEN ?4 ELSE attempted_at END, + attempted_at = CASE WHEN cast(?3 AS boolean) THEN cast(?4 AS text) ELSE attempted_at END, attempted_by = CASE WHEN cast(?5 AS boolean) THEN jsonb(?6) ELSE attempted_by END, errors = CASE WHEN cast(?7 AS boolean) THEN jsonb(?8) ELSE errors END, - finalized_at = CASE WHEN cast(?9 AS boolean) THEN ?10 ELSE finalized_at END, + finalized_at = CASE WHEN cast(?9 AS boolean) THEN cast(?10 AS text) ELSE finalized_at END, max_attempts = CASE WHEN cast(?11 AS boolean) THEN ?12 ELSE max_attempts END, metadata = CASE WHEN cast(?13 AS boolean) THEN jsonb(?14) ELSE metadata END, state = CASE WHEN cast(?15 AS boolean) THEN ?16 ELSE state END @@ -1779,13 +1778,13 @@ type JobUpdateFullParams struct { AttemptDoUpdate bool Attempt int64 AttemptedAtDoUpdate bool - AttemptedAt *time.Time + AttemptedAt *string AttemptedByDoUpdate bool AttemptedBy interface{} ErrorsDoUpdate bool Errors interface{} FinalizedAtDoUpdate bool - FinalizedAt *time.Time + FinalizedAt *string MaxAttemptsDoUpdate bool MaxAttempts int64 MetadataDoUpdate bool diff --git a/riverdriver/riversqlite/river_sqlite_driver.go b/riverdriver/riversqlite/river_sqlite_driver.go index 3499dae8..c819c097 100644 --- a/riverdriver/riversqlite/river_sqlite_driver.go +++ b/riverdriver/riversqlite/river_sqlite_driver.go @@ -725,7 +725,7 @@ func (e *Executor) JobRescueMany(ctx context.Context, params *riverdriver.JobRes ID: params.ID[i], Error: params.Error[i], FinalizedAt: timeStringNullable(params.FinalizedAt[i]), - ScheduledAt: params.ScheduledAt[i].UTC(), + ScheduledAt: timeString(params.ScheduledAt[i]), State: params.State[i], }); err != nil { return interpretError(err) @@ -905,7 +905,7 @@ func (e *Executor) JobSetStateIfRunningMany(ctx context.Context, params *riverdr } if params.FinalizedAt[i] != nil { setStateParams.FinalizedAtDoUpdate = true - setStateParams.FinalizedAt = params.FinalizedAt[i] + setStateParams.FinalizedAt = timeStringNullable(params.FinalizedAt[i]) } if params.MetadataDoMerge[i] { setStateParams.MetadataDoMerge = true @@ -913,7 +913,7 @@ func (e *Executor) JobSetStateIfRunningMany(ctx context.Context, params *riverdr } if params.ScheduledAt[i] != nil { setStateParams.ScheduledAtDoUpdate = true - setStateParams.ScheduledAt = *params.ScheduledAt[i] + setStateParams.ScheduledAt = timeString(*params.ScheduledAt[i]) } job, err := dbsqlc.New().JobSetStateIfRunning(ctx, dbtx, setStateParams) @@ -969,11 +969,6 @@ func (e *Executor) JobUpdate(ctx context.Context, params *riverdriver.JobUpdateP } func (e *Executor) JobUpdateFull(ctx context.Context, params *riverdriver.JobUpdateFullParams) (*rivertype.JobRow, error) { - attemptedAt := params.AttemptedAt - if attemptedAt != nil { - attemptedAt = ptrutil.Ptr(attemptedAt.UTC()) - } - attemptedBy, err := json.Marshal(params.AttemptedBy) if err != nil { return nil, err @@ -984,11 +979,6 @@ func (e *Executor) JobUpdateFull(ctx context.Context, params *riverdriver.JobUpd return nil, err } - finalizedAt := params.FinalizedAt - if finalizedAt != nil { - finalizedAt = ptrutil.Ptr(finalizedAt.UTC()) - } - metadata := params.Metadata if metadata == nil { metadata = []byte("{}") @@ -998,14 +988,14 @@ func (e *Executor) JobUpdateFull(ctx context.Context, params *riverdriver.JobUpd ID: params.ID, Attempt: int64(params.Attempt), AttemptDoUpdate: params.AttemptDoUpdate, - AttemptedAt: attemptedAt, + AttemptedAt: timeStringNullable(params.AttemptedAt), AttemptedAtDoUpdate: params.AttemptedAtDoUpdate, AttemptedBy: attemptedBy, AttemptedByDoUpdate: params.AttemptedByDoUpdate, ErrorsDoUpdate: params.ErrorsDoUpdate, Errors: errors, FinalizedAtDoUpdate: params.FinalizedAtDoUpdate, - FinalizedAt: finalizedAt, + FinalizedAt: timeStringNullable(params.FinalizedAt), MaxAttemptsDoUpdate: params.MaxAttemptsDoUpdate, MaxAttempts: int64(min(params.MaxAttempts, math.MaxInt64)), MetadataDoUpdate: params.MetadataDoUpdate,