Files
2026-07-13 12:24:33 +08:00

921 lines
28 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Standard
# Copyright (c) 2026 Samsung Electronics Co., Ltd.All Rights Reserved
#
# 2026/4/20 Add mock for hf3fs_fuse
# Wenwen Chen <wenwen.chen@samsung.com>
# Standard
from dataclasses import dataclass
from multiprocessing.shared_memory import SharedMemory
from pathlib import Path
from typing import Optional
from unittest.mock import patch
import asyncio
import importlib.util
import os
import random
import shlex
import socket
import subprocess
import time
# Third Party
import numpy as np
import pytest
import torch
# First Party
from lmcache.v1.cache_engine import LMCacheEngine, LMCacheEngineBuilder
from lmcache.v1.memory_allocators.mixed_memory_allocator import MixedMemoryAllocator
from lmcache.v1.metadata import LMCacheMetadata
if importlib.util.find_spec("pytest_benchmark") is None:
@pytest.fixture
def benchmark():
pytest.skip("pytest-benchmark is not installed")
@pytest.fixture(scope="session", autouse=True)
def disable_usage_tracking():
"""Keep the test suite from sending usage telemetry to the stats server.
Tests that exercise the telemetry itself (tests/test_usage_context.py)
re-enable it per-test via monkeypatch with an injected transport.
"""
os.environ["LMCACHE_TRACK_USAGE"] = "false"
yield
# This is to mock the constructor and destructor of
# MixedMemoryAllocator and PinMemoryAllocator to
# use pin_memory=True for their constructors and
# avoid calling cudaHostRegister and cudaHostUnregister
# which may throw an error if torch.empty returns a buffer
# that cannot be registered (which happens quicker on some machines,
# especially when torch is doing many allocations and frees)
# In production, using the cuda C++ API gives us a larger pinned buffer
# but for the tests, we do not need this so this mock leaves the unit tests
# functionally the same
"""
@pytest.fixture(autouse=True, scope="session")
def patch_mixed_allocator():
def fake_mixed_init(self, size: int, use_paging: bool = False, **kwargs):
# self.buffer = torch.empty(size, dtype=torch.uint8)
# ptr = self.buffer.data_ptr()
# err = torch.cuda.cudart().cudaHostRegister(ptr, size, 0)
# assert err == 0, (
# f"cudaHostRegister failed: {torch.cuda.cudart().cudaGetErrorString(err)}"
# )
self._unregistered = False
self.buffer = torch.empty(size, dtype=torch.uint8, pin_memory=True)
if use_paging:
assert "shape" in kwargs, (
"shape must be specified for paged memory allocator"
)
assert "dtype" in kwargs, (
"dtype must be specified for paged memory allocator"
)
assert "fmt" in kwargs, "fmt must be specified for paged memory allocator"
self.pin_allocator = PagedTensorMemoryAllocator(
tensor=self.buffer,
shape=kwargs["shape"],
dtype=kwargs["dtype"],
fmt=kwargs["fmt"],
)
else:
self.pin_allocator = TensorMemoryAllocator(self.buffer)
self.host_mem_lock = threading.Lock() if not use_paging else nullcontext()
self.buffer_allocator = BufferAllocator("cpu")
def fake_mixed_close(self):
if not self._unregistered:
torch.cuda.synchronize()
# torch.cuda.cudart().cudaHostUnregister(self.buffer.data_ptr())
self._unregistered = True
with (
patch(
"lmcache.v1.memory_allocators.mixed_memory_allocator.MixedMemoryAllocator.__init__",
fake_mixed_init,
),
patch(
"lmcache.v1.memory_allocators.mixed_memory_allocator.MixedMemoryAllocator.close",
fake_mixed_close,
),
):
yield
@pytest.fixture(autouse=True, scope="session")
def patch_pin_allocator():
def fake_pin_init(self, size: int, use_paging: bool = False, **kwargs):
# self.buffer = torch.empty(size, dtype=torch.uint8)
# ptr = self.buffer.data_ptr()
# err = torch.cuda.cudart().cudaHostRegister(ptr, size, 0)
# assert err == 0, (
# f"cudaHostRegister failed: {torch.cuda.cudart().cudaGetErrorString(err)}"
# )
self._unregistered = False
self.buffer = torch.empty(size, dtype=torch.uint8, pin_memory=True)
if use_paging:
assert "shape" in kwargs, (
"shape must be specified for paged memory allocator"
)
assert "dtype" in kwargs, (
"dtype must be specified for paged memory allocator"
)
assert "fmt" in kwargs, "fmt must be specified for paged memory allocator"
self.allocator = PagedTensorMemoryAllocator(
tensor=self.buffer,
shape=kwargs["shape"],
dtype=kwargs["dtype"],
fmt=kwargs["fmt"],
)
else:
self.allocator = TensorMemoryAllocator(self.buffer)
self.host_mem_lock = threading.Lock() if not use_paging else nullcontext()
def fake_pin_close(self):
if not self._unregistered:
torch.cuda.synchronize()
# torch.cuda.cudart().cudaHostUnregister(self.buffer.data_ptr())
self._unregistered = True
with (
patch(
"lmcache.v1.memory_allocators.pin_memory_allocator.PinMemoryAllocator.__init__",
fake_pin_init,
),
patch(
"lmcache.v1.memory_allocators.pin_memory_allocator.PinMemoryAllocator.close",
fake_pin_close,
),
):
yield
"""
class MockSyncGlideClient:
"""In-memory mock of a synchronous Glide (Valkey) client."""
_store: dict[bytes, bytes] = {}
#: Records the ``expiry`` argument from the most recent ``set`` call so
#: tests can assert TTL behavior. ``None`` means no expiry was passed.
last_expiry = None
def set(self, key: bytes, value, expiry=None) -> None:
self._store[key] = bytes(value)
type(self).last_expiry = expiry
def get(self, key: bytes, buffer=None):
data = self._store.get(key)
if buffer is None:
return data
# Mirror glide's native buffer-GET: write into the caller's buffer and
# return the number of bytes read (an int count), or None on miss.
if data is None:
return None
n = len(data)
buffer[:n] = data
return n
def exists(self, keys: list[bytes]) -> int:
return sum(1 for k in keys if k in self._store)
@classmethod
def reset_store(cls) -> None:
cls._store.clear()
cls.last_expiry = None
class MockRedis:
def __init__(
self, host=None, port=None, url=None, decode_responses=False, **kwargs
):
self.store = {}
self.host = host
self.port = port
self.url = url
self.decode_responses = decode_responses
def set(self, key, value):
self.store[key] = value
return True
def get(self, key):
return self.store.get(key, None)
def exists(self, key):
return key in self.store
def delete(self, key):
return self.store.pop(key, None) is not None
def scan(self, cursor=0, match=None):
keys = [s.encode("utf-8") for s in self.store.keys()]
return (0, keys)
def close(self):
pass
@classmethod
def from_url(cls, url, decode_responses=False, **kwargs):
"""Mock implementation of Redis.from_url"""
return cls(url=url, decode_responses=decode_responses, **kwargs)
@classmethod
def from_pool(cls, pool, **kwargs):
"""Mock implementation of Redis.from_pool"""
return cls(**kwargs)
class MockAsyncRedis(MockRedis):
"""Async version of MockRedis"""
async def set(self, key, value):
self.store[key] = value
return True
async def get(self, key):
return self.store.get(key, None)
async def exists(self, key):
return key in self.store
async def delete(self, key):
return self.store.pop(key, None) is not None
async def close(self):
pass
@classmethod
def from_url(cls, url, decode_responses=False, **kwargs):
"""Mock implementation of Redis.from_url"""
return cls(url=url, decode_responses=decode_responses, **kwargs)
@classmethod
def from_pool(cls, pool, **kwargs):
"""Mock implementation of Redis.from_pool"""
return cls(**kwargs)
class MockRedisSentinel:
def __init__(self, hosts_and_ports, socket_timeout=None, **kwargs):
self.hosts_and_ports = hosts_and_ports
self.socket_timeout = socket_timeout
# Create a shared store but separate instances for master/slave
self.shared_store = {}
self.master_redis = MockRedis()
self.slave_redis = MockRedis()
# Share the store between master and slave to simulate Redis Sentinel behavior
self.master_redis.store = self.shared_store
self.slave_redis.store = self.shared_store
def master_for(
self, service_name, socket_timeout=None, username=None, password=None, **kwargs
):
return self.master_redis
def slave_for(
self, service_name, socket_timeout=None, username=None, password=None, **kwargs
):
return self.slave_redis
class MockRESPClient:
"""In-memory mock of RESPClient so RESP connector tests never hit real Redis."""
def __init__(
self,
host: str,
port: int,
num_workers: int,
loop=None,
username: str = "",
password: str = "",
):
self._store: dict[str, bytes] = {} # key -> bytes
self._loop = loop
self._closed = False
self._username = username
self._password = password
async def exists(self, key: str) -> bool:
return key in self._store
def exists_sync(self, key: str) -> bool:
if self._loop is None:
return key in self._store
fut = asyncio.run_coroutine_threadsafe(self.exists(key), self._loop)
return fut.result(timeout=10.0)
def _copy_into_buf(self, buf: memoryview, data: bytes) -> None:
"""
Copy bytes into buffer; buf may be non-byte or multi-dimensional
(e.g. from bfloat16 tensor).
"""
view = buf.cast("B")
n = len(data)
try:
view[:n] = data
except (NotImplementedError, TypeError, ValueError):
# Multi-dimensional or non-contiguous:
# write via flat numpy view (same memory)
arr = np.asarray(view, dtype=np.uint8, copy=False)
arr.flat[:n] = np.frombuffer(data, dtype=np.uint8, count=n)
async def get(self, key: str, buf: memoryview) -> None:
data = self._store.get(key)
if data is None:
raise RuntimeError("key not found")
self._copy_into_buf(buf, data)
async def set(self, key: str, buf: memoryview) -> None:
self._store[key] = bytes(buf.cast("B"))
async def batch_get(self, keys: list, bufs: list) -> None:
if len(keys) != len(bufs):
raise ValueError("keys and bufs length mismatch")
for k, b in zip(keys, bufs, strict=False):
data = self._store.get(k)
if data is None:
raise RuntimeError("key not found")
self._copy_into_buf(b, data)
async def batch_set(self, keys: list, bufs: list) -> None:
if len(keys) != len(bufs):
raise ValueError("keys and bufs length mismatch")
for k, b in zip(keys, bufs, strict=False):
self._store[k] = bytes(b.cast("B"))
async def batch_exists(self, keys: list) -> list:
return [k in self._store for k in keys]
def batch_exists_sync(self, keys: list) -> list:
if self._loop is None:
return [k in self._store for k in keys]
fut = asyncio.run_coroutine_threadsafe(self.batch_exists(keys), self._loop)
return fut.result(timeout=10.0)
def close(self) -> None:
self._closed = True
self._store.clear()
class MockRedisCluster:
def __init__(
self, startup_nodes=None, max_connections=None, decode_responses=False, **kwargs
):
self.startup_nodes = startup_nodes or []
self.max_connections = max_connections
self.decode_responses = decode_responses
self.store = {}
async def set(self, key, value):
self.store[key] = value
return True
async def get(self, key):
return self.store.get(key, None)
async def exists(self, key):
return key in self.store
async def delete(self, key):
return self.store.pop(key, None) is not None
async def close(self):
pass
class MockHf3fsIoring:
"""Mock I/O ring that stores data to local temp storage.
This mock implements the same interface as the real hf3fs ioring,
but uses local filesystem for actual I/O operations.
"""
def __init__(
self,
mount_point: str,
entries: int,
for_read: bool = True,
timeout: int = 200,
numa: int = -1,
):
self.mount_point = mount_point
self.entries = entries
self.for_read = for_read
self.timeout = timeout
self.numa = numa
self._prepared_iov = None
self._prepared_is_read: Optional[bool] = None
self._prepared_fd: Optional[int] = None
self._prepared_offset: Optional[int] = None
self._result_bytes = 0
def prepare(self, iov, is_read: bool, fd: int, offset: int):
"""Prepare I/O operation - stores parameters for later use."""
self._prepared_iov = iov
self._prepared_is_read = is_read
self._prepared_fd = fd
self._prepared_offset = offset
def submit(self):
"""Submit I/O operation - performs actual file I/O.
For write operations: writes data from shared memory to file.
For read operations: reads data from file into shared memory.
"""
# Standard
import sys
if self._prepared_iov is None or self._prepared_fd is None:
return self
try:
# Get the shared memory buffer from iov
shm = self._prepared_iov.shm
if shm is None:
print(
f"MockHf3fsIoring.submit: shm is None, "
f"_prepared_iov={self._prepared_iov}",
file=sys.stderr,
)
return self
# Get the length of data to transfer
slice_start = 0
if hasattr(self._prepared_iov, "slice_start"):
slice_start = self._prepared_iov.slice_start
slice_end = None
if hasattr(self._prepared_iov, "slice_end"):
slice_end = self._prepared_iov.slice_end
if slice_end is not None:
length = slice_end - slice_start
elif slice_start > 0:
length = shm.size - slice_start if hasattr(shm, "size") else 4096
else:
length = shm.size if hasattr(shm, "size") else 4096
if self._prepared_is_read:
os.lseek(self._prepared_fd, self._prepared_offset, os.SEEK_SET)
# Read data from file
data = os.read(self._prepared_fd, length)
# Write data to shared memory using slice range
if slice_end:
shm.buf[slice_start:slice_end] = data
else:
shm.buf[slice_start:] = data
self._result_bytes = len(data)
else:
# Get data from shared memory using slice range
if slice_end:
data = bytes(shm.buf[slice_start:slice_end])
else:
data = bytes(shm.buf[slice_start:])
os.lseek(self._prepared_fd, self._prepared_offset, os.SEEK_SET)
# Write data to file
bytes_written = os.write(self._prepared_fd, data)
self._result_bytes = bytes_written
except Exception as e:
# Log error but don't fail - return 0 bytes
# Standard
import traceback
print(f"MockHf3fsIoring.submit error: {e}", file=sys.stderr)
traceback.print_exc(file=sys.stderr)
self._result_bytes = 0
return self
def wait(self, min_results: int = 1):
"""Wait for I/O completion and return results.
Returns:
List of MockIorResult, each with .result containing bytes transferred
"""
class MockIorResult:
def __init__(self, result: int):
self.result = result
return [MockIorResult(self._result_bytes)]
class MockHf3fsIovec:
"""Mock I/O vector for HF3FS operations."""
def __init__(
self,
shm: SharedMemory,
hf3fs_mount_point: str,
numa: int = -1,
slice_start: int = 0,
slice_end: Optional[int] = None,
):
self.shm = shm
self.mount_point = hf3fs_mount_point
self.numa = numa
self.slice_start = slice_start
self.slice_end = slice_end # None means full range
def __getitem__(self, index):
# Support slicing operation, e.g., iov[:length]
if isinstance(index, slice):
start = index.start or 0
end = index.stop
return MockHf3fsIovec(self.shm, self.mount_point, self.numa, start, end)
return self
def __len__(self):
if self.slice_end is not None:
return self.slice_end - self.slice_start
elif self.slice_start > 0:
return getattr(self.shm, "size", 4096) - self.slice_start
return getattr(self.shm, "size", 4096)
class MockHf3fsFuse:
"""Mock for hf3fs_fuse.io module functions."""
@staticmethod
def extract_mount_point(path: str) -> str:
"""Mock extract_mount_point - returns the path as-is if it exists.
In real 3FS, this function extracts the actual 3FS mount point from a path.
For testing, we just return the path if it exists, or return as-is.
"""
p = Path(path)
if p.exists():
return str(p.resolve())
return path
@staticmethod
def make_iovec(shm: SharedMemory, hf3fs_mount_point: str, numa: int = -1):
"""Mock make_iovec - creates a mock I/O vector."""
return MockHf3fsIovec(shm, hf3fs_mount_point, numa)
@staticmethod
def make_ioring(
mount_point: str,
entries: int,
for_read: bool = True,
timeout: int = 200,
numa: int = -1,
):
"""Mock make_ioring - creates a mock I/O ring."""
return MockHf3fsIoring(mount_point, entries, for_read, timeout, numa)
@staticmethod
def register_fd(fd: int):
"""Mock register_fd - no-op for testing."""
pass
@staticmethod
def deregister_fd(fd: int):
"""Mock deregister_fd - no-op for testing."""
pass
@dataclass
class LMCacheServerProcess:
server_url: str
server_process: object
@pytest.fixture(scope="function", autouse=True)
def mock_redis():
with (
patch("redis.Redis", MockRedis) as mock_redis_class,
patch("redis.from_url", MockRedis.from_url),
patch("redis.asyncio.Redis", MockAsyncRedis),
patch("redis.asyncio.from_url", MockAsyncRedis.from_url),
patch("redis.asyncio.ConnectionPool.from_url", lambda url, **kwargs: None),
patch("redis.asyncio.Redis.from_pool", MockAsyncRedis.from_pool),
):
yield mock_redis_class
@pytest.fixture(scope="function", autouse=True)
def mock_redis_sentinel():
with (
patch("redis.Sentinel", MockRedisSentinel) as mock,
patch("redis.asyncio.Sentinel", MockRedisSentinel),
):
yield mock
@pytest.fixture(scope="function", autouse=True)
def mock_redis_cluster():
with patch("redis.asyncio.cluster.RedisCluster", MockRedisCluster) as mock:
yield mock
@pytest.fixture(scope="function", autouse=True)
def mock_hf3fs():
"""Mock hf3fs_fuse.io module for testing without 3FS filesystem.
This fixture creates a fake hf3fs_fuse.io module in sys.modules before
any imports happen, allowing tests to run without the actual package.
"""
# Standard
from types import ModuleType
import sys
# Create a fake hf3fs_fuse module if it doesn't exist
hf3fs_fuse = ModuleType("hf3fs_fuse")
hf3fs_fuse.__file__ = "/mock/hf3fs_fuse/__init__.py"
# Create hf3fs_fuse.io module
hf3fs_io = ModuleType("hf3fs_fuse.io")
hf3fs_io.__file__ = "/mock/hf3fs_fuse/io.py"
# Add mock functions to hf3fs_fuse.io
hf3fs_io.extract_mount_point = MockHf3fsFuse.extract_mount_point
hf3fs_io.make_iovec = MockHf3fsFuse.make_iovec
hf3fs_io.make_ioring = MockHf3fsFuse.make_ioring
hf3fs_io.register_fd = MockHf3fsFuse.register_fd
hf3fs_io.deregister_fd = MockHf3fsFuse.deregister_fd
# Register modules in sys.modules
sys.modules["hf3fs_fuse"] = hf3fs_fuse
sys.modules["hf3fs_fuse.io"] = hf3fs_io
yield
# Cleanup: remove the mock modules after test
sys.modules.pop("hf3fs_fuse", None)
sys.modules.pop("hf3fs_fuse.io", None)
@pytest.fixture(scope="module")
def lmserver_v1_process(request):
def ensure_connection(host, port):
retries = 10
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
successful = False
while retries > 0:
retries -= 1
try:
print("Probing connection, remaining retries: ", retries)
client_socket.connect((host, port))
successful = True
break
except ConnectionRefusedError:
time.sleep(1)
print("Connection refused!")
continue
except Exception as e:
print(f"other Exception: {e}")
continue
client_socket.close()
return successful
# Specify remote device
device = request.param
# Start the process
max_retries = 5
while max_retries > 0:
max_retries -= 1
port_number = random.randint(10000, 65500)
print("Starting the lmcache v1 server process on port")
proc = subprocess.Popen(
shlex.split(
f"python3 -m lmcache.v1.server localhost {port_number} {device}"
)
)
# Wait for lmcache process to start
time.sleep(5)
successful = False
if proc.poll() is not None:
successful = True
else:
successful = ensure_connection("localhost", port_number)
if not successful:
proc.terminate()
proc.wait()
else:
break
# Yield control back to the test until it finishes
server_url = f"lm://localhost:{port_number}"
yield LMCacheServerProcess(server_url, proc)
# Terminate the process
proc.terminate()
proc.wait()
# Destroy remote disk path
if device not in ["cpu"]:
subprocess.run(shlex.split(f"rm -rf {device}"))
@pytest.fixture(scope="function")
def autorelease(request):
objects = []
def _factory(obj):
objects.append(obj)
return obj
yield _factory
# Cleanup all objects created by the factory
for obj in objects:
obj.close()
@pytest.fixture(scope="function")
def autorelease_v1(request):
objects = []
def _factory(obj, **kwargs):
if isinstance(obj, LMCacheEngine):
obj.post_init(**kwargs)
objects.append(obj)
return obj
yield _factory
LMCacheEngineBuilder.destroy("test")
# Cleanup all objects created by the factory
# IMPORTANT: We must close connectors to ensure AsyncPQExecutor and other
# async resources are properly cleaned up
# NOTE: Skip LMCacheEngine instances since destroy() already calls close()
for obj in objects:
if isinstance(obj, LMCacheEngine):
continue
try:
# Check if object has a close method
if hasattr(obj, "close"):
obj.close()
except Exception as e:
# Log but don't fail the test
print("Error during close obj:%s - %s", obj, e)
@pytest.fixture(scope="session")
def memory_allocator():
"""One MixedMemoryAllocator (5GB) for the whole test session;
.close() is a no-op per-test."""
_real = MixedMemoryAllocator(5 * 1024 * 1024 * 1024) # 5GB
class _NoCloseWrapper:
def __init__(self, real):
self._real = real
def __getattr__(self, name):
return getattr(self._real, name)
def close(self):
# No-op so per-test close() calls don't shut down the shared allocator
pass
try:
yield _NoCloseWrapper(_real)
finally:
# Actually close once when the session ends
_real.close()
@pytest.fixture(autouse=True) # function-scoped by default
def use_shared_allocator(request, monkeypatch, memory_allocator):
"""Default: patch. Opt out with @pytest.mark.no_shared_allocator."""
if request.node.get_closest_marker("no_shared_allocator"):
# do NOT patch for this test
yield
return
def _create_shared_allocator(config, metadata, numa_mapping):
return memory_allocator
monkeypatch.setattr(
LMCacheEngineBuilder,
"_Create_memory_allocator",
_create_shared_allocator,
)
yield
@pytest.fixture(scope="function")
def lmcache_engine_metadata(role="worker"):
"""Create a fresh LMCacheMetadata for each test."""
return LMCacheMetadata(
model_name="test_model",
world_size=1,
local_world_size=1,
worker_id=0,
local_worker_id=0,
kv_dtype=torch.bfloat16,
kv_shape=(32, 2, 256, 32, 128),
use_mla=False,
role=role,
)
@pytest.fixture(scope="session")
def bigtable_emulator():
"""Start or connect to the Bigtable emulator for integration testing."""
# Standard
import os
import shutil
import subprocess
import time
existing_host = os.environ.get("BIGTABLE_EMULATOR_HOST")
if existing_host:
print(f"\n[Fixture] Reusing existing Bigtable emulator at {existing_host}...")
yield existing_host
return
if os.environ.get("CI") == "true" or os.environ.get("GITHUB_ACTIONS") == "true":
pytest.skip("Skipping Bigtable emulator integration tests in CI")
if not shutil.which("gcloud"):
pytest.skip(
"gcloud CLI not found, skipping Bigtable Emulator integration tests"
)
port = "8899"
host_port = f"localhost:{port}"
print(f"\n[Fixture] Starting Bigtable emulator on {host_port}...")
emulator_process = subprocess.Popen(
[
"gcloud",
"beta",
"emulators",
"bigtable",
"start",
f"--host-port={host_port}",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
os.environ["BIGTABLE_EMULATOR_HOST"] = host_port
def is_port_open(h: str, p: int, t: float = 1.0) -> bool:
# Standard
import socket
try:
with socket.create_connection((h, p), timeout=t):
return True
except OSError:
return False
# Wait for the emulator to initialize
port_num = int(port)
start_time = time.time()
success = False
while time.time() - start_time < 10.0:
if is_port_open("localhost", port_num):
success = True
break
time.sleep(0.5)
if not success:
print(
f"\n[Fixture] Bigtable emulator failed to bind to {host_port} within 10s!"
)
try:
stdout, stderr = emulator_process.communicate(timeout=2.0)
print(f"Stdout:\n{stdout.decode('utf-8', errors='ignore')}")
print(f"Stderr:\n{stderr.decode('utf-8', errors='ignore')}")
except Exception as e:
print(f"Could not retrieve process logs: {e}")
emulator_process.kill()
pytest.fail("Failed to start Bigtable emulator")
yield host_port
print("\n[Fixture] Stopping Bigtable emulator...")
emulator_process.terminate()
try:
emulator_process.wait(timeout=5)
except subprocess.TimeoutExpired:
emulator_process.kill()
if "BIGTABLE_EMULATOR_HOST" in os.environ:
del os.environ["BIGTABLE_EMULATOR_HOST"]