/**
   * Evaluate a script and allow for the submission of both a transform {@link Function} and {@link
   * Consumer}. The {@link Function} will transform the result after script evaluates but before
   * transaction commit and before the returned {@link CompletableFuture} is completed. The {@link
   * Consumer} will take the result for additional processing after the script evaluates and after
   * the {@link CompletableFuture} is completed, but before the transaction is committed.
   *
   * @param script the script to evaluate
   * @param language the language to evaluate it in
   * @param boundVars the bindings to evaluate in the context of the script
   * @param transformResult a {@link Function} that transforms the result - can be {@code null}
   * @param withResult a {@link Consumer} that accepts the result - can be {@code null}
   */
  public CompletableFuture<Object> eval(
      final String script,
      final String language,
      final Bindings boundVars,
      final Function<Object, Object> transformResult,
      final Consumer<Object> withResult) {
    final String lang = Optional.ofNullable(language).orElse("gremlin-groovy");

    logger.debug(
        "Preparing to evaluate script - {} - in thread [{}]",
        script,
        Thread.currentThread().getName());

    final Bindings bindings = new SimpleBindings();
    bindings.putAll(this.globalBindings);
    bindings.putAll(boundVars);
    beforeEval.accept(bindings);

    final CompletableFuture<Object> evaluationFuture = new CompletableFuture<>();
    final FutureTask<Void> f =
        new FutureTask<>(
            () -> {
              try {
                logger.debug(
                    "Evaluating script - {} - in thread [{}]",
                    script,
                    Thread.currentThread().getName());

                final Object o = scriptEngines.eval(script, bindings, lang);

                // apply a transformation before sending back the result - useful when trying to
                // force serialization
                // in the same thread that the eval took place given ThreadLocal nature of graphs as
                // well as some
                // transactional constraints
                final Object result = null == transformResult ? o : transformResult.apply(o);
                evaluationFuture.complete(result);

                // a mechanism for taking the final result and doing something with it in the same
                // thread, but
                // AFTER the eval and transform are done and that future completed.  this provides a
                // final means
                // for working with the result in the same thread as it was eval'd
                if (withResult != null) withResult.accept(result);

                afterSuccess.accept(bindings);
              } catch (Exception ex) {
                final Throwable root = null == ex.getCause() ? ex : ExceptionUtils.getRootCause(ex);

                // thread interruptions will typically come as the result of a timeout, so in those
                // cases,
                // check for that situation and convert to TimeoutException
                if (root instanceof InterruptedException)
                  evaluationFuture.completeExceptionally(
                      new TimeoutException(
                          String.format(
                              "Script evaluation exceeded the configured threshold of %s ms for request [%s]: %s",
                              scriptEvaluationTimeout, script, root.getMessage())));
                else {
                  afterFailure.accept(bindings, root);
                  evaluationFuture.completeExceptionally(root);
                }
              }

              return null;
            });

    executorService.execute(f);

    if (scriptEvaluationTimeout > 0) {
      // Schedule a timeout in the thread pool for future execution
      final ScheduledFuture<?> sf =
          scheduledExecutorService.schedule(
              () -> {
                logger.info(
                    "Timing out script - {} - in thread [{}]",
                    script,
                    Thread.currentThread().getName());
                if (!f.isDone()) {
                  afterTimeout.accept(bindings);
                  f.cancel(true);
                }
              },
              scriptEvaluationTimeout,
              TimeUnit.MILLISECONDS);

      // Cancel the scheduled timeout if the eval future is complete or the script evaluation failed
      // with exception
      evaluationFuture.handleAsync(
          (v, t) -> {
            logger.debug(
                "Killing scheduled timeout on script evaluation as the eval completed (possibly with exception).");
            return sf.cancel(true);
          });
    }

    return evaluationFuture;
  }