gunicorn/tests/test_dirty_stash.py
Benoit Chesneau 709a6ad159
feat(dirty): add stash - global shared state between workers (#3503)
* feat(dirty): add stash - global shared state between workers

Add a simple key-value store (stash) that allows dirty workers to share
state through the arbiter. Tables are stored directly in arbiter memory
for fast access and simplicity.

Features:
- Auto-create tables on first access
- Dict-like interface via stash.table()
- Pattern matching for keys (glob patterns)
- Module-level API: stash.put(), stash.get(), stash.delete(), etc.

Usage:
    from gunicorn.dirty import stash

    stash.put("sessions", "user:1", {"name": "Alice"})
    user = stash.get("sessions", "user:1")

    # Or dict-like
    sessions = stash.table("sessions")
    sessions["user:1"] = {"name": "Alice"}

New files:
- gunicorn/dirty/stash.py - Client API and StashTable class
- Protocol additions for MSG_TYPE_STASH and STASH_OP_* codes

Note: Tables are ephemeral - lost if arbiter restarts.

* test(dirty): add tests for stash protocol and encoding

Test coverage for:
- Stash message creation and encoding
- Protocol constants (MSG_TYPE_STASH, STASH_OP_*)
- Error classes (StashError, StashTableNotFoundError, StashKeyNotFoundError)
- StashTable dict-like interface
- Edge cases: unicode, complex values, special patterns

* example(dirty): add stash usage example and integration tests

- Add SessionApp to dirty_app.py demonstrating stash usage
- Add /session/* endpoints to wsgi_app.py
- Add test_stash_integration.py with Docker tests
- Update docker-compose.yml with stash-test service
- Fix: Set GUNICORN_DIRTY_SOCKET in dirty arbiter for worker access

* docs(dirty): add stash documentation
2026-02-12 21:45:49 +01:00

207 lines
6.6 KiB
Python

#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
"""Tests for dirty stash (shared state) functionality."""
import pytest
from gunicorn.dirty.stash import (
StashClient,
StashTable,
StashError,
StashTableNotFoundError,
StashKeyNotFoundError,
)
from gunicorn.dirty.protocol import (
BinaryProtocol,
DirtyProtocol,
MSG_TYPE_STASH,
STASH_OP_PUT,
STASH_OP_GET,
STASH_OP_DELETE,
STASH_OP_KEYS,
STASH_OP_CLEAR,
STASH_OP_INFO,
STASH_OP_ENSURE,
STASH_OP_DELETE_TABLE,
STASH_OP_TABLES,
STASH_OP_EXISTS,
make_stash_message,
)
class TestStashProtocol:
"""Test stash protocol encoding."""
def test_make_stash_message_basic(self):
"""Test basic stash message creation."""
msg = make_stash_message(123, STASH_OP_PUT, "test_table")
assert msg["type"] == "stash"
assert msg["id"] == 123
assert msg["op"] == STASH_OP_PUT
assert msg["table"] == "test_table"
def test_make_stash_message_with_key_value(self):
"""Test stash message with key and value."""
msg = make_stash_message(
456, STASH_OP_PUT, "sessions",
key="user:1", value={"name": "Alice"}
)
assert msg["key"] == "user:1"
assert msg["value"] == {"name": "Alice"}
def test_make_stash_message_with_pattern(self):
"""Test stash message with pattern."""
msg = make_stash_message(
789, STASH_OP_KEYS, "sessions",
pattern="user:*"
)
assert msg["pattern"] == "user:*"
def test_encode_stash_message(self):
"""Test binary encoding of stash message."""
msg = make_stash_message(
123, STASH_OP_PUT, "test",
key="k", value="v"
)
encoded = BinaryProtocol._encode_from_dict(msg)
assert isinstance(encoded, bytes)
assert len(encoded) > 16 # Header + payload
def test_stash_message_roundtrip(self):
"""Test encode/decode roundtrip for stash message."""
original = make_stash_message(
12345, STASH_OP_GET, "cache",
key="my_key"
)
encoded = BinaryProtocol._encode_from_dict(original)
msg_type, request_id, payload = BinaryProtocol.decode_message(encoded)
assert msg_type == "stash"
assert payload["op"] == STASH_OP_GET
assert payload["table"] == "cache"
assert payload["key"] == "my_key"
def test_stash_operations_have_unique_codes(self):
"""Test that all stash operations have unique codes."""
ops = [
STASH_OP_PUT,
STASH_OP_GET,
STASH_OP_DELETE,
STASH_OP_KEYS,
STASH_OP_CLEAR,
STASH_OP_INFO,
STASH_OP_ENSURE,
STASH_OP_DELETE_TABLE,
STASH_OP_TABLES,
STASH_OP_EXISTS,
]
assert len(ops) == len(set(ops))
class TestStashTable:
"""Test StashTable dict-like interface."""
def test_stash_table_name(self):
"""Test StashTable name property."""
# Create a mock client
class MockClient:
pass
table = StashTable(MockClient(), "test_table")
assert table.name == "test_table"
class TestStashErrors:
"""Test stash error classes."""
def test_stash_error_base(self):
"""Test base StashError."""
error = StashError("test error")
assert str(error) == "test error"
assert isinstance(error, Exception)
def test_stash_table_not_found_error(self):
"""Test StashTableNotFoundError."""
error = StashTableNotFoundError("my_table")
assert error.table_name == "my_table"
assert "my_table" in str(error)
def test_stash_key_not_found_error(self):
"""Test StashKeyNotFoundError."""
error = StashKeyNotFoundError("my_table", "my_key")
assert error.table_name == "my_table"
assert error.key == "my_key"
assert "my_key" in str(error)
class TestStashProtocolConstants:
"""Test protocol constants for stash."""
def test_msg_type_stash_exists(self):
"""Test MSG_TYPE_STASH constant exists."""
assert MSG_TYPE_STASH == 0x10
def test_dirty_protocol_exports_stash_type(self):
"""Test DirtyProtocol exports stash type."""
assert DirtyProtocol.MSG_TYPE_STASH == "stash"
def test_stash_op_codes(self):
"""Test stash operation codes are integers."""
assert isinstance(STASH_OP_PUT, int)
assert isinstance(STASH_OP_GET, int)
assert isinstance(STASH_OP_DELETE, int)
assert isinstance(STASH_OP_KEYS, int)
assert isinstance(STASH_OP_CLEAR, int)
assert isinstance(STASH_OP_INFO, int)
assert isinstance(STASH_OP_ENSURE, int)
assert isinstance(STASH_OP_DELETE_TABLE, int)
assert isinstance(STASH_OP_TABLES, int)
assert isinstance(STASH_OP_EXISTS, int)
class TestStashEncodingEdgeCases:
"""Test edge cases in stash encoding."""
def test_encode_empty_table_name(self):
"""Test encoding with empty table name."""
msg = make_stash_message(1, STASH_OP_TABLES, "")
encoded = BinaryProtocol._encode_from_dict(msg)
assert isinstance(encoded, bytes)
def test_encode_unicode_table_name(self):
"""Test encoding with unicode table name."""
msg = make_stash_message(1, STASH_OP_PUT, "テスト", key="k", value="v")
encoded = BinaryProtocol._encode_from_dict(msg)
_, _, payload = BinaryProtocol.decode_message(encoded)
assert payload["table"] == "テスト"
def test_encode_complex_value(self):
"""Test encoding with complex nested value."""
value = {
"name": "test",
"count": 42,
"nested": {"a": [1, 2, 3]},
"data": b"binary data",
}
msg = make_stash_message(1, STASH_OP_PUT, "test", key="k", value=value)
encoded = BinaryProtocol._encode_from_dict(msg)
_, _, payload = BinaryProtocol.decode_message(encoded)
assert payload["value"] == value
def test_encode_none_key(self):
"""Test encoding with None key (for table-level ops)."""
msg = make_stash_message(1, STASH_OP_TABLES, "")
assert "key" not in msg
def test_encode_special_characters_in_pattern(self):
"""Test encoding with special characters in pattern."""
msg = make_stash_message(
1, STASH_OP_KEYS, "test",
pattern="user:*:session:?"
)
encoded = BinaryProtocol._encode_from_dict(msg)
_, _, payload = BinaryProtocol.decode_message(encoded)
assert payload["pattern"] == "user:*:session:?"