diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b2288c1..0288852 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,8 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - # Setup Java Installation - - name: Set up JDK + - name: Set up JDK uses: actions/setup-java@v2 with: java-version: '16' @@ -28,11 +27,9 @@ jobs: run: ./gradlew :protocol:generateProto :protocol:generateJavascriptPackage # The website has to be build after the buildJavascriptPackage task has been run. - name: Npm Install Website - uses: bahmutov/npm-install@v1.6.0 - with: - working-directory: 'website' - useLockFile: true - # Builds the actual website for production + run: npm install --legacy-peer-deps + working-directory: website + # Builds the actual website for production - name: Build Website run: CI='' npm run build-all working-directory: website @@ -46,11 +43,39 @@ jobs: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_TOKEN }} # Build the image and push it to the registry - - name: Build the Docker image + - name: Create Server Docker Meta + id: docker_server_meta + uses: crazy-max/ghaction-docker-meta@v2 + with: + images: ehenoma/jsheets + tags: | + type=raw,value=latest,enable=${{ endsWith(GitHub.ref, 'main') }} + type=ref,event=tag + flavor: | + latest=false + - name: Build Server + uses: docker/build-push-action@v2 + with: + context: . + file: server/deploy/Dockerfile + push: true + tags: ${{ steps.docker_server_meta.outputs.tags }} + labels: ${{ steps.docker_server_meta.outputs.labels }} + - name: Create Runtime Docker Meta + id: docker_runtime_meta + uses: crazy-max/ghaction-docker-meta@v2 + with: + images: ehenoma/jsheets-runtime + tags: | + type=raw,value=latest,enable=${{ endsWith(GitHub.ref, 'main') }} + type=ref,event=tag + flavor: | + latest=false + - name: Build Runtime uses: docker/build-push-action@v2 with: context: . - file: ./server/deploy/Dockerfile - platforms: linux/amd64,linux/arm64,linux/386 + file: runtime/deploy/Dockerfile push: true - tags: ehenoma/jsheets:${{ env.BRANCH }} + tags: ${{ steps.docker_runtime_meta.outputs.tags }} + labels: ${{ steps.docker_runtime_meta.outputs.labels }} diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3d68665 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Security Policy + +JShell deals with user code execution and is thus not spared of security vulnerabilities. +They will most likely not affect the browser/client but the server side. + +It is recommended not to run any component that evaluates code in an environment without +isolation/security. Jsheets provides mechanisms to control/configure what code can do, +io operations are disabled by default. Be **careful** when editing security configs. + +## Supported Versions + +Ensure that your deployment of JSheets uses a supported version. We do not provide security +updates for old versions. It is always important to keep up to date and, if possible, use the +latest version. + +| Version | Supported | +| ------- | ------------------ | +| 0.1.x | :white_check_mark: | + +## Discovering a Vulnerability + +Please contact the administrator of the Jsheets instance and inform him/her about the vulnerability +(or at least that one exists). It is in their hands to decide wether to temporarily disable execution +of code until the vulnerability is fixed. Please **report** all vulnerabilities you find to protect +both administrators and users. + +## Reporting a Vulnerability + +Prefer to report "severe" vulnerabilities to [merlinosayimwen@gmail.com](mailto://merlinosayimwen@gmail.com) to +ensure that as view people can abuse them until they are fixed. If you do not get a response within a few days, you +are free to contact other maintainers directly and, if no one responds, create an issue. diff --git a/build.gradle b/build.gradle index a1cd562..28deca7 100644 --- a/build.gradle +++ b/build.gradle @@ -14,4 +14,6 @@ ext { consuleClientVersion = '1.4.5' javaxAnnotationVersion = '1.3.2' recordBuilderVersion = '28' + guavaVersion = '31.0.1-jre' + bucket4jVersion = '6.3.0' } \ No newline at end of file diff --git a/common/build.gradle b/common/build.gradle new file mode 100644 index 0000000..7af5f2f --- /dev/null +++ b/common/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'java' +} + +group 'dev.jsheets' +version '0.1.0' + +sourceCompatibility = 16 +targetCompatibility = 16 + +repositories { + mavenCentral() +} + +dependencies { + testImplementation "org.junit.jupiter:junit-jupiter-api:$junitPlatformVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitPlatformVersion" + implementation "com.google.guava:guava:$guavaVersion" + compileOnly "io.soabase.record-builder:record-builder-core:$recordBuilderVersion" + annotationProcessor "io.soabase.record-builder:record-builder-processor:$recordBuilderVersion" +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/evaluation/src/main/java/jsheets/config/CamelCase.java b/common/src/main/java/jsheets/config/CamelCase.java similarity index 100% rename from evaluation/src/main/java/jsheets/config/CamelCase.java rename to common/src/main/java/jsheets/config/CamelCase.java diff --git a/evaluation/src/main/java/jsheets/config/CombinedConfig.java b/common/src/main/java/jsheets/config/CombinedConfig.java similarity index 100% rename from evaluation/src/main/java/jsheets/config/CombinedConfig.java rename to common/src/main/java/jsheets/config/CombinedConfig.java diff --git a/evaluation/src/main/java/jsheets/config/Config.java b/common/src/main/java/jsheets/config/Config.java similarity index 100% rename from evaluation/src/main/java/jsheets/config/Config.java rename to common/src/main/java/jsheets/config/Config.java diff --git a/common/src/main/java/jsheets/config/Configs.java b/common/src/main/java/jsheets/config/Configs.java new file mode 100644 index 0000000..16319e5 --- /dev/null +++ b/common/src/main/java/jsheets/config/Configs.java @@ -0,0 +1,13 @@ +package jsheets.config; + +public final class Configs { + private Configs() {} + + public static Config loadAll(Config.Source... sources) { + var configs = new Config[sources.length]; + for (int source = 0; source < sources.length; source++) { + configs[source] = sources[source].load(); + } + return CombinedConfig.of(configs); + } +} \ No newline at end of file diff --git a/evaluation/src/main/java/jsheets/config/EnvironmentConfig.java b/common/src/main/java/jsheets/config/EnvironmentConfig.java similarity index 100% rename from evaluation/src/main/java/jsheets/config/EnvironmentConfig.java rename to common/src/main/java/jsheets/config/EnvironmentConfig.java diff --git a/common/src/main/java/jsheets/config/FileConfigSource.java b/common/src/main/java/jsheets/config/FileConfigSource.java new file mode 100644 index 0000000..482cb98 --- /dev/null +++ b/common/src/main/java/jsheets/config/FileConfigSource.java @@ -0,0 +1,62 @@ +package jsheets.config; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; + +import io.soabase.recordbuilder.core.RecordBuilder; + +@RecordBuilder +public record FileConfigSource( + Config.Key key, + Path relativePath, + Path workingDirectory +) implements Config.Source { + + public static FileConfigSource inCurrentWorkingDirectory( + Config.Key key, + Path relativePath + ) { + var workingDirectory = Path.of(System.getProperty("user.dir")); + return new FileConfigSource(key, relativePath, workingDirectory); + } + + @Override + public Config load() { + return loadContent() + .map(content -> RawConfig.of(Map.of(key.toString(), content))) + .orElseGet(() -> RawConfig.of(Map.of())); + } + + private Optional loadContent() { + return loadFromSystem().or(this::loadFromClassPath); + } + + private Optional loadFromSystem() { + var fullPath = workingDirectory.resolve(relativePath); + try { + return Optional.of( + Files.readString(fullPath, StandardCharsets.UTF_8) + ); + } catch (IOException failedRead) { + return Optional.empty(); + } + } + + private Optional loadFromClassPath() { + var resources = Thread.currentThread().getContextClassLoader(); + var file = resources.getResourceAsStream(relativePath.toString()); + if (file == null) { + return Optional.empty(); + } + try (var input = new BufferedInputStream(file)) { + return Optional.of(new String(input.readAllBytes())); + } catch (IOException failedRead) { + return Optional.empty(); + } + } +} \ No newline at end of file diff --git a/evaluation/src/main/java/jsheets/config/MissingField.java b/common/src/main/java/jsheets/config/MissingField.java similarity index 100% rename from evaluation/src/main/java/jsheets/config/MissingField.java rename to common/src/main/java/jsheets/config/MissingField.java diff --git a/evaluation/src/main/java/jsheets/config/RawConfig.java b/common/src/main/java/jsheets/config/RawConfig.java similarity index 100% rename from evaluation/src/main/java/jsheets/config/RawConfig.java rename to common/src/main/java/jsheets/config/RawConfig.java diff --git a/evaluation/src/main/java/jsheets/config/ResolvedField.java b/common/src/main/java/jsheets/config/ResolvedField.java similarity index 100% rename from evaluation/src/main/java/jsheets/config/ResolvedField.java rename to common/src/main/java/jsheets/config/ResolvedField.java diff --git a/common/src/main/java/jsheets/event/EventSink.java b/common/src/main/java/jsheets/event/EventSink.java new file mode 100644 index 0000000..db420f3 --- /dev/null +++ b/common/src/main/java/jsheets/event/EventSink.java @@ -0,0 +1,24 @@ +package jsheets.event; + +import java.util.function.Supplier; + +public interface EventSink { + void post(Object event); + + /** + * Only creates the event to post if the sink is enabled. + *

+ * This method is preferred if creating an event is associated with some + * overhead. It only runs the {@code eventFactory}, if there is a chance that + * it is subscribed. + * + * @param eventFactory Lazily creates the event + */ + default void postIfEnabled(Supplier eventFactory) { + post(eventFactory.get()); + } + + static EventSink ignore() { + return event -> {}; + } +} diff --git a/common/src/main/java/jsheets/event/GuavaEventSink.java b/common/src/main/java/jsheets/event/GuavaEventSink.java new file mode 100644 index 0000000..8c4e5ae --- /dev/null +++ b/common/src/main/java/jsheets/event/GuavaEventSink.java @@ -0,0 +1,28 @@ +package jsheets.event; + +import java.util.Objects; + +import com.google.common.eventbus.EventBus; + +public final class GuavaEventSink implements EventSink { + public static EventSink forBus(EventBus bus) { + Objects.requireNonNull(bus, "bus"); + return new GuavaEventSink(bus); + } + + private final EventBus bus; + + private GuavaEventSink(EventBus bus) { + this.bus = bus; + } + + @Override + public void post(Object event) { + bus.post(event); + } + + @Override + public String toString() { + return "GuavaEventSink(bus=%s)".formatted(bus); + } +} \ No newline at end of file diff --git a/common/src/main/java/jsheets/event/LabeledEvent.java b/common/src/main/java/jsheets/event/LabeledEvent.java new file mode 100644 index 0000000..5a6a972 --- /dev/null +++ b/common/src/main/java/jsheets/event/LabeledEvent.java @@ -0,0 +1,7 @@ +package jsheets.event; + +import java.util.Map; + +public interface LabeledEvent { + Map labels(); +} diff --git a/evaluation/src/test/java/jsheets/config/CamelCaseTest.java b/common/src/test/java/jsheets/config/CamelCaseTest.java similarity index 100% rename from evaluation/src/test/java/jsheets/config/CamelCaseTest.java rename to common/src/test/java/jsheets/config/CamelCaseTest.java diff --git a/evaluation/src/test/java/jsheets/config/EnvironmentConfigTest.java b/common/src/test/java/jsheets/config/EnvironmentConfigTest.java similarity index 100% rename from evaluation/src/test/java/jsheets/config/EnvironmentConfigTest.java rename to common/src/test/java/jsheets/config/EnvironmentConfigTest.java diff --git a/deploy/minimal/README.md b/deploy/compose/README.md similarity index 100% rename from deploy/minimal/README.md rename to deploy/compose/README.md diff --git a/deploy/compose/deploy.sh b/deploy/compose/deploy.sh new file mode 100755 index 0000000..28a9eb7 --- /dev/null +++ b/deploy/compose/deploy.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +docker-compose -f monitoring.yml -f main.yml "$@" \ No newline at end of file diff --git a/deploy/minimal/docker-compose.yml b/deploy/compose/main.yml similarity index 76% rename from deploy/minimal/docker-compose.yml rename to deploy/compose/main.yml index 8a3119d..b4afe6c 100644 --- a/deploy/minimal/docker-compose.yml +++ b/deploy/compose/main.yml @@ -3,12 +3,19 @@ services: server: image: ehenoma/jsheets:latest restart: always + build: + context: ../../ + dockerfile: ./server/deploy/Dockerfile environment: JSHEETS_SERVER_PORT: 8080 - JSHEETS_MONGODB_URI: mongodb://root:root@document-store/jsheets + JSHEETS_MONGODB_URI: mongodb://root:root@document-store/jsheets?authSource=admin JSHEETS_ZOOKEEPER_CONNECTION_STRING: zookeeper:2181 + JSHEETS_SERVER_RATE_LIMIT_CAPACITY: 100 + JSHEETS_SERVER_RATE_LIMIT_REFILL_PER_SECOND: 50 ports: - "8080:8080" + depends_on: + - document-store networks: - document-store - zookeeper @@ -38,14 +45,19 @@ services: restart: always container_name: runtime hostname: runtime + build: + context: ../../ + dockerfile: ./runtime/deploy/Dockerfile environment: JSHEETS_RUNTIME_SERVER_PORT: 8080 JSHEETS_RUNTIME_ZOOKEEPER_CONNECTION_STRING: zookeeper:2181 JSHEETS_RUNTIME_SERVICE_ADVERTISED_HOST: runtime:8080 + depends_on: + - zookeeper networks: - zookeeper - runtime networks: document-store: zookeeper: - runtime: \ No newline at end of file + runtime: diff --git a/deploy/compose/monitoring.yml b/deploy/compose/monitoring.yml new file mode 100644 index 0000000..04154f7 --- /dev/null +++ b/deploy/compose/monitoring.yml @@ -0,0 +1,35 @@ +version: '3.7' +services: + influxdb: + image: influxdb:2.0 + hostname: influxdb + ports: + - '8086:8086' + volumes: + - influxdb-storage:/var/lib/influxdb + environment: + DOCKER_INFLUXDB_DB: jsheets + DOCKER_INFLUXDB_INIT_ORG: jsheets + DOCKER_INFLUXDB_INIT_BUCKET: jsheets + DOCKER_INFLUXDB_INIT_USERNAME: ${INFLUXDB_USERNAME} + DOCKER_INFLUXDB_INIT_PASSWORD: ${INFLUXDB_PASSWORD} + DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: ${INFLUXDB_TOKEN} + networks: + - influxdb + chronograf: + image: chronograf:latest + ports: + - '127.0.0.1:8888:8888' + volumes: + - chronograf-storage:/var/lib/chronograf + depends_on: + - influxdb + environment: + INFLUXDB_URL: http://influxdb:8086 + INFLUXDB_USERNAME: ${INFLUXDB_USERNAME} + INFLUXDB_PASSWORD: ${INFLUXDB_PASSWORD} +volumes: + influxdb-storage: + chronograf-storage: +networks: + influxdb: \ No newline at end of file diff --git a/evaluation/README.md b/evaluation/README.md new file mode 100644 index 0000000..446a35f --- /dev/null +++ b/evaluation/README.md @@ -0,0 +1,15 @@ +## Events +When building the `EvaluationEngine` you can pass an `EventSink`, it will then +post events type `EvaluationEvent`. Those events can be used to record +metrics and run custom code with pretty loose coupling. + +If no `EventSink` is configured, the engine will typically not attempt to +create any events, thus no overhead is produced. + +Following events are currently posted: + +| Name | Posted by | When | +|-------|-----------|--------| +| `EvaluationStartEvent` | `EvaluationEngine` | An evaluation begins +| `EvaluationStopEvent` | `EvaluationEngine` | An evaluation completes or fails +| `BoxLifecycleEvent` | `ForkedExecutionEnvironment` | A JVM is starting/ready/stopping | \ No newline at end of file diff --git a/evaluation/build.gradle b/evaluation/build.gradle index c17c1f7..2a5d6b2 100644 --- a/evaluation/build.gradle +++ b/evaluation/build.gradle @@ -18,12 +18,13 @@ ext { dependencies { implementation project(':protocol') + implementation project(':common') implementation "org.ow2.asm:asm:$asmVersion" implementation "com.google.protobuf:protobuf-java-util:$protobufJavaVersion" implementation "com.google.flogger:flogger:$floggerVersion" + testRuntimeOnly "com.google.flogger:flogger-slf4j-backend:$floggerVersion" testImplementation "org.junit.jupiter:junit-jupiter-api:$junitPlatformVersion" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitPlatformVersion" - testRuntimeOnly "com.google.flogger:flogger-slf4j-backend:$floggerVersion" compileOnly "io.soabase.record-builder:record-builder-core:$recordBuilderVersion" annotationProcessor "io.soabase.record-builder:record-builder-processor:$recordBuilderVersion" } diff --git a/evaluation/src/main/java/jsheets/evaluation/EvaluationEvent.java b/evaluation/src/main/java/jsheets/evaluation/EvaluationEvent.java new file mode 100644 index 0000000..f10a4ea --- /dev/null +++ b/evaluation/src/main/java/jsheets/evaluation/EvaluationEvent.java @@ -0,0 +1,11 @@ +package jsheets.evaluation; + +import jsheets.event.LabeledEvent; + +/** + * The evaluation engine produces events during preprocessing and evaluation + * of snippets that can be used to create metrics and trigger custom code. + */ +public interface EvaluationEvent extends LabeledEvent { + String snippetId(); +} diff --git a/evaluation/src/main/java/jsheets/evaluation/EvaluationStartEvent.java b/evaluation/src/main/java/jsheets/evaluation/EvaluationStartEvent.java new file mode 100644 index 0000000..52ab046 --- /dev/null +++ b/evaluation/src/main/java/jsheets/evaluation/EvaluationStartEvent.java @@ -0,0 +1,11 @@ +package jsheets.evaluation; + +import java.util.Map; + +import io.soabase.recordbuilder.core.RecordBuilder; + +@RecordBuilder +public record EvaluationStartEvent( + String snippetId, + Map labels +) implements EvaluationEvent {} \ No newline at end of file diff --git a/evaluation/src/main/java/jsheets/evaluation/EvaluationStopEvent.java b/evaluation/src/main/java/jsheets/evaluation/EvaluationStopEvent.java new file mode 100644 index 0000000..3ba4a3d --- /dev/null +++ b/evaluation/src/main/java/jsheets/evaluation/EvaluationStopEvent.java @@ -0,0 +1,34 @@ +package jsheets.evaluation; + +import java.time.Duration; +import java.util.Map; + +import io.soabase.recordbuilder.core.RecordBuilder; + +@RecordBuilder +public record EvaluationStopEvent( + String snippetId, + Duration duration, + Status status, + Map labels +) implements EvaluationEvent { + + public boolean hasFailed() { + return status.equals(Status.Failed); + } + + public enum Status { + /** + * The evaluation was successful, there were no errors in the user code. + */ + CompletedSuccessfully, + /** + * The evaluation could be run but there were errors in the user code. + */ + CompletedWithErrors, + /** + * The evaluation failed, this must not indicate that user code is invalid. + */ + Failed + } +} \ No newline at end of file diff --git a/evaluation/src/main/java/jsheets/evaluation/sandbox/ViolationEvent.java b/evaluation/src/main/java/jsheets/evaluation/sandbox/ViolationEvent.java new file mode 100644 index 0000000..d7913c9 --- /dev/null +++ b/evaluation/src/main/java/jsheets/evaluation/sandbox/ViolationEvent.java @@ -0,0 +1,16 @@ +package jsheets.evaluation.sandbox; + +import java.util.Collection; +import java.util.Map; + +import io.soabase.recordbuilder.core.RecordBuilder; +import jsheets.evaluation.EvaluationEvent; +import jsheets.evaluation.sandbox.validation.Analysis; + +@RecordBuilder +public record ViolationEvent( + String snippetId, + String componentId, + Collection violations, + Map labels +) implements EvaluationEvent {} diff --git a/evaluation/src/main/java/jsheets/evaluation/shell/ShellEvaluation.java b/evaluation/src/main/java/jsheets/evaluation/shell/ShellEvaluation.java index c27436d..37d6fc9 100644 --- a/evaluation/src/main/java/jsheets/evaluation/shell/ShellEvaluation.java +++ b/evaluation/src/main/java/jsheets/evaluation/shell/ShellEvaluation.java @@ -1,12 +1,10 @@ package jsheets.evaluation.shell; import java.time.Clock; -import java.util.Comparator; -import java.util.Locale; -import java.util.Map; -import java.util.UUID; +import java.time.Duration; +import java.time.Instant; +import java.util.*; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; import jdk.jshell.JShell; import jdk.jshell.SnippetEvent; @@ -21,42 +19,93 @@ import jsheets.SnippetSources; import jsheets.StartEvaluationRequest; import jsheets.evaluation.Evaluation; +import jsheets.evaluation.EvaluationStartEventBuilder; +import jsheets.evaluation.EvaluationStopEvent; +import jsheets.evaluation.EvaluationStopEventBuilder; import jsheets.evaluation.failure.FailedEvaluation; import jsheets.evaluation.shell.environment.ExecutionEnvironment; import jsheets.evaluation.shell.execution.ExecutionMethod; +import jsheets.event.EventSink; final class ShellEvaluation implements Evaluation { - private enum Stage { Initial, Starting, Evaluating, Terminated } - private static final FluentLogger log = FluentLogger.forEnclosingClass(); private volatile JShell shell; private volatile ExecutionMethod executionMethod; + private volatile Instant startTime; private final Evaluation.Listener listener; private final ExecutionEnvironment environment; private final ExecutionMethod.Factory executionMethodFactory; private final MessageOutput messageOutput; - private final AtomicReference stage = new AtomicReference<>(Stage.Initial); + private final EventSink events; + private final Clock clock; + private final Collection builtinImports; + /* Is updated whenever errors occur, otherwise stays successful */ + private volatile EvaluationStopEvent.Status stopStatus = + EvaluationStopEvent.Status.CompletedSuccessfully; ShellEvaluation( + Clock clock, ExecutionEnvironment environment, ExecutionMethod.Factory executionMethodFactory, Evaluation.Listener listener, - MessageOutput messageOutput + EventSink events, + MessageOutput messageOutput, + Collection builtinImports ) { + this.clock = clock; this.listener = listener; this.messageOutput = messageOutput; this.executionMethodFactory = executionMethodFactory; this.environment = environment; + this.events = events; + this.builtinImports = builtinImports; } public void start(StartEvaluationRequest request) { shell = createShell(); + setupInitialShellState(shell); executionMethod = executionMethodFactory.create(shell); + startTime = clock.instant(); messageOutput.open(); - listener.send(evaluateSources(request)); - listener.close(); - cleanUp(); + var snippetId = request.getSnippet().getReference().getSnippetId(); + postStartEvent(snippetId); + try { + listener.send(evaluateSources(request)); + listener.close(); + } finally { + cleanUp(); + postStopEvent(snippetId); + } + } + + private void postStartEvent(String snippetId) { + events.postIfEnabled(() -> EvaluationStartEventBuilder.builder() + .snippetId(snippetId) + .labels(createEventLabels()) + .build() + ); + } + + private void postStopEvent(String snippetId) { + events.postIfEnabled(() -> { + var duration = startTime == null + ? Duration.ZERO + : Duration.between(startTime, clock.instant()); + return EvaluationStopEventBuilder.builder() + .snippetId(snippetId) + .labels(createEventLabels()) + .status(stopStatus) + .duration(duration) + .build(); + }); + } + + private Map createEventLabels() { + return Map.of( + "executionEnvironment", environment.getClass().getName(), + "startTime", startTime == null ? "-1" : startTime.toString() + ); } private EvaluateResponse evaluateSources(StartEvaluationRequest request) { @@ -75,8 +124,8 @@ private void evaluateComponent( ) { messageOutput.updateCurrentComponentId(component.getId()); try { - for (var snippet : executionMethod.execute(component.getCode()) ) { - reportEvent(component.getId(), snippet, response); + for (var snippet : executionMethod.execute(component.getCode())) { + reportSnippetEvent(component.getId(), snippet, response); } messageOutput.flush(); } catch (Throwable failedEvaluation) { @@ -89,6 +138,7 @@ private void reportError( EvaluateResponse.Builder response, Throwable failure ) { + updateStopStatus(EvaluationStopEvent.Status.Failed); FailedEvaluation.capture(failure).ifPresentOrElse( value -> reportFailedEvaluation(component, response, value), () -> reportInternalFailure(component, response, failure) @@ -122,7 +172,7 @@ private void reportInternalFailure( ); } - private void reportEvent( + private void reportSnippetEvent( String componentId, SnippetEvent event, EvaluateResponse.Builder response @@ -140,29 +190,42 @@ private void reportEvent( .build() ); } - case REJECTED -> reportFailedEvent(componentId, event, response); + case REJECTED -> reportFailure(componentId, event, response); } } - private void reportFailedEvent( + private void reportFailure( String componentId, SnippetEvent event, EvaluateResponse.Builder response ) { - shell.diagnostics(event.snippet()).forEach(diagnostic -> - response.addError( - EvaluationError.newBuilder() - .setComponentId(componentId) - .setKind(diagnostic.getCode()) - .setMessage(diagnostic.getMessage(Locale.ENGLISH)) - .setSpan( - CodeSpan.newBuilder() - .setStart(diagnostic.getStartPosition()) - .setEnd(diagnostic.getEndPosition()) - .build() - ).build() - ) - ); + var diagnostics = shell.diagnostics(event.snippet()).toList(); + if (!diagnostics.isEmpty()) { + updateStopStatus(EvaluationStopEvent.Status.CompletedWithErrors); + } + for (var diagnostic : diagnostics) { + response.addError(EvaluationError.newBuilder() + .setComponentId(componentId) + .setKind(diagnostic.getCode()) + .setMessage(diagnostic.getMessage(Locale.ENGLISH)) + .setSpan(CodeSpan.newBuilder() + .setStart(diagnostic.getStartPosition()) + .setEnd(diagnostic.getEndPosition()) + .build()) + .build()); + } + } + + /* Once the stop status is *Failed* it may not change to something else */ + private void updateStopStatus(EvaluationStopEvent.Status target) { + switch (target) { + case CompletedWithErrors -> { + if (stopStatus.equals(EvaluationStopEvent.Status.CompletedSuccessfully)) { + stopStatus = target; + } + } + case Failed -> stopStatus = target; + } } private JShell createShell() { @@ -174,6 +237,20 @@ private JShell createShell() { .build(); } + private void setupInitialShellState(JShell shell) { + for (var packagePath : builtinImports) { + var events = shell.eval("import %s;".formatted(packagePath)); + for (var event : events) { + if (!event.status().isDefined()) { + throw new IllegalStateException( + "failed to import %s".formatted(packagePath), + event.exception() + ); + } + } + } + } + @Override public void stop() { cleanUp(); @@ -188,7 +265,7 @@ public String toString() { return MoreObjects.toStringHelper(this) .add("shell", shell) .add("environment", environment) - .add("stage", stage) + .add("eventSink", events) .toString(); } } diff --git a/evaluation/src/main/java/jsheets/evaluation/shell/ShellEvaluationEngine.java b/evaluation/src/main/java/jsheets/evaluation/shell/ShellEvaluationEngine.java index 77da92c..3aea9fd 100644 --- a/evaluation/src/main/java/jsheets/evaluation/shell/ShellEvaluationEngine.java +++ b/evaluation/src/main/java/jsheets/evaluation/shell/ShellEvaluationEngine.java @@ -2,7 +2,10 @@ import com.google.common.util.concurrent.ThreadFactoryBuilder; +import java.time.Clock; import java.time.Duration; +import java.util.Collection; +import java.util.List; import java.util.Objects; import java.util.concurrent.Executor; import java.util.concurrent.Executors; @@ -15,6 +18,7 @@ import jsheets.evaluation.shell.environment.StandardEnvironment; import jsheets.evaluation.shell.execution.ExecutionMethod; import jsheets.evaluation.shell.execution.SystemBasedExecutionMethodFactory; +import jsheets.event.EventSink; public final class ShellEvaluationEngine implements EvaluationEngine { private final Executor workerPool; @@ -22,19 +26,28 @@ public final class ShellEvaluationEngine implements EvaluationEngine { private final ExecutionEnvironment executionEnvironment; private final ExecutionMethod.Factory executionMethodFactory; private final Duration messageFlushInterval; + private final EventSink events; + private final Clock clock; + private final Collection builtinImports; private ShellEvaluationEngine( + Clock clock, Executor workerPool, ScheduledExecutorService scheduler, ExecutionEnvironment executionEnvironment, ExecutionMethod.Factory executionMethodFactory, - Duration messageFlushInterval + Duration messageFlushInterval, + EventSink events, + Collection builtinImports ) { + this.clock = clock; this.workerPool = workerPool; this.scheduler = scheduler; this.executionEnvironment = executionEnvironment; this.messageFlushInterval = messageFlushInterval; this.executionMethodFactory = executionMethodFactory; + this.events = events; + this.builtinImports = builtinImports; } @Override @@ -49,14 +62,17 @@ public Evaluation start( private ShellEvaluation createEvaluation(Evaluation.Listener listener) { return new ShellEvaluation( + clock, executionEnvironment, executionMethodFactory, listener, + events, new MessageOutput( messageFlushInterval, scheduler, listener - ) + ), + builtinImports ); } @@ -70,6 +86,9 @@ public static final class Builder { private Duration messageFlushInterval; private ScheduledExecutorService scheduler; private ExecutionMethod.Factory executionMethodFactory; + private EventSink events; + private Clock clock; + private Collection builtinImports; public Builder useWorkerPool(Executor pool) { Objects.requireNonNull(pool, "workerPool"); @@ -77,6 +96,18 @@ public Builder useWorkerPool(Executor pool) { return this; } + public Builder withClock(Clock clock) { + Objects.requireNonNull(clock, "clock"); + this.clock = clock; + return this; + } + + public Builder withEventSink(EventSink sink) { + Objects.requireNonNull(sink, "sink"); + events = sink; + return this; + } + public Builder useEnvironment(ExecutionEnvironment environment) { Objects.requireNonNull(environment, "environment"); this.environment = environment; @@ -89,6 +120,12 @@ public Builder useExecutionMethodFactory(ExecutionMethod.Factory factory) { return this; } + public Builder useBuiltinImports(Collection builtinImports) { + Objects.requireNonNull(builtinImports, "builtinImports"); + this.builtinImports = builtinImports; + return this; + } + public Builder useScheduler(ScheduledExecutorService scheduler) { Objects.requireNonNull(scheduler, "scheduler"); this.scheduler = scheduler; @@ -103,14 +140,25 @@ public Builder withMessageFlushInterval(Duration messageFlushInterval) { public EvaluationEngine create() { return new ShellEvaluationEngine( + selectClock(), selectWorkerPool(), selectScheduler(), selectEnvironment(), selectExecutionMethodFactory(), - selectMessageFlushInterval() + selectMessageFlushInterval(), + selectEventSink(), + selectBuiltinImports() ); } + private Clock selectClock() { + return clock == null ? Clock.systemUTC() : clock; + } + + private EventSink selectEventSink() { + return events == null ? EventSink.ignore() : events; + } + private ExecutionMethod.Factory selectExecutionMethodFactory() { return executionMethodFactory == null ? SystemBasedExecutionMethodFactory.create() @@ -137,6 +185,10 @@ private Executor selectWorkerPool() { return workerPool == null ? createDefaultWorkerPool() : workerPool; } + private Collection selectBuiltinImports() { + return builtinImports == null ? List.of() : builtinImports; + } + private Executor createDefaultWorkerPool() { return Executors.newCachedThreadPool( new ThreadFactoryBuilder() diff --git a/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/BoxLifecycleEvent.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/BoxLifecycleEvent.java new file mode 100644 index 0000000..6ebecf8 --- /dev/null +++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/BoxLifecycleEvent.java @@ -0,0 +1,16 @@ +package jsheets.evaluation.shell.environment.fork; + +import java.util.Map; + +import io.soabase.recordbuilder.core.RecordBuilder; +import jsheets.event.LabeledEvent; + +@RecordBuilder +public record BoxLifecycleEvent( + long processId, + Stage stage, + Map labels +) implements LabeledEvent { + + public enum Stage { Starting, Running, Stopping } +} diff --git a/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkedExecutionControl.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkedExecutionControl.java index 84ed0ec..308dd7d 100644 --- a/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkedExecutionControl.java +++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkedExecutionControl.java @@ -107,7 +107,6 @@ public void stop() throws EngineTerminationException, InternalException { } } - @Override public void close() { super.close(); diff --git a/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkedExecutionEnvironment.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkedExecutionEnvironment.java index 2b73b00..15203d9 100644 --- a/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkedExecutionEnvironment.java +++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkedExecutionEnvironment.java @@ -10,18 +10,21 @@ import jdk.jshell.spi.ExecutionControlProvider; import jsheets.evaluation.shell.environment.ClassFileStore; import jsheets.evaluation.shell.environment.ExecutionEnvironment; +import jsheets.event.EventSink; public final class ForkedExecutionEnvironment implements ExecutionEnvironment { public static ForkedExecutionEnvironment create( ClassFileStore store, - Collection virtualMachineOptions + Collection virtualMachineOptions, + EventSink events ) { Objects.requireNonNull(store, "store"); Objects.requireNonNull(virtualMachineOptions, "virtualMachineOptions"); return new ForkedExecutionEnvironment( store, virtualMachineOptions, - createDaemonScheduler() + createDaemonScheduler(), + events ); } @@ -33,15 +36,18 @@ private static ScheduledExecutorService createDaemonScheduler() { private final ClassFileStore store; private final Collection virtualMachineOptions; private final ScheduledExecutorService scheduler; + private final EventSink events; private ForkedExecutionEnvironment( ClassFileStore store, Collection virtualMachineOptions, - ScheduledExecutorService scheduler + ScheduledExecutorService scheduler, + EventSink events ) { this.store = store; this.virtualMachineOptions = virtualMachineOptions; this.scheduler = scheduler; + this.events = events; } @Override @@ -49,7 +55,8 @@ public ExecutionControlProvider control(String name) { return ForkingExecutionControlProvider.create( virtualMachineOptions, store, - scheduler + scheduler, + events ); } diff --git a/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkingExecutionControlProvider.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkingExecutionControlProvider.java index b2ca932..b02e838 100644 --- a/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkingExecutionControlProvider.java +++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkingExecutionControlProvider.java @@ -32,6 +32,7 @@ import jdk.jshell.spi.ExecutionEnv; import jsheets.evaluation.shell.environment.ClassFileStore; import jsheets.evaluation.shell.environment.EmptyClassFileStore; +import jsheets.event.EventSink; import static jdk.jshell.execution.Util.remoteInputOutput; @@ -47,7 +48,8 @@ public static ForkingExecutionControlProvider create() { Executors.newScheduledThreadPool( 1, new ThreadFactoryBuilder().setDaemon(true).build() - ) + ), + EventSink.ignore() ); } @@ -58,7 +60,8 @@ public static ForkingExecutionControlProvider create() { public static ForkingExecutionControlProvider create( Collection rawVirtualMachineOptions, ClassFileStore classFileStore, - ScheduledExecutorService scheduler + ScheduledExecutorService scheduler, + EventSink events ) { Objects.requireNonNull(classFileStore, "classFileStore"); Objects.requireNonNull(rawVirtualMachineOptions, "rawVirtualMachineOptions"); @@ -67,7 +70,8 @@ public static ForkingExecutionControlProvider create( defaultTimeout, List.copyOf(rawVirtualMachineOptions), classFileStore, - scheduler + scheduler, + events ); } @@ -76,19 +80,22 @@ public static ForkingExecutionControlProvider create( private final List rawVirtualMachineOptions; private final ScheduledExecutorService scheduler; private final ClassFileStore classFileStore; + private final EventSink events; private ForkingExecutionControlProvider( Duration executionTimeout, Duration connectTimeout, List rawVirtualMachineOptions, ClassFileStore classFileStore, - ScheduledExecutorService scheduler + ScheduledExecutorService scheduler, + EventSink events ) { this.executionTimeout = executionTimeout; this.connectTimeout = connectTimeout; this.rawVirtualMachineOptions = rawVirtualMachineOptions; this.classFileStore = classFileStore; this.scheduler = scheduler; + this.events = events; } @Override @@ -124,10 +131,25 @@ private Box initiate(int port) { /* timeout */ (int) connectTimeout.toMillis(), /* connectorOptions*/ Collections.emptyMap() ); - return new Box( - initiator.vm(), - initiator.process() - ); + var box = new Box(initiator.vm(), initiator.process()); + postLifecycleEvent(box, BoxLifecycleEvent.Stage.Starting); + return box; + } + + private void postLifecycleEvent(Box box, BoxLifecycleEvent.Stage stage) { + events.postIfEnabled(() -> { + var labels = Map.of( + "executionTimeout", executionTimeout.toMillis(), + "connectTimeout", connectTimeout.toMillis(), + "remoteAgentClassName", remoteAgentClassName, + "virtualMachineOptions", String.join(" ", rawVirtualMachineOptions) + ); + return BoxLifecycleEventBuilder.builder() + .processId(box.process.pid()) + .stage(stage) + .labels(labels) + .build(); + }); } private static final int backlog = 1; @@ -141,6 +163,7 @@ ExecutionControl create(ExecutionEnv environment) throws IOException { } } + private ExecutionControl accept( ServerSocket listener, ExecutionEnv environment, @@ -182,8 +205,10 @@ private BiFunction createControl( remoteAgentClassName, classFileStore ); + postLifecycleEvent(box, BoxLifecycleEvent.Stage.Running); hooks.add(event -> environment.closeDown()); hooks.add(event -> control.disposeMachine()); + hooks.add(event -> postLifecycleEvent(box, BoxLifecycleEvent.Stage.Stopping)); scheduleExecutionTimeout(control); return control; }; diff --git a/evaluation/src/test/java/jsheets/evaluation/shell/ShellEvaluationEngineTest.java b/evaluation/src/test/java/jsheets/evaluation/shell/ShellEvaluationEngineTest.java index c26d359..d88e7f2 100644 --- a/evaluation/src/test/java/jsheets/evaluation/shell/ShellEvaluationEngineTest.java +++ b/evaluation/src/test/java/jsheets/evaluation/shell/ShellEvaluationEngineTest.java @@ -1,5 +1,7 @@ package jsheets.evaluation.shell; +import java.nio.file.Path; +import java.util.List; import java.util.UUID; import jsheets.EvaluateResponse; @@ -7,6 +9,7 @@ import jsheets.Snippet; import jsheets.SnippetSources; import jsheets.StartEvaluationRequest; +import jsheets.config.FileConfigSource; import jsheets.evaluation.Evaluation; import jsheets.evaluation.shell.environment.StandardEnvironment; import org.junit.jupiter.api.Test; @@ -28,6 +31,7 @@ public void testExecution() { var engine = ShellEvaluationEngine.newBuilder() .useEnvironment(environment) .useWorkerPool(Runnable::run) + .useBuiltinImports(List.of("java.util.*")) .create(); var request = StartEvaluationRequest.newBuilder() .setSnippet( @@ -57,7 +61,7 @@ public void testExecution() { .addCodeComponents(code("6", "new Test().toString()")) .addCodeComponents(code("7", """ System.out.println("Hello, World!"); - System.out.println("Hello, World!"); + System.out.println(List.of()); """)) .build() ).build(); diff --git a/evaluation/src/test/resources/defaultImports.txt b/evaluation/src/test/resources/defaultImports.txt new file mode 100644 index 0000000..e4863c9 --- /dev/null +++ b/evaluation/src/test/resources/defaultImports.txt @@ -0,0 +1 @@ +java.util.* \ No newline at end of file diff --git a/runtime/README.md b/runtime/README.md index f6f4faa..248cfa0 100644 --- a/runtime/README.md +++ b/runtime/README.md @@ -60,6 +60,14 @@ thus `SERVER_PORT` has to be specified as `JSHEETS_RUNTIME_SERVER_PORT`. | evaluation.sandbox.disable | `EVALUATION_SANDBOX_DISABLE` | `false` | Disables the sandbox for code execution **dangerous** | | zookeeper.connectionString | `ZOOKEEPER_CONNECTION_STRING` | none | Connection string to zookeeper | | zookeeper.connectBackoff | `ZOOKEEPER_CONNECT_BACKOFF` | `1000` | Initial backoff after failed zookeeper connection | +| monitoring.backend | `MONITORING_BACKEND` | none | Backend used for monitoring. If no backend is configured, monitoring is disabled | +| monitoring.influx.userName | `MONITORING_INFLUX_USER_NAME` | none | Influx user name | +| monitoring.influx.password | `MONITORING_INFLUX_PASSWORD` | none | Influx password | +| monitoring.influx.db | `MONITORING_INFLUX_DB` | `jsheets` | Name of the influx database | +| monitoring.influx.uri | `MONITORING_INFLUX_URI` | `http://localhost:8086` | URI of the influx service | +| monitoring.influx.org | `MONITORING_INFLUX_ORG` | none | Influx org | +| monitoring.influx.bucket | `MONITORING_INFLUX_BUCKET ` | `jsheets` | Influx bucket | +| monitoring.influx.step | `MONITORING_INFLUX_STEP` | `10` | Influx reporting interval in seconds | ### Sandboxing The JVM itself is a sufficient sandbox, if we restrict the methods @@ -156,6 +164,18 @@ It is important to keep the evaluations per instance fairly low to reduce the amount of evaluations that are affected by crashes and lower usage of system resources (such as processors and memory). +### Monitoring +Monitoring can be enabled by specifying a monitoring backend. +Currently, only `influx` is supported. + +Set the `JSHEETS_RUNTIME_MONITORING_BACKEND=influx` and configure influx +credentials using +```dotenv +JSHEETS_RUNTIME_MONITORING_INFLUX_USER_NAME=${YOUR_USER_NAME} +JSHEETS_RUNTIME_MONITORING_INFLUX_PASSWORD=${YOUR_PASSWORD} +JSHEETS_RUNTIME_MONITORING_INFLUX_BUCKET=${YOUR_BUCKET} +``` + ### Handling Crashes If the *runtime* crashes, it is taken out of the service discovery and will not diff --git a/runtime/build.gradle b/runtime/build.gradle index b9b1603..0c05fd7 100644 --- a/runtime/build.gradle +++ b/runtime/build.gradle @@ -25,8 +25,10 @@ ext { dependencies { implementation project(':protocol') implementation project(':evaluation') + implementation project(':common') implementation "org.apache.curator:curator-x-discovery:$curatorVersion" implementation "io.micrometer:micrometer-core:$micrometerVersion" + implementation "io.micrometer:micrometer-registry-influx:$micrometerVersion" implementation "org.mongodb:mongodb-driver-sync:$mongoDbDriverVersion" implementation "org.slf4j:slf4j-simple:$slf4jVersion" implementation "io.grpc:grpc-all:$grpcVersion" diff --git a/runtime/src/main/java/jsheets/runtime/App.java b/runtime/src/main/java/jsheets/runtime/App.java index 07f3c87..a063b85 100644 --- a/runtime/src/main/java/jsheets/runtime/App.java +++ b/runtime/src/main/java/jsheets/runtime/App.java @@ -16,6 +16,7 @@ import jsheets.evaluation.shell.environment.fork.ForkingExecutionControlProvider; import jsheets.evaluation.shell.environment.sandbox.SandboxClassFileCheck; import jsheets.runtime.evaluation.EvaluationModule; +import jsheets.runtime.monitoring.MonitoringModule; public final class App { private static final FluentLogger log = FluentLogger.forEnclosingClass(); @@ -48,7 +49,8 @@ private static Injector configureInjector() { ServerSetupModule.create(), ConfigModule.create(), ZookeeperModule.create(), - EvaluationModule.create() + EvaluationModule.create(), + MonitoringModule.create() ); } diff --git a/runtime/src/main/java/jsheets/runtime/ConfigModule.java b/runtime/src/main/java/jsheets/runtime/ConfigModule.java index 031cd14..59635ac 100644 --- a/runtime/src/main/java/jsheets/runtime/ConfigModule.java +++ b/runtime/src/main/java/jsheets/runtime/ConfigModule.java @@ -1,6 +1,8 @@ package jsheets.runtime; import java.util.ArrayList; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; import com.google.inject.AbstractModule; import com.google.inject.Provides; @@ -26,7 +28,7 @@ private ConfigModule() {} Config createConfig(@Named("environment") Config environment) { var configs = new ArrayList(); configs.add(environment); - configs.add(EvaluationConfigSource.fromClassPath().load()); + configs.add(EvaluationConfigSource.create().load()); return CombinedConfig.of(configs.toArray(Config[]::new)); } @@ -36,4 +38,10 @@ Config createConfig(@Named("environment") Config environment) { Config environmentConfig() { return EnvironmentConfig.prefixed(environmentPrefix).load(); } + + @Provides + @Singleton + Executor executor() { + return Executors.newCachedThreadPool(); + } } \ No newline at end of file diff --git a/runtime/src/main/java/jsheets/runtime/evaluation/EvaluationConfigSource.java b/runtime/src/main/java/jsheets/runtime/evaluation/EvaluationConfigSource.java index abf54d4..6d98c12 100644 --- a/runtime/src/main/java/jsheets/runtime/evaluation/EvaluationConfigSource.java +++ b/runtime/src/main/java/jsheets/runtime/evaluation/EvaluationConfigSource.java @@ -1,25 +1,19 @@ package jsheets.runtime.evaluation; -import java.io.BufferedInputStream; -import java.io.IOException; -import java.util.Collection; -import java.util.List; -import java.util.Optional; +import java.nio.file.Path; -import com.google.common.flogger.FluentLogger; - -import com.google.inject.Provides; - -import javax.inject.Named; +import com.google.api.client.util.Strings; import jsheets.config.Config; -import jsheets.config.RawConfig; -import jsheets.runtime.ServerSetup; +import jsheets.config.Configs; +import jsheets.config.FileConfigSource; /** * Reads the {@code AccessGraph} configuration from the classpath. */ public final class EvaluationConfigSource implements Config.Source { - private static final FluentLogger log = FluentLogger.forEnclosingClass(); + public static EvaluationConfigSource create() { + return new EvaluationConfigSource(); + } private EvaluationConfigSource() {} @@ -40,12 +34,12 @@ public static Config.Key disableSandboxKey() { private static final Config.Key defaultImportsKey = Config.Key.ofString("evaluation.defaultImports"); - public static Config.Key accessGraphKey() { - return accessGraphKey; + public static Config.Key defaultImportsKey() { + return defaultImportsKey; } - public static EvaluationConfigSource fromClassPath() { - return new EvaluationConfigSource(); + public static Config.Key accessGraphKey() { + return accessGraphKey; } private static final Config.Key virtualMachineOptionsKey = @@ -55,44 +49,35 @@ static Config.Key virtualMachineOptionsKey() { return virtualMachineOptionsKey; } - private static final String accessGraphFilePath = - "runtime/evaluation/sandbox/accessGraph.txt"; - - private static final String virtualMachineOptionsFilePath = - "runtime/evaluation/fork/virtualMachineOptions.txt"; - - private static final String defaultImportsFilePath = - "runtime/evaluation/defaultImportsKey.txt"; - @Override public Config load() { - var config = RawConfig.newBuilder(); - readFullFile(accessGraphFilePath).ifPresent(value -> - config.with(accessGraphKey, value) + var directory = determineConfigPath(); + return Configs.loadAll( + new FileConfigSource( + accessGraphKey, + Path.of("runtime/evaluation/sandbox/accessGraph.txt"), + directory + ), + new FileConfigSource( + virtualMachineOptionsKey, + Path.of("runtime/evaluation/fork/virtualMachineOptions.txt"), + directory + ), + new FileConfigSource( + defaultImportsKey, + Path.of("runtime/evaluation/defaultImports.txt"), + directory + ) ); - readFullFile(virtualMachineOptionsFilePath).ifPresent(value -> - config.with(virtualMachineOptionsKey, value) - ); - readFullFile(defaultImportsFilePath).ifPresent(value -> - config.with(defaultImportsKey, value) - ); - return config.create(); } - private static Optional readFullFile(String path) { - var resources = Thread.currentThread().getContextClassLoader(); - var file = resources.getResourceAsStream(path); - if (file == null) { - log.atConfig().log("could not find %s in classpath", path); - return Optional.empty(); - } - try (var input = new BufferedInputStream(file)) { - return Optional.of(new String(input.readAllBytes())); - } catch (IOException failedRead) { - log.atWarning() - .withCause(failedRead) - .log("failed to read %s", path); - } - return Optional.empty(); + private static final String configPathOverrideField = + "JSHEETS_RUNTIME_CONFIG_PATH"; + + private static Path determineConfigPath() { + var specialPath = System.getenv(configPathOverrideField); + return Strings.isNullOrEmpty(specialPath) + ? Path.of(System.getProperty("user.dir")) + : Path.of(specialPath); } } \ No newline at end of file diff --git a/runtime/src/main/java/jsheets/runtime/evaluation/EvaluationModule.java b/runtime/src/main/java/jsheets/runtime/evaluation/EvaluationModule.java index cff23f4..44b6ae9 100644 --- a/runtime/src/main/java/jsheets/runtime/evaluation/EvaluationModule.java +++ b/runtime/src/main/java/jsheets/runtime/evaluation/EvaluationModule.java @@ -4,7 +4,6 @@ import com.google.inject.Provides; import com.google.inject.Singleton; -import javax.inject.Named; import jsheets.evaluation.EvaluationEngine; import jsheets.evaluation.sandbox.access.AccessGraph; import jsheets.evaluation.sandbox.validation.ForbiddenMemberFilter; @@ -12,37 +11,51 @@ import jsheets.evaluation.shell.environment.ExecutionEnvironment; import jsheets.evaluation.shell.environment.fork.ForkedExecutionEnvironment; import jsheets.evaluation.shell.environment.sandbox.SandboxClassFileCheck; -import jsheets.evaluation.shell.environment.sandbox.SandboxedEnvironment; import jsheets.evaluation.shell.environment.StandardEnvironment; import jsheets.config.Config; import jsheets.evaluation.shell.execution.SystemBasedExecutionMethodFactory; +import jsheets.event.EventSink; import java.util.Collection; import java.util.List; -import static jsheets.runtime.evaluation.EvaluationConfigSource.accessGraphKey; -import static jsheets.runtime.evaluation.EvaluationConfigSource.disableSandboxKey; -import static jsheets.runtime.evaluation.EvaluationConfigSource.virtualMachineOptionsKey; +import static jsheets.runtime.evaluation.EvaluationConfigSource.*; public final class EvaluationModule extends AbstractModule { public static EvaluationModule create() { return new EvaluationModule(); } - private EvaluationModule() {} + private EvaluationModule() { + } + + private static final String fallbackDefaultImports = """ + java.lang.* + java.math.* + java.time.* + java.text.* + java.util.* + java.util.function.* + java.util.stream.* + """; @Provides @Singleton - EvaluationEngine evaluationEngine(ExecutionEnvironment environment) { + EvaluationEngine evaluationEngine(Config config, ExecutionEnvironment environment) { + var builtinImports = defaultImportsKey().in(config) + .or(fallbackDefaultImports) + .lines() + .toList(); return ShellEvaluationEngine.newBuilder() .useEnvironment(environment) .useExecutionMethodFactory(SystemBasedExecutionMethodFactory.create()) + .useBuiltinImports(builtinImports) .create(); } @Provides @Singleton - ExecutionEnvironment executionEnvironment(Config config) { + ExecutionEnvironment executionEnvironment(Config config, EventSink events) { boolean disableSandbox = disableSandboxKey().in(config).orNone().orElse(false); if (disableSandbox) { @@ -54,7 +67,8 @@ ExecutionEnvironment executionEnvironment(Config config) { SandboxClassFileCheck.of( List.of(ForbiddenMemberFilter.create(accessGraph)) ), - listVirtualMachineOptions(config) + listVirtualMachineOptions(config), + events ); } diff --git a/runtime/src/main/java/jsheets/runtime/monitoring/EvaluationEngineMonitoring.java b/runtime/src/main/java/jsheets/runtime/monitoring/EvaluationEngineMonitoring.java new file mode 100644 index 0000000..a0fa574 --- /dev/null +++ b/runtime/src/main/java/jsheets/runtime/monitoring/EvaluationEngineMonitoring.java @@ -0,0 +1,75 @@ +package jsheets.runtime.monitoring; + +import java.util.concurrent.atomic.AtomicInteger; + +import com.google.common.eventbus.Subscribe; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import jsheets.evaluation.EvaluationStartEvent; +import jsheets.evaluation.EvaluationStopEvent; + +final class EvaluationEngineMonitoring { + static EvaluationEngineMonitoring register(MeterRegistry registry) { + return new EvaluationEngineMonitoring( + registry.gauge( + "jsheets.runtime.evaluation.activeEvaluations", + new AtomicInteger(0) + ), + Counter.builder("jsheets.runtime.evaluation.successfulEvaluations") + .description("Count of evaluations that completed without errors") + .register(registry), + Counter.builder("jsheets.runtime.evaluation.erroneousEvaluations") + .description("Count of evaluations that completed with errors") + .register(registry), + Counter.builder("jsheets.runtime.evaluation.failedEvaluations") + .description("Count of evaluations that failed internally") + .register(registry), + Timer.builder("jsheets.runtime.evaluation.duration") + .description("Average duration of evaluations") + .register(registry) + ); + } + + private final AtomicInteger activeEvaluations; + private final Counter successfulEvaluations; + private final Counter failedEvaluations; + private final Counter erroneousEvaluations; + private final Timer evaluationDuration; + + private EvaluationEngineMonitoring( + AtomicInteger activeEvaluations, + Counter successfulEvaluations, + Counter erroneousEvaluations, + Counter failedEvaluations, + Timer evaluationDuration + ) { + this.activeEvaluations = activeEvaluations; + this.successfulEvaluations = successfulEvaluations; + this.failedEvaluations = failedEvaluations; + this.erroneousEvaluations = erroneousEvaluations; + this.evaluationDuration = evaluationDuration; + } + + @Subscribe + public void recordStart(EvaluationStartEvent start) { + activeEvaluations.incrementAndGet(); + } + + @Subscribe + public void recordStop(EvaluationStopEvent stop) { + activeEvaluations.decrementAndGet(); + if (!stop.hasFailed()) { + // We should not record the time of failed evaluations, failure indicates + // that it never started and will thus only contain the time it took to + // perform preprocessing and startup till a failure occurred. + evaluationDuration.record(stop.duration()); + } + switch (stop.status()) { + case Failed -> failedEvaluations.increment(); + case CompletedSuccessfully -> successfulEvaluations.increment(); + case CompletedWithErrors -> erroneousEvaluations.increment(); + } + } +} \ No newline at end of file diff --git a/runtime/src/main/java/jsheets/runtime/monitoring/ForkEnvironmentMonitoring.java b/runtime/src/main/java/jsheets/runtime/monitoring/ForkEnvironmentMonitoring.java new file mode 100644 index 0000000..ee3f29f --- /dev/null +++ b/runtime/src/main/java/jsheets/runtime/monitoring/ForkEnvironmentMonitoring.java @@ -0,0 +1,51 @@ +package jsheets.runtime.monitoring; + +import java.util.concurrent.atomic.AtomicInteger; + +import com.google.common.eventbus.Subscribe; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import jsheets.evaluation.shell.environment.fork.BoxLifecycleEvent; + +final class ForkEnvironmentMonitoring { + static ForkEnvironmentMonitoring register(MeterRegistry metrics) { + var activeBoxCount = metrics.gauge( + "jsheets.runtime.evaluation.fork.activeBoxCount", + new AtomicInteger(0) + ); + var startedBoxes = Counter + .builder("jsheets.runtime.evaluation.fork.startedBoxes") + .description("The number of boxes that have been started") + .register(metrics); + return new ForkEnvironmentMonitoring(activeBoxCount, startedBoxes); + } + + private final AtomicInteger activeBoxCount; + private final Counter startedBoxes; + + private ForkEnvironmentMonitoring( + AtomicInteger activeBoxCount, + Counter startedBoxes + ) { + this.activeBoxCount = activeBoxCount; + this.startedBoxes = startedBoxes; + } + + @Subscribe + public void receiveLifecycleUpdate(BoxLifecycleEvent event) { + switch (event.stage()) { + case Starting -> { + activeBoxCount.incrementAndGet(); + startedBoxes.increment(); + } + case Stopping -> activeBoxCount.decrementAndGet(); + } + } + + @Override + public String toString() { + return "ForkEnvironmentMonitoring(activeBoxCount=%s, startedBoxes=%s)" + .formatted(activeBoxCount, startedBoxes); + } +} \ No newline at end of file diff --git a/runtime/src/main/java/jsheets/runtime/monitoring/MonitoringModule.java b/runtime/src/main/java/jsheets/runtime/monitoring/MonitoringModule.java new file mode 100644 index 0000000..42d99ec --- /dev/null +++ b/runtime/src/main/java/jsheets/runtime/monitoring/MonitoringModule.java @@ -0,0 +1,108 @@ +package jsheets.runtime.monitoring; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.Executor; + +import com.google.common.eventbus.AsyncEventBus; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.google.inject.Singleton; + +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.influx.InfluxConfig; +import io.micrometer.influx.InfluxMeterRegistry; +import io.soabase.recordbuilder.core.RecordBuilder; + +import jsheets.config.Config; +import jsheets.config.Config.Key; +import jsheets.event.EventSink; +import jsheets.event.GuavaEventSink; + +import static jsheets.config.Config.Key.ofInt; +import static jsheets.config.Config.Key.ofString; + +public final class MonitoringModule extends AbstractModule { + public static MonitoringModule create() { + return new MonitoringModule(); + } + + private MonitoringModule() {} + + private final Key monitoringBackendKey = ofString("monitoring.backend"); + + @Provides + @Singleton + EventSink eventSink(Executor executor, Config config) { + return selectRegistry(config).map(registry -> { + var bus = new AsyncEventBus("monitoring", executor); + bus.register(EvaluationEngineMonitoring.register(registry)); + bus.register(ForkEnvironmentMonitoring.register(registry)); + return GuavaEventSink.forBus(bus); + }).orElseGet(EventSink::ignore); + } + + private Optional selectRegistry(Config config) { + var backend = monitoringBackendKey.in(config).or("").toLowerCase(); + if (backend.startsWith("influx")) { + return Optional.of(createInfluxRegistry(config)); + } + return Optional.empty(); + } + + private MeterRegistry createInfluxRegistry(Config config) { + return new InfluxMeterRegistry( + FixedInfluxConfig.fromConfig(config), + Clock.SYSTEM + ); + } + + @RecordBuilder + record FixedInfluxConfig( + String password, + String userName, + String token, + String org, + String bucket, + String uri, + String db, + Duration step + ) implements InfluxConfig { + + static final Key orgKey = ofString("monitoring.influx.org"); + static final Key bucketKey = ofString("monitoring.influx.bucket"); + static final Key tokenKey = ofString("monitoring.influx.token"); + static final Key userNameKey = ofString("monitoring.influx.userName"); + static final Key passwordKey = ofString("monitoring.influx.password"); + static final Key databaseKey = ofString("monitoring.influx.db"); + static final Key uriKey = ofString("monitoring.influx.uri"); + static final Key stepKey = ofInt("monitoring.influx.step"); + + private static final String defaultInfluxBucket = "jsheets"; + private static final String defaultDatabase = "jsheets"; + private static final String defaultUri = "http://localhost:8086"; + private static final int defaultStep = 10; + + static InfluxConfig fromConfig(Config config) { + var influx = MonitoringModuleFixedInfluxConfigBuilder.builder() + .password(passwordKey.in(config).or("")) + .org(orgKey.in(config).or("")) + .token(tokenKey.in(config).or("")) + .bucket(bucketKey.in(config).or(defaultInfluxBucket)) + .userName(userNameKey.in(config).or("")) + .uri(uriKey.in(config).or(defaultUri)) + .db(databaseKey.in(config).or(defaultDatabase)) + .step(Duration.ofSeconds(stepKey.in(config).or(defaultStep))) + .build(); + System.out.println(influx); + return influx; + } + + @Override + public String get(String key) { + return null; + } + } +} \ No newline at end of file diff --git a/runtime/src/main/resources/runtime/evaluation/sandbox/accessGraph.txt b/runtime/src/main/resources/runtime/evaluation/sandbox/accessGraph.txt index 4a34b50..833f913 100644 --- a/runtime/src/main/resources/runtime/evaluation/sandbox/accessGraph.txt +++ b/runtime/src/main/resources/runtime/evaluation/sandbox/accessGraph.txt @@ -2,8 +2,14 @@ java.lang !java.lang.Thread !java.lang.reflect !java.lang.invoke -java.lang.System -!java.lang.System#exit +!java.lang.System +java.lang.System.out +java.lang.System.err +java.lang.System.nanoTime +java.lang.System.currentTimeMillis +java.lang.System.identityHashCode +java.lang.System.arraycopy +java.lang.System.lineSeparator !java.lang.Runtime !java.lang.Process !java.lang.ProcessBuilder @@ -11,9 +17,14 @@ java.lang.System !java.lang.ThreadDeath !java.lang.ThreadGroup !java.util.concurrent +!java.util.jar +java.nio.charset +java.util java.text java.time java.math + java.io.Scanner java.io.PrintStream#print java.io.PrintStream#println + diff --git a/server/build.gradle b/server/build.gradle index afdb6b6..fa73216 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -26,12 +26,14 @@ ext { dependencies { implementation project(':protocol') implementation project(':evaluation') + implementation project(':common') implementation "org.apache.curator:curator-x-discovery:$curatorVersion" implementation "io.micrometer:micrometer-core:$micrometerVersion" implementation "org.mongodb:mongodb-driver-sync:$mongoDbDriverVersion" implementation "com.ecwid.consul:consul-api:$consuleClientVersion" implementation "io.javalin:javalin:$javalinVersion" implementation "org.slf4j:slf4j-simple:$slf4jVersion" + implementation "com.github.vladimir-bukhtoyarov:bucket4j-core:$bucket4jVersion" implementation "com.google.protobuf:protobuf-java-util:$protobufJavaVersion" implementation "net.sourceforge.argparse4j:argparse4j:$argsParseVersion" implementation "javax.annotation:javax.annotation-api:$javaxAnnotationVersion" diff --git a/server/src/main/java/jsheets/server/endpoint/WebSocketRateLimit.java b/server/src/main/java/jsheets/server/endpoint/WebSocketRateLimit.java new file mode 100644 index 0000000..f7f9514 --- /dev/null +++ b/server/src/main/java/jsheets/server/endpoint/WebSocketRateLimit.java @@ -0,0 +1,42 @@ +package jsheets.server.endpoint; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.Bucket4j; +import io.javalin.http.HttpCode; +import io.javalin.websocket.WsConfig; +import org.eclipse.jetty.websocket.api.CloseStatus; + +import java.util.Objects; +import java.util.function.Consumer; + +public final class WebSocketRateLimit implements Consumer { + public static WebSocketRateLimit withBandwith(Bandwidth bandwidth) { + Objects.requireNonNull(bandwidth, "bandwidth"); + return new WebSocketRateLimit( + Bucket4j.builder() + .addLimit(bandwidth) + .build() + ); + } + + private final Bucket bucket; + + private WebSocketRateLimit(Bucket bucket) { + this.bucket = bucket; + } + + private static final CloseStatus tooManyRequests = new CloseStatus( + HttpCode.TOO_MANY_REQUESTS.getStatus(), + "the evaluation engine load is too high right now" + ); + + @Override + public void accept(WsConfig config) { + config.onConnect(connection -> { + if (!bucket.tryConsume(1)) { + connection.session.close(tooManyRequests); + } + }); + } +} diff --git a/server/src/main/java/jsheets/server/evaluation/EvaluationEndpoint.java b/server/src/main/java/jsheets/server/evaluation/EvaluationEndpoint.java index 1d1e89b..c8fbd71 100644 --- a/server/src/main/java/jsheets/server/evaluation/EvaluationEndpoint.java +++ b/server/src/main/java/jsheets/server/evaluation/EvaluationEndpoint.java @@ -1,28 +1,44 @@ package jsheets.server.evaluation; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.function.BiConsumer; import java.util.function.Consumer; +import io.github.bucket4j.Bandwidth; import io.javalin.Javalin; import io.javalin.websocket.WsConfig; import io.javalin.websocket.WsContext; import jsheets.server.endpoint.Endpoint; +import jsheets.server.endpoint.WebSocketRateLimit; +import javax.annotation.Nullable; import javax.inject.Inject; +import javax.inject.Named; public final class EvaluationEndpoint implements Endpoint { private final ConnectionTable table; + @Nullable + private final Bandwidth bandwidth; @Inject - EvaluationEndpoint(ConnectionTable table) { + EvaluationEndpoint( + ConnectionTable table, + @Named("evaluationBandwidth") @Nullable Bandwidth bandwidth + ) { this.table = table; + this.bandwidth = bandwidth; } + private static final String route = "/api/v1/evaluate"; + @Override public void configure(Javalin server) { - server.ws("/api/v1/evaluate", table::listen); + if (bandwidth != null) { + server.wsBefore(route, WebSocketRateLimit.withBandwith(bandwidth)); + } + server.ws(route, table::listen); } static final class ConnectionTable { diff --git a/server/src/main/java/jsheets/server/evaluation/EvaluationModule.java b/server/src/main/java/jsheets/server/evaluation/EvaluationModule.java index 9547997..8666c1b 100644 --- a/server/src/main/java/jsheets/server/evaluation/EvaluationModule.java +++ b/server/src/main/java/jsheets/server/evaluation/EvaluationModule.java @@ -5,6 +5,8 @@ import com.google.inject.Provides; import com.google.inject.Singleton; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Refill; import jsheets.config.Config; import jsheets.evaluation.EvaluationEngine; import jsheets.evaluation.shell.ShellEvaluationEngine; @@ -16,7 +18,12 @@ import org.apache.curator.x.discovery.ServiceDiscoveryBuilder; import org.apache.curator.x.discovery.ServiceProvider; +import javax.annotation.Nullable; +import javax.inject.Named; +import java.time.Duration; +import java.util.List; import java.util.Optional; +import java.util.concurrent.Executor; import java.util.concurrent.Executors; public final class EvaluationModule extends AbstractModule { @@ -26,18 +33,58 @@ public static EvaluationModule create() { return new EvaluationModule(); } - private EvaluationModule() {} + private EvaluationModule() { + } @Provides @Singleton EvaluationEngine evaluationEngine( - Optional curatorBinding + Optional curatorBinding, + Executor executor ) { - return curatorBinding.map(this::createRemoteEvaluationEngine) + return curatorBinding + .map(client -> createRemoteEvaluationEngine(client, executor)) .orElseGet(this::createEmbeddedEvaluationEngine); } - private EvaluationEngine createRemoteEvaluationEngine(CuratorFramework client) { + @Provides + Executor executor() { + return Executors.newCachedThreadPool(); + } + + private static final Config.Key disableRateLimitKey = + Config.Key.ofFlag("server.rateLimit.disable"); + + private static final Config.Key bandwidthCapacityKey = + Config.Key.ofInt("server.rateLimit.capacity"); + + private static final Config.Key refillPerSecondKey = + Config.Key.ofInt("server.rateLimit.refillPerSecond"); + + private static final int defaultBandwidthCapacity = 100; + private static final int defaultRefillPerSecond = 50; + + @Provides + @Singleton + @Nullable + @Named("evaluationBandwidth") + Bandwidth evaluationBandwidth(Config config) { + if (disableRateLimitKey.in(config).or(false)) { + return null; + } + return Bandwidth.classic( + bandwidthCapacityKey.in(config).or(defaultBandwidthCapacity), + Refill.greedy( + refillPerSecondKey.in(config).or(defaultRefillPerSecond), + Duration.ofSeconds(1) + ) + ); + } + + private EvaluationEngine createRemoteEvaluationEngine( + CuratorFramework client, + Executor executor + ) { var provider = createServiceProvider(client); try { provider.start(); @@ -51,12 +98,25 @@ private EvaluationEngine createRemoteEvaluationEngine(CuratorFramework client) { log.atWarning().withCause(failure).log("failed to close service discovery"); } })); - return PooledEvaluationEngine.of(ZookeeperEngineDiscovery.create(provider)); + return PooledEvaluationEngine.of( + ZookeeperEngineDiscovery.create(executor, provider) + ); } private EvaluationEngine createEmbeddedEvaluationEngine() { return ShellEvaluationEngine.newBuilder() .useWorkerPool(Executors.newCachedThreadPool()) + .useBuiltinImports( + List.of( + "java.lang.*", + "java.math.*", + "java.time.*", + "java.text.*", + "java.util.*", + "java.util.function.*", + "java.util.stream.*" + ) + ) .create(); } diff --git a/server/src/main/java/jsheets/server/evaluation/client/ZookeeperEngineDiscovery.java b/server/src/main/java/jsheets/server/evaluation/client/ZookeeperEngineDiscovery.java index 9c82833..9c69b8e 100644 --- a/server/src/main/java/jsheets/server/evaluation/client/ZookeeperEngineDiscovery.java +++ b/server/src/main/java/jsheets/server/evaluation/client/ZookeeperEngineDiscovery.java @@ -1,26 +1,47 @@ package jsheets.server.evaluation.client; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; import com.google.common.flogger.FluentLogger; + import io.grpc.ManagedChannelBuilder; import jsheets.evaluation.EvaluationEngine; +import org.apache.curator.x.discovery.ServiceInstance; import org.apache.curator.x.discovery.ServiceProvider; +import java.time.Duration; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; -// TODO: Pool Connections public final class ZookeeperEngineDiscovery implements EnginePool { private static final FluentLogger log = FluentLogger.forEnclosingClass(); - public static ZookeeperEngineDiscovery create(ServiceProvider services) { + public static ZookeeperEngineDiscovery create( + Executor executor, + ServiceProvider services + ) { + Objects.requireNonNull(executor, "executor"); Objects.requireNonNull(services, "services"); - return new ZookeeperEngineDiscovery(services); + return new ZookeeperEngineDiscovery(executor, services); } private final ServiceProvider services; + private final Executor executor; + + private static final Duration idleTimeout = Duration.ofMinutes(1); + private final Cache connectionPool = + CacheBuilder.newBuilder() + .expireAfterAccess(idleTimeout.toMillis(), TimeUnit.MILLISECONDS) + .expireAfterWrite(5, TimeUnit.MINUTES) + .build(); - private ZookeeperEngineDiscovery(ServiceProvider services) { + private ZookeeperEngineDiscovery( + Executor executor, + ServiceProvider services + ) { + this.executor = executor; this.services = services; } @@ -28,10 +49,8 @@ private ZookeeperEngineDiscovery(ServiceProvider services) { public Optional select() { try { var service = services.getInstance(); - var channel = ManagedChannelBuilder.forAddress(service.getAddress(), service.getPort()) - .usePlaintext() - .build(); - return Optional.of(SnippetRuntimeEngine.forChannel(channel)); + var connection = connectionPool.get(service.getId(), () -> connect(service)); + return Optional.of(connection); } catch (Exception failure) { log.atWarning() .withCause(failure) @@ -40,4 +59,13 @@ public Optional select() { return Optional.empty(); } } + + private EvaluationEngine connect(ServiceInstance target) { + var channel = ManagedChannelBuilder.forAddress(target.getAddress(), target.getPort()) + .usePlaintext() + .executor(executor) + .idleTimeout(idleTimeout.toMillis(), TimeUnit.MILLISECONDS) + .build(); + return SnippetRuntimeEngine.forChannel(channel); + } } diff --git a/settings.gradle b/settings.gradle index 83749f2..44c0e03 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,4 +5,5 @@ include 'runtime' include 'protocol' include 'website' include 'server' +include 'common' diff --git a/website/src/App.tsx b/website/src/App.tsx index 25fc83d..bcfcf74 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -18,7 +18,7 @@ const welcomeSheet = createWelcomeSheet() const blankSheet = createBlankSheet() export default function App() { - const [evaluate, evaluating] = useEvaluate() + const [evaluate, evaluating, cooldown] = useEvaluate() const history = useHistory() const [sharedId, setSharedId] = useState('') const [shareVisible, setShareVisible] = useState(false) @@ -41,6 +41,7 @@ export default function App() { @@ -48,6 +49,7 @@ export default function App() { { - console.log({error}) + client.onerror = () => { listener.onEnd() } @@ -78,14 +78,17 @@ export default class Client { } catch (error) { throw new Error(JSON.stringify({error, note: 'received invalid message', message: message.data})) } - response.getErrorList()?.forEach(listener.onError) + response.getErrorList()?.forEach(listener.onEvaluationError) response.getMissingSourcesList()?.forEach(listener.onMissingSources) response.getResultList()?.forEach(listener.onResult) } client.onclose = event => { + console.log({message: 'evaluation was closed', event}) + if (isWebSocketErrorCode(event.code)) { + listener.onServiceError(event.code) + } listener.onEnd() - console.log({event}) } return new WebSocketEvaluation(client) @@ -96,6 +99,10 @@ export default class Client { } } +function isWebSocketErrorCode(code: number) { + return code >= 400 && code < 600 +} + class WebSocketEvaluation implements Evaluation { constructor(private readonly connection: WebSocket) {} diff --git a/website/src/editor/Editor.tsx b/website/src/editor/Editor.tsx index 043eb1c..54f9d2f 100644 --- a/website/src/editor/Editor.tsx +++ b/website/src/editor/Editor.tsx @@ -1,10 +1,11 @@ import React, {useEffect} from 'react' -import {EditorView} from '@codemirror/view' +import {EditorView, keymap} from '@codemirror/view' import {Compartment, EditorState} from '@codemirror/state' import {basicSetup} from '@codemirror/basic-setup' import {java} from "./java/language"; import {ThemeKey, useTheme} from '../theme/ThemeContext' import {editorThemes, highlightingThemes} from './themes/themes' +import {defaultKeymap, insertTab} from "@codemirror/commands" const tabSize = 2 @@ -15,6 +16,13 @@ function createView(initialContent: string, element: Element, theme: ThemeKey) { doc: initialContent, extensions: [ basicSetup, + keymap.of([ + ...defaultKeymap, + { + key: 'Tab', + run: insertTab + } + ]), EditorState.tabSize.of(tabSize), java(), themeState.of(editorThemes[theme]), diff --git a/website/src/editor/themes/dark.ts b/website/src/editor/themes/dark.ts index b2f6c85..0107760 100644 --- a/website/src/editor/themes/dark.ts +++ b/website/src/editor/themes/dark.ts @@ -4,6 +4,7 @@ import {EditorView} from '@codemirror/view' export const theme = EditorView.theme({ ".cm-scroller": { + fontVariantLigatures: 'none', fontFamily: `'JetBrains Mono', Menlo, Monaco, source-code-pro, Consolas, monospace` }, "&": { diff --git a/website/src/editor/themes/light.ts b/website/src/editor/themes/light.ts index c7027db..75a54de 100644 --- a/website/src/editor/themes/light.ts +++ b/website/src/editor/themes/light.ts @@ -4,6 +4,7 @@ import {customTags} from '../java/language' export const theme = EditorView.theme({ ".cm-scroller": { + fontVariantLigatures: 'none', fontFamily: `'JetBrains Mono', 'Roboto Mono', Menlo, Monaco, source-code-pro, Consolas, monospace` }, "&": { diff --git a/website/src/sheet/ImportedSheet.tsx b/website/src/sheet/ImportedSheet.tsx index 500be8c..5812151 100644 --- a/website/src/sheet/ImportedSheet.tsx +++ b/website/src/sheet/ImportedSheet.tsx @@ -11,6 +11,7 @@ import themed from '../theme/themed' interface ImportedSheetProperties { evaluating?: boolean + isCooldown?: boolean evaluate: (start: StartEvaluationRequest) => void captureSnippet: CaptureSnippetReference } diff --git a/website/src/sheet/ShareModal.tsx b/website/src/sheet/ShareModal.tsx index bf942ad..f8a7747 100644 --- a/website/src/sheet/ShareModal.tsx +++ b/website/src/sheet/ShareModal.tsx @@ -38,7 +38,7 @@ export default function ShareModal(properties: ShareModalProperties) { onOk={() => properties.onVisibilityChange?.(false)} footer={[]} > -

Your Sheet has been saved. You can use the link too share it.

+

Your Sheet has been saved. You can use the link to share it.

export interface SheetProperties { initial?: SheetState evaluating?: boolean + isCooldown?: boolean evaluate: (request: StartEvaluationRequest) => void captureSnippet?: CaptureSnippetReference } @@ -85,6 +86,7 @@ export default function Sheet(properties: SheetProperties) { > void capture?: (reference: SnippetReference) => void @@ -80,6 +81,7 @@ class ExistingSnippet /> void capture?: (reference: SnippetReference) => void } @@ -177,6 +180,7 @@ export default function Snippet(properties: SnippetProperties) { void running?: boolean + isCooldown?: boolean } const AddMenu: React.FC<{addComponent: AddComponent, t: TFunction}> = ({addComponent, t}) => ( @@ -76,7 +78,8 @@ export default function SnippetExtras(properties: SnippetExtrasProperties) { diff --git a/website/src/sheet/useEvaluate.ts b/website/src/sheet/useEvaluate.ts index c7a52f8..1e2dc8b 100644 --- a/website/src/sheet/useEvaluate.ts +++ b/website/src/sheet/useEvaluate.ts @@ -7,16 +7,26 @@ import {useDispatch} from "react-redux"; import {useState} from "react"; import {removeOutput, reportOutput} from "./state"; import {SnippetComponentOutput} from "./index"; +import modal from "antd/lib/modal"; +import useTimedFlag from "../util/useTimedFlag"; -type UseEvaluate = [(start: StartEvaluationRequest) => void, boolean] +type UseEvaluate = [(start: StartEvaluationRequest) => void, boolean, boolean] + +const clientSideCooldown = 1500 export default function useEvaluate(): UseEvaluate { const dispatch = useDispatch() const [evaluating, setEvaluating] = useState(false) + const [isCooldown, setCooldown] = useTimedFlag(false, clientSideCooldown) + const evaluate = (start: StartEvaluationRequest) => { + if (evaluating || isCooldown) { + return + } const client = Client.create() setEvaluating(true) dispatch(removeOutput({})) + setCooldown(true) client.evaluate( start, new Listener({ @@ -29,11 +39,13 @@ export default function useEvaluate(): UseEvaluate { }) ) } - return [evaluate, evaluating] + return [evaluate, evaluating, isCooldown] } type ReportOutput = (componentId: string, output: SnippetComponentOutput) => void +const tooManyRequestsCode = 429 + class Listener implements EvaluationListener { constructor( private readonly callback: { reportOutput: ReportOutput, close: () => void } @@ -44,7 +56,17 @@ class Listener implements EvaluationListener { this.callback.close() } - onError = (error: EvaluationError) => { + onServiceError = (code: number) =>{ + if (code == tooManyRequestsCode) { + modal.error({ + title: 'Too many requests', + content: 'The server is receiving too many evaluation requests right' + + 'now, please try again in a few seconds.' + }) + } + } + + onEvaluationError = (error: EvaluationError) => { this.callback.reportOutput(error.getComponentId(), { span: { start: error.getSpan()?.getStart() || 0,