Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Kinesis] add Scala kinesis-mock build behind feature flag #12559

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions localstack-core/localstack/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -896,6 +896,20 @@ def populate_edge_configuration(
# randomly inject faults to Kinesis
KINESIS_ERROR_PROBABILITY = float(os.environ.get("KINESIS_ERROR_PROBABILITY", "").strip() or 0.0)

# SEMI-PUBLIC: "node" (default); not actively communicated
# Select whether to use the node or scala build when running Kinesis Mock
KINESIS_MOCK_PROVIDER_ENGINE = os.environ.get("KINESIS_MOCK_PROVIDER_ENGINE", "").strip() or "node"

# set the maximum Java heap size corresponding to the '-Xmx<size>' flag
KINESIS_MOCK_MAXIMUM_HEAP_SIZE = (
os.environ.get("KINESIS_MOCK_MAXIMUM_HEAP_SIZE", "").strip() or "512m"
)

# set the initial Java heap size corresponding to the '-Xms<size>' flag
KINESIS_MOCK_INITIAL_HEAP_SIZE = (
os.environ.get("KINESIS_MOCK_INITIAL_HEAP_SIZE", "").strip() or "256m"
)

# randomly inject faults to DynamoDB
DYNAMODB_ERROR_PROBABILITY = float(os.environ.get("DYNAMODB_ERROR_PROBABILITY", "").strip() or 0.0)
DYNAMODB_READ_ERROR_PROBABILITY = float(
Expand Down
99 changes: 81 additions & 18 deletions localstack-core/localstack/services/kinesis/kinesis_mock_server.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import logging
import os
import threading
from abc import abstractmethod
from pathlib import Path
from typing import Dict, List, Optional, Tuple

from localstack import config
from localstack.services.kinesis.packages import kinesismock_package
from localstack.services.kinesis.packages import (
KinesisMockEngine,
kinesismock_package,
kinesismock_scala_package,
)
from localstack.utils.common import TMP_THREADS, ShellCommandThread, get_free_tcp_port, mkdir
from localstack.utils.run import FuncThread
from localstack.utils.serving import Server
Expand All @@ -21,7 +26,7 @@ class KinesisMockServer(Server):
def __init__(
self,
port: int,
js_path: Path,
exe_path: Path,
latency: str,
account_id: str,
host: str = "localhost",
Expand All @@ -32,7 +37,7 @@ def __init__(
self._latency = latency
self._data_dir = data_dir
self._data_filename = f"{self._account_id}.json"
self._js_path = js_path
self._exe_path = exe_path
self._log_level = log_level
super().__init__(port, host)

Expand All @@ -51,15 +56,9 @@ def do_start_thread(self) -> FuncThread:
t.start()
return t

def _create_shell_command(self) -> Tuple[List, Dict]:
"""
Helper method for creating kinesis mock invocation command
:return: returns a tuple containing the command list and a dictionary with the environment variables
"""

@property
def _environment_variables(self) -> Dict:
env_vars = {
# Use the `server.json` packaged next to the main.js
"KINESIS_MOCK_CERT_PATH": str((self._js_path.parent / "server.json").absolute()),
"KINESIS_MOCK_PLAIN_PORT": self.port,
# Each kinesis-mock instance listens to two ports - secure and insecure.
# LocalStack uses only one - the insecure one. Block the secure port to avoid conflicts.
Expand Down Expand Up @@ -91,13 +90,60 @@ def _create_shell_command(self) -> Tuple[List, Dict]:
env_vars["PERSIST_INTERVAL"] = config.KINESIS_MOCK_PERSIST_INTERVAL

env_vars["LOG_LEVEL"] = self._log_level
cmd = ["node", self._js_path]
return cmd, env_vars

return env_vars

@abstractmethod
def _create_shell_command(self) -> Tuple[List, Dict]:
"""
Helper method for creating kinesis mock invocation command
:return: returns a tuple containing the command list and a dictionary with the environment variables
"""
pass

def _log_listener(self, line, **_kwargs):
LOG.info(line.rstrip())


class KinesisMockScalaServer(KinesisMockServer):
def _create_shell_command(self) -> Tuple[List, Dict]:
cmd = ["java", "-jar", *self._get_java_vm_options(), str(self._exe_path)]
return cmd, self._environment_variables

@property
def _environment_variables(self) -> Dict:
default_env_vars = super()._environment_variables
kinesis_mock_installer = kinesismock_scala_package.get_installer()
return {
**default_env_vars,
**kinesis_mock_installer.get_java_env_vars(),
}

def _get_java_vm_options(self) -> list[str]:
return [
f"-Xms{config.KINESIS_MOCK_INITIAL_HEAP_SIZE}",
f"-Xmx{config.KINESIS_MOCK_MAXIMUM_HEAP_SIZE}",
"-XX:MaxGCPauseMillis=500",
"-XX:+ExitOnOutOfMemoryError",
]


class KinesisMockNodeServer(KinesisMockServer):
@property
def _environment_variables(self) -> Dict:
node_env_vars = {
# Use the `server.json` packaged next to the main.js
"KINESIS_MOCK_CERT_PATH": str((self._exe_path.parent / "server.json").absolute()),
}

default_env_vars = super()._environment_variables
return {**node_env_vars, **default_env_vars}

def _create_shell_command(self) -> Tuple[List, Dict]:
cmd = ["node", self._exe_path]
return cmd, self._environment_variables


class KinesisServerManager:
default_startup_timeout = 60

Expand Down Expand Up @@ -136,8 +182,6 @@ def _create_kinesis_mock_server(self, account_id: str) -> KinesisMockServer:
config.KINESIS_LATENCY -> configure stream latency (in milliseconds)
"""
port = get_free_tcp_port()
kinesismock_package.install()
kinesis_mock_js_path = Path(kinesismock_package.get_installer().get_executable_path())

# kinesis-mock stores state in json files <account_id>.json, so we can dump everything into `kinesis/`
persist_path = os.path.join(config.dirs.data, "kinesis")
Expand All @@ -159,12 +203,31 @@ def _create_kinesis_mock_server(self, account_id: str) -> KinesisMockServer:
log_level = "INFO"
latency = config.KINESIS_LATENCY + "ms"

server = KinesisMockServer(
# Install the Scala Kinesis Mock build if specified in KINESIS_MOCK_PROVIDER_ENGINE
if KinesisMockEngine(config.KINESIS_MOCK_PROVIDER_ENGINE) == KinesisMockEngine.SCALA:
kinesismock_scala_package.install()
kinesis_mock_path = Path(
kinesismock_scala_package.get_installer().get_executable_path()
)

return KinesisMockScalaServer(
port=port,
exe_path=kinesis_mock_path,
log_level=log_level,
latency=latency,
data_dir=persist_path,
account_id=account_id,
)

# Otherwise, install the NodeJS version (default)
kinesismock_package.install()
kinesis_mock_path = Path(kinesismock_package.get_installer().get_executable_path())

return KinesisMockNodeServer(
port=port,
js_path=kinesis_mock_js_path,
exe_path=kinesis_mock_path,
log_level=log_level,
latency=latency,
data_dir=persist_path,
account_id=account_id,
)
return server
80 changes: 67 additions & 13 deletions localstack-core/localstack/services/kinesis/packages.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,82 @@
import os
from enum import StrEnum
from functools import lru_cache
from typing import List
from typing import Any, List

from localstack.packages import Package
from localstack.packages.core import NodePackageInstaller
from localstack.packages import InstallTarget, Package
from localstack.packages.core import GitHubReleaseInstaller, NodePackageInstaller
from localstack.packages.java import JavaInstallerMixin, java_package

_KINESIS_MOCK_VERSION = os.environ.get("KINESIS_MOCK_VERSION") or "0.4.9"
_KINESIS_MOCK_VERSION = os.environ.get("KINESIS_MOCK_VERSION") or "0.4.12"


class KinesisMockPackage(Package[NodePackageInstaller]):
def __init__(self, default_version: str = _KINESIS_MOCK_VERSION):
class KinesisMockEngine(StrEnum):
NODE = "node"
SCALA = "scala"

@classmethod
def _missing_(cls, value: str | Any) -> str:
# default to 'node' if invalid enum
if not isinstance(value, str):
return cls(cls.NODE)
return cls.__members__.get(value.upper(), cls.NODE)
Comment on lines +18 to +22
Copy link
Member

Choose a reason for hiding this comment

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

Nit: Not entirely happy with this fallback logic here - it is effectively unused, as we only ever check if it is scala anyway, and we would silently accept obviously wrong values. Also, if we ever switch the default, we have to do it in multiple places. Nothing to hold back the merge, but I think we could potentially make this easier.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah I suppose this is unideal but I wanted to make sure (since we're feature-flagging this strategy) that nothing gets broken if incorrect values are used. Definitely erring on the defensive side.

What have we done in past for situations like this?

Copy link
Member

Choose a reason for hiding this comment

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

Usually we would fallback, but at least with a warning in the log.



class KinesisMockNodePackageInstaller(NodePackageInstaller):
def __init__(self, version: str):
super().__init__(package_name="kinesis-local", version=version)


class KinesisMockScalaPackageInstaller(JavaInstallerMixin, GitHubReleaseInstaller):
def __init__(self, version: str = _KINESIS_MOCK_VERSION):
super().__init__(
name="kinesis-local", tag=f"v{version}", github_slug="etspaceman/kinesis-mock"
)

# Kinesis Mock requires JRE 21+
self.java_version = "21"

def _get_github_asset_name(self) -> str:
return "kinesis-mock.jar"

def _prepare_installation(self, target: InstallTarget) -> None:
java_package.get_installer(self.java_version).install(target)

def get_java_home(self) -> str | None:
"""Override to use the specific Java version"""
return java_package.get_installer(self.java_version).get_java_home()


class KinesisMockScalaPackage(Package[KinesisMockScalaPackageInstaller]):
def __init__(
self,
default_version: str = _KINESIS_MOCK_VERSION,
):
super().__init__(name="Kinesis Mock", default_version=default_version)

@lru_cache
def _get_installer(self, version: str) -> NodePackageInstaller:
return KinesisMockPackageInstaller(version)
def _get_installer(self, version: str) -> KinesisMockScalaPackageInstaller:
return KinesisMockScalaPackageInstaller(version)

def get_versions(self) -> List[str]:
return [_KINESIS_MOCK_VERSION]
return [_KINESIS_MOCK_VERSION] # Only supported on v0.4.12+


class KinesisMockPackageInstaller(NodePackageInstaller):
def __init__(self, version: str):
super().__init__(package_name="kinesis-local", version=version)
class KinesisMockNodePackage(Package[KinesisMockNodePackageInstaller]):
def __init__(
self,
default_version: str = _KINESIS_MOCK_VERSION,
):
super().__init__(name="Kinesis Mock", default_version=default_version)

@lru_cache
def _get_installer(self, version: str) -> KinesisMockNodePackageInstaller:
return KinesisMockNodePackageInstaller(version)

def get_versions(self) -> List[str]:
return [_KINESIS_MOCK_VERSION]


kinesismock_package = KinesisMockPackage()
# leave as 'kinesismock_package' for backwards compatability
kinesismock_package = KinesisMockNodePackage()
kinesismock_scala_package = KinesisMockScalaPackage()
10 changes: 9 additions & 1 deletion localstack-core/localstack/services/kinesis/plugins.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import localstack.config as config
from localstack.packages import Package, package


@package(name="kinesis-mock")
def kinesismock_package() -> Package:
from localstack.services.kinesis.packages import kinesismock_package
from localstack.services.kinesis.packages import (
KinesisMockEngine,
kinesismock_package,
kinesismock_scala_package,
)

if KinesisMockEngine(config.KINESIS_MOCK_PROVIDER_ENGINE) == KinesisMockEngine.SCALA:
return kinesismock_scala_package

return kinesismock_package
19 changes: 19 additions & 0 deletions tests/aws/services/kinesis/test_kinesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,25 @@ def test_subscribe_to_shard_with_java_sdk_v2_lambda(
assert response_content == "ok"


@pytest.mark.skipif(
condition=is_aws_cloud(),
reason="Duplicate of all tests in TestKinesis. Since we cannot unmark test cases, only run against LocalStack.",
)
class TestKinesisMockScala(TestKinesis):
@pytest.fixture(autouse=True)
def set_kinesis_mock_scala_engine(self, monkeypatch):
monkeypatch.setattr(config, "KINESIS_MOCK_PROVIDER_ENGINE", "scala")

@pytest.fixture(autouse=True, scope="function")
def override_snapshot_session(self, _snapshot_session):
# Replace the scope_key of the snapshot session to reference parent class' recorded snapshots
_snapshot_session.scope_key = _snapshot_session.scope_key.replace(
"TestKinesisMockScala", "TestKinesis"
)
# Ensure we load in the previously recorded state now that the scope key has been updated
_snapshot_session.recorded_state = _snapshot_session._load_state()


class TestKinesisPythonClient:
@markers.skip_offline
@markers.aws.only_localstack
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/cli/test_lpm.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,15 @@ def test_install_with_package(runner):
result = runner.invoke(cli, ["install", "kinesis-mock"])
assert result.exit_code == 0
assert os.path.exists(kinesismock_package.get_installed_dir())


@markers.skip_offline
def test_install_with_package_override(runner, monkeypatch):
from localstack import config
from localstack.services.kinesis.packages import kinesismock_scala_package

monkeypatch.setattr(config, "KINESIS_MOCK_PROVIDER_ENGINE", "scala")

result = runner.invoke(cli, ["install", "kinesis-mock"])
assert result.exit_code == 0
assert os.path.exists(kinesismock_scala_package.get_installed_dir())
Loading