This is an automated email from the ASF dual-hosted git repository.

MartijnVisser pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-connector-kafka.git


The following commit(s) were added to refs/heads/main by this push:
     new 5accb66f [FLINK-40703][tests] Cover KafkaSink recovery after pooled 
transaction ID reuse
5accb66f is described below

commit 5accb66f79e7427def1ba3ec5810e189bc0ed48c
Author: Aleksandr Savonin <[email protected]>
AuthorDate: Thu Sep 17 21:22:43 2026 +0200

    [FLINK-40703][tests] Cover KafkaSink recovery after pooled transaction ID 
reuse
    
    Generated-by: Claude Fable 5.1
---
 .../kafka/sink/KafkaSinkRecoveryITCase.java        | 537 +++++++++++++++++++++
 1 file changed, 537 insertions(+)

diff --git 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/sink/KafkaSinkRecoveryITCase.java
 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/sink/KafkaSinkRecoveryITCase.java
new file mode 100644
index 00000000..6e86ff56
--- /dev/null
+++ 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/sink/KafkaSinkRecoveryITCase.java
@@ -0,0 +1,537 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.flink.connector.kafka.sink;
+
+import org.apache.flink.api.common.eventtime.WatermarkStrategy;
+import org.apache.flink.api.common.functions.MapFunction;
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.api.common.state.ListState;
+import org.apache.flink.api.common.state.ListStateDescriptor;
+import org.apache.flink.api.common.typeinfo.BasicTypeInfo;
+import org.apache.flink.api.common.typeutils.SimpleTypeSerializerSnapshot;
+import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot;
+import org.apache.flink.api.common.typeutils.base.TypeSerializerSingleton;
+import 
org.apache.flink.api.connector.source.lib.NumberSequenceSource.NumberSequenceSplit;
+import org.apache.flink.api.connector.source.util.ratelimit.RateLimiter;
+import org.apache.flink.configuration.CheckpointingOptions;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.configuration.CoreOptions;
+import org.apache.flink.configuration.ExternalizedCheckpointRetention;
+import org.apache.flink.configuration.RestartStrategyOptions;
+import org.apache.flink.connector.base.DeliveryGuarantee;
+import org.apache.flink.connector.datagen.source.DataGeneratorSource;
+import org.apache.flink.connector.kafka.sink.internal.KafkaCommitter;
+import org.apache.flink.connector.kafka.sink.internal.TransactionalIdFactory;
+import org.apache.flink.connector.kafka.testutils.KafkaUtil;
+import org.apache.flink.connector.kafka.testutils.TestKafkaContainer;
+import org.apache.flink.connector.kafka.util.AdminUtils;
+import org.apache.flink.core.execution.JobClient;
+import org.apache.flink.core.memory.DataInputView;
+import org.apache.flink.core.memory.DataOutputView;
+import org.apache.flink.runtime.minicluster.MiniCluster;
+import org.apache.flink.runtime.state.FunctionInitializationContext;
+import org.apache.flink.runtime.state.FunctionSnapshotContext;
+import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration;
+import org.apache.flink.streaming.api.checkpoint.CheckpointedFunction;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.test.junit5.InjectMiniCluster;
+import org.apache.flink.test.junit5.MiniClusterExtension;
+import org.apache.flink.testutils.junit.SharedObjectsExtension;
+import org.apache.flink.testutils.junit.SharedReference;
+import org.apache.flink.util.IOUtils;
+
+import org.apache.kafka.clients.admin.AdminClient;
+import org.apache.kafka.clients.admin.TransactionListing;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.clients.consumer.KafkaConsumer;
+import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.serialization.ByteArrayDeserializer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.api.io.TempDir;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.UUID;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.stream.Collectors;
+
+import static 
org.apache.flink.configuration.StateRecoveryOptions.SAVEPOINT_PATH;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Exercises transactional id reuse across checkpoint recovery with a real 
sink pipeline.
+ *
+ * <p>C1 completes and commits its transaction. C2's asynchronous snapshot is 
blocked rather than
+ * failed so processing can continue through C3, which precommits a 
transaction reusing C1's
+ * transactional id, while C1 remains the latest completed checkpoint. The job 
is then cancelled and
+ * restored from C1 with the same transactional id prefix and operator 
identities.
+ *
+ * <p>This exercises the recovery fencing scenario described in {@link
+ * KafkaCommitter#logFencedRequest}. The restored job must replay the 
uncheckpointed records and
+ * successfully commit new transactions without losing or duplicating records. 
The first checkpoint
+ * after restoration must make the replayed record visible to {@code 
read_committed} consumers; an
+ * abandoned transaction under the reused transactional id must not block that 
output.
+ */
+@Testcontainers
+class KafkaSinkRecoveryITCase {
+    private static final long TIMEOUT_SECONDS = 60;
+
+    @RegisterExtension
+    static final MiniClusterExtension MINI_CLUSTER =
+            new MiniClusterExtension(
+                    new MiniClusterResourceConfiguration.Builder()
+                            .setNumberTaskManagers(1)
+                            .setNumberSlotsPerTaskManager(2)
+                            .build());
+
+    @Container
+    static final TestKafkaContainer KAFKA =
+            KafkaUtil.createKafkaContainer(KafkaSinkRecoveryITCase.class);
+
+    @RegisterExtension final SharedObjectsExtension sharedObjects = 
SharedObjectsExtension.create();
+
+    private final List<AutoCloseable> cleanupActions = new ArrayList<>();
+
+    @AfterEach
+    void tearDown() throws Throwable {
+        cleanupActions.add(KafkaUtil::checkProducerLeak);
+        IOUtils.closeAll(cleanupActions, Throwable.class);
+    }
+
+    @Test
+    void recoversPooledTransactionsWithoutLosingNewRecords(
+            @TempDir Path checkpointDirectory, @InjectMiniCluster MiniCluster 
miniCluster)
+            throws Exception {
+        final String topic = "pooling-recovery-" + UUID.randomUUID();
+        final String transactionalIdPrefix = "pooling-" + UUID.randomUUID();
+        final Properties kafkaProperties = kafkaProperties();
+        KafkaUtil.createNewTopicAndWaitForPartitionAssignment(topic, 1, 1, 
kafkaProperties);
+        cleanupActions.add(
+                () -> {
+                    try (AdminClient admin = 
AdminClient.create(kafkaProperties)) {
+                        admin.deleteTopics(Collections.singleton(topic))
+                                .all()
+                                .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+                    }
+                });
+
+        final SharedReference<PipelineControls> originalControls =
+                
sharedObjects.add(PipelineControls.blockingSnapshotsAfterFirstCheckpoint());
+        final SharedReference<PipelineControls> recoveredControls =
+                sharedObjects.add(PipelineControls.withoutSnapshotBlocking());
+        final JobClient original =
+                startJob(topic, transactionalIdPrefix, checkpointDirectory, 
originalControls, null);
+        emit(originalControls, 0L);
+        final String checkpointOnePath =
+                miniCluster
+                        .triggerCheckpoint(original.getJobID())
+                        .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        final long checkpointOneId =
+                
awaitCheckpointId(originalControls.get().snapshottedCheckpoints);
+        awaitCommittedRecords(topic, List.of(0L));
+
+        // C1's completed transaction is returned to the pool at C2.
+        emit(originalControls, 1L);
+        final CompletableFuture<String> checkpointTwo =
+                miniCluster.triggerCheckpoint(original.getJobID());
+        final long checkpointTwoId = 
awaitCheckpointId(originalControls.get().blockedCheckpoints);
+        assertThat(checkpointTwoId).isGreaterThan(checkpointOneId);
+        
assertThat(awaitCheckpointId(originalControls.get().snapshottedCheckpoints))
+                .isEqualTo(checkpointTwoId);
+        assertThat(checkpointTwo).isNotDone();
+
+        // C3 precommits the transaction reusing C1's transactional id.
+        emit(originalControls, 2L);
+        final CompletableFuture<String> checkpointThree =
+                miniCluster.triggerCheckpoint(original.getJobID());
+        final long checkpointThreeId =
+                
awaitCheckpointId(originalControls.get().snapshottedCheckpoints);
+        // C2 can occupy the only async snapshot worker. The next record 
proves that C3
+        // finished its entire synchronous phase, including the sink precommit.
+        emit(originalControls, 3L);
+        assertThat(checkpointThreeId).isGreaterThan(checkpointTwoId);
+        assertThat(checkpointTwo).isNotDone();
+        assertThat(checkpointThree).isNotDone();
+
+        try (AdminClient admin = AdminClient.create(kafkaProperties)) {
+            assertThat(AdminUtils.getOpenTransactionsForTopics(admin, 
Collections.singleton(topic)))
+                    .extracting(TransactionListing::transactionalId)
+                    .as("C3 reused C1's transactional id before recovery")
+                    .contains(
+                            TransactionalIdFactory.buildTransactionalId(
+                                    transactionalIdPrefix, 0, 0));
+        }
+
+        cancelAndWait(original, originalControls.get());
+        
assertThat(checkpointTwo).failsWithin(Duration.ofSeconds(TIMEOUT_SECONDS));
+        
assertThat(checkpointThree).failsWithin(Duration.ofSeconds(TIMEOUT_SECONDS));
+
+        // Restore exactly C1, preserving both the sink prefix and operator 
identities.
+        final JobClient recovered =
+                startJob(
+                        topic,
+                        transactionalIdPrefix,
+                        checkpointDirectory,
+                        recoveredControls,
+                        checkpointOnePath);
+        emit(recoveredControls, 1L);
+        
miniCluster.triggerCheckpoint(recovered.getJobID()).get(TIMEOUT_SECONDS, 
TimeUnit.SECONDS);
+        awaitCommittedRecords(topic, List.of(0L, 1L));
+
+        emit(recoveredControls, 2L);
+        emit(recoveredControls, 3L);
+        
miniCluster.triggerCheckpoint(recovered.getJobID()).get(TIMEOUT_SECONDS, 
TimeUnit.SECONDS);
+        awaitCommittedRecords(topic, List.of(0L, 1L, 2L, 3L));
+        cancelAndWait(recovered, recoveredControls.get());
+
+        final List<Long> records =
+                KafkaUtil.drainAllRecordsFromTopic(topic, kafkaProperties, 
true).stream()
+                        .map(KafkaSinkRecoveryITCase::decodeRecord)
+                        .collect(Collectors.toList());
+        assertThat(records).containsExactly(0L, 1L, 2L, 3L);
+    }
+
+    private JobClient startJob(
+            String topic,
+            String transactionalIdPrefix,
+            Path checkpointDirectory,
+            SharedReference<PipelineControls> controls,
+            @Nullable String restorePath)
+            throws Exception {
+        final Configuration configuration = new Configuration();
+        configuration.set(CoreOptions.DEFAULT_PARALLELISM, 1);
+        configuration.set(RestartStrategyOptions.RESTART_STRATEGY, "disable");
+        configuration.set(
+                CheckpointingOptions.CHECKPOINTS_DIRECTORY, 
checkpointDirectory.toUri().toString());
+        configuration.set(
+                CheckpointingOptions.EXTERNALIZED_CHECKPOINT_RETENTION,
+                ExternalizedCheckpointRetention.RETAIN_ON_CANCELLATION);
+        if (restorePath != null) {
+            configuration.set(SAVEPOINT_PATH, restorePath);
+        }
+        final StreamExecutionEnvironment environment =
+                
StreamExecutionEnvironment.getExecutionEnvironment(configuration);
+        // Long.MAX_VALUE disables periodic scheduling while allowing manual 
checkpoints.
+        environment.enableCheckpointing(Long.MAX_VALUE);
+        environment.getCheckpointConfig().setMaxConcurrentCheckpoints(2);
+
+        final DataGeneratorSource<Long> source =
+                new DataGeneratorSource<>(
+                        value -> {
+                            controls.get().emittedRecords.add(value);
+                            return value;
+                        },
+                        Long.MAX_VALUE,
+                        parallelism -> new PermitRateLimiter(controls),
+                        BasicTypeInfo.LONG_TYPE_INFO);
+        environment
+                .fromSource(source, WatermarkStrategy.noWatermarks(), 
"controlled source")
+                .uid("source")
+                .map(new CheckpointGate(controls))
+                .uid("checkpoint-gate")
+                .sinkTo(
+                        KafkaSink.<Long>builder()
+                                
.setBootstrapServers(KAFKA.getBootstrapServers())
+                                
.setTransactionalIdPrefix(transactionalIdPrefix)
+                                
.setTransactionNamingStrategy(TransactionNamingStrategy.POOLING)
+                                
.setDeliveryGuarantee(DeliveryGuarantee.EXACTLY_ONCE)
+                                .setRecordSerializer(
+                                        
KafkaRecordSerializationSchema.builder()
+                                                .setTopic(topic)
+                                                
.setValueSerializationSchema(new RecordSerializer())
+                                                .build())
+                                .build())
+                .uid("kafka-sink");
+        final JobClient job = environment.executeAsync("pooled transaction 
recovery");
+        cleanupActions.add(0, () -> cancelAndWait(job, controls.get()));
+        return job;
+    }
+
+    private static void emit(SharedReference<PipelineControls> controls, long 
expectedValue)
+            throws InterruptedException {
+        controls.get().allowRecord();
+        assertThat(controls.get().emittedRecords.poll(TIMEOUT_SECONDS, 
TimeUnit.SECONDS))
+                .as("next record from the checkpointed source")
+                .isEqualTo(expectedValue);
+    }
+
+    private static long awaitCheckpointId(BlockingQueue<Long> checkpoints)
+            throws InterruptedException {
+        final Long checkpointId = checkpoints.poll(TIMEOUT_SECONDS, 
TimeUnit.SECONDS);
+        assertThat(checkpointId).as("checkpoint reached the test 
gate").isNotNull();
+        return checkpointId;
+    }
+
+    private static void cancelAndWait(JobClient job, PipelineControls 
controls) throws Exception {
+        try {
+            try {
+                job.cancel().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+            } catch (ExecutionException cancellationFailure) {
+                // Cancellation may be rejected for a terminal job. Ignore 
that rejection so
+                // cleanup does not obscure an earlier test failure.
+                if (!job.getJobStatus()
+                        .get(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+                        .isGloballyTerminalState()) {
+                    throw cancellationFailure;
+                }
+            }
+            job.getJobExecutionResult()
+                    .handle((result, failure) -> null)
+                    .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        } finally {
+            // Cancellation interrupts blocked snapshots. Release the gate here
+            // in case cancellation fails.
+            controls.releaseCheckpointGates.countDown();
+        }
+    }
+
+    private static void awaitCommittedRecords(String topic, List<Long> 
expected) {
+        final Properties properties = kafkaProperties();
+        properties.put("key.deserializer", 
ByteArrayDeserializer.class.getName());
+        properties.put("value.deserializer", 
ByteArrayDeserializer.class.getName());
+        properties.put("isolation.level", "read_committed");
+        properties.put("enable.auto.commit", false);
+        final List<Long> records = new ArrayList<>();
+        final long deadline = System.nanoTime() + 
TimeUnit.SECONDS.toNanos(TIMEOUT_SECONDS);
+        try (KafkaConsumer<byte[], byte[]> consumer = new 
KafkaConsumer<>(properties)) {
+            final List<TopicPartition> partitions =
+                    Collections.singletonList(new TopicPartition(topic, 0));
+            consumer.assign(partitions);
+            consumer.seekToBeginning(partitions);
+            while (records.size() < expected.size() && System.nanoTime() < 
deadline) {
+                for (ConsumerRecord<byte[], byte[]> record :
+                        consumer.poll(Duration.ofMillis(100))) {
+                    records.add(decodeRecord(record));
+                }
+            }
+        }
+        assertThat(records)
+                .as("visible committed Kafka records")
+                .containsExactlyElementsOf(expected);
+    }
+
+    private static Properties kafkaProperties() {
+        final Properties properties = new Properties();
+        properties.put("bootstrap.servers", KAFKA.getBootstrapServers());
+        return properties;
+    }
+
+    private static long decodeRecord(ConsumerRecord<byte[], byte[]> record) {
+        return ByteBuffer.wrap(record.value()).getLong();
+    }
+
+    private static final class RecordSerializer implements 
SerializationSchema<Long> {
+        @Override
+        public byte[] serialize(Long value) {
+            return ByteBuffer.allocate(Long.BYTES).putLong(value).array();
+        }
+    }
+
+    /**
+     * Coordinates the test thread, source task, and asynchronous snapshot 
threads through source
+     * permits, checkpoint notifications, and snapshot gates.
+     */
+    private static final class PipelineControls {
+        private final boolean blockAfterFirstCheckpoint;
+        private final AtomicLong firstCheckpoint = new AtomicLong(-1L);
+        private final BlockingQueue<Long> emittedRecords = new 
LinkedBlockingQueue<>();
+        private final BlockingQueue<Long> blockedCheckpoints = new 
LinkedBlockingQueue<>();
+        private final BlockingQueue<Long> snapshottedCheckpoints = new 
LinkedBlockingQueue<>();
+        private final CountDownLatch releaseCheckpointGates = new 
CountDownLatch(1);
+        private int permits;
+        @Nullable private CompletableFuture<Void> pendingPermit;
+
+        private PipelineControls(boolean blockAfterFirstCheckpoint) {
+            this.blockAfterFirstCheckpoint = blockAfterFirstCheckpoint;
+        }
+
+        private static PipelineControls 
blockingSnapshotsAfterFirstCheckpoint() {
+            return new PipelineControls(true);
+        }
+
+        private static PipelineControls withoutSnapshotBlocking() {
+            return new PipelineControls(false);
+        }
+
+        private synchronized CompletionStage<Void> acquire() {
+            if (permits > 0) {
+                permits--;
+                return CompletableFuture.completedFuture(null);
+            }
+            pendingPermit = new CompletableFuture<>();
+            return pendingPermit;
+        }
+
+        private void allowRecord() {
+            final CompletableFuture<Void> permit;
+            synchronized (this) {
+                if (pendingPermit == null) {
+                    permits++;
+                    return;
+                }
+                permit = pendingPermit;
+                pendingPermit = null;
+            }
+            permit.complete(null);
+        }
+    }
+
+    private static final class PermitRateLimiter implements 
RateLimiter<NumberSequenceSplit> {
+        private final SharedReference<PipelineControls> controls;
+
+        private PermitRateLimiter(SharedReference<PipelineControls> controls) {
+            this.controls = controls;
+        }
+
+        @Override
+        public CompletionStage<Void> acquire(int numberOfEvents) {
+            return controls.get().acquire();
+        }
+    }
+
+    private static final class CheckpointGate
+            implements MapFunction<Long, Long>, CheckpointedFunction {
+        private final SharedReference<PipelineControls> controls;
+        private transient ListState<Long> state;
+
+        private CheckpointGate(SharedReference<PipelineControls> controls) {
+            this.controls = controls;
+        }
+
+        @Override
+        public Long map(Long value) {
+            return value;
+        }
+
+        @Override
+        public void snapshotState(FunctionSnapshotContext context) throws 
Exception {
+            controls.get().firstCheckpoint.compareAndSet(-1L, 
context.getCheckpointId());
+            // Persist a value so GateSerializer can block the asynchronous 
snapshot.
+            state.update(Collections.singletonList(context.getCheckpointId()));
+            
controls.get().snapshottedCheckpoints.add(context.getCheckpointId());
+        }
+
+        @Override
+        public void initializeState(FunctionInitializationContext context) 
throws Exception {
+            state =
+                    context.getOperatorStateStore()
+                            .getListState(
+                                    new ListStateDescriptor<>(
+                                            "checkpoint-id", new 
GateSerializer(controls)));
+        }
+    }
+
+    private static final class GateSerializer extends 
TypeSerializerSingleton<Long> {
+        @Nullable private final SharedReference<PipelineControls> controls;
+
+        private GateSerializer(@Nullable SharedReference<PipelineControls> 
controls) {
+            this.controls = controls;
+        }
+
+        @Override
+        public boolean isImmutableType() {
+            return true;
+        }
+
+        @Override
+        public Long createInstance() {
+            return 0L;
+        }
+
+        @Override
+        public Long copy(Long value) {
+            return value;
+        }
+
+        @Override
+        public Long copy(Long value, Long reuse) {
+            return value;
+        }
+
+        @Override
+        public int getLength() {
+            return Long.BYTES;
+        }
+
+        @Override
+        public void serialize(Long checkpointId, DataOutputView output) throws 
IOException {
+            if (controls != null
+                    && controls.get().blockAfterFirstCheckpoint
+                    && checkpointId != controls.get().firstCheckpoint.get()) {
+                controls.get().blockedCheckpoints.add(checkpointId);
+                try {
+                    // Block until cancellation interrupts this thread or 
teardown releases
+                    // the gate. Record and checkpoint waits are bounded by 
the test.
+                    controls.get().releaseCheckpointGates.await();
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    throw new IOException("Checkpoint cancelled by test", e);
+                }
+            }
+            output.writeLong(checkpointId);
+        }
+
+        @Override
+        public Long deserialize(DataInputView input) throws IOException {
+            return input.readLong();
+        }
+
+        @Override
+        public Long deserialize(Long reuse, DataInputView input) throws 
IOException {
+            return deserialize(input);
+        }
+
+        @Override
+        public void copy(DataInputView input, DataOutputView output) throws 
IOException {
+            output.writeLong(input.readLong());
+        }
+
+        @Override
+        public TypeSerializerSnapshot<Long> snapshotConfiguration() {
+            return new GateSerializerSnapshot();
+        }
+    }
+
+    /** The gate is test coordination only; the checkpoint's persisted value 
is a plain long. */
+    public static final class GateSerializerSnapshot extends 
SimpleTypeSerializerSnapshot<Long> {
+        public GateSerializerSnapshot() {
+            super(() -> new GateSerializer(null));
+        }
+    }
+}

Reply via email to