embulk-executor-remoteserver 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +42 -0
  3. data/.gitignore +14 -0
  4. data/Dockerfile +14 -0
  5. data/LICENSE +21 -0
  6. data/README.md +30 -0
  7. data/build.gradle +70 -0
  8. data/docker-compose.yml +24 -0
  9. data/docker/run_embulk_server.sh +3 -0
  10. data/gradle.properties +1 -0
  11. data/gradle/dependency-locks/compileClasspath.lockfile +40 -0
  12. data/gradle/dependency-locks/testCompileClasspath.lockfile +49 -0
  13. data/gradle/wrapper/gradle-wrapper.jar +0 -0
  14. data/gradle/wrapper/gradle-wrapper.properties +5 -0
  15. data/gradlew +172 -0
  16. data/gradlew.bat +84 -0
  17. data/lib/embulk/executor/remoteserver.rb +3 -0
  18. data/settings.gradle +1 -0
  19. data/src/main/java/org/embulk/executor/remoteserver/EmbulkClient.java +111 -0
  20. data/src/main/java/org/embulk/executor/remoteserver/EmbulkServer.java +32 -0
  21. data/src/main/java/org/embulk/executor/remoteserver/Host.java +32 -0
  22. data/src/main/java/org/embulk/executor/remoteserver/InitializeSessionCommand.java +94 -0
  23. data/src/main/java/org/embulk/executor/remoteserver/Launcher.java +25 -0
  24. data/src/main/java/org/embulk/executor/remoteserver/NotifyTaskStateCommand.java +23 -0
  25. data/src/main/java/org/embulk/executor/remoteserver/PluginArchive.java +170 -0
  26. data/src/main/java/org/embulk/executor/remoteserver/RemoteServerExecutor.java +131 -0
  27. data/src/main/java/org/embulk/executor/remoteserver/RemoveSessionCommand.java +24 -0
  28. data/src/main/java/org/embulk/executor/remoteserver/Session.java +177 -0
  29. data/src/main/java/org/embulk/executor/remoteserver/SessionManager.java +37 -0
  30. data/src/main/java/org/embulk/executor/remoteserver/SessionState.java +143 -0
  31. data/src/main/java/org/embulk/executor/remoteserver/StartTaskCommand.java +51 -0
  32. data/src/main/java/org/embulk/executor/remoteserver/TaskExecutionException.java +11 -0
  33. data/src/main/java/org/embulk/executor/remoteserver/TaskState.java +5 -0
  34. data/src/main/java/org/embulk/executor/remoteserver/UpdateTaskStateData.java +55 -0
  35. data/src/main/resources/logback.xml +11 -0
  36. data/src/test/java/org/embulk/executor/remoteserver/TestRemoteServerExecutor.java +80 -0
  37. data/src/test/resources/json/test1.json +1 -0
  38. data/src/test/resources/json/test2.json +1 -0
  39. data/test/Gemfile +4 -0
  40. data/test/Gemfile.lock +20 -0
  41. data/test/setup.sh +8 -0
  42. metadata +119 -0
@@ -0,0 +1,131 @@
1
+ package org.embulk.executor.remoteserver;
2
+
3
+ import com.google.inject.Inject;
4
+ import org.embulk.config.Config;
5
+ import org.embulk.config.ConfigDefault;
6
+ import org.embulk.config.ConfigInject;
7
+ import org.embulk.config.ConfigSource;
8
+ import org.embulk.config.ModelManager;
9
+ import org.embulk.config.Task;
10
+ import org.embulk.exec.ForSystemConfig;
11
+ import org.embulk.spi.Exec;
12
+ import org.embulk.spi.ExecutorPlugin;
13
+ import org.embulk.spi.ProcessState;
14
+ import org.embulk.spi.ProcessTask;
15
+ import org.embulk.spi.Schema;
16
+ import org.jruby.embed.ScriptingContainer;
17
+ import org.slf4j.Logger;
18
+ import org.slf4j.LoggerFactory;
19
+
20
+ import java.io.File;
21
+ import java.io.FileOutputStream;
22
+ import java.io.IOException;
23
+ import java.io.UncheckedIOException;
24
+ import java.nio.file.Files;
25
+ import java.util.Collections;
26
+ import java.util.List;
27
+ import java.util.concurrent.TimeoutException;
28
+
29
+ public class RemoteServerExecutor implements ExecutorPlugin {
30
+ private static final Logger log = LoggerFactory.getLogger(RemoteServerExecutor.class);
31
+ private static final Host DEFAULT_HOST = new Host("localhost", 30000);
32
+ private final ConfigSource systemConfig;
33
+ private final ScriptingContainer jruby;
34
+
35
+ interface PluginTask extends Task {
36
+ @Config("hosts")
37
+ @ConfigDefault("[]")
38
+ List<Host> getHosts();
39
+
40
+ @Config("timeout_seconds")
41
+ @ConfigDefault("3600")
42
+ int getTimeoutSeconds();
43
+
44
+ @ConfigInject
45
+ ModelManager getModelManager();
46
+ }
47
+
48
+ @Inject
49
+ public RemoteServerExecutor(@ForSystemConfig ConfigSource systemConfig, ScriptingContainer jruby) {
50
+ this.systemConfig = systemConfig;
51
+ this.jruby = jruby;
52
+ }
53
+
54
+ @Override
55
+ public void transaction(ConfigSource config, Schema outputSchema, int inputTaskCount, Control control) {
56
+ PluginTask task = config.loadConfig(PluginTask.class);
57
+ if (task.getHosts().isEmpty()) {
58
+ log.info("Hosts is empty. Run with a local server.");
59
+ try (EmbulkServer _autoclosed = EmbulkServer.start(DEFAULT_HOST.getName(), DEFAULT_HOST.getPort(), 1)) {
60
+ control.transaction(outputSchema, inputTaskCount, new ExecutorImpl(inputTaskCount, task, Collections.singletonList(DEFAULT_HOST)));
61
+ } catch (IOException e) {
62
+ throw new UncheckedIOException(e);
63
+ }
64
+ } else {
65
+ control.transaction(outputSchema, inputTaskCount, new ExecutorImpl(inputTaskCount, task, task.getHosts()));
66
+ }
67
+ }
68
+
69
+ private class ExecutorImpl implements ExecutorPlugin.Executor {
70
+ private final PluginTask pluginTask;
71
+ private final int inputTaskCount;
72
+ private final List<Host> hosts;
73
+
74
+ ExecutorImpl(int inputTaskCount, PluginTask pluginTask, List<Host> hosts) {
75
+ this.inputTaskCount = inputTaskCount;
76
+ this.pluginTask = pluginTask;
77
+ this.hosts = hosts;
78
+ }
79
+
80
+ @Override
81
+ public void execute(ProcessTask processTask, ProcessState state) {
82
+ byte[] pluginArchiveBytes;
83
+ List<PluginArchive.GemSpec> gemSpecs;
84
+ try {
85
+ File tempFile = Exec.getTempFileSpace().createTempFile("gems", ".zip");
86
+ gemSpecs = archivePlugins(tempFile);
87
+ pluginArchiveBytes = Files.readAllBytes(tempFile.toPath());
88
+ } catch (IOException e) {
89
+ throw new UncheckedIOException(e);
90
+ }
91
+ // Remove 'jruby_global_bundler_plugin_source_directory' (--bundle option)
92
+ // because all gems will be loaded via PluginArchive on server
93
+ ConfigSource systemConfigToSend = systemConfig.deepCopy().remove("jruby_global_bundler_plugin_source_directory");
94
+
95
+ ModelManager modelManager = pluginTask.getModelManager();
96
+ String systemConfigJson = modelManager.writeObject(systemConfigToSend);
97
+ String pluginTaskJson = modelManager.writeObject(pluginTask);
98
+ String processTaskJson = modelManager.writeObject(processTask);
99
+
100
+ SessionState sessionState = new SessionState(
101
+ systemConfigJson, pluginTaskJson, processTaskJson, gemSpecs, pluginArchiveBytes, state, inputTaskCount, modelManager);
102
+ try (EmbulkClient client = EmbulkClient.open(sessionState, hosts)) {
103
+ client.createSession();
104
+
105
+ state.initialize(inputTaskCount, inputTaskCount);
106
+ for (int i = 0; i < inputTaskCount; i++) {
107
+ if (state.getOutputTaskState(i).isCommitted()) {
108
+ log.warn("Skipped resumed task {}", i);
109
+ continue;
110
+ }
111
+ client.startTask(i);
112
+ }
113
+ sessionState.waitUntilCompleted(pluginTask.getTimeoutSeconds() + 1); // Add 1 sec to consider network latency
114
+ } catch (InterruptedException | TimeoutException e) {
115
+ throw new IllegalStateException(e);
116
+ } catch (IOException e) {
117
+ throw new UncheckedIOException(e);
118
+ }
119
+ }
120
+
121
+ private List<PluginArchive.GemSpec> archivePlugins(File tempFile) throws IOException {
122
+ // archive plugins
123
+ PluginArchive archive = new PluginArchive.Builder()
124
+ .addLoadedRubyGems(jruby)
125
+ .build();
126
+ try (FileOutputStream fos = new FileOutputStream(tempFile)) {
127
+ return archive.dump(fos);
128
+ }
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,24 @@
1
+ package org.embulk.executor.remoteserver;
2
+
3
+ import com.github.kamatama41.nsocket.Connection;
4
+ import com.github.kamatama41.nsocket.SyncCommand;
5
+
6
+ public class RemoveSessionCommand implements SyncCommand<String, Void> {
7
+ static final String ID = "remove_session";
8
+ private final SessionManager sessionManager;
9
+
10
+ RemoveSessionCommand(SessionManager sessionManager) {
11
+ this.sessionManager = sessionManager;
12
+ }
13
+
14
+ @Override
15
+ public Void apply(String sessionId, Connection connection) {
16
+ sessionManager.removeSession(sessionId);
17
+ return null;
18
+ }
19
+
20
+ @Override
21
+ public String getId() {
22
+ return ID;
23
+ }
24
+ }
@@ -0,0 +1,177 @@
1
+ package org.embulk.executor.remoteserver;
2
+
3
+ import com.github.kamatama41.nsocket.Connection;
4
+ import org.embulk.EmbulkEmbed;
5
+ import org.embulk.config.ConfigSource;
6
+ import org.embulk.config.ModelManager;
7
+ import org.embulk.config.TaskReport;
8
+ import org.embulk.spi.Exec;
9
+ import org.embulk.spi.ExecSession;
10
+ import org.embulk.spi.ProcessTask;
11
+ import org.embulk.spi.util.Executors;
12
+ import org.jruby.embed.ScriptingContainer;
13
+ import org.slf4j.Logger;
14
+ import org.slf4j.LoggerFactory;
15
+
16
+ import java.io.ByteArrayInputStream;
17
+ import java.io.IOException;
18
+ import java.io.InputStream;
19
+ import java.io.UncheckedIOException;
20
+ import java.nio.file.Files;
21
+ import java.nio.file.Path;
22
+ import java.util.LinkedList;
23
+ import java.util.List;
24
+ import java.util.Queue;
25
+ import java.util.concurrent.ConcurrentHashMap;
26
+ import java.util.concurrent.ConcurrentMap;
27
+ import java.util.concurrent.ExecutorService;
28
+ import java.util.concurrent.TimeUnit;
29
+
30
+ import static java.nio.charset.StandardCharsets.UTF_8;
31
+
32
+ class Session implements AutoCloseable {
33
+ private static final Logger log = LoggerFactory.getLogger(Session.class);
34
+ private final String id;
35
+ private final EmbulkEmbed embed;
36
+ private final ScriptingContainer jruby;
37
+ private final RemoteServerExecutor.PluginTask pluginTask;
38
+ private final ProcessTask processTask;
39
+ private final List<PluginArchive.GemSpec> gemSpecs;
40
+ private final byte[] pluginArchive;
41
+ private final ExecSession session;
42
+ private final ModelManager modelManager;
43
+ private final ConcurrentMap<Integer, Queue<UpdateTaskStateData>> bufferMap;
44
+ private final ExecutorService sessionRunner;
45
+ private volatile Connection connection;
46
+
47
+ Session(
48
+ String id,
49
+ String systemConfig,
50
+ String pluginTaskConfig,
51
+ String processTaskConfig,
52
+ List<PluginArchive.GemSpec> gemSpecs,
53
+ byte[] pluginArchive
54
+ ) {
55
+ this.id = id;
56
+ this.embed = newEmbulkBootstrap(systemConfig).initialize();
57
+ this.jruby = embed.getInjector().getInstance(ScriptingContainer.class);
58
+ this.modelManager = embed.getModelManager();
59
+ this.pluginTask = modelManager.readObject(RemoteServerExecutor.PluginTask.class, pluginTaskConfig);
60
+ this.processTask = modelManager.readObject(ProcessTask.class, processTaskConfig);
61
+ this.gemSpecs = gemSpecs;
62
+ this.pluginArchive = pluginArchive;
63
+ this.session = ExecSession.builder(embed.getInjector()).build();
64
+ loadPluginArchive();
65
+ this.bufferMap = new ConcurrentHashMap<>();
66
+ this.sessionRunner = java.util.concurrent.Executors.newCachedThreadPool(r -> {
67
+ Thread t = new Thread(r);
68
+ t.setName("session-runner-" + id);
69
+ t.setDaemon(true);
70
+ return t;
71
+ });
72
+ }
73
+
74
+ void runTaskAsynchronously(int taskIndex) {
75
+ sessionRunner.submit(() -> Exec.doWith(session, () -> {
76
+ runTask(taskIndex);
77
+ return null;
78
+ }));
79
+ }
80
+
81
+ private void runTask(int taskIndex) throws InterruptedException {
82
+ bufferMap.putIfAbsent(taskIndex, new LinkedList<>());
83
+ try {
84
+ Executors.process(session, processTask, taskIndex, new Executors.ProcessStateCallback() {
85
+ @Override
86
+ public void started() {
87
+ sendCommand(taskIndex, new UpdateTaskStateData(id, taskIndex, TaskState.STARTED));
88
+ }
89
+
90
+ @Override
91
+ public void inputCommitted(TaskReport report) {
92
+ UpdateTaskStateData data = new UpdateTaskStateData(id, taskIndex, TaskState.INPUT_COMMITTED);
93
+ data.setTaskReport(modelManager.writeObject(report));
94
+ sendCommand(taskIndex, data);
95
+ }
96
+
97
+ @Override
98
+ public void outputCommitted(TaskReport report) {
99
+ UpdateTaskStateData data = new UpdateTaskStateData(id, taskIndex, TaskState.OUTPUT_COMMITTED);
100
+ data.setTaskReport(modelManager.writeObject(report));
101
+ sendCommand(taskIndex, data);
102
+ }
103
+ });
104
+ sendCommand(taskIndex, new UpdateTaskStateData(id, taskIndex, TaskState.FINISHED));
105
+ } catch (Exception e) {
106
+ log.warn(String.format("Failed to run task[%d]", taskIndex), e);
107
+ UpdateTaskStateData data = new UpdateTaskStateData(id, taskIndex, TaskState.FAILED);
108
+ data.setErrorMessage(e.getMessage());
109
+ sendCommand(taskIndex, data);
110
+ }
111
+
112
+ Queue<UpdateTaskStateData> buffer = bufferMap.get(taskIndex);
113
+ if (buffer.isEmpty()) {
114
+ return;
115
+ }
116
+
117
+ // Flush buffer if remaining
118
+ int waitSeconds = 10;
119
+ while (!buffer.isEmpty()) {
120
+ if (connection.isOpen()) {
121
+ flushBuffer(taskIndex, connection);
122
+ return;
123
+ }
124
+ log.warn("Connection is closed, wait {} seconds until reconnected.", waitSeconds);
125
+ TimeUnit.SECONDS.sleep(waitSeconds);
126
+ }
127
+ }
128
+
129
+ void updateConnection(Connection connection) {
130
+ this.connection = connection;
131
+ }
132
+
133
+ private void sendCommand(int taskIndex, UpdateTaskStateData data) {
134
+ bufferMap.get(taskIndex).offer(data);
135
+ if (!connection.isOpen()) {
136
+ log.warn("Connection is closed, add data to buffer.");
137
+ return;
138
+ }
139
+ flushBuffer(taskIndex, connection);
140
+ }
141
+
142
+ private void flushBuffer(int taskIndex, Connection connection) {
143
+ UpdateTaskStateData data;
144
+ Queue<UpdateTaskStateData> buffer = bufferMap.get(taskIndex);
145
+ while ((data = buffer.poll()) != null) {
146
+ connection.sendCommand(NotifyTaskStateCommand.ID, data);
147
+ }
148
+ }
149
+
150
+ private static EmbulkEmbed.Bootstrap newEmbulkBootstrap(String configJson) {
151
+ ConfigSource systemConfig = getSystemConfig(configJson);
152
+ return new EmbulkEmbed.Bootstrap().setSystemConfig(systemConfig);
153
+ }
154
+
155
+ private static ConfigSource getSystemConfig(String configJson) {
156
+ try (InputStream in = new ByteArrayInputStream(configJson.getBytes(UTF_8))) {
157
+ return EmbulkEmbed.newSystemConfigLoader().fromJson(in);
158
+ } catch (IOException e) {
159
+ throw new UncheckedIOException(e);
160
+ }
161
+ }
162
+
163
+ private void loadPluginArchive() {
164
+ try (ByteArrayInputStream bis = new ByteArrayInputStream(pluginArchive)) {
165
+ Path gemsDir = Files.createTempDirectory("embulk_gems");
166
+ PluginArchive.load(gemsDir.toFile(), gemSpecs, bis).restoreLoadPathsTo(jruby);
167
+ } catch (IOException e) {
168
+ throw new UncheckedIOException(e);
169
+ }
170
+ }
171
+
172
+ @Override
173
+ public void close() {
174
+ log.debug("Closing the session {}", id);
175
+ sessionRunner.shutdownNow();
176
+ }
177
+ }
@@ -0,0 +1,37 @@
1
+ package org.embulk.executor.remoteserver;
2
+
3
+ import com.github.kamatama41.nsocket.Connection;
4
+
5
+ import java.util.List;
6
+ import java.util.concurrent.ConcurrentHashMap;
7
+ import java.util.concurrent.ConcurrentMap;
8
+
9
+ class SessionManager {
10
+ private final ConcurrentMap<String, Session> sessionMap;
11
+
12
+ SessionManager() {
13
+ this.sessionMap = new ConcurrentHashMap<>();
14
+ }
15
+
16
+ void registerNewSession(String sessionId,
17
+ String systemConfig,
18
+ String pluginTaskConfig,
19
+ String processTaskConfig,
20
+ List<PluginArchive.GemSpec> gemSpecs,
21
+ byte[] pluginArchive,
22
+ Connection connection) {
23
+ Session session = sessionMap.computeIfAbsent(
24
+ sessionId, (k) -> new Session(
25
+ sessionId, systemConfig, pluginTaskConfig, processTaskConfig, gemSpecs, pluginArchive));
26
+ session.updateConnection(connection);
27
+ }
28
+
29
+ Session getSession(String sessionId) {
30
+ return sessionMap.get(sessionId);
31
+ }
32
+
33
+ void removeSession(String sessionId) {
34
+ Session removed = sessionMap.remove(sessionId);
35
+ removed.close();
36
+ }
37
+ }
@@ -0,0 +1,143 @@
1
+ package org.embulk.executor.remoteserver;
2
+
3
+ import org.embulk.config.ModelManager;
4
+ import org.embulk.config.TaskReport;
5
+ import org.embulk.spi.ProcessState;
6
+ import org.slf4j.Logger;
7
+ import org.slf4j.LoggerFactory;
8
+
9
+ import java.util.List;
10
+ import java.util.Map;
11
+ import java.util.UUID;
12
+ import java.util.concurrent.ConcurrentHashMap;
13
+ import java.util.concurrent.CountDownLatch;
14
+ import java.util.concurrent.TimeUnit;
15
+ import java.util.concurrent.TimeoutException;
16
+ import java.util.stream.Collectors;
17
+
18
+ class SessionState {
19
+ private static final Logger log = LoggerFactory.getLogger(SessionState.class);
20
+
21
+ private String sessionId;
22
+ private final String systemConfigJson;
23
+ private final String pluginTaskJson;
24
+ private final String processTaskJson;
25
+ private final List<PluginArchive.GemSpec> gemSpecs;
26
+ private final byte[] pluginArchiveBytes;
27
+
28
+ private final ProcessState state;
29
+ private final CountDownLatch timer;
30
+ private final int inputTaskCount;
31
+ private final ModelManager modelManager;
32
+ private volatile boolean isFinished;
33
+ private final Map<Integer, String> errorMessages;
34
+
35
+ SessionState(
36
+ String systemConfigJson, String pluginTaskJson, String processTaskJson,
37
+ List<PluginArchive.GemSpec> gemSpecs, byte[] pluginArchiveBytes,
38
+ ProcessState state, int inputTaskCount, ModelManager modelManager) {
39
+ this.sessionId = UUID.randomUUID().toString();
40
+ this.systemConfigJson = systemConfigJson;
41
+ this.pluginTaskJson = pluginTaskJson;
42
+ this.processTaskJson = processTaskJson;
43
+ this.gemSpecs = gemSpecs;
44
+ this.pluginArchiveBytes = pluginArchiveBytes;
45
+ this.state = state;
46
+ this.timer = new CountDownLatch(inputTaskCount);
47
+ this.inputTaskCount = inputTaskCount;
48
+ this.modelManager = modelManager;
49
+ this.isFinished = false;
50
+ this.errorMessages = new ConcurrentHashMap<>();
51
+ }
52
+
53
+ String getSessionId() {
54
+ return sessionId;
55
+ }
56
+
57
+ String getSystemConfigJson() {
58
+ return systemConfigJson;
59
+ }
60
+
61
+ String getPluginTaskJson() {
62
+ return pluginTaskJson;
63
+ }
64
+
65
+ String getProcessTaskJson() {
66
+ return processTaskJson;
67
+ }
68
+
69
+ List<PluginArchive.GemSpec> getGemSpecs() {
70
+ return gemSpecs;
71
+ }
72
+
73
+ byte[] getPluginArchiveBytes() {
74
+ return pluginArchiveBytes;
75
+ }
76
+
77
+ ProcessState getState() {
78
+ return state;
79
+ }
80
+
81
+ boolean isFinished() {
82
+ return isFinished;
83
+ }
84
+
85
+ synchronized void update(UpdateTaskStateData data) {
86
+ switch (data.getTaskState()) {
87
+ case STARTED:
88
+ state.getInputTaskState(data.getTaskIndex()).start();
89
+ state.getOutputTaskState(data.getTaskIndex()).start();
90
+ break;
91
+ case INPUT_COMMITTED:
92
+ state.getInputTaskState(data.getTaskIndex()).setTaskReport(getTaskReport(data.getTaskReport()));
93
+ break;
94
+ case OUTPUT_COMMITTED:
95
+ state.getOutputTaskState(data.getTaskIndex()).setTaskReport(getTaskReport(data.getTaskReport()));
96
+ break;
97
+ case FAILED:
98
+ errorMessages.put(data.getTaskIndex(), data.getErrorMessage());
99
+ timer.countDown();
100
+ break;
101
+ case FINISHED:
102
+ state.getInputTaskState(data.getTaskIndex()).finish();
103
+ state.getOutputTaskState(data.getTaskIndex()).finish();
104
+ timer.countDown();
105
+ showProgress(state, inputTaskCount);
106
+ break;
107
+ }
108
+ }
109
+
110
+ void waitUntilCompleted(int timeoutSeconds) throws InterruptedException, TimeoutException {
111
+ try {
112
+ if (!timer.await(timeoutSeconds, TimeUnit.SECONDS)) {
113
+ throw new TimeoutException(String.format("The session (%s) was time-out.", sessionId));
114
+ }
115
+ if (!errorMessages.isEmpty()) {
116
+ String message = errorMessages.entrySet().stream()
117
+ .map(e -> String.format("%d: %s", e.getKey(), e.getValue()))
118
+ .collect(Collectors.joining(System.lineSeparator()));
119
+ throw new TaskExecutionException(message);
120
+ }
121
+ } finally {
122
+ isFinished = true;
123
+ }
124
+ }
125
+
126
+ private TaskReport getTaskReport(String json) {
127
+ return modelManager.readObject(TaskReport.class, json);
128
+ }
129
+
130
+ private static void showProgress(ProcessState state, int taskCount) {
131
+ int started = 0;
132
+ int finished = 0;
133
+ for (int i = 0; i < taskCount; i++) {
134
+ if (state.getOutputTaskState(i).isStarted()) {
135
+ started++;
136
+ }
137
+ if (state.getOutputTaskState(i).isFinished()) {
138
+ finished++;
139
+ }
140
+ }
141
+ log.info(String.format("{done:%3d / %d, running: %d}", finished, taskCount, started - finished));
142
+ }
143
+ }