Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add batching processor #139

Merged
merged 30 commits into from
Mar 29, 2022
Merged

Conversation

ryamagishi
Copy link
Contributor

@ryamagishi ryamagishi commented Dec 9, 2021

Motivation

  • Task batching is a common-pattern that many Decaton users often implement by their own.
    • i.e. Batching several tasks of type T to List and process them at once. e.g. when downstream-DB supports batching I/O (which often very efficient)
    • Batch-flushing should be done in time-based and size-based.
  • So it's better to provide BatchingProcessor built-in to meet the common needs

Related Issue: #128

@CLAassistant
Copy link

CLAassistant commented Dec 9, 2021

CLA assistant check
All committers have signed the CLA.

@ryamagishi ryamagishi marked this pull request as draft December 9, 2021 02:12
@Override
public void process(ProcessingContext<T> context, T task) throws InterruptedException {
BatchingTask<T> newTask = new BatchingTask<>(context.deferCompletion(), context, task);
boolean isInitialTask = windowedTasks.isEmpty();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The motivation is to make a decision before adding a Task, and to scheduleFlush after adding a Task.


/**
* *MUST* call {@link BatchingTask#completion}'s {@link DeferredCompletion#complete()} or
* {@link BatchingTask#context}'s {@link ProcessingContext#retry()} method.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a result of worrying about the retry process, I left it to the implementation side, but I think that it should be improved...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is the main method of this class that we expect users to implement, the document should be bit more informative IMO.

At least it should cover topics like what to do after complete processing batch of tasks. (call .completion().complete() or retry() for each right?),

what happens if an error is thrown, which threads may possibly call this method etc.

Copy link
Member

@ocadaruma ocadaruma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for submitting a patch!
Added few early feedbacks.

return;
}
synchronized (windowedTasks) {
processBatchingTasks(windowedTasks);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like clearing passed batchingTasks is also implementer's responsibility?
I guess it's not good design, as it could be error-prone.

Let's pass copy of windowedTasks and clear it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the advice! I fixed it.
1a2e2c2

}

private void flush() {
if (!windowedTasks.isEmpty()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, is that intentional? (not windowedTasks.isEmpty()?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry... I made a mistake.
4fdf913


// visible for testing
Runnable flushTask() {
return this::flush;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems flushTask() isn't called periodically?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about the following, but it was certainly difficult to understand, so I fixed it!

  1. When the first task comes in, scheduleFlush() is called.
  2. flush() empties windowsTasks.
  3. When the next task comes in after flush(), scheduleFlush() is called because windowTasks is empty.
    2ef1bc0

@kawamuray kawamuray added the new feature Add a new feature label Dec 13, 2021
@ryamagishi ryamagishi marked this pull request as ready for review December 27, 2021 03:53
@ryamagishi
Copy link
Contributor Author

I'm sorry for the late response 🙇
I fixed the github commented part, added comments and tests for BatchingProcessor and changed PR status from draft.
I would be grateful if you could review this when you have time.

Copy link
Member

@ocadaruma ocadaruma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for delaying the review!

Reviewed 1st round. PTAL comments.

* Batch-flushing should be done in time-based and size-based.
* @param <T> type of task to batch
*/
abstract public class BatchingProcessor<T> implements DecatonProcessor<T> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nits] should be ordered as public abstract

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fixed it. 4471e71

* @param capacity size limit for this processor. Every time tasks’size reaches capacity,
* tasks in past before reaching capacity are pushed to {@link BatchingTask#processBatchingTasks(List)}.
*/
public BatchingProcessor(long lingerMillis, int capacity) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the general practice, the constructor of abstract class should be marked as protected

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fixed it. f6f97ba

@Accessors(fluent = true)
public static class BatchingTask<T> {
@Getter(AccessLevel.NONE)
public final Completion completion;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make all fields as private and provide getter explicitly?

Also I think getter for completion is necessary to complete the task

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fixed it. c668bb3

BatchingTask<T> newTask = new BatchingTask<>(context.deferCompletion(), context, task);
windowedTasks.add(newTask);
if (windowedTasks.size() >= this.capacity) {
flush();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be executed in scheduler thread to avoid unnecessary lock contention between Decaton's processor thread and scheduler thread

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, then the problem is at the time flush() called, windowedTasks may contain more tasks than batch size, it's also not desirable.

Ok, then what do you think about following strategy?

  • Instead of using single final windowedTasks, have non-final List<BatchingTask> for "current" batch.
  • When "current" batch exceeds batch size, submit it to flusher and substitute current batch with new List.

Decaton will pause consumption if there's too many pending tasks, so even with this strategy, heap usage will not grow indefinitely.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry, I probably don't understand what you're saying...
I tried to correct it with my own understanding, but please point out if it is wrong 🙇
cb78a8b


processor.process(context, task1);
processor.process(context, task2);
Thread.sleep(lingerMs * 2); // doubling just to make sure flush completed in background
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Thread.sleep to wait something happen makes test result flaky.
Let's use CountDownLatch to make sure the condition happens. (Referring CompactionProcessorTest may be helpful)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your reference.
I fixed it so that the test result is stable as much as possible.
357f2fb

@ryamagishi
Copy link
Contributor Author

Thank you for your polite review!
I have corrected all the comments I received.
Please review again.

* *MUST* call {@link BatchingTask#completion}'s {@link DeferredCompletion#complete()} or
* {@link BatchingTask#context}'s {@link ProcessingContext#retry()} method.
*/
abstract void processBatchingTasks(List<BatchingTask<T>> batchingTasks);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the general practice, let's make this method protected

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fixed it. 5ae0959

public void process(ProcessingContext<T> context, T task) throws InterruptedException {
BatchingTask<T> newTask = new BatchingTask<>(context.deferCompletion(), context, task);
windowedTasks.add(newTask);
if (windowedTasks.size() >= this.capacity) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In flush-thread centralization and batch.size-bound point of view, looks okay in current impl.

But I suggested in #139 (comment) is something like below:

public abstract class BatchingProcessor<T> implements DecatonProcessor<T> {
    private List<BatchingTask<T>> currentBatch = new ArrayList<>();

    private void scheduleFlush() {
        executor.schedule(this::periodicallyFlushTask, lingerMillis, TimeUnit.MILLISECONDS);
    }

    private void periodicallyFlushTask() {
        final List<BatchingTask<T>> batch;
        synchronized (this) {
            if (!currentBatch.isEmpty()) {
                batch = currentBatch;
                currentBatch = new ArrayList<>();
            } else {
                batch = null;
            }
        }
        if (batch != null) {
            processBatchingTasks(batch);
        }
        scheduleFlush();
    }

    public void process(ProcessingContext<T> context, T task) throws InterruptedException {
        synchronized (this) {
            if (currentBatch.size() >= batchSize) {
                final List<BatchingTask<T>> batch = currentBatch;
                executor.submit(() -> processBatchingTasks(batch));
                currentBatch = new ArrayList<>();
            }
            currentBatch.add(task);
        }
    }
}

I guess it's more straightforward than maintaining single windowedTasks instance with calling subList...?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be executed in scheduler thread to avoid unnecessary lock contention between Decaton's processor thread and scheduler thread

Thank you! I finally understood the meaning of the above sentence.
executor.submit(() -> processBatchingTasks(batch)); is important.
I fixed it. 9e35f1b

@ryamagishi
Copy link
Contributor Author

ryamagishi commented Jan 20, 2022

#139 (comment)
Thanks for your test code advice.
While trying, I noticed that the abstract class mock wasn't working as expected.
So I fixed it to write test code simply without mocking. 87e11bd
ref: https://www.baeldung.com/junit-test-abstract-class

@ryamagishi
Copy link
Contributor Author

@ocadaruma @kawamuray
Sorry for delaying the reply.
I've been trying to fix the integrationTest for a week or two, but I couldn't solve it...
I need to understand how ProcessorTestSuite works, but that's beyond my understanding😭
I'll continue to make efforts after this comment, but I'd appreciate it if you could tell me why the testBatchingProcessor_capacity fails. (Even if I extend timeout, it will time out.)

I have corrected other comments I received.
As soon as this PR is merged, I will add a documentation like task-compaction.adoc in another PR.

@ocadaruma
Copy link
Member

Thanks for the fix.

why the testBatchingProcessor_capacity fails

I guess it's simply because linger-based flush is disabled on that test?
So, unless each processor instance receives exactly multiple of 100 (batch size) of tasks, few remaining tasks will be never get flushed.

public abstract class BatchingProcessor<T> implements DecatonProcessor<T> {

private final ScheduledExecutorService executor;
private List<BatchingTask<T>> currentBatch = Collections.synchronizedList(new ArrayList<>());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh what I meant was for BatchingProcessorTest. (#139 (comment))
For this, every modification is done in synchronized block so not necessary to be wrapped I think.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry, I misunderstood.
I fixed it.
08ed867, 242898b


@Value
@Accessors(fluent = true)
@RequiredArgsConstructor
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RequiredArgsConstructor is redundant as there's @Value

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fixed it.
aa51f27

@ryamagishi
Copy link
Contributor Author

I guess it's simply because linger-based flush is disabled on that test?
So, unless each processor instance receives exactly multiple of 100 (batch size) of tasks, few remaining tasks will be never get flushed.

Thank you for the advice.
I tried setting NUM_KEYS, NUM_SUBSCRIPTION_INSTANCES, NUM_PARTITIONS in ProcessorTestSuite to 1, but testBatchingProcessor_capacity() still failed...
I removed it for now.
ref: https://github.com/line/decaton/blob/master/testing/src/main/java/com/linecorp/decaton/testing/processor/ProcessorTestSuite.java#L102-L104
7821641

@ryamagishi
Copy link
Contributor Author

Sorry for the late reply every time.
I will try to improve 🙇

@ocadaruma
Copy link
Member

ocadaruma commented Mar 20, 2022

Thanks for the update!

but testBatchingProcessor_capacity() still failed

Ah maybe what I meant was more simple.

  • In testBatchingProcessor_capacity test, lingerMillis was set to Integer.MAX.
  • It means that batch-flushing is done by only size-based.
  • Let's see this by example. Let's say we set capacity to 2 and lingerMillis to Int.MAX. Then what happens we feed 3 tasks?
    • 2 tasks will be processed because it will reach the capacity
    • However, remaining 1 task will be never get flushed, so it causes consumption stuck.

I removed it for now.

However, I agree with removing it. Because what we want to check in integration test is BatchingProcessor's behavior in overall (as commented in #139 (comment)), so we don't have to write test scenario for each of linger=MAX, capacity=MAX I think.


/**
* Instantiate {@link BatchingProcessor}.
* If you only need one limit, please set large enough value to another.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As described in #139 (comment), we found that consumption will get stuck if we specify lingerMillis to Int.MAX.

Hm, so I think realistically we always specify non-Int.MAX value for both parameters.
Then let's just remove this line? To avoid mis-use of these parameters.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly, I fixed it.
0cb57f7

ProcessorTestSuite
.builder(rule)
.configureProcessorsBuilder(builder -> builder.thenProcess(
new BatchingProcessor<TestTask>(1000, Integer.MAX_VALUE) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose the purpose of this integration test is to verify the behavior of BatchingProcessor in overall (rather than checking if BatchingProcessor is implemented correctly for each individual cases like linger=Max, capacity=Max etc), so let's also specify non-Int.MAX value for capacity here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fixed it. (I renamed the test name too.)
0317258

@ryamagishi
Copy link
Contributor Author

Thank you for your polite explanation every time!
If ProcessorTestSuite#numTasks = 10000 andBatchingProcessor#capacity = 100(and ProcessorTestSuite#NUM_SUBSCRIPTION_INSTANCES = 1), I thought it would succeed because the number is divisible.
However, I noticed that the number of tasks processed is actually not 10000. As you said, this doesn't work well.
I'm sorry that the pasted image is difficult to understand, my question has been solved.
スクリーンショット 2022-03-22 14 29 12

@ocadaruma
Copy link
Member

What I immediately thought is because of multiple partitions.
Since the partition count is set to 8 in ProcessorTestSuite, if we instantiate only 1 subscription instance, it will create 8 partition-processor. With partition.concurrency = 1 and ProcessorScope = THREAD, it will create 8 batching processor instances, and clearly the received tasks count will not be multiple of 100.

Copy link
Member

@ocadaruma ocadaruma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Thanks for your great work!

@kawamuray Please check the change for your comment.

Copy link
Member

@kawamuray kawamuray left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks 👍

@ryamagishi
Copy link
Contributor Author

ryamagishi commented Mar 29, 2022

May I merge it?
I don't have merge permission in the first place...

@ryamagishi
Copy link
Contributor Author

As soon as this PR is merged, I will add a documentation like task-compaction.adoc in another PR!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
new feature Add a new feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants