Repository: furiko-io/furiko (go)

Issue:
Bug: Cannot set DefaultPendingTimeoutSeconds to 0
In the JobControllerConfig spec, it mentions:

> To disable default pending timeout, set this to 0 or a negative number.

However, using 0 will not work, because it is a zero value and merging logic will skip over the field.

Some recommendations:

1. Change the type from `int64` to `*int64` to support specifying `0`. This may need to be done for other configs as well.
2. Don't allow negative values here, and use `0` to indicate that it should be disabled.
3. The current dynamic configuration implementation uses spf13/viper to merge configs. We should switch to a more predictable implementation (e.g. using https://github.com/imdario/mergo)

Pull request:
fix: Update config values to be nillable
Fixes #28.

Patch:
diff --git a/apis/config/v1/dynamicconfig_types.go b/apis/config/v1/dynamicconfig_types.go
index bab2af7..48b2f71 100644
--- a/apis/config/v1/dynamicconfig_types.go
+++ b/apis/config/v1/dynamicconfig_types.go
@@ -29,36 +29,36 @@ type JobControllerConfig struct {
 
 	// DefaultTTLSecondsAfterFinished is the default time-to-live (TTL) for a Job
 	// after it has finished. Lower this value to reduce the strain on the
-	// cluster/kubelet. This field can be set separately on each Job as well.
+	// cluster/kubelet. Set to 0 to delete immediately after the Job is finished.
 	//
 	// Default: 3600
 	// +optional
-	DefaultTTLSecondsAfterFinished int64 `json:"defaultTTLSecondsAfterFinished,omitempty"`
+	DefaultTTLSecondsAfterFinished *int64 `json:"defaultTTLSecondsAfterFinished,omitempty"`
 
 	// DefaultPendingTimeoutSeconds is default timeout to use if job does not
 	// specify the pending timeout. By default, this is a non-zero value to prevent
-	// permanently stuck jobs. To disable default pending timeout, set this to 0 or
-	// a negative number.
+	// permanently stuck jobs. To disable default pending timeout, set this to 0.
 	//
 	// Default: 900
 	// +optional
-	DefaultPendingTimeoutSeconds int64 `json:"defaultPendingTimeoutSeconds,omitempty"`
+	DefaultPendingTimeoutSeconds *int64 `json:"defaultPendingTimeoutSeconds,omitempty"`
 
 	// DeleteKillingTasksTimeoutSeconds is the duration we delete the task to kill
 	// it instead of using active deadline, if previous efforts were ineffective.
+	// Set this value to 0 to immediately use deletion.
 	//
 	// Default: 180
 	// +optional
-	DeleteKillingTasksTimeoutSeconds int64 `json:"deleteKillingTasksTimeoutSeconds,omitempty"`
+	DeleteKillingTasksTimeoutSeconds *int64 `json:"deleteKillingTasksTimeoutSeconds,omitempty"`
 
 	// ForceDeleteKillingTasksTimeoutSeconds is the duration before we use force
 	// deletion instead of normal deletion. This timeout is computed from the
 	// deletionTimestamp of the object, which may also include an additional delay
-	// of deletionGracePeriodSeconds.
+	// of deletionGracePeriodSeconds. Set this value to 0 to disable force deletion.
 	//
 	// Default: 120
 	// +optional
-	ForceDeleteKillingTasksTimeoutSeconds int64 `json:"forceDeleteKillingTasksTimeoutSeconds,omitempty"`
+	ForceDeleteKillingTasksTimeoutSeconds *int64 `json:"forceDeleteKillingTasksTimeoutSeconds,omitempty"`
 }
 
 // +kubebuilder:object:root=true
@@ -129,11 +129,12 @@ type CronControllerConfig struct {
 	// MaxMissedSchedules defines a maximum number of jobs that the controller
 	// should back-schedule, or attempt to create after coming back up from
 	// downtime. Having a sane value here would prevent a thundering herd of jobs
-	// being scheduled that would exhaust resources in the cluster.
+	// being scheduled that would exhaust resources in the cluster. Set this to 0 to
+	// disable back-scheduling.
 	//
 	// Default: 5
 	// +optional
-	MaxMissedSchedules int64 `json:"maxMissedSchedules,omitempty"`
+	MaxMissedSchedules *int64 `json:"maxMissedSchedules,omitempty"`
 
 	// MaxDowntimeThresholdSeconds defines the maximum downtime that the controller
 	// can tolerate. If the controller was intentionally shut down for an extended
diff --git a/apis/config/v1/zz_generated.deepcopy.go b/apis/config/v1/zz_generated.deepcopy.go
index c5bdd73..dc253ff 100644
--- a/apis/config/v1/zz_generated.deepcopy.go
+++ b/apis/config/v1/zz_generated.deepcopy.go
@@ -110,6 +110,11 @@ func (in *CronControllerConfig) DeepCopyInto(out *CronControllerConfig) {
 		*out = new(string)
 		**out = **in
 	}
+	if in.MaxMissedSchedules != nil {
+		in, out := &in.MaxMissedSchedules, &out.MaxMissedSchedules
+		*out = new(int64)
+		**out = **in
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CronControllerConfig.
@@ -300,6 +305,26 @@ func (in *HealthSpec) DeepCopy() *HealthSpec {
 func (in *JobControllerConfig) DeepCopyInto(out *JobControllerConfig) {
 	*out = *in
 	out.TypeMeta = in.TypeMeta
+	if in.DefaultTTLSecondsAfterFinished != nil {
+		in, out := &in.DefaultTTLSecondsAfterFinished, &out.DefaultTTLSecondsAfterFinished
+		*out = new(int64)
+		**out = **in
+	}
+	if in.DefaultPendingTimeoutSeconds != nil {
+		in, out := &in.DefaultPendingTimeoutSeconds, &out.DefaultPendingTimeoutSeconds
+		*out = new(int64)
+		**out = **in
+	}
+	if in.DeleteKillingTasksTimeoutSeconds != nil {
+		in, out := &in.DeleteKillingTasksTimeoutSeconds, &out.DeleteKillingTasksTimeoutSeconds
+		*out = new(int64)
+		**out = **in
+	}
+	if in.ForceDeleteKillingTasksTimeoutSeconds != nil {
+		in, out := &in.ForceDeleteKillingTasksTimeoutSeconds, &out.ForceDeleteKillingTasksTimeoutSeconds
+		*out = new(int64)
+		**out = **in
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JobControllerConfig.
diff --git a/apis/execution/v1alpha1/job_types.go b/apis/execution/v1alpha1/job_types.go
index 12094a8..8b1a86f 100644
--- a/apis/execution/v1alpha1/job_types.go
+++ b/apis/execution/v1alpha1/job_types.go
@@ -100,7 +100,7 @@ type JobSpec struct {
 	// controller.
 	//
 	// +optional
-	TTLSecondsAfterFinished *int32 `json:"ttlSecondsAfterFinished,omitempty"`
+	TTLSecondsAfterFinished *int64 `json:"ttlSecondsAfterFinished,omitempty"`
 }
 
 type JobType string
diff --git a/apis/execution/v1alpha1/zz_generated.deepcopy.go b/apis/execution/v1alpha1/zz_generated.deepcopy.go
index f291a1e..7d777fc 100644
--- a/apis/execution/v1alpha1/zz_generated.deepcopy.go
+++ b/apis/execution/v1alpha1/zz_generated.deepcopy.go
@@ -410,7 +410,7 @@ func (in *JobSpec) DeepCopyInto(out *JobSpec) {
 	}
 	if in.TTLSecondsAfterFinished != nil {
 		in, out := &in.TTLSecondsAfterFinished, &out.TTLSecondsAfterFinished
-		*out = new(int32)
+		*out = new(int64)
 		**out = **in
 	}
 }
diff --git a/config/crd/bases/execution.furiko.io_jobs.yaml b/config/crd/bases/execution.furiko.io_jobs.yaml
index ced50b3..5d6f688 100644
--- a/config/crd/bases/execution.furiko.io_jobs.yaml
+++ b/config/crd/bases/execution.furiko.io_jobs.yaml
@@ -4161,7 +4161,7 @@ spec:
                   type: object
                 ttlSecondsAfterFinished:
                   description: Specifies the maximum lifetime of a Job that is finished. If not set, it will be set to the DefaultTTLSecondsAfterFinished configuration value in the controller.
-                  format: int32
+                  format: int64
                   type: integer
                 type:
                   description: "Specifies the type of Job. Can be one of: Adhoc, Scheduled \n Default: Adhoc"
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index ecdd06d..03db3ce 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -24,10 +24,10 @@ import (
 
 var (
 	DefaultJobControllerConfig = &configv1.JobControllerConfig{
-		DefaultTTLSecondsAfterFinished:        3600,
-		DefaultPendingTimeoutSeconds:          900,
-		DeleteKillingTasksTimeoutSeconds:      180,
-		ForceDeleteKillingTasksTimeoutSeconds: 120,
+		DefaultTTLSecondsAfterFinished:        pointer.Int64(3600),
+		DefaultPendingTimeoutSeconds:          pointer.Int64(900),
+		DeleteKillingTasksTimeoutSeconds:      pointer.Int64(180),
+		ForceDeleteKillingTasksTimeoutSeconds: pointer.Int64(120),
 	}
 
 	DefaultCronControllerConfig = &configv1.CronControllerConfig{
@@ -35,7 +35,7 @@ var (
 		CronHashNames:               pointer.Bool(true),
 		CronHashSecondsByDefault:    pointer.Bool(false),
 		CronHashFields:              pointer.Bool(true),
-		MaxMissedSchedules:          5,
+		MaxMissedSchedules:          pointer.Int64(5),
 		MaxDowntimeThresholdSeconds: 300,
 		DefaultTimezone:             pointer.String("UTC"),
 	}
diff --git a/pkg/execution/controllers/croncontroller/cron.go b/pkg/execution/controllers/croncontroller/cron.go
index 0af1e42..9c194fb 100644
--- a/pkg/execution/controllers/croncontroller/cron.go
+++ b/pkg/execution/controllers/croncontroller/cron.go
@@ -161,9 +161,9 @@ func (w *CronWorker) syncOne(
 	}
 	now := Clock.Now().In(timezone)
 
-	maxMissedSchedules := 5
-	if cfg.MaxMissedSchedules > 0 {
-		maxMissedSchedules = int(cfg.MaxMissedSchedules)
+	var maxMissedSchedules int
+	if spec := cfg.MaxMissedSchedules; spec != nil {
+		maxMissedSchedules = int(*spec)
 	}
 
 	// Get next scheduled time repeatedly and enqueue for each scheduled time.
diff --git a/pkg/execution/controllers/jobcontroller/reconciler.go b/pkg/execution/controllers/jobcontroller/reconciler.go
index 30424c4..e7e98cd 100644
--- a/pkg/execution/controllers/jobcontroller/reconciler.go
+++ b/pkg/execution/controllers/jobcontroller/reconciler.go
@@ -430,7 +430,7 @@ func (w *Reconciler) handlePendingTasks(ctx context.Context, rj *execution.Job,
 	cfg *configv1.JobControllerConfig) error {
 	now := ktime.Now().Time
 
-	pendingTimeout := jobutil.GetPendingTimeout(rj, cfg.DefaultPendingTimeoutSeconds)
+	pendingTimeout := jobutil.GetPendingTimeout(rj, cfg)
 
 	// Pending timeout is disabled.
 	if pendingTimeout <= 0 {
@@ -541,7 +541,7 @@ func (w *Reconciler) handleKillJob(ctx context.Context, rj *execution.Job, tasks
 func (w *Reconciler) handleDeleteKillingTasks(
 	ctx context.Context, rj *execution.Job, tasks []jobtasks.Task, cfg *configv1.JobControllerConfig,
 ) (*execution.Job, error) {
-	timeout := time.Duration(cfg.DeleteKillingTasksTimeoutSeconds) * time.Second
+	timeout := jobutil.GetDeleteKillingTimeout(cfg)
 	needDelete := make([]jobtasks.Task, 0, len(tasks))
 	needDeleteMap := make(map[string]jobtasks.Task)
 
@@ -626,7 +626,13 @@ func (w *Reconciler) handleDeleteKillingTasks(
 func (w *Reconciler) handleForceDeleteKillingTasks(
 	ctx context.Context, rj *execution.Job, tasks []jobtasks.Task, cfg *configv1.JobControllerConfig,
 ) (*execution.Job, error) {
-	timeout := time.Duration(cfg.ForceDeleteKillingTasksTimeoutSeconds) * time.Second
+	timeout := jobutil.GetForceDeleteKillingTimeout(cfg)
+
+	// Force deletion is disabled.
+	if timeout <= 0 {
+		return rj, nil
+	}
+
 	needDelete := make([]jobtasks.Task, 0, len(tasks))
 	needDeleteMap := make(map[string]jobtasks.Task)
 
@@ -643,7 +649,7 @@ func (w *Reconciler) handleForceDeleteKillingTasks(
 			continue
 		}
 
-		if deletionTS.Add(timeout).Before(ktime.Now().Time) {
+		if !deletionTS.Add(timeout).After(ktime.Now().Time) {
 			klog.InfoS("jobcontroller: worker force deleting killing task",
 				"worker", w.Name(),
 				"namespace", rj.GetNamespace(),
@@ -710,12 +716,7 @@ func (w *Reconciler) handleTTLAfterFinished(
 	rj *execution.Job,
 	cfg *configv1.JobControllerConfig,
 ) error {
-	// Get TTL from JobSpec or fall back to global default.
-	ttl := rj.Spec.TTLSecondsAfterFinished
-	if ttl == nil {
-		spec := int32(cfg.DefaultTTLSecondsAfterFinished)
-		ttl = &spec
-	}
+	ttl := jobutil.GetTTLAfterFinished(rj, cfg)
 
 	// Skip if already being deleted.
 	if isDeleted(rj) {
@@ -728,8 +729,7 @@ func (w *Reconciler) handleTTLAfterFinished(
 	}
 
 	// Not yet expired.
-	duration := time.Duration(*ttl) * time.Second
-	if rj.Status.Condition.Finished.FinishedAt.Add(duration).After(ktime.Now().Time) {
+	if rj.Status.Condition.Finished.FinishedAt.Add(ttl).After(ktime.Now().Time) {
 		return nil
 	}
 
diff --git a/pkg/execution/mutation/mutation.go b/pkg/execution/mutation/mutation.go
index 12738aa..c5b3a8e 100644
--- a/pkg/execution/mutation/mutation.go
+++ b/pkg/execution/mutation/mutation.go
@@ -133,8 +133,8 @@ func (m *Mutator) MutateJob(rj *v1alpha1.Job) *webhook.Result {
 	if rj.Spec.Type == "" {
 		rj.Spec.Type = v1alpha1.JobTypeAdhoc
 	}
-	if ttl := int32(cfg.DefaultTTLSecondsAfterFinished); ttl > 0 && rj.Spec.TTLSecondsAfterFinished == nil {
-		rj.Spec.TTLSecondsAfterFinished = &ttl
+	if rj.Spec.TTLSecondsAfterFinished == nil {
+		rj.Spec.TTLSecondsAfterFinished = cfg.DefaultTTLSecondsAfterFinished
 	}
 
 	return result
diff --git a/pkg/execution/validation/validation.go b/pkg/execution/validation/validation.go
index 38c65e7..17859e8 100644
--- a/pkg/execution/validation/validation.go
+++ b/pkg/execution/validation/validation.go
@@ -282,7 +282,7 @@ func (v *Validator) ValidateJobSpec(spec *v1alpha1.JobSpec, fldPath *field.Path)
 		allErrs = append(allErrs, v.ValidateJobTemplateSpec(spec.Template, fldPath.Child("template"))...)
 	}
 	if spec.TTLSecondsAfterFinished != nil {
-		allErrs = append(allErrs, apivalidation.ValidateNonnegativeField(int64(*spec.TTLSecondsAfterFinished), fldPath.Child("ttlSecondsAfterFinished"))...)
+		allErrs = append(allErrs, apivalidation.ValidateNonnegativeField(*spec.TTLSecondsAfterFinished, fldPath.Child("ttlSecondsAfterFinished"))...)
 	}
 	return allErrs
 }
diff --git a/pkg/utils/execution/job/timeout.go b/pkg/utils/execution/job/timeout.go
index db373d0..dedb695 100644
--- a/pkg/utils/execution/job/timeout.go
+++ b/pkg/utils/execution/job/timeout.go
@@ -19,17 +19,48 @@ package job
 import (
 	"time"
 
+	configv1 "github.com/furiko-io/furiko/apis/config/v1"
 	execution "github.com/furiko-io/furiko/apis/execution/v1alpha1"
 )
 
 // GetPendingTimeout returns the pending timeout for the given Job.
-func GetPendingTimeout(rj *execution.Job, defaultTimeoutSeconds int64) time.Duration {
-	seconds := defaultTimeoutSeconds
-
-	// Override with configured timeout from task.
+func GetPendingTimeout(rj *execution.Job, cfg *configv1.JobControllerConfig) time.Duration {
+	var sec int64
+	if spec := cfg.DefaultPendingTimeoutSeconds; spec != nil {
+		sec = *spec
+	}
 	if taskPt := rj.Spec.Template.Task.PendingTimeoutSeconds; taskPt != nil && *taskPt >= 0 {
-		seconds = *taskPt
+		sec = *taskPt
+	}
+	return time.Duration(sec) * time.Second
+}
+
+// GetDeleteKillingTimeout returns the timeout before the controller starts killing tasks with deletion.
+func GetDeleteKillingTimeout(cfg *configv1.JobControllerConfig) time.Duration {
+	var sec int64
+	if spec := cfg.DeleteKillingTasksTimeoutSeconds; spec != nil {
+		sec = *spec
 	}
+	return time.Duration(sec) * time.Second
+}
 
-	return time.Duration(seconds) * time.Second
+// GetForceDeleteKillingTimeout returns the timeout before the controller starts force deletion.
+func GetForceDeleteKillingTimeout(cfg *configv1.JobControllerConfig) time.Duration {
+	var sec int64
+	if spec := cfg.ForceDeleteKillingTasksTimeoutSeconds; spec != nil {
+		sec = *spec
+	}
+	return time.Duration(sec) * time.Second
+}
+
+// GetTTLAfterFinished returns the TTL after a Job is finished.
+func GetTTLAfterFinished(rj *execution.Job, cfg *configv1.JobControllerConfig) time.Duration {
+	var sec int64
+	if spec := cfg.DefaultTTLSecondsAfterFinished; spec != nil {
+		sec = *spec
+	}
+	if ttl := rj.Spec.TTLSecondsAfterFinished; ttl != nil {
+		sec = *ttl
+	}
+	return time.Duration(sec) * time.Second
 }


Test patch:
diff --git a/pkg/execution/controllers/jobcontroller/controller_test.go b/pkg/execution/controllers/jobcontroller/controller_test.go
index a2afc5f..c79dfe7 100644
--- a/pkg/execution/controllers/jobcontroller/controller_test.go
+++ b/pkg/execution/controllers/jobcontroller/controller_test.go
@@ -26,6 +26,7 @@ import (
 
 	executiongroup "github.com/furiko-io/furiko/apis/execution"
 	execution "github.com/furiko-io/furiko/apis/execution/v1alpha1"
+	"github.com/furiko-io/furiko/pkg/config"
 	"github.com/furiko-io/furiko/pkg/execution/controllers/jobcontroller"
 	"github.com/furiko-io/furiko/pkg/execution/taskexecutor/podtaskexecutor"
 	"github.com/furiko-io/furiko/pkg/execution/tasks"
@@ -38,14 +39,13 @@ const (
 	jobNamespace = "test"
 	jobName      = "my-sample-job"
 
-	createTime     = "2021-02-09T04:06:00Z"
-	startTime      = "2021-02-09T04:06:01Z"
-	killTime       = "2021-02-09T04:06:10Z"
-	finishTime     = "2021-02-09T04:06:18Z"
-	now            = "2021-02-09T04:06:05Z"
-	later15m       = "2021-02-09T04:21:00Z"
-	later60m       = "2021-02-09T05:06:00Z"
-	finishAfterTTL = "2021-02-09T05:06:18Z"
+	createTime = "2021-02-09T04:06:00Z"
+	startTime  = "2021-02-09T04:06:01Z"
+	killTime   = "2021-02-09T04:06:10Z"
+	finishTime = "2021-02-09T04:06:18Z"
+	now        = "2021-02-09T04:06:05Z"
+	later15m   = "2021-02-09T04:21:00Z"
+	later60m   = "2021-02-09T05:06:00Z"
 )
 
 var (
@@ -171,6 +171,25 @@ var (
 		return newJob
 	}()
 
+	// Job with pod being deleted and force deletion is not allowed.
+	fakeJobPodDeletingForbidForceDeletion = func() *execution.Job {
+		newJob := fakeJobPodDeleting.DeepCopy()
+		newJob.Spec.Template.Task.ForbidForceDeletion = true
+		return newJob
+	}()
+
+	// Job with pod being force deleted.
+	fakeJobPodForceDeleting = func() *execution.Job {
+		newJob := generateJobStatusFromPod(fakeJobWithKillTimestamp, fakePodTerminating)
+		newJob.Status.Tasks[0].DeletedStatus = &execution.TaskStatus{
+			State:   execution.TaskKilled,
+			Result:  job.GetResultPtr(execution.JobResultKilled),
+			Reason:  "ForceDeleted",
+			Message: "Forcefully deleted the task, container may still be running",
+		}
+		return newJob
+	}()
+
 	// Job with pod already deleted.
 	fakeJobPodDeleted = func() *execution.Job {
 		newJob := fakeJobPodDeleting.DeepCopy()
@@ -192,6 +211,13 @@ var (
 	// Job that has succeeded.
 	fakeJobFinished = generateJobStatusFromPod(fakeJobResult, fakePodFinished)
 
+	// Job that has succeeded with a custom TTLSecondsAfterFinished.
+	fakeJobFinishedWithTTLAfterFinished = func() *execution.Job {
+		newJob := fakeJobFinished.DeepCopy()
+		newJob.Spec.TTLSecondsAfterFinished = pointer.Int64(0)
+		return newJob
+	}()
+
 	// Pod that is to be created.
 	fakePod, _ = podtaskexecutor.NewPod(fakeJob, 1)
 
@@ -226,6 +252,15 @@ var (
 	// Pod that is in Pending state and is in the process of being killed.
 	fakePodTerminating = killPod(fakePodPending, testutils.Mktime(killTime))
 
+	// Pod that is terminating and has deletion timestamp.
+	fakePodDeleting = func() *corev1.Pod {
+		newPod := fakePodTerminating.DeepCopy()
+		deleteTime := metav1.NewTime(testutils.Mkmtimep(killTime).
+			Add(time.Duration(*config.DefaultJobControllerConfig.DeleteKillingTasksTimeoutSeconds) * time.Second))
+		newPod.DeletionTimestamp = &deleteTime
+		return newPod
+	}()
+
 	// Pod that is in Pending state and is in the process of being killed by pending
 	// timeout.
 	fakePodPendingTimeoutTerminating = func() *corev1.Pod {
diff --git a/pkg/execution/controllers/jobcontroller/reconciler_test.go b/pkg/execution/controllers/jobcontroller/reconciler_test.go
index 60c2db6..aeab809 100644
--- a/pkg/execution/controllers/jobcontroller/reconciler_test.go
+++ b/pkg/execution/controllers/jobcontroller/reconciler_test.go
@@ -29,10 +29,12 @@ import (
 	ktesting "k8s.io/client-go/testing"
 	"k8s.io/client-go/tools/cache"
 	"k8s.io/client-go/tools/record"
+	"k8s.io/utils/pointer"
 
 	configv1 "github.com/furiko-io/furiko/apis/config/v1"
 	executiongroup "github.com/furiko-io/furiko/apis/execution"
 	execution "github.com/furiko-io/furiko/apis/execution/v1alpha1"
+	"github.com/furiko-io/furiko/pkg/config"
 	"github.com/furiko-io/furiko/pkg/execution/controllers/jobcontroller"
 	"github.com/furiko-io/furiko/pkg/execution/taskexecutor/podtaskexecutor"
 	"github.com/furiko-io/furiko/pkg/runtime/controllercontext/mock"
@@ -157,8 +159,7 @@ func TestReconciler(t *testing.T) {
 			},
 			configs: map[configv1.ConfigName]runtime.Object{
 				configv1.ConfigNameJobController: &configv1.JobControllerConfig{
-					// TODO(irvinlim): Change to 0 once https://github.com/furiko-io/furiko/issues/28 is fixed.
-					DefaultPendingTimeoutSeconds: -1,
+					DefaultPendingTimeoutSeconds: pointer.Int64(0),
 				},
 			},
 		},
@@ -202,8 +203,9 @@ func TestReconciler(t *testing.T) {
 			},
 		},
 		{
-			name:   "delete pod with kill timestamp",
-			now:    testutils.Mktime(killTime).Add(time.Minute * 3),
+			name: "delete pod with kill timestamp",
+			now: testutils.Mktime(killTime).
+				Add(time.Duration(*config.DefaultJobControllerConfig.DeleteKillingTasksTimeoutSeconds) * time.Second),
 			target: fakeJobWithKillTimestamp,
 			initialPods: []*corev1.Pod{
 				fakePodTerminating,
@@ -219,6 +221,51 @@ func TestReconciler(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "force delete pod with kill timestamp",
+			now: testutils.Mktime(killTime).
+				Add(time.Duration(*config.DefaultJobControllerConfig.DeleteKillingTasksTimeoutSeconds) * time.Second).
+				Add(time.Duration(*config.DefaultJobControllerConfig.ForceDeleteKillingTasksTimeoutSeconds) * time.Second),
+			target: fakeJobPodDeleting,
+			initialPods: []*corev1.Pod{
+				fakePodDeleting,
+			},
+			coreActions: runtimetesting.ActionTest{
+				Actions: []runtimetesting.Action{
+					runtimetesting.NewDeleteAction(resourcePod, jobNamespace, fakePod.Name),
+				},
+			},
+			executionActions: runtimetesting.ActionTest{
+				Actions: []runtimetesting.Action{
+					runtimetesting.NewUpdateStatusAction(resourceJob, jobNamespace, fakeJobPodForceDeleting),
+				},
+			},
+		},
+		{
+			name: "do not force delete pod if disabled via config",
+			now: testutils.Mktime(killTime).
+				Add(time.Duration(*config.DefaultJobControllerConfig.DeleteKillingTasksTimeoutSeconds) * time.Second).
+				Add(time.Duration(*config.DefaultJobControllerConfig.ForceDeleteKillingTasksTimeoutSeconds) * time.Second),
+			target: fakeJobPodDeleting,
+			initialPods: []*corev1.Pod{
+				fakePodDeleting,
+			},
+			configs: map[configv1.ConfigName]runtime.Object{
+				configv1.ConfigNameJobController: &configv1.JobControllerConfig{
+					ForceDeleteKillingTasksTimeoutSeconds: pointer.Int64(0),
+				},
+			},
+		},
+		{
+			name: "do not force delete pod if disabled via JobSpec",
+			now: testutils.Mktime(killTime).
+				Add(time.Duration(*config.DefaultJobControllerConfig.DeleteKillingTasksTimeoutSeconds) * time.Second).
+				Add(time.Duration(*config.DefaultJobControllerConfig.ForceDeleteKillingTasksTimeoutSeconds) * time.Second),
+			target: fakeJobPodDeletingForbidForceDeletion,
+			initialPods: []*corev1.Pod{
+				fakePodDeleting,
+			},
+		},
 		{
 			name:   "do nothing with already deleted pod",
 			now:    testutils.Mktime(killTime).Add(time.Minute * 3),
@@ -278,8 +325,9 @@ func TestReconciler(t *testing.T) {
 			},
 		},
 		{
-			name:   "delete finished job on TTL after finished",
-			now:    testutils.Mktime(finishAfterTTL),
+			name: "delete finished job on TTL after finished",
+			now: testutils.Mktime(finishTime).
+				Add(time.Duration(*config.DefaultJobControllerConfig.DefaultTTLSecondsAfterFinished) * time.Second),
 			target: fakeJobFinished,
 			initialPods: []*corev1.Pod{
 				fakePodFinished,
@@ -290,6 +338,37 @@ func TestReconciler(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:   "delete finished job immediately after finished if set via config",
+			now:    testutils.Mktime(finishTime),
+			target: fakeJobFinished,
+			initialPods: []*corev1.Pod{
+				fakePodFinished,
+			},
+			executionActions: runtimetesting.ActionTest{
+				Actions: []runtimetesting.Action{
+					runtimetesting.NewDeleteAction(resourceJob, jobNamespace, fakeJob.Name),
+				},
+			},
+			configs: map[configv1.ConfigName]runtime.Object{
+				configv1.ConfigNameJobController: &configv1.JobControllerConfig{
+					DefaultTTLSecondsAfterFinished: pointer.Int64(0),
+				},
+			},
+		},
+		{
+			name:   "delete finished job immediately after finished if set via JobSpec",
+			now:    testutils.Mktime(finishTime),
+			target: fakeJobFinishedWithTTLAfterFinished,
+			initialPods: []*corev1.Pod{
+				fakePodFinished,
+			},
+			executionActions: runtimetesting.ActionTest{
+				Actions: []runtimetesting.Action{
+					runtimetesting.NewDeleteAction(resourceJob, jobNamespace, fakeJob.Name),
+				},
+			},
+		},
 	}
 	for _, tt := range tests {
 		tt := tt
diff --git a/pkg/execution/mutation/mutation_test.go b/pkg/execution/mutation/mutation_test.go
index 0b6fd4b..362263b 100644
--- a/pkg/execution/mutation/mutation_test.go
+++ b/pkg/execution/mutation/mutation_test.go
@@ -32,6 +32,7 @@ import (
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/util/clock"
 	"k8s.io/client-go/tools/cache"
+	"k8s.io/utils/pointer"
 	"sigs.k8s.io/yaml"
 
 	configv1 "github.com/furiko-io/furiko/apis/config/v1"
@@ -70,8 +71,8 @@ var (
 			Kind:               v1alpha1.KindJobConfig,
 			Name:               objectMetaJobConfig.Name,
 			UID:                objectMetaJobConfig.UID,
-			Controller:         mkboolp(true),
-			BlockOwnerDeletion: mkboolp(true),
+			Controller:         pointer.Bool(true),
+			BlockOwnerDeletion: pointer.Bool(true),
 		},
 	}
 
@@ -602,7 +603,7 @@ func TestMutator_MutateJob(t *testing.T) {
 				Spec: v1alpha1.JobSpec{
 					Type:                    v1alpha1.JobTypeAdhoc,
 					Template:                &jobTemplateSpecBasic.Spec,
-					TTLSecondsAfterFinished: mkintp32(int32(config.DefaultJobControllerConfig.DefaultTTLSecondsAfterFinished)),
+					TTLSecondsAfterFinished: config.DefaultJobControllerConfig.DefaultTTLSecondsAfterFinished,
 				},
 			},
 		},
@@ -613,7 +614,7 @@ func TestMutator_MutateJob(t *testing.T) {
 				Spec: v1alpha1.JobSpec{
 					Type:                    v1alpha1.JobTypeAdhoc,
 					Template:                &jobTemplateSpecBasic.Spec,
-					TTLSecondsAfterFinished: mkintp32(int32(config.DefaultJobControllerConfig.DefaultTTLSecondsAfterFinished)),
+					TTLSecondsAfterFinished: config.DefaultJobControllerConfig.DefaultTTLSecondsAfterFinished,
 				},
 			},
 		},
@@ -1268,14 +1269,6 @@ func optionValues(values map[string]interface{}) string {
 	return string(bytes)
 }
 
-func mkboolp(i bool) *bool {
-	return &i
-}
-
-func mkintp32(i int32) *int32 {
-	return &i
-}
-
 func mkScheduleSpec(expr string, nbf *metav1.Time) *v1alpha1.ScheduleSpec {
 	scheduleSpec := &v1alpha1.ScheduleSpec{
 		Cron: &v1alpha1.CronSchedule{
diff --git a/pkg/execution/validation/validation_test.go b/pkg/execution/validation/validation_test.go
index 80f629b..053cbac 100644
--- a/pkg/execution/validation/validation_test.go
+++ b/pkg/execution/validation/validation_test.go
@@ -29,12 +29,14 @@ import (
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/util/clock"
 	"k8s.io/client-go/tools/cache"
+	"k8s.io/utils/pointer"
 
 	configv1 "github.com/furiko-io/furiko/apis/config/v1"
 	"github.com/furiko-io/furiko/apis/execution/v1alpha1"
 	"github.com/furiko-io/furiko/pkg/execution/validation"
 	"github.com/furiko-io/furiko/pkg/runtime/controllercontext/mock"
 	"github.com/furiko-io/furiko/pkg/utils/execution/jobconfig"
+	"github.com/furiko-io/furiko/pkg/utils/testutils"
 )
 
 const (
@@ -42,7 +44,7 @@ const (
 )
 
 var (
-	startTime = mkmtime(mockStartTime)
+	startTime = testutils.Mkmtimep(mockStartTime)
 
 	objectMetaJobConfig = metav1.ObjectMeta{
 		Name:      "jobconfig-sample",
@@ -56,8 +58,8 @@ var (
 			Kind:               v1alpha1.KindJobConfig,
 			Name:               objectMetaJobConfig.Name,
 			UID:                objectMetaJobConfig.UID,
-			Controller:         mkboolp(true),
-			BlockOwnerDeletion: mkboolp(true),
+			Controller:         pointer.Bool(true),
+			BlockOwnerDeletion: pointer.Bool(true),
 		},
 	}
 
@@ -90,10 +92,10 @@ var (
 		Spec: v1alpha1.JobTemplateSpec{
 			Task: v1alpha1.JobTaskSpec{
 				Template:              podTemplateSpecBasic,
-				PendingTimeoutSeconds: mkintp64(1800),
+				PendingTimeoutSeconds: pointer.Int64(1800),
 			},
-			MaxRetryAttempts:  mkintp32(5),
-			RetryDelaySeconds: mkintp64(60),
+			MaxRetryAttempts:  pointer.Int32(5),
+			RetryDelaySeconds: pointer.Int64(60),
 		},
 	}
 
@@ -102,7 +104,7 @@ var (
 		Spec: v1alpha1.JobTemplateSpec{
 			Task: v1alpha1.JobTaskSpec{
 				Template:              podTemplateSpecBasic,
-				PendingTimeoutSeconds: mkintp64(3600),
+				PendingTimeoutSeconds: pointer.Int64(3600),
 			},
 			MaxRetryAttempts:  jobTemplateSpecBasic.Spec.MaxRetryAttempts,
 			RetryDelaySeconds: jobTemplateSpecBasic.Spec.RetryDelaySeconds,
@@ -113,7 +115,7 @@ var (
 		ObjectMeta: jobTemplateSpecBasic.ObjectMeta,
 		Spec: v1alpha1.JobTemplateSpec{
 			Task:              jobTemplateSpecBasic.Spec.Task,
-			MaxRetryAttempts:  mkintp32(10),
+			MaxRetryAttempts:  pointer.Int32(10),
 			RetryDelaySeconds: jobTemplateSpecBasic.Spec.RetryDelaySeconds,
 		},
 	}
@@ -122,7 +124,7 @@ var (
 		ObjectMeta: jobTemplateSpecBasic.ObjectMeta,
 		Spec: v1alpha1.JobTemplateSpec{
 			Task:              jobTemplateSpecBasic.Spec.Task,
-			MaxRetryAttempts:  mkintp32(100),
+			MaxRetryAttempts:  pointer.Int32(100),
 			RetryDelaySeconds: jobTemplateSpecBasic.Spec.RetryDelaySeconds,
 		},
 	}
@@ -275,7 +277,7 @@ func TestValidateJobConfig(t *testing.T) {
 			},
 			cfgs: map[configv1.ConfigName]runtime.Object{
 				configv1.ConfigNameCronController: &configv1.CronControllerConfig{
-					CronHashNames: mkboolp(false),
+					CronHashNames: pointer.Bool(false),
 				},
 			},
 			wantErr: "spec.schedule.cron.expression: Invalid value: \"H 10 * * *\": cannot parse cron schedule",
@@ -512,7 +514,7 @@ func TestValidateJob(t *testing.T) {
 				Spec: v1alpha1.JobSpec{
 					Type:                    v1alpha1.JobTypeAdhoc,
 					Template:                &jobTemplateSpecBasic.Spec,
-					TTLSecondsAfterFinished: mkintp32(-300),
+					TTLSecondsAfterFinished: pointer.Int64(-300),
 				},
 			},
 			wantErr: "spec.ttlSecondsAfterFinished: Invalid value: -300",
@@ -750,13 +752,13 @@ func TestValidateJobUpdate(t *testing.T) {
 				Spec: v1alpha1.JobSpec{
 					Type:          v1alpha1.JobTypeAdhoc,
 					Template:      &jobTemplateSpecBasic.Spec,
-					KillTimestamp: mkmtime("2021-02-09T04:10:00Z"),
+					KillTimestamp: testutils.Mkmtimep("2021-02-09T04:10:00Z"),
 				},
 			},
 		},
 		{
 			name: "can set KillTimestamp in the past",
-			now:  mktime("2021-02-09T04:12:00Z"),
+			now:  testutils.Mktimep("2021-02-09T04:12:00Z"),
 			oldRj: &v1alpha1.Job{
 				ObjectMeta: objectMetaJob,
 				Spec: v1alpha1.JobSpec{
@@ -769,19 +771,19 @@ func TestValidateJobUpdate(t *testing.T) {
 				Spec: v1alpha1.JobSpec{
 					Type:          v1alpha1.JobTypeAdhoc,
 					Template:      &jobTemplateSpecBasic.Spec,
-					KillTimestamp: mkmtime("2021-02-09T04:10:00Z"),
+					KillTimestamp: testutils.Mkmtimep("2021-02-09T04:10:00Z"),
 				},
 			},
 		},
 		{
 			name: "can update KillTimestamp that was in the future",
-			now:  mktime("2021-02-09T04:08:00Z"),
+			now:  testutils.Mktimep("2021-02-09T04:08:00Z"),
 			oldRj: &v1alpha1.Job{
 				ObjectMeta: objectMetaJob,
 				Spec: v1alpha1.JobSpec{
 					Type:          v1alpha1.JobTypeAdhoc,
 					Template:      &jobTemplateSpecBasic.Spec,
-					KillTimestamp: mkmtime("2021-02-09T04:10:00Z"),
+					KillTimestamp: testutils.Mkmtimep("2021-02-09T04:10:00Z"),
 				},
 			},
 			newRj: &v1alpha1.Job{
@@ -789,19 +791,19 @@ func TestValidateJobUpdate(t *testing.T) {
 				Spec: v1alpha1.JobSpec{
 					Type:          v1alpha1.JobTypeAdhoc,
 					Template:      &jobTemplateSpecBasic.Spec,
-					KillTimestamp: mkmtime("2021-02-09T04:15:00Z"),
+					KillTimestamp: testutils.Mkmtimep("2021-02-09T04:15:00Z"),
 				},
 			},
 		},
 		{
 			name: "can shorten KillTimestamp to the past that was originally in the future",
-			now:  mktime("2021-02-09T04:08:00Z"),
+			now:  testutils.Mktimep("2021-02-09T04:08:00Z"),
 			oldRj: &v1alpha1.Job{
 				ObjectMeta: objectMetaJob,
 				Spec: v1alpha1.JobSpec{
 					Type:          v1alpha1.JobTypeAdhoc,
 					Template:      &jobTemplateSpecBasic.Spec,
-					KillTimestamp: mkmtime("2021-02-09T04:10:00Z"),
+					KillTimestamp: testutils.Mkmtimep("2021-02-09T04:10:00Z"),
 				},
 			},
 			newRj: &v1alpha1.Job{
@@ -809,19 +811,19 @@ func TestValidateJobUpdate(t *testing.T) {
 				Spec: v1alpha1.JobSpec{
 					Type:          v1alpha1.JobTypeAdhoc,
 					Template:      &jobTemplateSpecBasic.Spec,
-					KillTimestamp: mkmtime("2021-02-09T04:05:00Z"),
+					KillTimestamp: testutils.Mkmtimep("2021-02-09T04:05:00Z"),
 				},
 			},
 		},
 		{
 			name: "cannot update KillTimestamp that was originally in the past",
-			now:  mktime("2021-02-09T04:12:00Z"),
+			now:  testutils.Mktimep("2021-02-09T04:12:00Z"),
 			oldRj: &v1alpha1.Job{
 				ObjectMeta: objectMetaJob,
 				Spec: v1alpha1.JobSpec{
 					Type:          v1alpha1.JobTypeAdhoc,
 					Template:      &jobTemplateSpecBasic.Spec,
-					KillTimestamp: mkmtime("2021-02-09T04:10:00Z"),
+					KillTimestamp: testutils.Mkmtimep("2021-02-09T04:10:00Z"),
 				},
 			},
 			newRj: &v1alpha1.Job{
@@ -829,7 +831,7 @@ func TestValidateJobUpdate(t *testing.T) {
 				Spec: v1alpha1.JobSpec{
 					Type:          v1alpha1.JobTypeAdhoc,
 					Template:      &jobTemplateSpecBasic.Spec,
-					KillTimestamp: mkmtime("2021-02-09T04:15:00Z"),
+					KillTimestamp: testutils.Mkmtimep("2021-02-09T04:15:00Z"),
 				},
 			},
 			wantErr: "spec.killTimestamp: Invalid value: 2021-02-09 04:15:00 +0000 UTC: field is immutable once passed",
@@ -1077,28 +1079,3 @@ func checkError(err error, wantErr string) bool {
 
 	return false
 }
-
-func mkintp32(i int32) *int32 {
-	return &i
-}
-
-func mkintp64(i int64) *int64 {
-	return &i
-}
-
-func mkboolp(i bool) *bool {
-	return &i
-}
-
-func mktime(value string) *time.Time {
-	ts, err := time.Parse(time.RFC3339, value)
-	if err != nil {
-		panic(err) // panic ok for tests
-	}
-	return &ts
-}
-
-func mkmtime(value string) *metav1.Time {
-	mt := metav1.NewTime(*mktime(value))
-	return &mt
-}
diff --git a/pkg/runtime/configloader/configmap_loader_test.go b/pkg/runtime/configloader/configmap_loader_test.go
index 4f96337..a0765c3 100644
--- a/pkg/runtime/configloader/configmap_loader_test.go
+++ b/pkg/runtime/configloader/configmap_loader_test.go
@@ -26,6 +26,7 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/kubernetes"
 	fakeclientset "k8s.io/client-go/kubernetes/fake"
+	"k8s.io/utils/pointer"
 
 	configv1 "github.com/furiko-io/furiko/apis/config/v1"
 	"github.com/furiko-io/furiko/pkg/runtime/configloader"
@@ -114,29 +115,29 @@ func testKubernetesLoader(
 	update(`{"defaultPendingTimeoutSeconds": 180}`)
 	cfg, err = loadJobControllerConfig(mgr)
 	assert.NoError(t, err)
-	assert.Equal(t, int64(180), cfg.DefaultPendingTimeoutSeconds)
+	assert.Equal(t, pointer.Int64(180), cfg.DefaultPendingTimeoutSeconds)
 	assert.Zero(t, cfg.DefaultTTLSecondsAfterFinished)
 
 	// Update value
 	update(`{"defaultPendingTimeoutSeconds": 180, "defaultTTLSecondsAfterFinished": 3600}`)
 	cfg, err = loadJobControllerConfig(mgr)
 	assert.NoError(t, err)
-	assert.Equal(t, int64(180), cfg.DefaultPendingTimeoutSeconds)
-	assert.Equal(t, int64(3600), cfg.DefaultTTLSecondsAfterFinished)
+	assert.Equal(t, pointer.Int64(180), cfg.DefaultPendingTimeoutSeconds)
+	assert.Equal(t, pointer.Int64(3600), cfg.DefaultTTLSecondsAfterFinished)
 
 	// Store invalid JSON, previous values should still be retained
 	update(`{"defaultPendingTimeoutSeconds": 190, "defaultTTLSecondsAfterFinished": 3601`)
 	cfg, err = loadJobControllerConfig(mgr)
 	assert.NoError(t, err)
-	assert.Equal(t, int64(180), cfg.DefaultPendingTimeoutSeconds)
-	assert.Equal(t, int64(3600), cfg.DefaultTTLSecondsAfterFinished)
+	assert.Equal(t, pointer.Int64(180), cfg.DefaultPendingTimeoutSeconds)
+	assert.Equal(t, pointer.Int64(3600), cfg.DefaultTTLSecondsAfterFinished)
 
 	// Fix the JSON, now values should be updated
 	update(`{"defaultPendingTimeoutSeconds": 190, "defaultTTLSecondsAfterFinished": 3601}`)
 	cfg, err = loadJobControllerConfig(mgr)
 	assert.NoError(t, err)
-	assert.Equal(t, int64(190), cfg.DefaultPendingTimeoutSeconds)
-	assert.Equal(t, int64(3601), cfg.DefaultTTLSecondsAfterFinished)
+	assert.Equal(t, pointer.Int64(190), cfg.DefaultPendingTimeoutSeconds)
+	assert.Equal(t, pointer.Int64(3601), cfg.DefaultTTLSecondsAfterFinished)
 
 	// Store YAML instead
 	update(`---
@@ -145,8 +146,8 @@ defaultPendingTimeoutSeconds: 2345
 `)
 	cfg, err = loadJobControllerConfig(mgr)
 	assert.NoError(t, err)
-	assert.Equal(t, int64(1234), cfg.DefaultTTLSecondsAfterFinished)
-	assert.Equal(t, int64(2345), cfg.DefaultPendingTimeoutSeconds)
+	assert.Equal(t, pointer.Int64(1234), cfg.DefaultTTLSecondsAfterFinished)
+	assert.Equal(t, pointer.Int64(2345), cfg.DefaultPendingTimeoutSeconds)
 
 	// Store invalid YAML, previous values should still be retained
 	update(`---
@@ -155,8 +156,8 @@ defaultTTLSecondsAfterFinished: 512
 `)
 	cfg, err = loadJobControllerConfig(mgr)
 	assert.NoError(t, err)
-	assert.Equal(t, int64(1234), cfg.DefaultTTLSecondsAfterFinished)
-	assert.Equal(t, int64(2345), cfg.DefaultPendingTimeoutSeconds)
+	assert.Equal(t, pointer.Int64(1234), cfg.DefaultTTLSecondsAfterFinished)
+	assert.Equal(t, pointer.Int64(2345), cfg.DefaultPendingTimeoutSeconds)
 
 	// Fix the YAML, now should be updated
 	update(`---
@@ -165,6 +166,6 @@ defaultPendingTimeoutSeconds: 12312
 `)
 	cfg, err = loadJobControllerConfig(mgr)
 	assert.NoError(t, err)
-	assert.Equal(t, int64(512), cfg.DefaultTTLSecondsAfterFinished)
-	assert.Equal(t, int64(12312), cfg.DefaultPendingTimeoutSeconds)
+	assert.Equal(t, pointer.Int64(512), cfg.DefaultTTLSecondsAfterFinished)
+	assert.Equal(t, pointer.Int64(12312), cfg.DefaultPendingTimeoutSeconds)
 }
diff --git a/pkg/runtime/configloader/defaults_loader_test.go b/pkg/runtime/configloader/defaults_loader_test.go
index 30e8a22..f4ba85b 100644
--- a/pkg/runtime/configloader/defaults_loader_test.go
+++ b/pkg/runtime/configloader/defaults_loader_test.go
@@ -23,6 +23,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/utils/pointer"
 
 	configv1 "github.com/furiko-io/furiko/apis/config/v1"
 	"github.com/furiko-io/furiko/pkg/runtime/configloader"
@@ -35,8 +36,8 @@ func TestDefaultsLoader(t *testing.T) {
 	loader := configloader.NewDefaultsLoader()
 	loader.Defaults = map[configv1.ConfigName]runtime.Object{
 		configv1.ConfigNameJobController: &configv1.JobControllerConfig{
-			DefaultTTLSecondsAfterFinished: 123,
-			DefaultPendingTimeoutSeconds:   234,
+			DefaultTTLSecondsAfterFinished: pointer.Int64(123),
+			DefaultPendingTimeoutSeconds:   pointer.Int64(234),
 		},
 	}
 
@@ -47,8 +48,8 @@ func TestDefaultsLoader(t *testing.T) {
 
 	cfg, err := loadJobControllerConfig(mgr)
 	assert.NoError(t, err)
-	assert.Equal(t, int64(123), cfg.DefaultTTLSecondsAfterFinished)
-	assert.Equal(t, int64(234), cfg.DefaultPendingTimeoutSeconds)
+	assert.Equal(t, pointer.Int64(123), cfg.DefaultTTLSecondsAfterFinished)
+	assert.Equal(t, pointer.Int64(234), cfg.DefaultPendingTimeoutSeconds)
 
 	// Unset fields should be 0
 	assert.Zero(t, cfg.DeleteKillingTasksTimeoutSeconds)
@@ -68,8 +69,8 @@ func TestDefaultsLoader_LoaderOverride(t *testing.T) {
 	defaultLoader := configloader.NewDefaultsLoader()
 	defaultLoader.Defaults = map[configv1.ConfigName]runtime.Object{
 		configv1.ConfigNameJobController: &configv1.JobControllerConfig{
-			DefaultTTLSecondsAfterFinished: 123,
-			DefaultPendingTimeoutSeconds:   234,
+			DefaultTTLSecondsAfterFinished: pointer.Int64(123),
+			DefaultPendingTimeoutSeconds:   pointer.Int64(234),
 		},
 	}
 	mockLoader := newMockConfigLoader(MockConfig{
@@ -86,6 +87,6 @@ func TestDefaultsLoader_LoaderOverride(t *testing.T) {
 	// Non-empty CronControllerConfig overridden by mock loader.
 	cronCfg, err := loadCronControllerConfig(mgr)
 	assert.NoError(t, err)
-	assert.Equal(t, int64(100), cronCfg.MaxMissedSchedules)
+	assert.Equal(t, pointer.Int64(100), cronCfg.MaxMissedSchedules)
 	assert.Empty(t, cronCfg.MaxDowntimeThresholdSeconds)
 }
diff --git a/pkg/utils/execution/job/task_test.go b/pkg/utils/execution/job/task_test.go
index ca3184d..565e12d 100644
--- a/pkg/utils/execution/job/task_test.go
+++ b/pkg/utils/execution/job/task_test.go
@@ -98,7 +98,3 @@ func (t *stubTask) SetKillTimestamp(ctx context.Context, ts time.Time) error {
 	t.killTimestamp = &mts
 	return nil
 }
-
-func mkint64p(i int64) *int64 {
-	return &i
-}
diff --git a/pkg/utils/execution/job/task_utils_test.go b/pkg/utils/execution/job/task_utils_test.go
index db5d6ea..93dfe12 100644
--- a/pkg/utils/execution/job/task_utils_test.go
+++ b/pkg/utils/execution/job/task_utils_test.go
@@ -20,6 +20,8 @@ import (
 	"testing"
 	"time"
 
+	"k8s.io/utils/pointer"
+
 	execution "github.com/furiko-io/furiko/apis/execution/v1alpha1"
 	"github.com/furiko-io/furiko/pkg/utils/execution/job"
 )
@@ -48,7 +50,7 @@ func TestGetNextAllowedRetry(t *testing.T) {
 				Spec: execution.JobSpec{
 					Template: &execution.JobTemplateSpec{
 						MaxRetryAttempts:  &two,
-						RetryDelaySeconds: mkint64p(30),
+						RetryDelaySeconds: pointer.Int64(30),
 					},
 				},
 			},
@@ -106,7 +108,7 @@ func TestGetNextAllowedRetry(t *testing.T) {
 				Spec: execution.JobSpec{
 					Template: &execution.JobTemplateSpec{
 						MaxRetryAttempts:  &two,
-						RetryDelaySeconds: mkint64p(30),
+						RetryDelaySeconds: pointer.Int64(30),
 					},
 				},
 				Status: execution.JobStatus{
@@ -132,7 +134,7 @@ func TestGetNextAllowedRetry(t *testing.T) {
 				Spec: execution.JobSpec{
 					Template: &execution.JobTemplateSpec{
 						MaxRetryAttempts:  &two,
-						RetryDelaySeconds: mkint64p(30),
+						RetryDelaySeconds: pointer.Int64(30),
 					},
 				},
 				Status: execution.JobStatus{
@@ -157,7 +159,7 @@ func TestGetNextAllowedRetry(t *testing.T) {
 				Spec: execution.JobSpec{
 					Template: &execution.JobTemplateSpec{
 						MaxRetryAttempts:  &two,
-						RetryDelaySeconds: mkint64p(30),
+						RetryDelaySeconds: pointer.Int64(30),
 					},
 					KillTimestamp: &killTime,
 				},
@@ -184,7 +186,7 @@ func TestGetNextAllowedRetry(t *testing.T) {
 				Spec: execution.JobSpec{
 					Template: &execution.JobTemplateSpec{
 						MaxRetryAttempts:  &one,
-						RetryDelaySeconds: mkint64p(30),
+						RetryDelaySeconds: pointer.Int64(30),
 					},
 				},
 				Status: execution.JobStatus{
@@ -219,7 +221,7 @@ func TestGetNextAllowedRetry(t *testing.T) {
 				Spec: execution.JobSpec{
 					Template: &execution.JobTemplateSpec{
 						MaxRetryAttempts:  &one,
-						RetryDelaySeconds: mkint64p(30),
+						RetryDelaySeconds: pointer.Int64(30),
 					},
 				},
 				Status: execution.JobStatus{