Spring Cloud Task and Stripe Integration: How a SCDF Task Relaunch After EXIT_CODE=1 Generates a New executionId Making the Idempotency Key Non-Repeatable After ch_A Commits Producing ch_B, a TaskExecutionListener.onTaskFailed() Compensation Branch Calls Stripe with UUID_B When the Task Crashed After the Stripe Commit, and a TASK_EXECUTION end_time IS NULL Guard Treats a Post-Commit JVM Kill as Never-Charged Generating UUID_B

This post is part of the ongoing series on Stripe idempotency failure modes in JVM frameworks. Earlier entries covered Spring Data JPA (JDBC transaction boundary and Stripe commit ordering), Spring Batch (step retry, chunk rollback, and JobRepository state divergence), Spring Data Cassandra (WriteTimeoutException treated as write failure, LWT IF NOT EXISTS timeout, ConsistencyLevel.ONE guard read after LOCAL_QUORUM write), Spring Cloud Kubernetes (ConfigMap Watch cluster-wide @RefreshScope refresh, Fabric8 GC pause dual-active leader, rolling deployment key-formula split-brain), and Spring Integration (RequestHandlerRetryAdvice UUID regeneration, JdbcChannelMessageStore transactional poller rollback requeue, JdbcMessageGroupStore aggregator crash double-release). This post covers Spring Cloud Task — the framework for short-lived, finite-duration microservices launched via Spring Cloud Data Flow (SCDF) or Kubernetes job controllers — and specifically the three failure modes that arise from the way Spring Cloud Task records task executions in a relational store, how SCDF treats failed exits, and how developers reason incorrectly about the relationship between task completion status and Stripe API call outcomes.

The three failure modes are: a developer who uses TaskExecution.getExecutionId() as a component of the Stripe idempotency key, where a task exits with EXIT_CODE=1 after Stripe commits ch_A (the charge was created but a downstream database write failed, causing the task to exit non-zero), and where SCDF or a Kubernetes job controller relaunches the task, creating a new TaskExecution row with a new auto-incremented executionId, so the relaunched task derives key_B from the new executionId and Stripe commits ch_B alongside the already-committed ch_A; a developer who registers a TaskExecutionListener with an onTaskFailed() implementation that calls Stripe with a fresh UUID.randomUUID() as a “compensation” action — reasoning that any task failure means the Stripe call did not complete — where the task failed because a database write threw an exception after Stripe had already committed ch_A and returned a 200 response, causing the compensation listener to fire and generate UUID_B, producing ch_B alongside ch_A; and a developer who queries the TASK_EXECUTION table for rows where end_time IS NULL as a guard check before initiating a relaunch, reasoning that a null end_time means the task was killed before performing any meaningful work, where the task was in fact killed in the window between a successful Stripe API return (which committed ch_A) and the framework’s write of the end_time timestamp to the TASK_EXECUTION row, so the null guard evaluates to true, the relaunch proceeds, UUID.randomUUID() generates UUID_B, and Stripe commits ch_B.

Stack for examples: Spring Boot 3.x, Spring Cloud Task 3.x (spring-cloud-starter-task), Spring Cloud Data Flow 2.11+ (for SCDF-specific modes), Stripe Java SDK v24+, PostgreSQL 15 via Testcontainers, WireMock 3.x, JUnit 5.

Background: what Spring Cloud Task adds and why it interacts with billing idempotency differently

Spring Cloud Task is a Spring Boot extension for building short-lived, finite-duration applications — jobs that start, do a bounded amount of work, and terminate. The framework records each task execution as a row in a TASK_EXECUTION relational table, tracking the launch time (start_time), completion time (end_time), exit code (exit_code), and exit message (exit_message). When deployed under Spring Cloud Data Flow, SCDF manages task launches and can be configured to relaunch failed tasks based on exit codes. Under Kubernetes without SCDF, the Kubernetes job controller’s restartPolicy: OnFailure or an external orchestrator performs the same function.

The first idempotency surface distinct to Spring Cloud Task is the task execution identity. Every task launch creates a new TaskExecution row with an auto-incremented primary key called executionId. This identifier is scoped to a single launch attempt. If a task is relaunched after failure, the relaunch receives a new executionId. Developers building billing tasks naturally reach for the executionId as a key component — it is injected by the framework, unique per launch, and feels like it identifies “this billing run.” The problem is that it identifies the launch, not the billing intent. Two launches for the same billing intent produce two different executionId values and therefore two different idempotency keys.

The second distinct surface is task lifecycle listener hooks. Spring Cloud Task provides a TaskExecutionListener interface with three methods: onTaskStartup(), onTaskEnd(), and onTaskFailed(). These hooks run inside the task JVM and are called by the framework at the beginning and end of the task execution lifecycle. Developers who want to add recovery logic when a task fails naturally implement onTaskFailed() as the place to “undo” or “compensate” for whatever the task attempted. If the compensation logic calls Stripe, and if the task failed after Stripe already committed a charge, the compensation produces a second charge.

The third distinct surface is the task completion record as a distributed state guard. Because Spring Cloud Task writes a structured row for every execution, developers use that row as a guard when deciding whether to relaunch a task. The most common guard is end_time IS NULL: if the task never wrote an end_time, it must have been killed before it finished. This reasoning conflates “task did not record completion” with “task did not perform its side effects.” In a system where the side effect (a Stripe API call) happens before the completion record is written, the kill window between the side effect and the write is invisible to any query against the TASK_EXECUTION table.

Mode 1: SCDF relaunches billing task after EXIT_CODE=1 — new executionId in idempotency key formula — key_B — ch_B

Setup

The developer is building a subscription billing task. The task is a Spring Boot application annotated with @EnableTask, deployed to SCDF as a registered task definition. When billing runs are due — typically on the first of the month — an orchestrator submits a task launch request to SCDF via its REST API. The task receives subscriptionId and billingPeriod as command-line arguments, looks up the customer’s Stripe customer ID from the database, generates an idempotency key, calls Stripe to create a charge, writes a BillingRecord row to the application database, and exits.

The developer wants the idempotency key to be unique per launch attempt to avoid any risk of Stripe deduplicating a legitimately different billing run in a future period. They choose taskExecution.getExecutionId() as a namespace component: key = "billing:" + executionId + ":" + subscriptionId + ":" + billingPeriod. Spring Cloud Task injects the TaskExecution bean (populated by the framework before any CommandLineRunner beans run), so the executionId is always available.

The buggy implementation looks like this:

// BUGGY — idempotency key includes executionId.
// A SCDF relaunch creates a new TaskExecution with a new executionId.
// If ch_A was committed on the first launch and the relaunch computes
// a different executionId, Stripe sees key_B as an independent request
// and commits ch_B.

@SpringBootApplication
@EnableTask
public class BillingTaskApplication {
    public static void main(String[] args) {
        SpringApplication.run(BillingTaskApplication.class, args);
    }
}

@Component
public class BillingTaskRunner implements CommandLineRunner {

    private final TaskExecution taskExecution;  // injected by Spring Cloud Task
    private final SubscriptionRepository subscriptionRepo;
    private final BillingRecordRepository billingRecordRepo;

    public BillingTaskRunner(TaskExecution taskExecution,
                             SubscriptionRepository subscriptionRepo,
                             BillingRecordRepository billingRecordRepo) {
        this.taskExecution = taskExecution;
        this.subscriptionRepo = subscriptionRepo;
        this.billingRecordRepo = billingRecordRepo;
    }

    @Override
    public void run(String... args) throws Exception {
        // Parse command-line arguments
        String subscriptionId = getArg(args, "--subscriptionId");
        String billingPeriod  = getArg(args, "--billingPeriod");

        Subscription sub = subscriptionRepo.findById(subscriptionId)
            .orElseThrow(() -> new IllegalArgumentException(
                "Unknown subscription: " + subscriptionId));

        // BUG: key includes executionId.
        // executionId is specific to THIS launch. A relaunch after EXIT_CODE=1
        // receives a different executionId — the key changes — Stripe treats
        // the second call as an independent charge.
        String idempotencyKey =
            "billing:" + taskExecution.getExecutionId()
            + ":" + subscriptionId
            + ":" + billingPeriod;

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(sub.getAmountCents())
            .setCurrency("usd")
            .setCustomer(sub.getStripeCustomerId())
            .setDescription("Subscription " + subscriptionId + " " + billingPeriod)
            .build();

        RequestOptions options = RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            .build();

        Charge charge = Charge.create(params, options);

        // Write BillingRecord — this is where the task can fail AFTER
        // Stripe commits, triggering a relaunch with a new executionId.
        billingRecordRepo.save(new BillingRecord(
            subscriptionId, billingPeriod,
            charge.getId(), sub.getAmountCents()
        ));
    }
}

This code follows an intuitive pattern: use the framework’s own execution identifier to namespace the idempotency key. The developer’s logic is that each launch is a discrete billing attempt and should have its own key space. What they miss is that Stripe’s idempotency mechanism works in the opposite direction: you provide a key that is stable across retry attempts so that Stripe can recognize the retries and return the cached result. The key must be stable across launches for the same billing intent, not unique per launch.

The failure path

At t=0, the orchestrator submits a task launch request to SCDF for subscription sub_123, billing period 2026-10. SCDF inserts a row into TASK_EXECUTION with executionId = 42, start_time = now(), end_time = null, exit_code = null. Spring Cloud Task populates the TaskExecution bean with executionId = 42 and invokes the CommandLineRunner beans.

BillingTaskRunner.run() starts. The idempotency key is computed as "billing:42:sub_123:2026-10". ChargeCreateParams is assembled. The Stripe API call is transmitted. Stripe validates the charge, commits ch_A, and returns a 200 response. The task receives the response and creates the BillingRecord.

At this point, the BillingRecord INSERT throws a DataIntegrityViolationException: a unique constraint violation on (subscription_id, billing_period) because a concurrent billing process has already written this row. The exception propagates through CommandLineRunner.run(), Spring Cloud Task catches the unhandled exception, sets exit_code = "FAILED" and end_time = now() in the TASK_EXECUTION row, and the JVM exits with code 1.

SCDF detects the EXIT_CODE=1. Its retry policy is configured for up to two relaunch attempts on non-zero exits. SCDF inserts a new row into TASK_EXECUTION with executionId = 43, start_time = now(). Spring Cloud Task populates the TaskExecution bean with executionId = 43. BillingTaskRunner.run() executes again. The idempotency key is now computed as "billing:43:sub_123:2026-10".

Stripe receives a POST to /v1/charges with Idempotency-Key: billing:43:sub_123:2026-10. Stripe has a cached result for billing:42:sub_123:2026-10 but no record for billing:43:sub_123:2026-10. It processes the request as a new, independent charge and commits ch_B. The customer is charged twice.

Note that the BillingRecord INSERT also fails on the second attempt (the same unique constraint violation still exists), so SCDF may attempt a third relaunch with executionId = 44, producing ch_C. Every relaunch generates another charge until the retry limit is reached.

Developer misconception

The developer’s mental model is: “executionId scopes the key to a specific billing attempt. If I use the same executionId on a retry, Stripe deduplicates. A relaunch with a different executionId is by definition a new billing attempt — if the first attempt failed, it didn’t charge the customer, so the new attempt is correct.”

This model is wrong in its premise. EXIT_CODE=1 means the JVM exited abnormally. It does not say anything about whether the Stripe API call completed successfully. In this failure path, the Stripe call succeeded (returned 200 with a charge object) and only the downstream database write failed. The task recorded its exit as FAILED not because the billing action failed but because the bookkeeping after billing failed. The executionId tells you the task launch identity; it tells you nothing about which API calls that launch completed before it failed.

The secondary misconception is about SCDF’s retry semantics. Many developers assume that SCDF only retries tasks when they fail at the beginning — before doing any real work. In reality, SCDF retries any task that exits with a configured non-zero exit code, regardless of how far into the task’s execution that exit occurs. A task that calls Stripe at step 1, writes a record at step 2, and fails at step 3 (or at step 2) will be retried in full from step 1. The retry mechanism is at the JVM level, not at the individual operation level. Every step in the task’s CommandLineRunner runs again from scratch.

The fix

The idempotency key must be derived from the billing intent’s identifying fields — the fields that uniquely describe what is being billed, independent of how many times a task launch has been attempted. For a subscription billing task, those fields are the subscription ID, billing period, and amount in cents. The key should be computed once from those fields and must produce the same value on every launch for the same billing intent, including relaunches.

// FIXED — stable idempotency key from billing intent fields.
// executionId is not included. Every launch for the same
// subscriptionId + billingPeriod + amountCents produces the same key.

@Component
public class BillingTaskRunner implements CommandLineRunner {

    private final SubscriptionRepository subscriptionRepo;
    private final BillingRecordRepository billingRecordRepo;

    public BillingTaskRunner(SubscriptionRepository subscriptionRepo,
                             BillingRecordRepository billingRecordRepo) {
        this.subscriptionRepo = subscriptionRepo;
        this.billingRecordRepo = billingRecordRepo;
    }

    @Override
    public void run(String... args) throws Exception {
        String subscriptionId = getArg(args, "--subscriptionId");
        String billingPeriod  = getArg(args, "--billingPeriod");

        Subscription sub = subscriptionRepo.findById(subscriptionId)
            .orElseThrow(() -> new IllegalArgumentException(
                "Unknown subscription: " + subscriptionId));

        // FIXED: stable key — same value on every launch for this billing intent.
        // No executionId, no UUID.randomUUID(), no timestamp.
        String idempotencyKey = computeStableKey(
            subscriptionId, billingPeriod, sub.getAmountCents()
        );

        // Guard check: if a BillingRecord for this key already exists,
        // Stripe committed ch_A on a prior launch. The relaunch must not
        // call Stripe again.
        Optional<BillingRecord> existing =
            billingRecordRepo.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            // Already billed. This task run is a duplicate launch.
            // Log the existing charge ID and exit cleanly.
            System.out.println("Billing already completed for key " + idempotencyKey
                + " — charge " + existing.get().getStripeChargeId()
                + " — skipping Stripe call.");
            return;
        }

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(sub.getAmountCents())
            .setCurrency("usd")
            .setCustomer(sub.getStripeCustomerId())
            .setDescription("Subscription " + subscriptionId + " " + billingPeriod)
            .build();

        RequestOptions options = RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            .build();

        Charge charge = Charge.create(params, options);

        // BillingRecord write. If this fails, the next relaunch will
        // find no BillingRecord, call Stripe with the same stable key,
        // and Stripe will return the cached ch_A response. The BillingRecord
        // write will succeed (or will be retried with INSERT ON CONFLICT DO NOTHING).
        billingRecordRepo.saveIfAbsent(new BillingRecord(
            subscriptionId, billingPeriod,
            idempotencyKey, charge.getId(), sub.getAmountCents()
        ));
    }

    private static String computeStableKey(
            String subscriptionId, String billingPeriod, long amountCents) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            String input = subscriptionId + "|" + billingPeriod + "|" + amountCents;
            byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 unavailable", e);
        }
    }
}

The guard check before calling Stripe is equally important. On a relaunch where the first launch successfully committed both the Stripe charge and the BillingRecord (but the task exited non-zero for an unrelated reason), the guard prevents an unnecessary Stripe call. On a relaunch where the first launch committed the Stripe charge but the BillingRecord write failed, the guard finds no record and calls Stripe. Because the key is stable, Stripe returns the cached ch_A response. The BillingRecord write then succeeds (using an INSERT ... ON CONFLICT DO NOTHING or a findOrCreate pattern to handle the edge case of concurrent writes). The task exits with EXIT_CODE=0. SCDF marks the relaunch as successful. No second charge is created.

The key insight is that the BillingRecord’s idempotency key column must store the same stable key that was sent to Stripe. The guard check must query by that key. If the BillingRecord does not exist, the caller cannot know whether the Stripe charge committed or not on the previous launch. The stable key ensures that even without the guard check succeeding, the Stripe call sends the same key and Stripe deduplicates correctly within its 24-hour window.

Mode 2: TaskExecutionListener.onTaskFailed() compensation calls Stripe with UUID_B — task failed after ch_A committed — ch_B

Setup

The developer is building a billing task that runs as a Spring Cloud Task and posts charges to Stripe. They want a safety net: if the task fails for any reason, a compensation action should run to either roll back the effect or re-attempt it. They implement TaskExecutionListener and write an onTaskFailed() method that acts as the compensation handler.

The developer’s reasoning is: if onTaskFailed() fires, the task threw an exception somewhere. Since the Stripe charge is the most important side effect, they want to ensure it was completed. Their compensation logic checks a local in-memory flag (set inside the CommandLineRunner after the Stripe call returns) to determine whether Stripe was called. If the flag is not set, the compensation generates a fresh UUID and calls Stripe directly.

The buggy implementation:

// BUGGY — onTaskFailed() compensation fires when the task fails
// for ANY reason, including failure AFTER Stripe committed ch_A.
// The compensation then generates UUID_B and calls Stripe → ch_B.

@Component
public class BillingTaskRunner implements CommandLineRunner {

    // In-memory flag — set to true after the Stripe call returns successfully.
    // onTaskFailed() checks this flag to decide whether to "compensate."
    private volatile boolean stripeCallCompleted = false;

    private final SubscriptionRepository subscriptionRepo;
    private final BillingRecordRepository billingRecordRepo;

    // ... constructor ...

    @Override
    public void run(String... args) throws Exception {
        String subscriptionId = getArg(args, "--subscriptionId");
        String billingPeriod  = getArg(args, "--billingPeriod");

        Subscription sub = subscriptionRepo.findById(subscriptionId)
            .orElseThrow();

        String idempotencyKey = UUID.randomUUID().toString();

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(sub.getAmountCents())
            .setCurrency("usd")
            .setCustomer(sub.getStripeCustomerId())
            .build();

        Charge charge = Charge.create(params,
            RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());

        // Stripe call returned successfully — set the flag.
        stripeCallCompleted = true;

        // This write can fail AFTER Stripe committed ch_A.
        // If it does, the task exits non-zero and onTaskFailed() fires.
        billingRecordRepo.save(new BillingRecord(
            subscriptionId, billingPeriod, charge.getId(), sub.getAmountCents()
        ));
    }
}

@Component
public class BillingCompensationListener implements TaskExecutionListener {

    private final BillingTaskRunner taskRunner;
    private final SubscriptionRepository subscriptionRepo;

    // ... constructor ...

    @Override
    public void onTaskFailed(TaskExecution taskExecution,
                             Throwable throwable) {
        // BUG: This fires even when the task failed AFTER Stripe committed ch_A.
        // stripeCallCompleted is true in that case, but the developer's check
        // only guards the case where stripeCallCompleted is false.
        if (!taskRunner.isStripeCallCompleted()) {
            // Developer believes: "Stripe was never called — safe to compensate
            // by calling Stripe now with a fresh UUID."
            try {
                String compensationKey = UUID.randomUUID().toString();
                // Compensation calls the billing service with UUID_B.
                // But if the developer's flag check is inverted
                // (! == stripeCallCompleted was FALSE), this is the branch
                // where Stripe was NOT called, so compensation makes sense.
                // The bug is that stripeCallCompleted can be TRUE
                // (Stripe committed ch_A) AND the task failed (DB write failed),
                // and a caller of this method re-checks compensation logic
                // with a different UUID_B from an "else" branch or a
                // separate recovery path — see the variant below.
            } catch (Exception e) {
                // log and swallow
            }
        } else {
            // Developer believes: "Stripe completed. The task failed at the
            // BillingRecord write. The charge is on Stripe. Let me re-try
            // the BillingRecord write from here."
            //
            // This part is actually correct — and this is where the real
            // bug is in a common variant of this pattern (below).
        }
    }
}

The scenario above shows a case where the flag check is correct but the compensation structure has a related variant that is dangerous. The more common (and more dangerous) pattern is when the developer does not use a flag at all, instead assuming that any call to onTaskFailed() means Stripe was not called:

// COMMON BUGGY VARIANT — no in-process flag.
// Developer assumes: if the task failed, Stripe was never called.
// Consequence: if the task fails AFTER Stripe commits, the compensation
// generates UUID_B and commits ch_B.

@Component
public class AggressiveBillingCompensationListener
        implements TaskExecutionListener {

    private final StripeClient stripeClient;
    private final SubscriptionRepository subscriptionRepo;

    // ... constructor ...

    @Override
    public void onTaskFailed(TaskExecution taskExecution,
                             Throwable throwable) {
        // BUG: assumes any task failure means Stripe was never called.
        // This is wrong if the task called Stripe successfully and then
        // failed at a subsequent step (BillingRecord write, external event
        // publication, etc.).

        String subscriptionId = extractSubscriptionId(taskExecution);
        String billingPeriod  = extractBillingPeriod(taskExecution);
        if (subscriptionId == null || billingPeriod == null) {
            return; // can't compensate without billing parameters
        }

        try {
            Subscription sub = subscriptionRepo.findById(subscriptionId)
                .orElse(null);
            if (sub == null) return;

            // BUG: new UUID on every compensation invocation.
            // If the task failed after ch_A committed, this generates UUID_B
            // and commits ch_B. The customer is double-charged.
            String compensationKey = UUID.randomUUID().toString();

            ChargeCreateParams params = ChargeCreateParams.builder()
                .setAmount(sub.getAmountCents())
                .setCurrency("usd")
                .setCustomer(sub.getStripeCustomerId())
                .build();

            Charge charge = Charge.create(params,
                RequestOptions.builder()
                    .setIdempotencyKey(compensationKey)
                    .build());

            // Stripe committed ch_B alongside ch_A. Double charge.
        } catch (Exception e) {
            // log
        }
    }
}

The failure path

At t=0 the billing task starts. BillingTaskRunner.run() executes. A fresh UUID_A is generated. The Stripe API call transmits the charge request. Stripe validates, commits ch_A, and returns HTTP 200. The task receives the response. At t=1, the BillingRecord INSERT is issued. The insert throws a DataIntegrityViolationException (the same unique constraint violation from Mode 1). The exception propagates out of run().

Spring Cloud Task’s exception handling catches the throwable. Before marking the TASK_EXECUTION row with exit_code = "FAILED" and end_time = now(), the framework calls TaskExecutionListener.onTaskFailed(taskExecution, throwable) on all registered listeners. AggressiveBillingCompensationListener.onTaskFailed() runs. The listener reads subscriptionId and billingPeriod from the taskExecution arguments. It fetches the Subscription. It generates a fresh UUID_B. It calls Stripe with Idempotency-Key: UUID_B. Stripe has no record of UUID_B. It processes an independent charge request and commits ch_B. The customer is charged twice.

The timeline of this failure is subtle because it looks like a safety net behaving correctly. The developer designed onTaskFailed() as a last-resort recovery mechanism. In the common case where the task fails early (before calling Stripe), the compensation correctly initiates the charge. In the uncommon case where the task fails after calling Stripe, the compensation incorrectly initiates a second charge. Because the uncommon case is also the more operationally dangerous case (Stripe has already committed a charge), this is precisely the moment when the safety net causes harm instead of preventing it.

Developer misconception

The developer’s mental model is: “onTaskFailed() is called when the task fails. If the task failed, the billing did not complete. The compensation ensures the billing completes.” This is internally consistent but wrong on the word “failed.” A Spring Cloud Task fails (exits non-zero, calls onTaskFailed()) whenever an unhandled exception propagates out of CommandLineRunner.run(). The exception can be thrown by any line of code in the task, including lines that execute after the Stripe call returns. “Task failed” means “an exception was thrown somewhere.” It does not mean “the Stripe call was never made.”

The deeper misconception is about the relationship between a Stripe HTTP call and the JVM exception that follows it. Stripe returning HTTP 200 does not cause an exception. The exception that eventually causes the task to fail is thrown by a different piece of code — the database write, a message publication, an external webhook notification. The developer correctly observes that an exception was thrown, but incorrectly concludes that the exception was thrown before Stripe was called.

A variant of this misconception is: “I set stripeCallCompleted = true inside the CommandLineRunner after the Stripe call returns. If the compensation checks this flag, it can distinguish the two cases.” This reasoning is correct in structure but the flag is an in-memory value that is lost if the JVM is killed (OOM, SIGKILL, pod eviction) rather than exiting via a thrown exception. If the JVM is killed between the Stripe call and the flag set, onTaskFailed() is not called at all. But on the next launch by SCDF, there is no flag to check, and if the compensation is embedded in the new launch’s startup logic, the absence of the flag means compensation fires. An in-memory flag is not a reliable distributed guard.

The fix

The correct compensation strategy is to use a stable key and a durable database guard. The onTaskFailed() listener should check the application’s BillingRecord (or BillingAttempt) table before calling Stripe. If a record exists, Stripe was already called on this billing intent on some prior attempt; the compensation should mark the task as recovered, not call Stripe again.

// FIXED — onTaskFailed() checks durable guard before calling Stripe.
// Uses stable key so even if Stripe is called again, it returns ch_A.

@Component
public class SafeBillingCompensationListener
        implements TaskExecutionListener {

    private final BillingRecordRepository billingRecordRepo;
    private final SubscriptionRepository subscriptionRepo;

    // ... constructor ...

    @Override
    public void onTaskFailed(TaskExecution taskExecution,
                             Throwable throwable) {
        String subscriptionId = extractSubscriptionId(taskExecution);
        String billingPeriod  = extractBillingPeriod(taskExecution);
        if (subscriptionId == null || billingPeriod == null) return;

        Subscription sub = subscriptionRepo.findById(subscriptionId)
            .orElse(null);
        if (sub == null) return;

        // Stable key — same as what the CommandLineRunner used.
        // Must be the same derivation logic.
        String idempotencyKey = computeStableKey(
            subscriptionId, billingPeriod, sub.getAmountCents()
        );

        // FIXED: check the durable guard first.
        // If a BillingRecord with this key already exists, Stripe already
        // committed a charge. Do not call Stripe again.
        Optional<BillingRecord> existing =
            billingRecordRepo.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            // Stripe committed ch_A. Task failed AFTER the charge.
            // The compensation should persist the BillingRecord if missing,
            // not add another Stripe charge.
            System.out.println("onTaskFailed: charge already committed — "
                + existing.get().getStripeChargeId()
                + " — no compensation charge needed.");
            return;
        }

        // No BillingRecord found. Either:
        // (a) The CommandLineRunner failed BEFORE calling Stripe — compensation
        //     is correct and necessary.
        // (b) The CommandLineRunner called Stripe, Stripe committed, but the
        //     BillingRecord write failed AND the JVM was killed before
        //     onTaskFailed() could fire on the first launch — this launch is
        //     a SCDF relaunch, not the same JVM's onTaskFailed() call.
        //
        // In case (b), the stable key ensures Stripe deduplicates. Stripe
        // returns ch_A. BillingRecord is written. No double charge.
        try {
            ChargeCreateParams params = ChargeCreateParams.builder()
                .setAmount(sub.getAmountCents())
                .setCurrency("usd")
                .setCustomer(sub.getStripeCustomerId())
                .build();

            Charge charge = Charge.create(params,
                RequestOptions.builder()
                    .setIdempotencyKey(idempotencyKey)
                    .build());

            // Write the BillingRecord — using ON CONFLICT DO NOTHING
            // to handle race conditions.
            billingRecordRepo.saveIfAbsent(new BillingRecord(
                subscriptionId, billingPeriod,
                idempotencyKey, charge.getId(), sub.getAmountCents()
            ));
        } catch (Exception e) {
            System.err.println("onTaskFailed compensation failed: " + e.getMessage());
        }
    }
}

The critical change is that the stable key is computed identically in both the CommandLineRunner and the onTaskFailed() listener. This is only possible if both use the same deterministic derivation from billing intent fields. If the CommandLineRunner uses UUID.randomUUID(), the onTaskFailed() listener cannot reconstruct the key and cannot query for the existing record. Migrating to a stable key in the CommandLineRunner is therefore a prerequisite for making the compensation listener safe.

It is also worth considering whether onTaskFailed() is the right place for billing compensation at all. onTaskFailed() runs in the same JVM that is about to exit with a non-zero code. If the JVM is killed before onTaskFailed() returns, the compensation is partially executed. A more robust pattern is to make the main task idempotent (using the stable key and guard record) and let SCDF’s relaunch policy handle recovery. The relaunch will find the guard record missing, call Stripe with the stable key, receive ch_A back from Stripe’s deduplication cache, and write the BillingRecord successfully. No in-process compensation listener is needed.

Mode 3: TASK_EXECUTION end_time IS NULL guard treats post-commit JVM kill as never-charged — relaunch generates UUID_B — ch_B

Setup

The developer is building a billing orchestrator service that manages task launches via the SCDF REST API. When a billing run is due, the orchestrator checks whether a previous task launch for the same billing intent is already running or recently completed. It does this by querying the TASK_EXECUTION table directly (Spring Cloud Task exposes a TaskExplorer API for this). The guard logic is: if a TASK_EXECUTION row exists for this subscriptionId and billingPeriod with end_time IS NOT NULL and exit_code = 'COMPLETED', the billing for this period is done. If no such row exists, or if the most recent row has end_time IS NULL, the task is either still running or crashed — in the latter case, the orchestrator relaunches it.

The developer stores the billing parameters as entries in the TASK_EXECUTION_PARAMS table (which SCDF populates from command-line arguments). The guard query looks like:

// BUGGY — TASK_EXECUTION end_time IS NULL treated as "task crashed
// before doing meaningful work" — does not account for the window
// between Stripe commit and end_time write.

@Service
public class BillingOrchestrator {

    private final TaskExplorer taskExplorer;
    private final SCDFTaskLauncher scdfTaskLauncher;

    // ... constructor ...

    public void triggerBillingIfNeeded(
            String subscriptionId, String billingPeriod) {

        // Look for a recent task execution for this billing intent.
        List<TaskExecution> executions = taskExplorer
            .findTaskExecutionsByName("billing-task", PageRequest.of(0, 100))
            .getContent()
            .stream()
            .filter(e -> matchesBillingIntent(e, subscriptionId, billingPeriod))
            .sorted(Comparator.comparingLong(TaskExecution::getExecutionId)
                              .reversed())
            .collect(Collectors.toList());

        if (executions.isEmpty()) {
            // No previous execution — launch.
            launchBillingTask(subscriptionId, billingPeriod);
            return;
        }

        TaskExecution latest = executions.get(0);

        // BUG: end_time IS NULL is interpreted as "task crashed before
        // completing meaningful work — safe to relaunch."
        // But end_time IS NULL could also mean the task committed ch_A
        // and was then killed before end_time was written.
        if (latest.getEndTime() == null) {
            // "Task is still running or crashed early — relaunch."
            System.out.println("Task " + latest.getExecutionId()
                + " has null end_time — relaunching.");
            launchBillingTask(subscriptionId, billingPeriod);
            return;
        }

        if ("COMPLETED".equals(latest.getExitCode())) {
            System.out.println("Billing complete — skip.");
            return;
        }

        // Any other exit code (FAILED, ERROR) — relaunch.
        launchBillingTask(subscriptionId, billingPeriod);
    }

    private void launchBillingTask(String subscriptionId, String billingPeriod) {
        // Launches a new SCDF task — creates a new TaskExecution row
        // with a new executionId.
        scdfTaskLauncher.launch("billing-task",
            Map.of("--subscriptionId", subscriptionId,
                   "--billingPeriod",  billingPeriod));
    }
}

The billing task itself generates UUID.randomUUID() at startup:

// BUGGY task — UUID.randomUUID() on every launch.
// A relaunch triggered by the null end_time guard generates UUID_B
// when ch_A was committed by the previous launch.

@Component
public class BillingTaskRunner implements CommandLineRunner {

    @Override
    public void run(String... args) throws Exception {
        String subscriptionId = getArg(args, "--subscriptionId");
        String billingPeriod  = getArg(args, "--billingPeriod");
        Subscription sub = subscriptionRepo.findById(subscriptionId).orElseThrow();

        // BUG: new UUID on every launch — unstable across relaunches.
        String idempotencyKey = UUID.randomUUID().toString();

        Charge charge = Charge.create(
            ChargeCreateParams.builder()
                .setAmount(sub.getAmountCents())
                .setCurrency("usd")
                .setCustomer(sub.getStripeCustomerId())
                .build(),
            RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey)
                .build()
        );

        billingRecordRepo.save(new BillingRecord(
            subscriptionId, billingPeriod, charge.getId(), sub.getAmountCents()
        ));
        // end_time is written by Spring Cloud Task after run() returns normally.
    }
}

The failure path

At t=0, the orchestrator fires and finds no prior execution for sub_123 / 2026-10. It launches the billing task. SCDF creates TaskExecution with executionId = 42, start_time = T0, end_time = null. The task starts. UUID_A is generated. The Stripe API call is transmitted. Stripe commits ch_A and returns 200. The task receives the response.

At t=1, the JVM is killed. This can happen due to Kubernetes pod eviction (node memory pressure), an OOM kill (-Xmx set too low), a cloud provider spot instance reclaim, or an operator running kubectl delete pod during a deployment. Crucially, the kill happens between Stripe returning 200 and the BillingRecord write completing. Spring Cloud Task’s SimpleTaskExecution shutdown hook never runs. The TASK_EXECUTION row retains end_time = null and exit_code = null.

At t=2, the orchestrator runs its periodic check. It queries TASK_EXECUTION. It finds executionId = 42 with end_time = null. The guard evaluates to true: “task has null end_time — crashed before completing meaningful work — relaunch.” The orchestrator submits a new SCDF task launch. SCDF creates TaskExecution with executionId = 43, start_time = T2, end_time = null. The billing task starts with a fresh JVM. UUID.randomUUID() generates UUID_B. The Stripe API call transmits Idempotency-Key: UUID_B. Stripe has no record of UUID_B (Stripe’s cache maps to UUID_A, a different key). Stripe processes the charge as independent and commits ch_B. The customer is charged twice.

The window between Stripe returning 200 and the JVM writing end_time is small but real. It spans the BillingRecord INSERT, the Spring Cloud Task shutdown listener invocations, and the TASK_EXECUTION UPDATE SQL statement. Under normal operation this window is measured in tens of milliseconds. But it is finite, it exists on every task launch, and any external kill signal can land in that window.

Developer misconception

The developer’s mental model is: “end_time IS NULL means the task JVM was killed before it could record its completion. A task that is killed before it can record completion must have been killed early, before it did any real work. The TASK_EXECUTION table is the authoritative record of what happened.”

This model conflates two distinct concepts: task completion recording and task side-effect completion. Spring Cloud Task writes end_time after CommandLineRunner.run() returns or throws. The framework writes this record as a side effect of the task’s own shutdown. If the JVM is killed before that write, the record does not exist. But the absence of the record says nothing about how far the run() method progressed before the kill. The kill could have occurred at line 1 of run() (truly before any work) or at the last line after every side effect completed. The TASK_EXECUTION record is evidence of what the framework recorded, not evidence of what application code executed.

A secondary misconception is that JVM kills always happen at the beginning of task execution. In practice, a task that allocates a large data structure, calls an external HTTP API, writes to a database, and then exits may be killed at any of these steps. OOM kills are more likely to occur after memory allocation, not before it. Spot instance reclaims have no relationship to where the task is in its execution. The probability of a kill landing in the window after the Stripe call but before the end_time write is nonzero and cannot be assumed away.

The fix

The fix has two parts: the billing task must use a stable idempotency key, and the orchestrator’s guard must check the application-level BillingRecord table, not only the TASK_EXECUTION table.

// FIXED orchestrator — uses application-level BillingRecord as guard,
// not just TASK_EXECUTION end_time.

@Service
public class BillingOrchestrator {

    private final TaskExplorer taskExplorer;
    private final SCDFTaskLauncher scdfTaskLauncher;
    private final BillingRecordRepository billingRecordRepo;
    private final SubscriptionRepository subscriptionRepo;

    // ... constructor ...

    public void triggerBillingIfNeeded(
            String subscriptionId, String billingPeriod) {

        Subscription sub = subscriptionRepo.findById(subscriptionId)
            .orElseThrow();

        // FIXED: compute the stable key that the task will use.
        // Check the application-level guard before consulting TASK_EXECUTION.
        String stableKey = computeStableKey(
            subscriptionId, billingPeriod, sub.getAmountCents()
        );

        // If a BillingRecord with this key exists, Stripe committed ch_A
        // on a prior launch — regardless of what end_time says in TASK_EXECUTION.
        Optional<BillingRecord> existingRecord =
            billingRecordRepo.findByIdempotencyKey(stableKey);
        if (existingRecord.isPresent()) {
            System.out.println("Billing already completed — charge "
                + existingRecord.get().getStripeChargeId()
                + " — skipping relaunch.");
            return;
        }

        // No BillingRecord. Check TASK_EXECUTION to avoid launching
        // a task that is currently running.
        boolean isRunning = taskExplorer
            .findTaskExecutionsByName("billing-task", PageRequest.of(0, 100))
            .getContent().stream()
            .filter(e -> matchesBillingIntent(e, subscriptionId, billingPeriod))
            .anyMatch(e -> e.getEndTime() == null
                          && e.getStartTime() != null
                          && isRecentlyStarted(e.getStartTime()));

        if (isRunning) {
            System.out.println("Task is currently running — skipping relaunch.");
            return;
        }

        // Launch. The task will use the stable key. If ch_A was committed
        // on a prior launch but BillingRecord write failed, the relaunch's
        // Stripe call returns ch_A from Stripe's cache and the BillingRecord
        // write succeeds on this attempt.
        launchBillingTask(subscriptionId, billingPeriod);
    }
}

The isRecentlyStarted() check applies a grace period (e.g., 5 minutes) during which a null end_time is treated as “task might still be running” rather than “task was killed.” After the grace period, a null end_time is treated as a crash, but the application-level guard has already taken over as the authoritative completion check. The orchestrator no longer relies solely on end_time IS NULL to decide whether to relaunch.

The billing task itself also adopts the stable key:

// FIXED task — stable key, BillingRecord guard check at startup.
// A relaunch after a post-commit JVM kill uses the same key,
// Stripe returns ch_A from its cache, no ch_B is created.

@Component
public class BillingTaskRunner implements CommandLineRunner {

    private final SubscriptionRepository subscriptionRepo;
    private final BillingRecordRepository billingRecordRepo;

    @Override
    public void run(String... args) throws Exception {
        String subscriptionId = getArg(args, "--subscriptionId");
        String billingPeriod  = getArg(args, "--billingPeriod");
        Subscription sub = subscriptionRepo.findById(subscriptionId).orElseThrow();

        String idempotencyKey = computeStableKey(
            subscriptionId, billingPeriod, sub.getAmountCents()
        );

        // Guard: skip Stripe call if BillingRecord already committed.
        // Handles the case where this is a relaunch after post-commit kill.
        Optional<BillingRecord> existing =
            billingRecordRepo.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            System.out.println("BillingRecord exists for key " + idempotencyKey
                + " — charge " + existing.get().getStripeChargeId()
                + " — skipping Stripe call.");
            return;  // Task exits COMPLETED with no new charge.
        }

        Charge charge = Charge.create(
            ChargeCreateParams.builder()
                .setAmount(sub.getAmountCents())
                .setCurrency("usd")
                .setCustomer(sub.getStripeCustomerId())
                .build(),
            RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey)
                .build()
        );

        // Even if Stripe returns ch_A (cached from prior launch), we write
        // the BillingRecord with the charge ID. This handles the case where
        // the prior launch committed ch_A but was killed before writing the record.
        billingRecordRepo.saveIfAbsent(new BillingRecord(
            subscriptionId, billingPeriod,
            idempotencyKey, charge.getId(), sub.getAmountCents()
        ));
    }
}

The dual guard — once in the orchestrator (before launching) and once in the task itself (before calling Stripe) — is belt-and-suspenders. If the orchestrator’s guard misses a prior committed record (due to a race condition where the orchestrator queries before the BillingRecord write completes), the task’s own guard catches it. If the task is somehow launched concurrently by two orchestrator instances, the stable key and Stripe’s deduplication ensure only one charge is committed, and the saveIfAbsent pattern ensures only one BillingRecord is written.

Testing all three modes

Each of the three modes has a distinct test pattern. The goal is to verify that the idempotency key sent to Stripe is stable across the failure condition in question.

Mode 1: verify key stability across simulated SCDF relaunch

// Test: two task executions for the same billing intent must send
// the same Idempotency-Key to Stripe (simulating SCDF relaunch).

@SpringBootTest
@Import({BillingTaskRunner.class, BillingTaskConfiguration.class})
class Mode1SCDFRelaunchTest {

    @Autowired
    private ApplicationContext context;

    @Autowired
    private BillingRecordRepository billingRecordRepo;

    @RegisterExtension
    static WireMockExtension wm = WireMockExtension.newInstance()
        .options(wireMockConfig().dynamicPort())
        .build();

    @Test
    void scdfRelaunchwithStableKey_doesNotDoubleCharge() throws Exception {
        // Stub Stripe — capture Idempotency-Key headers.
        List<String> capturedKeys = new ArrayList<>();
        wm.stubFor(post(urlPathEqualTo("/v1/charges"))
            .willReturn(aResponse()
                .withStatus(200)
                .withBody(chargeJson("ch_A", 2000))
                .withTransformer("capture-idempotency-key", capturedKeys)));

        // Simulate first launch: run the CommandLineRunner directly.
        // Internally, it should use a stable key derived from billing fields.
        BillingTaskRunner runner = context.getBean(BillingTaskRunner.class);

        // First "launch" — simulated by direct invocation.
        runner.run("--subscriptionId=sub_123", "--billingPeriod=2026-10");

        // Simulate task failure after Stripe committed: delete the BillingRecord
        // to simulate a constraint violation rollback (for test purposes).
        // In a real SCDF relaunch, a new TaskExecution is created; here we
        // verify key stability by re-running the CommandLineRunner.
        billingRecordRepo.deleteAll(); // simulate failed write

        // Second "launch" — same billing parameters, simulates SCDF relaunch.
        runner.run("--subscriptionId=sub_123", "--billingPeriod=2026-10");

        // Assert: both Stripe calls used the same Idempotency-Key.
        assertThat(capturedKeys).hasSize(2);
        assertThat(capturedKeys.get(0)).isEqualTo(capturedKeys.get(1));
        // Stripe's deduplication activates because the key matches.
        // No second charge is created.
    }
}

Mode 2: verify compensation listener does not double-charge when BillingRecord exists

// Test: onTaskFailed() must NOT call Stripe when a BillingRecord
// with the stable key already exists.

@SpringBootTest
class Mode2CompensationListenerTest {

    @Autowired
    private SafeBillingCompensationListener compensationListener;

    @Autowired
    private BillingRecordRepository billingRecordRepo;

    @Autowired
    private SubscriptionRepository subscriptionRepo;

    @RegisterExtension
    static WireMockExtension wm = WireMockExtension.newInstance()
        .options(wireMockConfig().dynamicPort())
        .build();

    @Test
    void compensationListener_doesNotCallStripe_whenBillingRecordExists() {
        // Pre-condition: BillingRecord exists, simulating that ch_A was
        // committed on the main task run before the task failed.
        String subscriptionId = "sub_123";
        String billingPeriod  = "2026-10";
        Subscription sub = subscriptionRepo.findById(subscriptionId).orElseThrow();
        String stableKey = computeStableKey(
            subscriptionId, billingPeriod, sub.getAmountCents()
        );
        billingRecordRepo.save(new BillingRecord(
            subscriptionId, billingPeriod, stableKey, "ch_A", sub.getAmountCents()
        ));

        // Stripe stub — should NOT be called.
        wm.stubFor(post(urlPathEqualTo("/v1/charges"))
            .willReturn(aResponse().withStatus(200)
                .withBody(chargeJson("ch_B", sub.getAmountCents()))));

        // Simulate onTaskFailed() being called.
        TaskExecution failedExecution = buildTaskExecution(
            subscriptionId, billingPeriod, "FAILED"
        );
        compensationListener.onTaskFailed(failedExecution,
            new RuntimeException("BillingRecord write failed"));

        // Assert: Stripe was NOT called (no WireMock request recorded).
        wm.verify(0, postRequestedFor(urlPathEqualTo("/v1/charges")));
        // Assert: BillingRecord count is still 1 (not duplicated).
        assertThat(billingRecordRepo.countByIdempotencyKey(stableKey)).isEqualTo(1);
    }
}

Mode 3: verify relaunch after null end_time uses stable key and does not double-charge

// Test: a billing task relaunched after a post-commit JVM kill
// (simulated by a prior run that called Stripe but never wrote BillingRecord)
// sends the same Idempotency-Key and Stripe returns ch_A from its cache.

@SpringBootTest
@Testcontainers
class Mode3EndTimeNullRelaunched {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:15");

    @RegisterExtension
    static WireMockExtension wm = WireMockExtension.newInstance()
        .options(wireMockConfig().dynamicPort())
        .build();

    @Autowired
    private BillingTaskRunner taskRunner;

    @Autowired
    private BillingRecordRepository billingRecordRepo;

    @Test
    void relaunched_afterPostCommitKill_sendsSameKeyStripeDeduplicates()
            throws Exception {
        String subscriptionId = "sub_123";
        String billingPeriod  = "2026-10";

        // First call: Stripe returns ch_A.
        wm.stubFor(post(urlPathEqualTo("/v1/charges"))
            .willReturn(aResponse().withStatus(200)
                .withBody(chargeJson("ch_A", 2000))));

        // Simulate first launch calling Stripe but BillingRecord write failing
        // (JVM killed mid-write — we simulate by making the repo throw).
        when(billingRecordRepo.saveIfAbsent(any()))
            .thenThrow(new RuntimeException("simulated JVM kill mid-write"))
            .thenCallRealMethod(); // second call succeeds

        assertThatThrownBy(() ->
            taskRunner.run("--subscriptionId=sub_123", "--billingPeriod=2026-10"))
            .isInstanceOf(RuntimeException.class);

        // Capture the Idempotency-Key sent on the first call.
        LoggedRequest firstCall = wm.findAll(
            postRequestedFor(urlPathEqualTo("/v1/charges"))).get(0);
        String keyFromFirstCall = firstCall.getHeader("Idempotency-Key");

        // Second stub: Stripe returns ch_A again (deduplication).
        wm.stubFor(post(urlPathEqualTo("/v1/charges"))
            .willReturn(aResponse().withStatus(200)
                .withBody(chargeJson("ch_A", 2000))));

        // Second "launch" — simulates SCDF relaunch after null end_time.
        taskRunner.run("--subscriptionId=sub_123", "--billingPeriod=2026-10");

        // Assert: same Idempotency-Key was sent on both calls.
        LoggedRequest secondCall = wm.findAll(
            postRequestedFor(urlPathEqualTo("/v1/charges"))).get(1);
        String keyFromSecondCall = secondCall.getHeader("Idempotency-Key");

        assertThat(keyFromSecondCall).isEqualTo(keyFromFirstCall);
        // Stripe received the same key both times — deduplication active.
        // Only one charge exists.
        assertThat(billingRecordRepo.findAll()).hasSize(1);
        assertThat(billingRecordRepo.findAll().get(0).getStripeChargeId())
            .isEqualTo("ch_A");
    }
}

Diagnostic checklist

Use this checklist to audit a Spring Cloud Task billing pipeline for idempotency vulnerabilities before deploying to production.

Check Pass Fail
Idempotency key source: Where is the Stripe idempotency key derived from? Key is derived from stable billing intent fields: SHA-256(subscriptionId + billingPeriod + amountCents). Same fields, same key, on every launch for the same billing intent. Key is UUID.randomUUID(), derived from taskExecution.getExecutionId(), derived from a timestamp, or computed from any value that changes between SCDF task launches.
SCDF relaunch policy: Is the billing task configured to be relaunched on non-zero exit codes? Relaunch policy is enabled AND the billing task uses a stable key AND the orchestrator checks the application-level BillingRecord guard before relaunching. Relaunch policy is enabled AND the billing task uses UUID.randomUUID() or executionId-based keys. Every relaunch produces a new charge.
onTaskFailed() safety: Does the TaskExecutionListener.onTaskFailed() implementation call Stripe? No onTaskFailed() implementation calls Stripe, OR it checks a durable application-level guard (database row) with the stable key before calling Stripe, and uses the same stable key for the call. onTaskFailed() calls Stripe with UUID.randomUUID() or generates a new key, assuming task failure means Stripe was never called.
Orchestrator guard logic: Does the billing orchestrator use TASK_EXECUTION.end_time IS NULL as a guard for relaunching? The orchestrator checks the application-level BillingRecord table as the primary guard. TASK_EXECUTION.end_time IS NULL is used only to avoid launching a task that is still running (within a grace period), not to conclude that Stripe was never called. The orchestrator treats TASK_EXECUTION.end_time IS NULL as proof that the task crashed before calling Stripe. A post-commit JVM kill causes a relaunch that double-charges.
Kubernetes job controller: Is the billing task deployed as a Kubernetes Job with restartPolicy: OnFailure or backoffLimit > 0? Kubernetes job restart policy is set AND the billing task uses a stable idempotency key. A pod restart with backoffLimit > 0 is effectively a task relaunch and carries the same risks as a SCDF relaunch. Kubernetes job restart policy is set AND the billing task uses an unstable key. Pod restarts produce new charges.
BillingRecord write ordering: Is the BillingRecord written before or after the Stripe call? A BillingAttempt row (with the stable key, status=IN_PROGRESS) is written BEFORE the Stripe call in a separate transaction. After the Stripe call succeeds, the row is updated to COMPLETED with the charge ID. Guard checks query by key, not by status, so the record is found on relaunch even if the update did not commit. BillingRecord is written after the Stripe call succeeds. A JVM kill between the Stripe call and the write leaves no application-level evidence that Stripe was called. The guard on relaunch finds nothing and calls Stripe again.
Stable key in SCDF task arguments: Is the stable idempotency key passed as a task argument so it is stored in TASK_EXECUTION_PARAMS? The stable key is passed as a command-line argument (e.g., --idempotencyKey=abc123) and stored in TASK_EXECUTION_PARAMS. On relaunch, the key can be read from the task execution params and reused without recomputing. The idempotency key is computed inside the task JVM and not stored in TASK_EXECUTION_PARAMS. The orchestrator cannot pass the original key to a relaunch without access to the original computation inputs.
Audit log: Is the Stripe Idempotency-Key header logged alongside every Stripe API call? The key sent to Stripe is logged (at INFO level, not DEBUG) alongside the subscription ID, billing period, and Stripe charge ID in the application log and in a durable BillingAttempt table. Double-charge investigation can recover the key from both sources. Only the Stripe charge ID is logged. If a double charge occurs, it is impossible to determine from application logs whether two different keys were sent, which is the first question in any double-charge incident response.

Summary

The three Spring Cloud Task failure modes described in this post arise from the framework’s short-lived task model and the operational patterns that SCDF and Kubernetes impose on task execution. The executionId-based key fails because Spring Cloud Task assigns a new executionId to every launch, including relaunches; a key that changes between launches cannot deduplicate across them, and Stripe deduplicates only when the same key is presented on both calls. The onTaskFailed() compensation failure arises because task failure does not mean Stripe was never called; the compensation listener must query a durable application-level guard before calling Stripe, not assume from the failure signal alone that no charge was committed. The end_time IS NULL guard failure arises because Spring Cloud Task writes the end_time after all application code completes, creating a kill window in which ch_A can be committed by Stripe while the task’s completion record remains unwritten.

All three modes share the same root fix: the idempotency key must be derived deterministically from the billing intent’s identifying fields before any Stripe call is made, and a durable application-level guard record must be written before (or immediately after, using a stable key for deduplication safety) the Stripe call. The TASK_EXECUTION table is a task execution log, not a billing ledger; the billing ledger must be maintained separately and must be the primary source of truth for decisions about whether Stripe has been charged for a given billing intent. This pattern appears across the series in different guises: in Spring Batch, the JobExecutionId is the executionId analogue; in Spring Integration, the aggregator group state is the end_time analogue; in all cases, a framework-managed lifecycle record is an unreliable substitute for an application-level billing guard. The fix is always to derive the key from billing intent fields, store it as a durable record, and let Stripe’s own deduplication handle the cases where the durable record was not written before a crash.

Operating Spring Cloud Task billing tasks under SCDF or Kubernetes job controllers amplifies these failure modes because the relaunch mechanism is automatic. An ad-hoc task that a developer relaunches manually after a failure involves a human who might notice a prior charge in the Stripe Dashboard. An automatic SCDF relaunch involves no such check — it fires as soon as the configured retry threshold is reached and relaunch criteria are met. Keybrake’s proxy layer provides a persistent audit record of every Stripe call keyed by the presented idempotency key, independent of the billing task’s own logging infrastructure, which allows incident response teams to answer the key question — “did two different idempotency keys reach Stripe for this billing intent?” — even when the application logs have been rotated, the SCDF task history has been pruned, or the task JVM was killed before it could write its own logs.

Stop Spring Cloud Task → Stripe double charges at the proxy layer

Keybrake is an API key proxy for the SaaS APIs your agents and tasks call — Stripe, Twilio, Resend. Every proxied request gets a stable, durable idempotency key derived from the request fingerprint and stored in the audit log. SCDF relaunch key rotation, onTaskFailed() compensation fires, and end_time IS NULL orchestrator re-triggers cannot produce a second charge through Keybrake. Early access is open.