diff --git a/.github/workflows/ci-unwelcome-words.yml b/.github/workflows/ci-unwelcome-words.yml
index a369f6e..06dbda0 100644
--- a/.github/workflows/ci-unwelcome-words.yml
+++ b/.github/workflows/ci-unwelcome-words.yml
@@ -7,7 +7,7 @@ jobs:
   test:
     runs-on: ubuntu-20.04
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
         with:
           ref: ${{ github.sha }}
       - name: Checkout tool
diff --git a/.github/workflows/dev-docker-publish.yml b/.github/workflows/dev-docker-publish.yml
index a1f0711..7ad5102 100644
--- a/.github/workflows/dev-docker-publish.yml
+++ b/.github/workflows/dev-docker-publish.yml
@@ -5,43 +5,47 @@ on:
     branches-ignore:
       - master
       - version-*
+#     paths:
+#    - gradle.properties
+#    - package_info.json
 
 jobs:
   build:
     runs-on: ubuntu-20.04
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
       # Prepare custom build version
       - name: Get branch name
         id: branch
-        run: echo ::set-output name=branch_name::${GITHUB_REF#refs/*/}
+        run: echo "branch_name=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
+      - name: Get SHA of the commit
+        id: sha
+        run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
       - name: Get release_version
         id: ver
-        uses: christian-draeger/read-properties@1.0.1
+        uses: christian-draeger/read-properties@1.1.1
         with:
           path: gradle.properties
-          property: release_version
+          properties: release_version
       - name: Build custom release version
         id: release_ver
-        run: echo ::set-output name=value::"${{ steps.ver.outputs.value }}-${{ steps.branch.outputs.branch_name }}-${{ github.run_id }}"
+        run: echo value="${{ steps.ver.outputs.release_version }}-${{ steps.branch.outputs.branch_name }}-${{ github.run_id }}-${{ steps.sha.outputs.sha_short }}" >> $GITHUB_OUTPUT
       - name: Show custom release version
         run: echo ${{ steps.release_ver.outputs.value }}
       # Build and publish image
       - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v1
-      - uses: docker/login-action@v1
+        uses: docker/setup-buildx-action@v2
+      - uses: docker/login-action@v2
         with:
           registry: ghcr.io
-          username: ${{ github.repository_owner }}
-          password: ${{ secrets.CR_PAT }}
-      - run: echo "::set-output name=REPOSITORY_NAME::$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')"
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+      - run: echo "REPOSITORY_NAME=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_OUTPUT
         id: meta
       - name: Build and push
         id: docker_build
-        uses: docker/build-push-action@v2
+        uses: docker/build-push-action@v3
         with:
           push: true
           tags: ghcr.io/${{ github.repository }}:${{ steps.release_ver.outputs.value }}
-          labels: com.exactpro.th2.${{ steps.meta.outputs.REPOSITORY_NAME }}=${{ steps.ver.outputs.value }}
-          build-args: |
-            release_version=${{ steps.release_ver.outputs.value }}
\ No newline at end of file
+          labels: com.exactpro.th2.${{ steps.meta.outputs.REPOSITORY_NAME }}=${{ steps.ver.outputs.value }}
\ No newline at end of file
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 5e8229e..e529cdb 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -12,28 +12,26 @@ jobs:
   build:
     runs-on: ubuntu-20.04
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
       - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v1
-      - uses: docker/login-action@v1
+        uses: docker/setup-buildx-action@v2
+      - uses: docker/login-action@v2
         with:
           registry: ghcr.io
-          username: ${{ github.repository_owner }}
-          password: ${{ secrets.CR_PAT }}
-      - run: echo "::set-output name=REPOSITORY_NAME::$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')"
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+      - run: echo "REPOSITORY_NAME=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_OUTPUT
         id: meta
       - name: Read version from gradle.properties
         id: read_property
-        uses: christian-draeger/read-properties@1.0.1
+        uses: christian-draeger/read-properties@1.1.1
         with:
           path: ./gradle.properties
-          property: release_version
+          properties: release_version
       - name: Build and push
         id: docker_build
-        uses: docker/build-push-action@v2
+        uses: docker/build-push-action@v3
         with:
           push: true
-          tags: ghcr.io/${{ github.repository }}:${{ steps.read_property.outputs.value }}
-          labels: com.exactpro.th2.${{ steps.meta.outputs.REPOSITORY_NAME }}=${{ steps.read_property.outputs.value }}
-          build-args: |
-            release_version=${{ steps.read_property.outputs.value }}
\ No newline at end of file
+          tags: ghcr.io/${{ github.repository }}:${{ steps.read_property.outputs.release_version }}
+          labels: com.exactpro.th2.${{ steps.meta.outputs.REPOSITORY_NAME }}=${{ steps.read_property.outputs.release_version }}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index d313c84..4c2ff9e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM gradle:6.8-jdk11 AS build
+FROM gradle:7.6-jdk11 AS build
 ARG release_version
 ARG nexus_url
 ARG nexus_user
diff --git a/README.md b/README.md
index 337dae9..cbb9802 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@ has a "success" status, the status of the parent is wrong. Healer finds the pare
 
 ## Configuration
 
-There is an example of full configuration for the data processor
+There is an example of full configuration (infra-2.0) for the data processor
 
 ```yaml
 apiVersion: th2.exactpro.com/v1
@@ -17,12 +17,35 @@ spec:
   image-version: <verison>
   type: th2-conn
   custom-config:
-    name: test-event-healer
-    version: 1.0.0
-    maxCacheCapacity: 1000
+    stateSessionAlias: my-processor-state
+    enableStoreState: false
+    from: 2021-06-16T12:00:00.00Z
+    to: 2021-06-17T14:00:00.00Z
+    intervalLength: PT10M
+    syncInterval: PT10M
+    awaitTimeout: 10
+    awaitUnit: SECONDS
+    events:
+      bookToScope:
+        book1: []
+        book2: []
+    processorSettings:
+      name: test-event-healer
+      version: 1.0.0
+      maxCacheCapacity: 1000
   pins:
-    - name: server
-      connection-type: grpc
+    grpc:
+      client:
+        - name: to_data_provider
+          service-class: com.exactpro.th2.dataprovider.lw.grpc.DataProviderService
+          linkTo:
+            - box: lw-data-provider
+              pin: server
+        - name: to_data_provider_stream
+          service-class: com.exactpro.th2.dataprovider.lw.grpc.QueueDataProviderService
+          linkTo:
+            - box: lw-data-provider
+              pin: server
   extended-settings:
     service:
       enabled: true
diff --git a/build.gradle b/build.gradle
index 718ffa0..9750442 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,14 +1,11 @@
 plugins {
     id 'com.palantir.docker' version '0.25.0'
+    id 'org.jetbrains.kotlin.jvm' version '1.6.21'
+    id 'org.jetbrains.kotlin.kapt' version "1.6.21"
     id 'java'
-    id 'java-library'
     id 'application'
 }
 
-ext {
-    sharedDir           = file("${project.rootDir}/shared")
-}
-
 group 'com.exactpro.th2'
 version release_version
 
@@ -16,10 +13,6 @@ sourceCompatibility = JavaVersion.VERSION_11
 targetCompatibility = JavaVersion.VERSION_11
 
 repositories {
-    maven {
-        name 'MavenLocal'
-        url sharedDir
-    }
     mavenLocal()
     mavenCentral()
     maven {
@@ -52,23 +45,26 @@ jar {
 }
 
 dependencies {
-    api platform('com.exactpro.th2:bom:3.0.0')
+    api platform('com.exactpro.th2:bom:4.0.2')
+
+    compileOnly 'com.google.auto.service:auto-service:1.0.1'
+    kapt 'com.google.auto.service:auto-service:1.0.1'
 
-    implementation 'com.exactpro.th2:common:3.18.2'
-    implementation "com.exactpro.th2:grpc-crawler-data-processor:0.0.1"
+    implementation 'com.github.ben-manes.caffeine:caffeine:3.1.2'
 
-    implementation "com.exactpro.th2:cradle-cassandra:2.14.0"
+    implementation 'com.exactpro.th2:common:4.0.0-TH2-4262-reduce-load-book-and-page-3574992387-SNAPSHOT'
+    implementation "com.exactpro.th2:common-utils:0.0.1-book-and-page-3572891451-SNAPSHOT"
+    implementation "com.exactpro.th2:processor-core:0.0.1-TH2-4262-reduce-load-book-and-page-3739179455-7524a15-SNAPSHOT"
 
-    implementation "org.slf4j:slf4j-log4j12"
-    implementation "org.slf4j:slf4j-api"
-    implementation 'junit:junit:4.13.1'
+    implementation "com.exactpro.th2:cradle-cassandra:4.0.0-dev-version-4-2548754188-SNAPSHOT"
 
-    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0'
-    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.7.0'
+    testImplementation "org.jetbrains.kotlin:kotlin-test-junit5"
+    testImplementation 'org.mockito.kotlin:mockito-kotlin:4.1.0'
 
-    testImplementation group: 'io.grpc', name: 'grpc-testing', version: '1.32.1'
+    testImplementation "org.junit.jupiter:junit-jupiter-api:5.9.0"
+    testImplementation "org.junit.jupiter:junit-jupiter-params:5.9.0"
+    testRuntimeOnly("org.junit.jupiter:junit-jupiter:5.9.0")
 
-    testImplementation group: 'org.mockito', name: 'mockito-core', version: '3.11.2'
 }
 
 test {
@@ -76,7 +72,7 @@ test {
 }
 
 application {
-    mainClassName 'com.exactpro.th2.healer.BoxMain'
+    mainClass.set('com.exactpro.th2.processor.MainKt')
 }
 
 applicationName = 'service'
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index da9702f..070cb70 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
diff --git a/src/main/java/com/exactpro/th2/dataservice/healer/BoxMain.java b/src/main/java/com/exactpro/th2/dataservice/healer/BoxMain.java
deleted file mode 100644
index 25f234c..0000000
--- a/src/main/java/com/exactpro/th2/dataservice/healer/BoxMain.java
+++ /dev/null
@@ -1,125 +0,0 @@
-/*
- * Copyright 2020-2021 Exactpro (Exactpro Systems Limited)
- *
- * Licensed 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 com.exactpro.th2.dataservice.healer;
-
-import com.exactpro.cradle.CradleStorage;
-import com.exactpro.th2.dataservice.healer.cfg.HealerConfiguration;
-import com.exactpro.th2.dataservice.healer.grpc.HealerImpl;
-import com.exactpro.th2.common.schema.factory.CommonFactory;
-import com.exactpro.th2.common.schema.grpc.router.GrpcRouter;
-import io.grpc.Server;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.Deque;
-import java.util.concurrent.ConcurrentLinkedDeque;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.locks.Condition;
-import java.util.concurrent.locks.ReentrantLock;
-
-import static com.exactpro.th2.common.metrics.CommonMetrics.setLiveness;
-import static com.exactpro.th2.common.metrics.CommonMetrics.setReadiness;
-
-public class BoxMain {
-
-    private static final Logger LOGGER = LoggerFactory.getLogger(BoxMain.class);
-
-    public static void main(String[] args) {
-        Deque<AutoCloseable> resources = new ConcurrentLinkedDeque<>();
-        ReentrantLock lock = new ReentrantLock();
-        Condition condition = lock.newCondition();
-
-        configureShutdownHook(resources, lock, condition);
-        try {
-            setLiveness(true);
-
-            CommonFactory factory = CommonFactory.createFromArguments(args);
-            resources.add(factory);
-
-            GrpcRouter grpcRouter = factory.getGrpcRouter();
-            resources.add(grpcRouter);
-
-            HealerConfiguration configuration = factory.getCustomConfiguration(HealerConfiguration.class);
-            CradleStorage storage = factory.getCradleManager().getStorage();
-
-            HealerImpl handler = new HealerImpl(configuration, storage);
-
-            Server server = grpcRouter.startServer(handler).start();
-            resources.add(() -> {
-                LOGGER.info("Shutting down Healer gRPC server");
-
-                TimeUnit unit = TimeUnit.SECONDS;
-                long timeout = 5L;
-
-                if (server.shutdown().awaitTermination(timeout, unit)) {
-                    LOGGER.warn("Cannot shutdown server in {} millis. Shutdown now", unit.toMillis(timeout));
-                    server.shutdownNow();
-                }
-            });
-
-            setReadiness(true);
-
-            LOGGER.info("Healer started");
-
-            awaitShutdown(lock, condition);
-        } catch (InterruptedException e) {
-            LOGGER.info("The main thread interrupted", e);
-            Thread.currentThread().interrupt();
-        } catch (Exception e) {
-            LOGGER.error("Fatal error: " + e.getMessage(), e);
-            System.exit(1);
-        }
-    }
-
-    private static void awaitShutdown(ReentrantLock lock, Condition condition) throws InterruptedException {
-        try {
-            lock.lock();
-            LOGGER.info("Wait shutdown");
-            condition.await();
-            LOGGER.info("App shutdown");
-        } finally {
-            lock.unlock();
-        }
-    }
-
-    private static void configureShutdownHook(Deque<AutoCloseable> resources, ReentrantLock lock, Condition condition) {
-        Runtime.getRuntime().addShutdownHook(new Thread("Shutdown hook") {
-            @Override
-            public void run() {
-                LOGGER.info("Shutdown start");
-                setReadiness(false);
-                try {
-                    lock.lock();
-                    condition.signalAll();
-                } finally {
-                    lock.unlock();
-                }
-
-                resources.descendingIterator().forEachRemaining(resource -> {
-                    try {
-                        resource.close();
-                    } catch (Exception e) {
-                        LOGGER.error(e.getMessage(), e);
-                    }
-                });
-                setLiveness(false);
-                LOGGER.info("Shutdown end");
-            }
-        });
-    }
-
-}
diff --git a/src/main/java/com/exactpro/th2/dataservice/healer/cache/EventsCache.java b/src/main/java/com/exactpro/th2/dataservice/healer/cache/EventsCache.java
deleted file mode 100644
index 36bf775..0000000
--- a/src/main/java/com/exactpro/th2/dataservice/healer/cache/EventsCache.java
+++ /dev/null
@@ -1,101 +0,0 @@
-/*
- * Copyright 2020-2021 Exactpro (Exactpro Systems Limited)
- *
- * Licensed 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 com.exactpro.th2.dataservice.healer.cache;
-
-import java.util.LinkedHashMap;
-import java.util.Map;
-import java.util.concurrent.locks.Lock;
-import java.util.concurrent.locks.ReadWriteLock;
-import java.util.concurrent.locks.ReentrantReadWriteLock;
-
-public class EventsCache<K, V> extends LinkedHashMap<K, V> {
-    private final int maxCapacity;
-    private final Lock writeLock;
-    private final Lock readLock;
-
-    // These default values were taken from HashMap class
-    private static final int DEFAULT_INITIAL_CAPACITY = 16;
-    private static final float DEFAULT_LOAD_FACTOR = 0.75f;
-
-    public EventsCache(int maxCapacity) {
-        super(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, true);
-
-        if (maxCapacity <= 0)
-            throw new IllegalArgumentException("Capacity of EventsCache cannot be zero or negative");
-
-        this.maxCapacity = maxCapacity;
-
-        ReadWriteLock lock = new ReentrantReadWriteLock();
-        this.writeLock = lock.writeLock();
-        this.readLock = lock.readLock();
-    }
-
-    @Override
-    public V get(Object key) {
-        try {
-            readLock.lock();
-            return super.get(key);
-        } finally {
-            readLock.unlock();
-        }
-    }
-
-    @Override
-    public boolean containsKey(Object key) {
-        try {
-            readLock.lock();
-            return super.containsKey(key);
-        } finally {
-            readLock.unlock();
-        }
-    }
-
-    @Override
-    public int size() {
-        try {
-            readLock.lock();
-            return super.size();
-        } finally {
-            readLock.unlock();
-        }
-    }
-
-    @Override
-    public V put(K key, V value) {
-        try {
-            writeLock.lock();
-            return super.put(key, value);
-        } finally {
-            writeLock.unlock();
-        }
-    }
-
-    @Override
-    public V remove(Object key) {
-        try {
-            writeLock.lock();
-            return super.remove(key);
-        } finally {
-            writeLock.unlock();
-        }
-    }
-
-    @Override
-    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
-        return size() > maxCapacity;
-    }
-}
diff --git a/src/main/java/com/exactpro/th2/dataservice/healer/cfg/HealerConfiguration.java b/src/main/java/com/exactpro/th2/dataservice/healer/cfg/HealerConfiguration.java
deleted file mode 100644
index 7355366..0000000
--- a/src/main/java/com/exactpro/th2/dataservice/healer/cfg/HealerConfiguration.java
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * Copyright 2020-2021 Exactpro (Exactpro Systems Limited)
- *
- * Licensed 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 com.exactpro.th2.dataservice.healer.cfg;
-
-import com.fasterxml.jackson.annotation.JsonCreator;
-import com.fasterxml.jackson.annotation.JsonProperty;
-
-import java.util.Objects;
-
-public class HealerConfiguration {
-    private final String name;
-    private final String version;
-    private final int maxCacheCapacity;
-
-    @JsonCreator
-    public HealerConfiguration(@JsonProperty("name") String name,
-                               @JsonProperty("version") String version,
-                               @JsonProperty("maxCacheCapacity") int maxCacheCapacity) {
-        this.name = Objects.requireNonNull(name, "Name is required");
-        this.version = Objects.requireNonNull(version, "Version is required");
-
-        if (name.trim().isEmpty()) {
-            throw new IllegalArgumentException("Name of Healer cannot be empty");
-        }
-
-        if (version.trim().isEmpty()) {
-            throw new IllegalArgumentException("Version of Healer cannot be empty");
-        }
-
-        if (maxCacheCapacity <= 0)
-            throw new IllegalArgumentException("Size of cache cannot be negative or zero");
-
-        this.maxCacheCapacity = maxCacheCapacity;
-    }
-
-    public String getName() { return name; }
-
-    public String getVersion() { return version; }
-
-    public int getMaxCacheCapacity() { return maxCacheCapacity; }
-}
diff --git a/src/main/java/com/exactpro/th2/dataservice/healer/grpc/HealerImpl.java b/src/main/java/com/exactpro/th2/dataservice/healer/grpc/HealerImpl.java
deleted file mode 100644
index b1aacbd..0000000
--- a/src/main/java/com/exactpro/th2/dataservice/healer/grpc/HealerImpl.java
+++ /dev/null
@@ -1,207 +0,0 @@
-/*
- * Copyright 2020-2021 Exactpro (Exactpro Systems Limited)
- *
- * Licensed 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 com.exactpro.th2.dataservice.healer.grpc;
-
-import com.exactpro.cradle.CradleStorage;
-import com.exactpro.cradle.testevents.StoredTestEventId;
-import com.exactpro.cradle.testevents.StoredTestEventWrapper;
-import com.exactpro.th2.dataservice.healer.cache.EventsCache;
-import com.exactpro.th2.dataservice.healer.cfg.HealerConfiguration;
-import com.exactpro.th2.common.grpc.EventID;
-import com.exactpro.th2.common.grpc.EventStatus;
-import com.exactpro.th2.crawler.dataprocessor.grpc.CrawlerId;
-import com.exactpro.th2.crawler.dataprocessor.grpc.CrawlerInfo;
-import com.exactpro.th2.crawler.dataprocessor.grpc.DataProcessorGrpc;
-import com.exactpro.th2.crawler.dataprocessor.grpc.DataProcessorInfo;
-import com.exactpro.th2.crawler.dataprocessor.grpc.EventDataRequest;
-import com.exactpro.th2.crawler.dataprocessor.grpc.EventResponse;
-import com.exactpro.th2.crawler.dataprocessor.grpc.Status;
-import com.exactpro.th2.dataprovider.grpc.EventData;
-import io.grpc.stub.StreamObserver;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-
-import java.util.Collection;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
-import java.util.List;
-import java.util.ArrayList;
-import java.util.concurrent.ConcurrentHashMap;
-
-import static com.exactpro.th2.common.message.MessageUtils.toJson;
-
-
-public class HealerImpl extends DataProcessorGrpc.DataProcessorImplBase {
-
-    private static final Logger LOGGER = LoggerFactory.getLogger(HealerImpl.class);
-
-    private final HealerConfiguration configuration;
-    private final CradleStorage storage;
-    private final Map<String, InnerEvent> cache;
-    private final Set<CrawlerId> knownCrawlers = ConcurrentHashMap.newKeySet();
-
-    public HealerImpl(HealerConfiguration configuration, CradleStorage storage) {
-        this.configuration = Objects.requireNonNull(configuration, "Configuration cannot be null");
-        this.storage = Objects.requireNonNull(storage, "Cradle storage cannot be null");
-        this.cache = new EventsCache<>(configuration.getMaxCacheCapacity());
-    }
-
-    @Override
-    public void crawlerConnect(CrawlerInfo request, StreamObserver<DataProcessorInfo> responseObserver) {
-        try {
-
-            if (LOGGER.isInfoEnabled()) {
-                LOGGER.info("crawlerConnect request: {}", toJson(request, true));
-            }
-
-
-            knownCrawlers.add(request.getId());
-
-            DataProcessorInfo response = DataProcessorInfo.newBuilder()
-                    .setName(configuration.getName())
-                    .setVersion(configuration.getVersion())
-                    .build();
-
-            if (LOGGER.isInfoEnabled()) {
-                LOGGER.info("crawlerConnect response: {}", toJson(response, true));
-            }
-
-            responseObserver.onNext(response);
-            responseObserver.onCompleted();
-        } catch (Exception e) {
-            responseObserver.onError(e);
-            LOGGER.error("crawlerConnect error: " + e.getMessage(), e);
-        }
-    }
-
-    @Override
-    public void sendEvent(EventDataRequest request, StreamObserver<EventResponse> responseObserver) {
-        try {
-            if (LOGGER.isInfoEnabled()) {
-                LOGGER.info("sendEvent request: {}", toJson(request, true));
-            }
-
-            if (!knownCrawlers.contains(request.getId())) {
-
-                if (LOGGER.isWarnEnabled()) {
-                    LOGGER.warn("Received request from unknown crawler with id {}. Sending response with HandshakeRequired = true", toJson(request.getId(), true));
-                }
-
-                responseObserver.onNext(EventResponse.newBuilder()
-                        .setStatus(Status.newBuilder().setHandshakeRequired(true))
-                        .build());
-                responseObserver.onCompleted();
-                return;
-            }
-
-            int eventsCount = request.getEventDataCount();
-
-            heal(request.getEventDataList());
-
-            EventID lastEventId = null;
-
-            if (eventsCount > 0) {
-                lastEventId = request.getEventDataList().get(eventsCount - 1).getEventId();
-            }
-
-            EventResponse.Builder builder = EventResponse.newBuilder();
-
-            if (lastEventId != null) {
-                builder.setId(lastEventId);
-            }
-
-            EventResponse response = builder.build();
-
-            if (LOGGER.isInfoEnabled()) {
-                LOGGER.info("sendEvent response: {}", toJson(response, true));
-            }
-
-            responseObserver.onNext(response);
-            responseObserver.onCompleted();
-        } catch (Exception e) {
-            responseObserver.onError(e);
-            LOGGER.error("sendEvent error: " + e, e);
-        }
-    }
-
-    private void heal(Collection<EventData> events) throws IOException {
-        List<InnerEvent> eventAncestors;
-
-        for (EventData event: events) {
-            if (event.getSuccessful() == EventStatus.FAILED && event.hasParentEventId()) {
-
-                eventAncestors = getAncestors(event);
-
-                for (InnerEvent ancestor : eventAncestors) {
-                    StoredTestEventWrapper ancestorEvent = ancestor.event;
-
-                    if (ancestor.success) {
-                        storage.updateEventStatus(ancestorEvent, false);
-                        ancestor.markFailed();
-                        LOGGER.info("Event {} healed", ancestorEvent.getId());
-                    }
-                }
-            }
-        }
-    }
-
-    private List<InnerEvent> getAncestors(EventData event) throws IOException {
-        List<InnerEvent> eventAncestors = new ArrayList<>();
-        String parentId = event.getParentEventId().getId();
-
-        while (parentId != null) {
-            InnerEvent innerEvent;
-
-            if (cache.containsKey(parentId)) {
-                innerEvent = cache.get(parentId);
-            } else {
-                StoredTestEventWrapper parent = storage.getTestEvent(new StoredTestEventId(parentId));
-
-                innerEvent = new InnerEvent(parent, parent.isSuccess());
-                cache.put(parentId, innerEvent);
-            }
-
-            eventAncestors.add(innerEvent);
-
-            if (!innerEvent.success) break;
-
-            StoredTestEventId eventId = innerEvent.event.getParentId();
-
-            if (eventId == null)
-                break;
-
-            parentId = eventId.toString();
-        }
-
-        return eventAncestors;
-    }
-
-    private static class InnerEvent {
-        private final StoredTestEventWrapper event;
-        private volatile boolean success;
-
-        private InnerEvent(StoredTestEventWrapper event, boolean success) {
-            this.event = event;
-            this.success = success;
-        }
-
-        private void markFailed() { this.success = false; }
-    }
-}
diff --git a/src/main/kotlin/com/exactpro/th2/processor/healer/Factory.kt b/src/main/kotlin/com/exactpro/th2/processor/healer/Factory.kt
new file mode 100644
index 0000000..e4ee726
--- /dev/null
+++ b/src/main/kotlin/com/exactpro/th2/processor/healer/Factory.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2022 Exactpro (Exactpro Systems Limited)
+ *
+ * Licensed 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 com.exactpro.th2.processor.healer
+
+import com.exactpro.th2.common.event.Event
+import com.exactpro.th2.processor.api.IProcessor
+import com.exactpro.th2.processor.api.IProcessorFactory
+import com.exactpro.th2.processor.api.IProcessorSettings
+import com.exactpro.th2.processor.api.ProcessorContext
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.databind.module.SimpleModule
+import com.google.auto.service.AutoService
+import java.time.Instant
+
+@Suppress("unused")
+@AutoService(IProcessorFactory::class)
+class Factory : IProcessorFactory {
+    override fun registerModules(configureMapper: ObjectMapper) {
+        with(configureMapper) {
+            registerModule(SimpleModule().addAbstractTypeMapping(IProcessorSettings::class.java, Settings::class.java))
+        }
+    }
+
+    override fun create(context: ProcessorContext): IProcessor {
+        with(context) {
+            requireNotNull(settings) {
+                "Settings can not be null"
+            }.let { settings ->
+                check(settings is Settings) {
+                    "Settings type mismatch expected: ${Settings::class}, actual: ${settings::class}"
+                }
+                return Processor(
+                    commonFactory.cradleManager.storage,
+                    scheduler,
+                    eventBatcher,
+                    processorEventId,
+                    settings,
+                    state
+                )
+            }
+        }
+    }
+
+    override fun createProcessorEvent(): Event = Event.start()
+        .name("Healer event data processor ${Instant.now()}")
+        .description("Will contain all events with errors and information about processed events")
+        .type("Microservice")
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/exactpro/th2/processor/healer/Processor.kt b/src/main/kotlin/com/exactpro/th2/processor/healer/Processor.kt
new file mode 100644
index 0000000..3f24361
--- /dev/null
+++ b/src/main/kotlin/com/exactpro/th2/processor/healer/Processor.kt
@@ -0,0 +1,215 @@
+/*
+ * Copyright 2022 Exactpro (Exactpro Systems Limited)
+ *
+ * Licensed 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 com.exactpro.th2.processor.healer
+
+import com.exactpro.cradle.BookId
+import com.exactpro.cradle.CradleStorage
+import com.exactpro.cradle.testevents.StoredTestEventId
+import com.exactpro.th2.common.event.Event.Status.FAILED
+import com.exactpro.th2.common.grpc.Event
+import com.exactpro.th2.common.grpc.EventID
+import com.exactpro.th2.common.grpc.EventStatus
+import com.exactpro.th2.common.util.toInstant
+import com.exactpro.th2.common.utils.event.EventBatcher
+import com.exactpro.th2.processor.api.IProcessor
+import com.exactpro.th2.processor.healer.state.State
+import com.exactpro.th2.processor.utility.OBJECT_MAPPER
+import com.exactpro.th2.processor.utility.log
+import com.github.benmanes.caffeine.cache.Cache
+import com.github.benmanes.caffeine.cache.Caffeine
+import mu.KotlinLogging
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.ScheduledExecutorService
+
+typealias EventBuilder = com.exactpro.th2.common.event.Event
+
+class Processor(
+    private val cradleStore: CradleStorage,
+    private val scheduler: ScheduledExecutorService,
+    private val eventBatcher: EventBatcher,
+    processorEventId: EventID,
+    configuration: Settings,
+    serializedState: ByteArray?
+) : IProcessor {
+
+    private val interval = configuration.updateUnsubmittedEventInterval
+    private val timeUtil = configuration.updateUnsubmittedEventTimeUnit
+    private val attempts = configuration.updateUnsubmittedEventAttempts
+
+    private val unsubmittedEvents: MutableMap<StoredTestEventId, Int> = ConcurrentHashMap()
+
+    private val statusCache: Cache<StoredTestEventId, Any> = Caffeine.newBuilder()
+        .maximumSize(configuration.maxCacheCapacity.toLong())
+        .executor(scheduler)
+        .build()
+
+    init {
+        serializedState?.let {
+            OBJECT_MAPPER.readValue(it, State::class.java)
+                .unsubmittedEvents.forEach { stateEventId ->
+                    val sickEventId = stateEventId.toStateEventId()
+                    heal(processorEventId, null, sickEventId)
+                }
+        }
+    }
+
+    override fun handle(intervalEventId: EventID, event: Event) {
+        if (event.status == EventStatus.SUCCESS || !event.hasParentId()) {
+            return
+        }
+
+        heal(
+            intervalEventId,
+            event.id.toStoredTestEventId(),
+            event.parentId.toStoredTestEventId()
+        )
+    }
+
+    override fun serializeState(): ByteArray? = if (unsubmittedEvents.isEmpty()) {
+        null
+    } else {
+        OBJECT_MAPPER.writeValueAsBytes(unsubmittedEvents.keys.toState())
+    }
+
+    override fun close() { }
+
+    /**
+     * @return true if at least the first is healed
+     */
+    private fun heal(
+        reportEventId: EventID,
+        childEventId: StoredTestEventId?,
+        parentEventId: StoredTestEventId?
+    ): Boolean {
+        var sickEventId = parentEventId
+        var result = false
+        while (sickEventId != null) {
+            if (statusCache.getIfPresent(sickEventId) === FAKE_OBJECT) {
+                K_LOGGER.debug { "The $sickEventId has been already updated${ childEventId?.let { " for $childEventId child event id" } ?: "" }" }
+                sickEventId = null
+            } else {
+                val parentEvent = cradleStore.getTestEvent(sickEventId)
+                if (parentEvent == null) {
+                    val attempt = unsubmittedEvents.compute(sickEventId) { _, previous ->
+                        when (val next = (previous ?: 0) + 1) {
+                            attempts -> null
+                            else     -> next
+                        }
+                    }
+                    when (attempt) {
+                        null    -> reportUnhealedEvent(reportEventId, sickEventId)
+                        else    -> scheduleHeal(reportEventId, childEventId, sickEventId, attempt)
+                    }
+                    sickEventId = null
+                } else {
+                    result = true
+                    sickEventId = if (parentEvent.isSuccess) {
+                        cradleStore.updateEventStatus(parentEvent, false) //FIXME: push sub-event for updated, catch exception
+                        reportUpdateEvent(reportEventId, parentEvent.id)
+                        unsubmittedEvents.remove(sickEventId)
+                        parentEvent.parentId
+                    } else {
+                        K_LOGGER.debug {
+                            "The ${parentEvent.id} has already has failed status${ childEventId?.let { " for $childEventId child event id" } ?: "" }"
+                        }
+                        null
+                    }
+                    statusCache.put(parentEvent.id, FAKE_OBJECT)
+                }
+            }
+        }
+        return result
+    }
+
+    private fun scheduleHeal(
+        reportEventId: EventID,
+        childEventId: StoredTestEventId?,
+        sickEventId: StoredTestEventId,
+        attempt: Int
+    ) {
+        scheduler.schedule({
+                runCatching {
+                    heal(reportEventId, childEventId, sickEventId)
+                }.onFailure { e ->
+                    K_LOGGER.error(e) { "Failure to heal $sickEventId event" }
+                    reportErrorEvent(reportEventId, sickEventId, e)
+                }
+            }, interval, timeUtil
+        )
+        reportUnsubmittedEvent(reportEventId, sickEventId, attempt)
+    }
+
+    private fun reportUnhealedEvent(reportEventId: EventID, eventId: StoredTestEventId) {
+        eventBatcher.onEvent(
+            EventBuilder.start()
+                .name(
+                    "Heal of $eventId event failure after $attempts attempts with interval $interval $timeUtil"
+                )
+                .type(EVENT_TYPE_UNSUBMITTED_EVENT)
+                .status(FAILED)
+                .toProto(reportEventId)
+                .log(K_LOGGER)
+        )
+    }
+
+    private fun reportErrorEvent(reportEventId: EventID, eventId: StoredTestEventId, e: Throwable) {
+        // FIXME: Add link to updated event
+        eventBatcher.onEvent(
+            EventBuilder.start()
+                .name("Heal of $eventId event failure")
+                .type(EVENT_TYPE_INTERNAL_ERROR)
+                .exception(e, true)
+                .status(FAILED)
+                .toProto(reportEventId)
+                .log(K_LOGGER)
+        )
+    }
+
+    private fun reportUnsubmittedEvent(intervalEventId: EventID, eventId: StoredTestEventId, attempt: Int) {
+        // FIXME: Add link to updated event
+        eventBatcher.onEvent(
+            EventBuilder.start()
+                .name("The $eventId hasn't been submitted to cradle yet, attempt $attempt")
+                .type(EVENT_TYPE_UNSUBMITTED_EVENT)
+                .toProto(intervalEventId)
+                .log(K_LOGGER)
+        )
+    }
+
+    private fun reportUpdateEvent(intervalEventId: EventID, eventId: StoredTestEventId) {
+        // FIXME: Add link to updated event
+        eventBatcher.onEvent(
+            EventBuilder.start()
+                .name("Updated status of $eventId")
+                .type("Update status")
+                .toProto(intervalEventId)
+        )
+    }
+
+    companion object {
+        private val K_LOGGER = KotlinLogging.logger {}
+        private val FAKE_OBJECT: Any = Object()
+        private const val EVENT_TYPE_INTERNAL_ERROR: String = "Internal error"
+        private const val EVENT_TYPE_UNSUBMITTED_EVENT: String = "Unsubmitted event"
+
+        private fun EventID.toStoredTestEventId(): StoredTestEventId = StoredTestEventId(
+            BookId(bookName),
+            scope,
+            startTimestamp.toInstant(),
+            id
+        )
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/exactpro/th2/processor/healer/Settings.kt b/src/main/kotlin/com/exactpro/th2/processor/healer/Settings.kt
new file mode 100644
index 0000000..cb35a59
--- /dev/null
+++ b/src/main/kotlin/com/exactpro/th2/processor/healer/Settings.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2022 Exactpro (Exactpro Systems Limited)
+ *
+ * Licensed 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 com.exactpro.th2.processor.healer
+
+import com.exactpro.th2.processor.api.IProcessorSettings
+import java.util.concurrent.TimeUnit
+
+data class Settings(
+    val maxCacheCapacity: Int = 1_024,
+    val updateUnsubmittedEventInterval: Long = 1,
+    val updateUnsubmittedEventTimeUnit: TimeUnit = TimeUnit.SECONDS,
+    val updateUnsubmittedEventAttempts: Int = 100
+) : IProcessorSettings {
+    init {
+        require(maxCacheCapacity > 0) {
+            "Size of cache cannot be negative or zero, actual $maxCacheCapacity"
+        }
+        require(updateUnsubmittedEventInterval > 0) {
+            "Update unsubmitted event interval cannot be negative or zero, actual $updateUnsubmittedEventInterval"
+        }
+        require(updateUnsubmittedEventAttempts > 0) {
+            "Update unsubmitted event attempts cannot be negative or zero, actual $updateUnsubmittedEventAttempts"
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/exactpro/th2/processor/healer/Utils.kt b/src/main/kotlin/com/exactpro/th2/processor/healer/Utils.kt
new file mode 100644
index 0000000..54e74c4
--- /dev/null
+++ b/src/main/kotlin/com/exactpro/th2/processor/healer/Utils.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2022 Exactpro (Exactpro Systems Limited)
+ *
+ * Licensed 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 com.exactpro.th2.processor.healer
+
+import com.exactpro.cradle.testevents.StoredTestEventId
+import com.exactpro.th2.processor.healer.state.State
+import com.exactpro.th2.processor.healer.state.StateEventId
+
+fun StoredTestEventId.toStateEventId() = StateEventId(bookId.name, scope, startTimestamp, id)
+
+fun Set<StoredTestEventId>.toState(): State = State(
+    map { it.toStateEventId() }
+)
\ No newline at end of file
diff --git a/src/main/kotlin/com/exactpro/th2/processor/healer/state/State.kt b/src/main/kotlin/com/exactpro/th2/processor/healer/state/State.kt
new file mode 100644
index 0000000..a716405
--- /dev/null
+++ b/src/main/kotlin/com/exactpro/th2/processor/healer/state/State.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2022 Exactpro (Exactpro Systems Limited)
+ *
+ * Licensed 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 com.exactpro.th2.processor.healer.state
+
+data class State @JvmOverloads constructor(
+    val unsubmittedEvents: List<StateEventId> = emptyList()
+)
diff --git a/src/main/kotlin/com/exactpro/th2/processor/healer/state/StateEventId.kt b/src/main/kotlin/com/exactpro/th2/processor/healer/state/StateEventId.kt
new file mode 100644
index 0000000..fe41ae0
--- /dev/null
+++ b/src/main/kotlin/com/exactpro/th2/processor/healer/state/StateEventId.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2022 Exactpro (Exactpro Systems Limited)
+ *
+ * Licensed 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 com.exactpro.th2.processor.healer.state
+
+import com.exactpro.cradle.BookId
+import com.exactpro.cradle.testevents.StoredTestEventId
+import com.fasterxml.jackson.annotation.JsonProperty
+import java.time.Instant
+
+
+data class StateEventId(
+    @JsonProperty("book") val book: String,
+    @JsonProperty("scope") val scope: String,
+    @JsonProperty("timestamp") val timestamp: Instant,
+    @JsonProperty("id") val id: String
+) {
+    init {
+        check(book.isNotBlank()) { "Book can't be blank, $this" }
+        check(scope.isNotBlank()) { "Scope can't be blank, $this" }
+        check(timestamp != Instant.MIN) { "Timestamp can't be equal as ${Instant.MIN}, $this" }
+        check(id.isNotBlank()) { "Id can't be blank, $this" }
+    }
+
+    fun toStateEventId() = StoredTestEventId(BookId(book), scope, timestamp, id)
+}
diff --git a/src/test/java/com/exactpro/th2/dataservice/healer/EventsCacheTest.java b/src/test/java/com/exactpro/th2/dataservice/healer/EventsCacheTest.java
deleted file mode 100644
index b4b6b0c..0000000
--- a/src/test/java/com/exactpro/th2/dataservice/healer/EventsCacheTest.java
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * Copyright 2020-2021 Exactpro (Exactpro Systems Limited)
- *
- * Licensed 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 com.exactpro.th2.dataservice.healer;
-
-import com.exactpro.th2.dataservice.healer.cache.EventsCache;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import java.util.Map;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-public class EventsCacheTest {
-    private final Map<String, Integer> cache = new EventsCache<>(10);
-
-    @BeforeEach
-    public void prepare() {
-        for (int i = 0; i < 11; i++) {
-            cache.put(String.valueOf(i), i);
-        }
-    }
-
-    @Test
-    public void maxSizeTest() { assertEquals(cache.size(), 10); }
-
-    @Test
-    public void firstElementRemoved() { assertFalse(cache.containsValue(0)); }
-
-    @Test
-    public void accessedElementPutLast() {
-        cache.get("1");
-
-        cache.put(String.valueOf(12), 12);
-
-        assertTrue(cache.containsKey("1"), () -> "Cache must contain 1 but doesn't: " + cache);
-    }
-}
diff --git a/src/test/java/com/exactpro/th2/dataservice/healer/HealerTest.java b/src/test/java/com/exactpro/th2/dataservice/healer/HealerTest.java
deleted file mode 100644
index 560ba6a..0000000
--- a/src/test/java/com/exactpro/th2/dataservice/healer/HealerTest.java
+++ /dev/null
@@ -1,231 +0,0 @@
-/*
- * Copyright 2020-2021 Exactpro (Exactpro Systems Limited)
- *
- * Licensed 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 com.exactpro.th2.dataservice.healer;
-
-import com.exactpro.cradle.CradleStorage;
-import com.exactpro.cradle.testevents.StoredTestEvent;
-import com.exactpro.cradle.testevents.StoredTestEventId;
-import com.exactpro.cradle.testevents.StoredTestEventWrapper;
-import com.exactpro.cradle.testevents.TestEventToStore;
-import com.exactpro.cradle.utils.CradleStorageException;
-import com.exactpro.th2.common.grpc.EventID;
-import com.exactpro.th2.common.grpc.EventStatus;
-import com.exactpro.th2.crawler.dataprocessor.grpc.CrawlerId;
-import com.exactpro.th2.crawler.dataprocessor.grpc.CrawlerInfo;
-import com.exactpro.th2.crawler.dataprocessor.grpc.DataProcessorGrpc;
-import com.exactpro.th2.crawler.dataprocessor.grpc.DataProcessorInfo;
-import com.exactpro.th2.crawler.dataprocessor.grpc.EventDataRequest;
-import com.exactpro.th2.crawler.dataprocessor.grpc.EventResponse;
-import com.exactpro.th2.dataprovider.grpc.EventData;
-import com.exactpro.th2.dataservice.healer.cfg.HealerConfiguration;
-import com.exactpro.th2.dataservice.healer.grpc.HealerImpl;
-import io.grpc.ManagedChannel;
-import io.grpc.Server;
-import io.grpc.inprocess.InProcessChannelBuilder;
-import io.grpc.inprocess.InProcessServerBuilder;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import java.io.IOException;
-import java.time.Instant;
-import java.time.temporal.ChronoUnit;
-import java.util.ArrayList;
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-public class HealerTest {
-
-    private static final String HEALER_NAME = "healer";
-    private static final String HEALER_VERSION = "1";
-    private static final String CRAWLER_NAME = "crawler";
-    private static final String PARENT_EVENT_ID = "parent_event_id";
-    private static final String CHILD_EVENT_ID = "child_event_id";
-    private static final String GRANDCHILD_EVENT_ID = "grandchild_event_id";
-    private static final HealerConfiguration CONFIGURATION = new HealerConfiguration(HEALER_NAME, HEALER_VERSION, 100);
-    private static final CrawlerId CRAWLER_ID = CrawlerId.newBuilder().setName(CRAWLER_NAME).build();
-    private static final CrawlerInfo CRAWLER_INFO = CrawlerInfo.newBuilder().setId(CRAWLER_ID).build();
-    private static final CradleStorage STORAGE_MOCK = mock(CradleStorage.class);
-    private static final List<StoredTestEventWrapper> events = new ArrayList<>();
-
-    private static Server server;
-    private static ManagedChannel channel;
-    private static DataProcessorGrpc.DataProcessorBlockingStub blockingStub;
-
-    @BeforeEach
-    public void prepare() throws IOException, CradleStorageException {
-        String serverName = InProcessServerBuilder.generateName();
-
-        server = InProcessServerBuilder.forName(serverName)
-                .addService(new HealerImpl(CONFIGURATION, STORAGE_MOCK))
-                .build()
-                .start();
-        channel = InProcessChannelBuilder.forName(serverName)
-                .usePlaintext()
-                .directExecutor()
-                .build();
-
-        blockingStub = DataProcessorGrpc.newBlockingStub(channel);
-
-        when(STORAGE_MOCK.getTestEvent(any(StoredTestEventId.class))).then(invocation -> {
-            StoredTestEventId id = invocation.getArgument(0);
-
-            for (StoredTestEventWrapper storedEvent : events) {
-                if (storedEvent.getId().toString().equals(id.toString()))
-                    return storedEvent;
-            }
-
-            return null;
-        });
-
-        createEvents();
-    }
-
-    @AfterEach
-    public void shutdown() {
-        server.shutdown();
-        channel.shutdown();
-        events.clear();
-    }
-
-    @Test
-    public void handshakeHandling() {
-        DataProcessorInfo dataServiceInfo = blockingStub.crawlerConnect(CRAWLER_INFO);
-        assertEquals(HEALER_NAME, dataServiceInfo.getName());
-        assertEquals(HEALER_VERSION, dataServiceInfo.getVersion());
-    }
-
-    @Test
-    public void correctEventIdInResponse() {
-        EventID eventId1 = EventID.newBuilder().setId("event_id1").build();
-        EventID eventId2 = EventID.newBuilder().setId("event_id2").build();
-
-        EventDataRequest request = EventDataRequest.newBuilder()
-                .setId(CRAWLER_INFO.getId())
-                .addEventData(EventData.newBuilder().setEventId(eventId1).build())
-                .addEventData(EventData.newBuilder().setEventId(eventId2).build())
-                .build();
-
-        blockingStub.crawlerConnect(CRAWLER_INFO);
-        EventResponse response = blockingStub.sendEvent(request);
-
-        assertEquals(eventId2.getId(), response.getId().getId());
-    }
-
-    @Test
-    public void healedCorrectly() throws IOException {
-        EventID parentId = EventID.newBuilder().setId(PARENT_EVENT_ID).build();
-        EventID childId = EventID.newBuilder().setId(CHILD_EVENT_ID).build();
-        EventID grandchildId = EventID.newBuilder().setId(GRANDCHILD_EVENT_ID).build();
-
-        EventData parentEvent = EventData.newBuilder()
-                .setEventId(parentId)
-                .setSuccessful(EventStatus.SUCCESS)
-                .build();
-
-        EventData childEvent = EventData.newBuilder()
-                .setEventId(childId)
-                .setParentEventId(parentId)
-                .setSuccessful(EventStatus.SUCCESS)
-                .build();
-
-        EventData grandchildEvent = EventData.newBuilder()
-                .setEventId(grandchildId)
-                .setParentEventId(childId)
-                .setSuccessful(EventStatus.FAILED)
-                .build();
-
-        EventDataRequest request = EventDataRequest.newBuilder()
-                .setId(CRAWLER_INFO.getId())
-                .addEventData(parentEvent)
-                .addEventData(childEvent)
-                .addEventData(grandchildEvent)
-                .build();
-
-        blockingStub.crawlerConnect(CRAWLER_INFO);
-        blockingStub.sendEvent(request);
-
-        verify(STORAGE_MOCK).updateEventStatus(events.get(0), false);
-        verify(STORAGE_MOCK).updateEventStatus(events.get(1), false);
-    }
-
-    @Test
-    public void crawlerUnknown() {
-        EventResponse response = blockingStub.sendEvent(EventDataRequest.newBuilder()
-                .setId(CRAWLER_INFO.getId())
-                .addEventData(EventData.getDefaultInstance())
-                .build());
-
-        assertTrue(response.getStatus().getHandshakeRequired());
-    }
-
-    private void createEvents() throws CradleStorageException {
-        Instant instant = Instant.now();
-
-        TestEventToStore parentEventToStore = TestEventToStore.builder()
-                .startTimestamp(instant)
-                .endTimestamp(instant.plus(1, ChronoUnit.MINUTES))
-                .name("parent_event_name")
-                .content(new byte[]{1, 2, 3})
-                .id(new StoredTestEventId(PARENT_EVENT_ID))
-                .success(true)
-                .type("event_type")
-                .success(true)
-                .build();
-
-        StoredTestEvent parentEventData = StoredTestEvent.newStoredTestEventSingle(parentEventToStore);
-        StoredTestEventWrapper parentEvent = new StoredTestEventWrapper(parentEventData);
-
-        TestEventToStore childEventToStore = TestEventToStore.builder()
-                .startTimestamp(instant.plus(2, ChronoUnit.MINUTES))
-                .endTimestamp(instant.plus(3, ChronoUnit.MINUTES))
-                .name("child_event_name")
-                .content(new byte[]{1, 2, 3})
-                .id(new StoredTestEventId("child_event_id"))
-                .parentId(new StoredTestEventId(PARENT_EVENT_ID))
-                .success(true)
-                .type("event_type")
-                .build();
-
-        StoredTestEvent childEventData = StoredTestEvent.newStoredTestEventSingle(childEventToStore);
-        StoredTestEventWrapper childEvent = new StoredTestEventWrapper(childEventData);
-
-        TestEventToStore grandchildEventToStore = TestEventToStore.builder()
-                .startTimestamp(instant.plus(4, ChronoUnit.MINUTES))
-                .endTimestamp(instant.plus(5, ChronoUnit.MINUTES))
-                .name("grandchild_event_name")
-                .content(new byte[]{1, 2, 3})
-                .id(new StoredTestEventId(GRANDCHILD_EVENT_ID))
-                .parentId(new StoredTestEventId(CHILD_EVENT_ID))
-                .type("event_type")
-                .success(false)
-                .build();
-
-        StoredTestEvent grandchildEventData = StoredTestEvent.newStoredTestEventSingle(grandchildEventToStore);
-        StoredTestEventWrapper grandchildEvent = new StoredTestEventWrapper(grandchildEventData);
-
-        events.add(parentEvent);
-        events.add(childEvent);
-        events.add(grandchildEvent);
-    }
-}
diff --git a/src/test/kotlin/com/exactpro/th2/processor/healer/ProcessorTest.kt b/src/test/kotlin/com/exactpro/th2/processor/healer/ProcessorTest.kt
new file mode 100644
index 0000000..b47bb2f
--- /dev/null
+++ b/src/test/kotlin/com/exactpro/th2/processor/healer/ProcessorTest.kt
@@ -0,0 +1,427 @@
+/*
+ * Copyright 2022 Exactpro (Exactpro Systems Limited)
+ *
+ * Licensed 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 com.exactpro.th2.processor.healer
+
+import com.exactpro.cradle.BookId
+import com.exactpro.cradle.PageId
+import com.exactpro.cradle.cassandra.CassandraCradleStorage
+import com.exactpro.cradle.testevents.StoredTestEventId
+import com.exactpro.cradle.testevents.StoredTestEventSingle
+import com.exactpro.th2.common.grpc.Event
+import com.exactpro.th2.common.grpc.EventID
+import com.exactpro.th2.common.grpc.EventStatus
+import com.exactpro.th2.common.grpc.Message
+import com.exactpro.th2.common.grpc.RawMessage
+import com.exactpro.th2.common.utils.event.EventBatcher
+import com.exactpro.th2.common.utils.message.toTimestamp
+import com.exactpro.th2.processor.api.IProcessor
+import com.exactpro.th2.processor.healer.state.State
+import com.exactpro.th2.processor.utility.OBJECT_MAPPER
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import java.time.Instant
+import java.util.concurrent.ScheduledExecutorService
+import java.util.concurrent.ScheduledFuture
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+class ProcessorTest {
+    private val cradleStorage: CassandraCradleStorage = mock {  }
+    private val schedulerImmediateExecute: ScheduledExecutorService = mock {
+        on { schedule(any(), any(), any()) }.doAnswer { invocation ->
+            (invocation.arguments[0] as Runnable).run()
+            return@doAnswer mock<ScheduledFuture<*>> { }
+        }
+    }
+    private val schedulerNeverExecute: ScheduledExecutorService = mock { }
+    private val eventBatcher: EventBatcher = mock {  }
+    private val processor: IProcessor = Processor(
+        cradleStorage,
+        schedulerImmediateExecute,
+        eventBatcher,
+        PROCESSOR_EVENT_ID,
+        SETTINGS,
+        null
+    )
+
+
+    @AfterEach
+    fun afterEach() {
+        processor.close()
+    }
+
+    @Test
+    fun `handle message`() {
+        assertFailsWith<UnsupportedOperationException>("Call unsupported expected overload") {
+            processor.handle(INTERVAL_EVENT_ID, Message.getDefaultInstance())
+        }
+    }
+
+    @Test
+    fun `handle raw message`() {
+        assertFailsWith<UnsupportedOperationException>("Call unsupported expected overload") {
+            processor.handle(INTERVAL_EVENT_ID, RawMessage.getDefaultInstance())
+        }
+    }
+
+    @Test
+    fun `store restore state`() {
+        val eventA = A_EVENT_ID.toSingleEvent(null, "A", true)
+        val eventB = B_EVENT_ID.toSingleEvent(eventA.id, "B", false)
+
+        // Collect unsubmitted events
+        val cradleStorageA = mock<CassandraCradleStorage> {
+            on { getTestEvent(eq(eventB.id)) }.thenReturn(eventB)
+        }
+        val eventBatcherA = mock<EventBatcher> { }
+        val processorA = Processor(
+            cradleStorageA,
+            schedulerNeverExecute,
+            eventBatcherA,
+            PROCESSOR_EVENT_ID,
+            Settings(
+                updateUnsubmittedEventInterval = Long.MAX_VALUE,
+                updateUnsubmittedEventTimeUnit = TimeUnit.DAYS,
+            ),
+            null
+        )
+
+        processorA.handle(INTERVAL_EVENT_ID, eventB.toGrpcEvent())
+
+        verify(cradleStorageA, never().description("Update events")).updateEventStatus(any(), any())
+
+        verify(eventBatcherA, times(1).description("Publish events")).onEvent(any())
+
+        val state = assertNotNull(processorA.serializeState(), "Not empty state")
+        assertEquals(setOf(eventA.id).toState(), OBJECT_MAPPER.readValue(state, State::class.java), "State with A event")
+
+
+        // Restart processor and heal unsubmitted events again
+        val counter = AtomicInteger(SETTINGS.updateUnsubmittedEventAttempts)
+        val cradleStorageB = mock<CassandraCradleStorage> {
+            on { getTestEvent(eq(eventA.id)) }.thenAnswer {
+                if (counter.decrementAndGet() == 0) eventA else null
+            }
+        }
+        val eventBatcherB = mock<EventBatcher> { }
+        val processorB = Processor(
+            cradleStorageB,
+            schedulerImmediateExecute,
+            eventBatcherB,
+            PROCESSOR_EVENT_ID,
+            SETTINGS,
+            state
+        )
+
+        assertEquals(0, counter.get(), "Requests to cradle storage mock")
+        verify(cradleStorageB, times(SETTINGS.updateUnsubmittedEventAttempts).description("Load event A"))
+            .getTestEvent(eq(eventA.id))
+        verify(cradleStorageB, times(SETTINGS.updateUnsubmittedEventAttempts).description("Load events"))
+            .getTestEvent(any())
+        verify(cradleStorageB, times(1).description("Update event A"))
+            .updateEventStatus(eq(eventA), eq(false))
+        verify(cradleStorageB, times(1).description("Update events"))
+            .updateEventStatus(any(), any())
+
+        verify(eventBatcherB, times(SETTINGS.updateUnsubmittedEventAttempts).description("Publish events"))
+            .onEvent(any())
+
+        assertNull(processorB.serializeState(), "Empty state")
+    }
+
+    @Test
+    fun `retry success for unsubmitted event`() {
+        val counter = AtomicInteger(SETTINGS.updateUnsubmittedEventAttempts)
+        val eventA = A_EVENT_ID.toSingleEvent(null, "A", true).apply {
+            whenever(cradleStorage.getTestEvent(eq(this.id))).thenAnswer {
+                if (counter.decrementAndGet() == 0) this else null
+            }
+        }
+        val eventB = B_EVENT_ID.toSingleEvent(eventA.id, "B", false).apply {
+            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+        }
+
+        processor.handle(INTERVAL_EVENT_ID, eventB.toGrpcEvent())
+
+        assertEquals(0, counter.get(), "Requests to cradle storage mock")
+        verify(cradleStorage, times(SETTINGS.updateUnsubmittedEventAttempts).description("Load event A"))
+            .getTestEvent(eq(eventA.id))
+        verify(cradleStorage, times(SETTINGS.updateUnsubmittedEventAttempts).description("Load events"))
+            .getTestEvent(any())
+        verify(cradleStorage, times(1).description("Update event A"))
+            .updateEventStatus(eq(eventA), eq(false))
+        verify(cradleStorage, times(1).description("Update events"))
+            .updateEventStatus(any(), any())
+
+        verify(eventBatcher, times(SETTINGS.updateUnsubmittedEventAttempts).description("Publish events"))
+            .onEvent(any())
+
+        assertNull(processor.serializeState(), "Empty state")
+    }
+
+    @Test
+    fun `retry failure for unsubmitted event`() {
+        val eventA = A_EVENT_ID.toSingleEvent(null, "A", false)
+        val eventB = B_EVENT_ID.toSingleEvent(eventA.id, "B", false).apply {
+            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+        }
+
+        processor.handle(INTERVAL_EVENT_ID, eventB.toGrpcEvent())
+
+        verify(cradleStorage, times(SETTINGS.updateUnsubmittedEventAttempts).description("Load event A"))
+            .getTestEvent(eq(eventA.id))
+        verify(cradleStorage, times(SETTINGS.updateUnsubmittedEventAttempts).description("Load events"))
+            .getTestEvent(any())
+        verify(cradleStorage, never().description("Update events")).updateEventStatus(any(), any())
+
+        verify(eventBatcher, times(SETTINGS.updateUnsubmittedEventAttempts).description("Publish events"))
+            .onEvent(any())
+
+        assertNull(processor.serializeState(), "Empty state")
+    }
+
+    @Test
+    fun `doesn't heal success event`() {
+        val eventA = A_EVENT_ID.toSingleEvent(null, "A", true).apply {
+            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+        }
+        val eventB = B_EVENT_ID.toSingleEvent(eventA.id, "B", true).apply {
+            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+        }
+
+        processor.handle(INTERVAL_EVENT_ID, eventB.toGrpcEvent())
+        verify(cradleStorage, never().description("Load events")).getTestEvent(any())
+        verify(cradleStorage, never().description("Update events")).updateEventStatus(any(), any())
+
+        verify(eventBatcher, never().description("Publish events")).onEvent(any())
+    }
+
+    @Test
+    fun `heal parent events until failed event`() {
+        val eventA = A_EVENT_ID.toSingleEvent(null, "A", false).apply {
+            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+        }
+        val eventB = B_EVENT_ID.toSingleEvent(eventA.id, "B", true).apply {
+            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+        }
+        val eventC = C_EVENT_ID.toSingleEvent(eventB.id, "C", true).apply {
+            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+        }
+        val eventD = D_EVENT_ID.toSingleEvent(eventC.id, "D", false).apply {
+            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+        }
+
+        processor.handle(INTERVAL_EVENT_ID, eventD.toGrpcEvent())
+        verify(cradleStorage, times(1).description("Load event A")).getTestEvent(eq(eventA.id))
+        verify(cradleStorage, times(1).description("Load event B")).getTestEvent(eq(eventB.id))
+        verify(cradleStorage, times(1).description("Load event C")).getTestEvent(eq(eventC.id))
+        verify(cradleStorage, never().description("Load event D")).getTestEvent(eq(eventD.id))
+        verify(cradleStorage, times(3).description("Load events")).getTestEvent(any())
+
+        verify(cradleStorage, never().description("Update event A")).updateEventStatus(eq(eventA), eq(false))
+        verify(cradleStorage, times(1).description("Update event B")).updateEventStatus(eq(eventB), eq(false))
+        verify(cradleStorage, times(1).description("Update event C")).updateEventStatus(eq(eventC), eq(false))
+        verify(cradleStorage, never().description("Update event D")).updateEventStatus(eq(eventD), eq(false))
+        verify(cradleStorage, times(2).description("Update events")).updateEventStatus(any(), any())
+
+        verify(eventBatcher, times(2).description("Publish events")).onEvent(any())
+    }
+
+//    @Test // cache is drained asynchronously
+//    fun `eviction from cache`() {
+//        val eventA = A_EVENT_ID.createSingleEvent(null, "A", true).apply {
+//            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+//        }
+//        val eventB = B_EVENT_ID.createSingleEvent(eventA.id, "B", false).apply {
+//            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+//        }
+//
+//        val eventC = C_EVENT_ID.createSingleEvent(null, "C", true).apply {
+//            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+//        }
+//        val eventD = D_EVENT_ID.createSingleEvent(eventC.id, "D", false).apply {
+//            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+//        }
+//
+//        processor.handle(INTERVAL_EVENT_ID, eventB.toGrpcEvent())
+//        verify(cradleStorage, times(1).description("Load event A")).getTestEvent(eq(eventA.id))
+//        verify(cradleStorage, times(1).description("Load events")).getTestEvent(any())
+//
+//        verify(cradleStorage, times(1).description("Update event A")).updateEventStatus(eq(eventA), eq(false))
+//        verify(cradleStorage, times(1).description("Update events")).updateEventStatus(any(), any())
+//
+//        verify(eventBatcher, times(1).description("Publish events")).onEvent(any())
+//
+//
+//        processor.handle(INTERVAL_EVENT_ID, eventD.toGrpcEvent())
+//        verify(cradleStorage, times(1).description("Load event C")).getTestEvent(eq(eventC.id))
+//        verify(cradleStorage, times(2).description("Load events")).getTestEvent(any())
+//
+//        verify(cradleStorage, times(1).description("Update event C")).updateEventStatus(eq(eventC), eq(false))
+//        verify(cradleStorage, times(2).description("Update events")).updateEventStatus(any(), any())
+//
+//        verify(eventBatcher, times(2).description("Publish events")).onEvent(any())
+//
+//        val eventAUpdated = eventA.id.createSingleEvent(null, "A", false).apply {
+//            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+//        }
+//
+//        processor.handle(INTERVAL_EVENT_ID, eventB.toGrpcEvent())
+//        verify(cradleStorage, times(1).description("Load updated event A")).getTestEvent(eq(eventAUpdated.id))
+//        verify(cradleStorage, times(3).description("Load events")).getTestEvent(any())
+//
+//        verify(cradleStorage, times(2).description("Update events")).updateEventStatus(any(), any())
+//
+//        verify(eventBatcher, times(2).description("Publish events")).onEvent(any())
+//    }
+
+    @Test
+    fun `heal parent event twice`() {
+        val eventA = A_EVENT_ID.toSingleEvent(null, "A", true).apply {
+            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+        }
+        val eventB = B_EVENT_ID.toSingleEvent(eventA.id, "B", false).apply {
+            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+        }
+        val eventC = C_EVENT_ID.toSingleEvent(eventA.id, "C", false).apply {
+            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+        }
+
+        processor.handle(INTERVAL_EVENT_ID, eventB.toGrpcEvent())
+        verify(cradleStorage, times(1).description("Load event A")).getTestEvent(eq(eventA.id))
+        verify(cradleStorage, never().description("Load event B")).getTestEvent(eq(eventB.id))
+        verify(cradleStorage, never().description("Load event C")).getTestEvent(eq(eventC.id))
+        verify(cradleStorage, times(1).description("Load events")).getTestEvent(any())
+
+        verify(cradleStorage, times(1).description("Update event A")).updateEventStatus(eq(eventA), eq(false))
+        verify(cradleStorage, times(1).description("Update events")).updateEventStatus(any(), any())
+
+        verify(eventBatcher, times(1).description("Publish event")).onEvent(any())
+
+        processor.handle(INTERVAL_EVENT_ID, eventC.toGrpcEvent())
+        verify(cradleStorage, times(1).description("Load events")).getTestEvent(any())
+        verify(cradleStorage, times(1).description("Update events")).updateEventStatus(any(), any())
+        verify(eventBatcher, times(1).description("Publish event")).onEvent(any())
+    }
+
+    @Test
+    fun `heal failed event twice`() {
+        val eventA = A_EVENT_ID.toSingleEvent(null, "A", true).apply {
+            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+        }
+        val eventB = B_EVENT_ID.toSingleEvent(eventA.id, "B", false).apply {
+            whenever(cradleStorage.getTestEvent(eq(this.id))).thenReturn(this)
+        }
+
+        repeat(2) { iteration ->
+            processor.handle(INTERVAL_EVENT_ID, eventB.toGrpcEvent())
+            verify(cradleStorage, times(1).description("Load event A, iteration $iteration")).getTestEvent(eq(eventA.id))
+            verify(cradleStorage, never().description("Load event B, iteration $iteration")).getTestEvent(eq(eventB.id))
+            verify(cradleStorage, times(1).description("Load events, iteration $iteration")).getTestEvent(any())
+
+            verify(cradleStorage, times(1).description("Update event A, iteration $iteration")).updateEventStatus(eq(eventA), eq(false))
+            verify(cradleStorage, times(1).description("Update events, iteration $iteration")).updateEventStatus(any(), any())
+
+            verify(eventBatcher, times(1).description("Publish event, iteration $iteration")).onEvent(any())
+        }
+    }
+
+    private fun StoredTestEventSingle.toGrpcEvent() = Event.newBuilder().apply {
+        id = this@toGrpcEvent.id.toGrpcEventId()
+        this@toGrpcEvent.parentId?.let {
+            parentId = it.toGrpcEventId()
+        }
+        name = this@toGrpcEvent.name
+        type = this@toGrpcEvent.type
+        status = if (this@toGrpcEvent.isSuccess) EventStatus.SUCCESS else EventStatus.FAILED
+    }.build()
+
+    private fun StoredTestEventId.toGrpcEventId() = EventID.newBuilder().apply {
+        bookName = this@toGrpcEventId.bookId.name
+        scope = this@toGrpcEventId.scope
+        id = this@toGrpcEventId.id
+        startTimestamp = this@toGrpcEventId.startTimestamp.toTimestamp()
+    }.build()
+
+    private fun String.toEventId() = StoredTestEventId(
+        BookId(BOOK_NAME),
+        SCOPE_NAME,
+        Instant.now(),
+        this
+    )
+
+    private fun String.toSingleEvent(
+        parentId: StoredTestEventId?,
+        description: String,
+        success: Boolean
+    ): StoredTestEventSingle = this.toEventId()
+        .toSingleEvent(parentId, description, success)
+
+    private fun StoredTestEventId.toSingleEvent(
+        parentId: StoredTestEventId?,
+        description: String,
+        success: Boolean
+    ): StoredTestEventSingle = StoredTestEventSingle(
+        this,
+        "$description name",
+        "$description type",
+        parentId,
+        null,
+        success,
+        ByteArray(10),
+        emptySet(),
+        PageId(bookId, PAGE_NAME),
+        null,
+        Instant.now()
+    )
+
+    companion object {
+        private const val BOOK_NAME = "book"
+        private const val PAGE_NAME = "page"
+        private const val SCOPE_NAME = "scope"
+        private const val A_EVENT_ID = "a_event_id"
+        private const val B_EVENT_ID = "b_event_id"
+        private const val C_EVENT_ID = "c_event_id"
+        private const val D_EVENT_ID = "d_event_id"
+
+        private val SETTINGS = Settings(1, 1, TimeUnit.MILLISECONDS, 3)
+        private val PROCESSOR_EVENT_ID = EventID.newBuilder().apply {
+            bookName = BOOK_NAME
+            scope = SCOPE_NAME
+            id = "processor event id"
+            startTimestamp = Instant.now().toTimestamp()
+        }.build()
+        private val INTERVAL_EVENT_ID = EventID.newBuilder().apply {
+            bookName = BOOK_NAME
+            scope = SCOPE_NAME
+            id = "interval event id"
+            startTimestamp = Instant.now().toTimestamp()
+        }.build()
+    }
+}
\ No newline at end of file
diff --git a/src/test/resources/log4j2.properties b/src/test/resources/log4j2.properties
new file mode 100644
index 0000000..2869db8
--- /dev/null
+++ b/src/test/resources/log4j2.properties
@@ -0,0 +1,29 @@
+#
+#  Copyright 2022 Exactpro (Exactpro Systems Limited)
+#
+#  Licensed 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.
+#
+
+name=Th2Logger
+# Console appender configuration
+appender.console.type=Console
+appender.console.name=consoleLogger
+appender.console.layout.type=PatternLayout
+appender.console.layout.pattern=%d{dd MMM yyyy HH:mm:ss,SSS} %-6p [%-15t] %c - %m%n
+# Root logger level
+rootLogger.level=INFO
+# Root logger referring to console appender
+rootLogger.appenderRef.stdout.ref=consoleLogger
+
+logger.th2.name=com.exactpro.th2
+logger.th2.level=DEBUG
\ No newline at end of file
diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000..ca6ee9c
--- /dev/null
+++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-inline
\ No newline at end of file