This commit is contained in:
osukhoroslov committed 2026-11-07 22:19:18 +03:00
1 parent 0bf71ed374
commit 8963ac82d5
13 files changed
+5206

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
# Распределенное key-value хранилище с шардингом
В этом задании вам предстоит реализовать распределенную систему, в которой можно хранить записи вида _(ключ, значение)_. Ключами и значениями могут быть произвольные строки. В системе может храниться только одна запись с данным ключом. Для каких-то ключей записи могут отсутствовать. Распределение используемых ключей может быть любым, необязательно равномерным. Система должна поддерживать стандартные операции для работы с данными в подобных key-value хранилищах: записать значение для ключа, прочитать значение по ключу и удалить ключ.
Система состоит из нескольких идентичных узлов, на каждом из которых запущен процесс _StorageNode_. Для поддержки горизонтального масштабирования каждый узел отвечает за хранение только некоторой части данных. А именно, для любого возможного значения ключа должен быть определен (один и только один) узел, "отвечающий" за хранение соответствующей записи. Таким образом, каждый узел в системе отвечает за некоторое подмножество ключей и хранение связанных с этими ключами записей. Хранить данные на диске не требуется, достаточно держать все записи в памяти.
Нагрузка на узлы системы должна распределяться равномерно. А именно, каждый узел должен хранить примерно одинаковую долю записей - в идеале _R/N_ записей, где _R_ - суммарное число хранимых записей, а _N_ - число узлов. В тестах допустимым считается отклонение не более чем на 10% от идеального значения.
В ходе работы системы в неё могут добавляться новые узлы, а существующие - удаляться. Добавление и удаление узлов происходит по команде администратора. При этом каждому узлу в системе отправляется соответствующее уведомление. Как обычно, узлы также могут внезапно отказывать. Чтобы не создавать зависимость от предыдущего ДЗ, будем считать, что отказы узлов уже обнаруживаются и обрабатываются каким-то образом. Соответственно, от вас не требуется реализовывать функционал group membership. В случае отказа узла и невозможности быстро восстановить его работу, этот узел может быть удален из системы через описанный выше механизм. Хранимые на отказавшем узле записи становятся временно недоступны или теряются насовсем. Эту проблему мы будем решать позже в рамках следующего задания.
При изменении состава узлов должна происходить _перебалансировка_ - перераспределение ключей (зон ответственности) и записей (хранимых данных) между узлами для восстановления ранее описанных свойств. Во-первых, для любого возможного значения ключа должен существовать узел, отвечающий за него. Во-вторых, каждый узел по-прежнему должен хранить примерно одинаковую долю записей. Таким образом, при добавлении нового узла, он должен принять на себя некоторую часть ключей и хранимых записей. А при отключении узла, ключи, за которые он отвечал, и хранимые им записи должны распределяться между оставшимися узлами.
Перебалансировка сопряжена с расходами на перемещение хранимых данных между узлами. Требуется минимизировать эти расходы - в идеале должно перемещаться только _R/N_ записей. В тестах допустимым считается отклонение не более чем на 10% от идеального значения.
Клиенты системы не знают о распределении данных между узлами и могут отправлять свои запросы к любому узлу. Соответственно узел должен уметь обслуживать запросы на чтение и запись к _любым_ ключам. Если узел не отвечает за данный ключ, то он должен переслать запрос правильному узлу, дождаться ответа и вернуть его локальному клиенту. Таймауты и ошибки в ответах поддерживать необязательно.
Каждый узел должен локально определять владельца любого ключа, без необходимости делать дополнительные пересылки. Такая маршрутизация называется _zero-hop_. Для полного балла поиск владельца должен занимать _O(log N)_ или меньше, где _N_ - число узлов. Объем используемой для поиска памяти не должен зависеть от числа хранимых записей, поэтому просто хранить набор пар «ключ → узел» для всех ключей нельзя.
Если вам плохо понятны некоторые требования, изучите соответствующие тесты - это часть условия задания.
## Реализация
Для реализации и тестирования решения используется фреймворк AnySystem (см. материалы первого семинара). В папке `solution` размещена заготовка для решения [node.py](solution/node.py). Вам надо доработать реализацию класса `StorageNode` так, чтобы проходили все тесты.
При инициализации узлу передается его уникальный id, а также список id всех узлов в системе на данный момент. В начале теста узлы инициализируются одновременно, то есть каждый из них получает полный список узлов на момент старта системы.
Узел должен поддерживать обработку следующих локальных сообщений (форматы запросов и ответов описаны в заготовке):
- _GET(key)_ - вернуть значение записи с ключом `key` или `null`, если ключ отсутствует,
- _PUT(key, value)_ - сохранить запись с ключом `key` и значением `value`,
- _DELETE(key)_ - удалить запись с ключом `key` и вернуть прежнее значение или `null`, если ключ отсутствует,
- _NODE_ADDED_ - уведомление о добавлении нового узла в систему,
- _NODE_REMOVED_ - уведомление об удалении узла из системы,
- _COUNT_RECORDS_ - возвращает число записей, хранимых на данном узле (используется для тестирования),
- _DUMP_KEYS_ - возвращает ключи записей, хранимых на данном узле (используется для тестирования).
Для взаимодействия между узлами вы можете использовать любые собственные типы сообщений.
Ваши реализации процессов AnySystem должны соблюдать правило [изоляции процессов](../readme.md#изоляция-процессов-anysystem): изменяемое состояние каждого процесса должно храниться только в `self`, а общая изменяемая память между процессами запрещена. Все взаимодействия между процессами должны происходить через сообщения, как и в реальной распределённой системе.
### Рекомендации и замечания
1. Как вы могли догадаться, распределять по шардам необходимо только запросы _GET_, _PUT_ и _DELETE_. Кроме того, необходимо реализовать перебалансировку ключей при получении _NODE_ADDED_ или _NODE_REMOVED_.
2. Вашей реализации необязательно поддерживать узлы в консистентном состоянии в любой момент времени в процессе перебалансировки - достаточно, чтобы они пришли в такое состояние через какое-то ограниченное число сообщений.
3. Во время перебалансировки при добавлении узла система должна обрабатывать _GET_, _PUT_ и _DELETE_, поступающие на любой узел, включая новый. В тестах NODE ADDED DURING... проверяются такие запросы одного типа. Ответ можно отложить до завершения переноса. Перенос данных не должен затирать новые значения или восстанавливать удалённые записи.
4. Учтите, что в тестах окончание перебалансировки определяется по косвенным признакам, таким как отсутствие изменений числа хранимых записей на узлах (см. функцию `step_until_stabilized`), поскольку мы не стали требовать от вас возвращать явные ответы на _NODE_ADDED_ и _NODE_REMOVED_.
5. Не требуется реализовывать подтверждения и ретраи передаваемых между узлами запросов, в тестах сеть надежная.
## Тестирование
### Локальное тестирование
Тесты находятся в папке `tests`. Есть два варианта их запуска.
Рекомендуемый вариант запуска тестов - через готовый Docker-образ. В этом случае используемое окружение будет аналогично тестирующей системе. Для запуска тестов выполните команду:
```commandline
docker run --pull always --rm -t -v ./solution:/solution distsys.ru/course/kv-sharding:latest [ЗДЕСЬ МОЖНО УКАЗАТЬ ОПЦИИ]
```
Для запуска полного набора тестов с теми же параметрами, что и в тестирующей системе, выполните команду:
```commandline
docker run --pull always --rm -t -v ./solution:/solution distsys.ru/course/kv-sharding:latest
```
Вы также можете запустить тесты, скомпилировав их локально с помощью компилятора Rust. Такой вариант может быть удобен, если вы хотите лучше изучить или доработать тесты. Скомпилируйте тесты с помощью команды `cargo install --locked --path tests`. Для запуска тестов выполните команду:
```commandline
distsys-kv-sharding [ЗДЕСЬ МОЖНО УКАЗАТЬ ОПЦИИ]
```
Запустить только один из тестов можно с помощью опции `-t`. По умолчанию вывод тестов не содержит трассы (последовательности событий во время выполнения каждого из тестов), а только финальную сводку. Включить вывод трасс можно с помощью флага `-d`. Все доступные опции можно посмотреть с помощью флага `-h`.
В тестах MC NORMAL и MC NODE REMOVED вы можете столкнуться с ошибкой "state depth exceeds maximum allowed depth...". Эти тесты основаны на model checking (см. первый семинар) и, чтобы ограничить время тестирования, глубина проверяемых состояний системы в них ограничена. Соответственно, если за заданное число шагов обработка запроса не завершится, то будет выдана эта ошибка. Ограничения на глубину заданы так, чтобы в них легко укладывалась эффективное решение. В случае такой ошибки рекомендуется доработать решение, чтобы избавиться от избыточных действий.
Бонусы за пробелы в тестах начисляются по [общим правилам](../readme.md#бонусы-за-пробелы-в-тестах).
### Проверка в тестирующей системе
Отправьте ваше решение в тестирующую систему следуя [инструкции](../readme.md) и дождитесь результатов.
## Оценивание
Автоматические тесты приносят до 9 баллов. Если хотя бы один тест базового блока не пройден, итоговая оценка за задание - 0. После прохождения базового блока каждый из трёх остальных блоков приносит 2 балла, если пройдены все его тесты.
| Блок | Тесты | Баллы |
|---|---|---:|
| Базовые операции, маршрутизация и память | `SINGLE NODE`, `INSERTS`, `DELETES`, `ZERO HOP`, `MEMORY GROWTH`, `MC NORMAL` | 3 |
| Добавление и удаление узлов, перенос данных | `NODE ADDED`, `NODE REMOVED`, `NODE REMOVED AFTER CRASH`, `MC NODE REMOVED`, `MIGRATION`, `SCALE UP DOWN` | 2 |
| Запросы во время добавления узла | `NODE ADDED DURING INSERTS`, `NODE ADDED DURING DELETIONS`, `NODE ADDED DURING READS` | 2 |
| Равномерность распределения и число перемещённых записей | `DISTRIBUTION`, `DISTRIBUTION NODE ADDED`, `DISTRIBUTION NODE REMOVED` | 2 |
Ещё 1 балл можно получить на защите, если поиск владельца ключа занимает _O(log N)_ или меньше. Обоснуйте эту оценку в отчёте. Укажите также время обновления структуры маршрутизации и её расход памяти. Если используете виртуальные узлы, обоснуйте их число _V_ на физический узел и учтите _V_ в оценках сложности.
К решению приложите отчёт в файле `solution/readme.md` по [общим требованиям](../readme.md#отчёт). Без отчёта автоматические тесты запускаются, но защита не проводится и решение не засчитывается.
+102
View File
@@ -0,0 +1,102 @@
from anysystem import Context, Message, Process
from typing import List
class StorageNode(Process):
def __init__(self, node_id: str, nodes: List[str]):
self._id = node_id
self._nodes = set(nodes)
self._data = {}
def on_local_message(self, msg: Message, ctx: Context):
# Get value for the key.
# Request:
# GET {"key": "some key"}
# Response:
# GET_RESP {"key": "some key", "value": "value for this key"}
# GET_RESP {"key": "some key", "value": null} - if record for this key is not found
if msg.type == 'GET':
key = msg['key']
value = self._data.get(key)
resp = Message('GET_RESP', {
'key': key,
'value': value
})
ctx.send_local(resp)
# Store (key, value) record.
# Request:
# PUT {"key": "some key", "value: "some value"}
# Response:
# PUT_RESP {"key": "some key", "value: "some value"}
elif msg.type == 'PUT':
key = msg['key']
value = msg['value']
self._data[key] = value
resp = Message('PUT_RESP', {
'key': key,
'value': value
})
ctx.send_local(resp)
# Delete value for the key.
# Request:
# DELETE {"key": "some key"}
# Response:
# DELETE_RESP {"key": "some key", "value": "some value"}
elif msg.type == 'DELETE':
key = msg['key']
value = self._data.pop(key, None)
resp = Message('DELETE_RESP', {
'key': key,
'value': value
})
ctx.send_local(resp)
# Notification that a new node is added to the system.
# Request:
# NODE_ADDED {"id": "node id"}
# Response:
# N/A
elif msg.type == 'NODE_ADDED':
self._nodes.add(msg['id'])
# Notification that a node is removed from the system.
# Request:
# NODE_REMOVED {"id": "node id"}
# Response:
# N/A
elif msg.type == 'NODE_REMOVED':
self._nodes.remove(msg['id'])
# Get number of records stored on the node.
# Request:
# COUNT_RECORDS {}
# Response:
# COUNT_RECORDS_RESP {"count": 100}
elif msg.type == 'COUNT_RECORDS':
resp = Message('COUNT_RECORDS_RESP', {
'count': len(self._data)
})
ctx.send_local(resp)
# Get keys of records stored on the node.
# Request:
# DUMP_KEYS {}
# Response:
# DUMP_KEYS_RESP {"keys": ["key1", "key2", ...]}
elif msg.type == 'DUMP_KEYS':
resp = Message('DUMP_KEYS_RESP', {
'keys': list(self._data.keys())
})
ctx.send_local(resp)
def on_start(self, ctx: Context):
pass
def on_message(self, msg: Message, sender: str, ctx: Context):
# Implement node-to-node communication using any message types
pass
def on_timer(self, timer_name: str, ctx: Context):
pass
@@ -0,0 +1,5 @@
target
**/target
**/__pycache__
**/.pytest_cache
*.pyc
File diff suppressed because it is too large. Load diff
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "distsys-kv-sharding"
version = "0.1.0"
edition = "2021"
resolver = "3"
[dependencies]
anysystem = "=0.3.0"
indexmap = "2.12"
assertables = "3.2.2"
clap = { version = "3.2.17", features = ["cargo", "derive"] }
decorum = "0.3.1"
env_logger = "0.9.0"
log = "0.4.17"
pyo3 = { version = "=0.29.2", features = ["auto-initialize"] }
rand = "0.8.5"
rand_pcg = "0.3.1"
serde = { version = "1.0.142", features = ["derive"] }
serde_json = "1.0.83"
sugars = "3.0.1"
+19
View File
@@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1
FROM rust:1.97.1-bookworm@sha256:0e2bcaef56d041a486784e54104a81aebe0da44bd03019bd70bc0401e42e4a97 AS builder
RUN apt-get update && apt-get install -y build-essential python3-dev
WORKDIR /tests
COPY . .
RUN --mount=type=cache,id=distsys-course-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=distsys-course-cargo-target-rust-1-97-1,target=/tests/target,sharing=locked \
cargo install --locked --path . --root /opt/course
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y python3-dev && rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/course/bin/distsys-kv-sharding /usr/local/bin/distsys-kv-sharding
WORKDIR /solution
ENTRYPOINT ["timeout", "--verbose", "-k", "10", "600", "distsys-kv-sharding", "-i", "node.py"]
File diff suppressed because it is too large. Load diff
+125
View File
@@ -0,0 +1,125 @@
use std::collections::HashSet;
use rand::distributions::WeightedIndex;
use rand::prelude::*;
use rand_pcg::{Lcg128Xsl64, Pcg64};
use serde::{Deserialize, Serialize};
use sugars::boxed;
use anysystem::python::PyProcessFactory;
use anysystem::System;
#[derive(Serialize)]
pub struct GetReqMessage<'a> {
pub key: &'a str,
}
#[derive(Deserialize)]
pub struct GetRespMessage<'a> {
pub key: &'a str,
pub value: Option<&'a str>,
}
#[derive(Serialize)]
pub struct PutReqMessage<'a> {
pub key: &'a str,
pub value: &'a str,
}
#[derive(Deserialize)]
pub struct PutRespMessage<'a> {
pub key: &'a str,
pub value: &'a str,
}
#[derive(Serialize)]
pub struct DeleteReqMessage<'a> {
pub key: &'a str,
}
#[derive(Deserialize)]
pub struct DeleteRespMessage<'a> {
pub key: &'a str,
pub value: Option<&'a str>,
}
#[derive(Serialize)]
pub struct EmptyMessage {}
#[derive(Serialize)]
pub struct NodeMessage<'a> {
pub id: &'a str,
}
#[derive(Deserialize)]
pub struct DumpKeysRespMessage {
pub keys: HashSet<String>,
}
#[derive(Deserialize)]
pub struct CountRecordsRespMessage {
pub count: u64,
}
#[derive(Copy, Clone)]
pub struct TestConfig<'a> {
pub process_factory: &'a PyProcessFactory,
pub proc_count: u32,
pub seed: u64,
}
pub fn build_system(config: &TestConfig, measure_max_size: bool) -> System {
let mut sys = System::new(config.seed);
sys.network().set_delays(0.01, 0.1);
let mut proc_names = Vec::new();
for n in 0..config.proc_count {
proc_names.push(format!("{n}"));
}
for n in 0..config.proc_count {
let proc_name = proc_names[n as usize].clone();
let mut proc = config
.process_factory
.build((proc_name.clone(), proc_names.clone()), config.seed);
if measure_max_size {
proc.set_max_size_freq(1000000);
}
// process and node on which it runs have the same name
let node_name = proc_name.clone();
sys.add_node(&node_name);
sys.add_process(&proc_name, boxed!(proc), &node_name);
}
sys
}
pub fn add_node(name: &str, sys: &mut System, config: &TestConfig) {
let proc_name = name.to_string();
let mut proc_names = sys.process_names();
proc_names.push(proc_name.clone());
let proc = config
.process_factory
.build((proc_name.clone(), proc_names), config.seed);
let node_name = proc_name.clone();
sys.add_node(&node_name);
sys.add_process(&proc_name, boxed!(proc), &node_name);
}
pub fn random_proc(sys: &System, rand: &mut Lcg128Xsl64) -> String {
sys.process_names().choose(rand).unwrap().clone()
}
const SYMBOLS: [char; 36] = [
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's',
't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
];
const WEIGHTS: [usize; 36] = [
13, 16, 3, 8, 8, 5, 6, 23, 4, 8, 24, 12, 2, 1, 1, 10, 5, 8, 10, 1, 24, 3, 1, 8, 12, 22, 5, 20,
18, 5, 5, 2, 1, 3, 16, 22,
];
pub fn random_string(length: usize, rand: &mut Pcg64) -> String {
let dist = WeightedIndex::new(WEIGHTS).unwrap();
rand.sample_iter(&dist)
.take(length)
.map(|x| SYMBOLS[x])
.collect()
}
+254
View File
@@ -0,0 +1,254 @@
mod common;
mod python_seed;
mod shared_state;
mod tests;
mod tests_mc;
use indexmap::IndexMap;
use std::io::Write;
use clap::Parser;
use env_logger::Builder;
use log::LevelFilter;
use anysystem::python::PyProcessFactory;
use anysystem::test::{TestResult, TestSuite};
use crate::common::TestConfig;
use crate::tests::*;
use crate::tests_mc::*;
/// Sharded KV Store Homework Tests
#[derive(Parser, Debug)]
#[clap(about, long_about = None)]
struct Args {
/// Path to Python file with solution
#[clap(long = "impl", short = 'i', default_value = "solution/node.py")]
solution_path: String,
/// Test to run (optional)
#[clap(long = "test", short)]
test: Option<String>,
/// Print execution trace
#[clap(long, short)]
debug: bool,
/// Number of nodes used in tests
#[clap(long, short, default_value = "10")]
node_count: u32,
/// Random seed used in tests
#[clap(long, short, default_value = "123")]
seed: u64,
}
fn main() {
let args = Args::parse();
python_seed::configure(args.seed);
if !shared_state::validate_or_report(&args.solution_path, &["StorageNode"]) {
return;
}
if args.debug {
Builder::new()
.filter(Some("anysystem"), LevelFilter::Debug)
.format(|buf, record| writeln!(buf, "{}", record.args()))
.init();
}
let process_factory = PyProcessFactory::new(&args.solution_path, "StorageNode");
let config = TestConfig {
process_factory: &process_factory,
proc_count: args.node_count,
seed: args.seed,
};
let mut single_config = config;
single_config.proc_count = 1;
let mut mc_config = config;
mc_config.proc_count = 3;
let mut tests = TestSuite::new();
tests.add("SINGLE NODE", test_single_node, single_config);
tests.add("INSERTS", test_inserts, config);
tests.add("DELETES", test_deletes, config);
tests.add("MEMORY GROWTH", test_memory_growth, config);
tests.add("ZERO HOP", test_zero_hop, config);
tests.add("MC NORMAL", test_mc_normal, mc_config);
tests.add("NODE ADDED", test_node_added, config);
tests.add(
"NODE ADDED DURING INSERTS",
test_node_added_during_inserts,
config,
);
tests.add(
"NODE ADDED DURING DELETIONS",
test_node_added_during_deletions,
config,
);
tests.add(
"NODE ADDED DURING READS",
test_node_added_during_reads,
config,
);
tests.add("NODE REMOVED", test_node_removed, config);
tests.add(
"NODE REMOVED AFTER CRASH",
test_node_removed_after_crash,
config,
);
tests.add("MC NODE REMOVED", test_mc_node_removed, mc_config);
tests.add("MIGRATION", test_migration, config);
tests.add("SCALE UP DOWN", test_scale_up_down, config);
tests.add("DISTRIBUTION", test_distribution, config);
tests.add(
"DISTRIBUTION NODE ADDED",
test_distribution_node_added,
config,
);
tests.add(
"DISTRIBUTION NODE REMOVED",
test_distribution_node_removed,
config,
);
if let Some(test) = &args.test {
tests.run_test(test);
} else {
let (_, results) = tests.run();
let score = score(results);
println!("\nSCORE: {score}\n");
}
}
const BASIC_TESTS: &[&str] = &[
"SINGLE NODE",
"INSERTS",
"DELETES",
"MEMORY GROWTH",
"ZERO HOP",
"MC NORMAL",
];
const ADDITIONAL_GROUPS: &[(&str, &[&str])] = &[
(
"Node changes",
&[
"NODE ADDED",
"NODE REMOVED",
"NODE REMOVED AFTER CRASH",
"MC NODE REMOVED",
"MIGRATION",
"SCALE UP DOWN",
],
),
(
"Requests during node addition",
&[
"NODE ADDED DURING INSERTS",
"NODE ADDED DURING DELETIONS",
"NODE ADDED DURING READS",
],
),
(
"Distribution",
&[
"DISTRIBUTION",
"DISTRIBUTION NODE ADDED",
"DISTRIBUTION NODE REMOVED",
],
),
];
fn group_passed(name: &str, tests: &[&str], results: &mut IndexMap<String, TestResult>) -> bool {
let mut failed = Vec::new();
for &test in tests {
let result = results
.swap_remove(test)
.unwrap_or_else(|| panic!("missing result for {test}"));
if result.is_err() {
failed.push(test);
}
}
if failed.is_empty() {
println!("{name} tests: passed");
true
} else {
println!("{name} tests: not passed {failed:?}");
false
}
}
fn score(mut results: IndexMap<String, TestResult>) -> u32 {
let basic_passed = group_passed("Basic", BASIC_TESTS, &mut results);
let mut points = if basic_passed { 3 } else { 0 };
for &(name, tests) in ADDITIONAL_GROUPS {
if group_passed(name, tests, &mut results) && basic_passed {
points += 2;
}
}
assert!(results.is_empty(), "unscored tests: {:?}", results.keys());
points
}
#[cfg(test)]
mod scoring_tests {
use super::*;
fn score_with_failures(failed: &[&str]) -> u32 {
let mut results: IndexMap<String, TestResult> = BASIC_TESTS
.iter()
.chain(ADDITIONAL_GROUPS.iter().flat_map(|(_, tests)| tests.iter()))
.map(|&name| (name.to_string(), Ok(true)))
.collect();
for &name in failed {
*results.get_mut(name).unwrap() = Err("failed".to_string());
}
score(results)
}
#[test]
fn every_basic_test_is_required() {
for name in [
"SINGLE NODE",
"INSERTS",
"DELETES",
"MC NORMAL",
"ZERO HOP",
"MEMORY GROWTH",
] {
assert_eq!(score_with_failures(&[name]), 0, "{name}");
}
}
#[test]
fn additional_groups_earn_two_points_each() {
let change = "NODE ADDED";
let read = "NODE ADDED DURING READS";
let balance = "DISTRIBUTION NODE ADDED";
let cases: &[(&[&str], u32)] = &[
(&[], 9),
(&[change], 7),
(&[read], 7),
(&[balance], 7),
(&[change, read], 5),
(&[change, balance], 5),
(&[read, balance], 5),
(&[change, read, balance], 3),
];
for &(failures, expected) in cases {
assert_eq!(score_with_failures(failures), expected, "{failures:?}");
}
}
#[test]
fn concurrent_requests_are_scored_as_one_group() {
let requests = [
"NODE ADDED DURING INSERTS",
"NODE ADDED DURING DELETIONS",
"NODE ADDED DURING READS",
];
for name in requests {
assert_eq!(score_with_failures(&[name]), 7, "{name}");
}
assert_eq!(score_with_failures(&requests), 7);
}
}
@@ -0,0 +1,33 @@
//! Set Python's hash seed before the interpreter (including the validator) starts.
use std::env;
use std::process::Command;
pub fn configure(seed: u64) {
// Python accepts only 32-bit hash seeds; the simulator keeps the full u64 seed.
let hash_seed = (seed as u32).to_string();
if env::var("PYTHONHASHSEED").as_deref() == Ok(hash_seed.as_str()) {
return;
}
// Restart with an inherited environment instead of changing it at runtime:
// on Windows, Rust and CPython can use different copies of the environment.
let mut command = Command::new(env::current_exe().expect("cannot locate test executable"));
command
.args(env::args_os().skip(1))
.env("PYTHONHASHSEED", hash_seed);
// Preserve the PID so timeout/signals still reach the test process directly.
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let error = command.exec();
panic!("cannot restart tests with PYTHONHASHSEED: {error}");
}
#[cfg(not(unix))]
{
let status = command
.status()
.expect("cannot restart tests with PYTHONHASHSEED");
std::process::exit(status.code().unwrap_or(1));
}
}
@@ -0,0 +1,78 @@
use std::ffi::CString;
use pyo3::prelude::*;
use pyo3::types::{PyList, PyModule};
const VALIDATOR_CODE: &str = include_str!("../shared_state_validator.py");
fn validator_module<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyModule>> {
let code = CString::new(VALIDATOR_CODE).expect("validator source contains a null byte");
PyModule::from_code(
py,
&code,
c"shared_state_validator.py",
c"shared_state_validator",
)
}
#[cfg(test)]
fn validate_source(
source: &str,
filename: &str,
class_names: &[&str],
) -> Result<Vec<String>, String> {
Python::attach(|py| {
let module = validator_module(py).map_err(|error| error.to_string())?;
let class_names = PyList::new(py, class_names).map_err(|error| error.to_string())?;
module
.getattr("validate_source")
.and_then(|function| function.call1((source, filename, class_names)))
.and_then(|result| result.extract::<Vec<String>>())
.map_err(|error| error.to_string())
})
}
fn validate_solution(path: &str, class_names: &[&str]) -> Result<Vec<String>, String> {
Python::attach(|py| {
let module = validator_module(py).map_err(|error| error.to_string())?;
let class_names = PyList::new(py, class_names).map_err(|error| error.to_string())?;
module
.getattr("validate_solution")
.and_then(|function| function.call1((path, class_names)))
.and_then(|result| result.extract::<Vec<String>>())
.map_err(|error| error.to_string())
})
}
pub fn validate_or_report(path: &str, class_names: &[&str]) -> bool {
match validate_solution(path, class_names) {
Ok(violations) if violations.is_empty() => true,
Ok(violations) => {
println!("ERROR: shared state between AnySystem processes is forbidden");
for violation in violations {
println!("{violation}");
}
println!("\nSCORE: 0\n");
false
}
Err(error) => panic!("failed to validate solution for shared state: {error}"),
}
}
#[cfg(test)]
mod tests {
use super::validate_source;
#[test]
fn embeds_validator_and_rejects_shared_state() {
let violations = validate_source(
"class ProcessImpl:\n shared = {}\n",
"solution.py",
&["ProcessImpl"],
)
.unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].contains("ProcessImpl.shared"));
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,324 @@
use std::collections::HashSet;
use indexmap::IndexMap;
use rand::prelude::SliceRandom;
use rand::SeedableRng;
use rand_pcg::Pcg64;
use sugars::boxed;
use anysystem::logger::LogEntry;
use anysystem::mc::{
predicates::{collects, goals, invariants},
strategies::Bfs,
InvariantFn, McState, ModelChecker, StrategyConfig,
};
use anysystem::test::TestResult;
use anysystem::Message;
use crate::common::*;
pub fn test_mc_normal(config: &TestConfig) -> TestResult {
let sys = build_system(config, false);
let mut mc = ModelChecker::new(&sys);
let mut rand = Pcg64::seed_from_u64(config.seed);
let key = random_string(8, &mut rand).to_uppercase();
let value = random_string(8, &mut rand);
let max_steps = 10;
let mut proc = random_proc(&sys, &mut rand);
let achieved_states = check_mc_get(&mut mc, &proc, &key, None, max_steps, None)?;
proc = random_proc(&sys, &mut rand);
let achieved_states = check_mc_put(
&mut mc,
&proc,
&key,
&value,
max_steps,
Some(achieved_states),
)?;
proc = random_proc(&sys, &mut rand);
let achieved_states = check_mc_get(
&mut mc,
&proc,
&key,
Some(&value),
max_steps,
Some(achieved_states),
)?;
proc = random_proc(&sys, &mut rand);
let achieved_states = check_mc_delete(
&mut mc,
&proc,
&key,
Some(&value),
max_steps,
Some(achieved_states),
)?;
proc = random_proc(&sys, &mut rand);
let achieved_states =
check_mc_get(&mut mc, &proc, &key, None, max_steps, Some(achieved_states))?;
proc = random_proc(&sys, &mut rand);
check_mc_delete(&mut mc, &proc, &key, None, max_steps, Some(achieved_states))?;
Ok(true)
}
pub fn test_mc_node_removed(config: &TestConfig) -> TestResult {
let sys = build_system(config, false);
let mut mc = ModelChecker::new(&sys);
let mut rand = Pcg64::seed_from_u64(config.seed);
// insert random key-value pairs
let keys_count = 40;
let mut achieved_states = None;
let mut kv = IndexMap::new();
for _ in 0..keys_count {
let k = random_string(8, &mut rand).to_uppercase();
let v = random_string(8, &mut rand);
let proc = random_proc(&sys, &mut rand);
let res = check_mc_put(&mut mc, &proc, &k, &v, 10, achieved_states)?;
achieved_states = Some(res);
kv.insert(k, v);
}
// remove a node from the system
let removed_proc = random_proc(&sys, &mut rand);
achieved_states = Some(check_mc_node_removed(
&mut mc,
sys.process_names(),
&removed_proc,
15,
achieved_states.unwrap_or_else(|| panic!("no states found after {keys_count} GET queries")),
)?);
let alive_proc = sys
.process_names()
.into_iter()
.filter(|proc| *proc != removed_proc)
.collect::<Vec<String>>();
// check that all data is still in the storage
for (k, v) in kv {
let proc = alive_proc.choose(&mut rand).unwrap().clone();
achieved_states = Some(check_mc_get(
&mut mc,
&proc,
&k,
Some(&v),
10,
achieved_states,
)?);
}
Ok(true)
}
// UTILS ---------------------------------------------------------------------------------------------------------------
fn check_mc_get<S>(
mc: &mut ModelChecker,
proc: S,
key: S,
expected: Option<S>,
max_steps: u64,
start_states: Option<HashSet<McState>>,
) -> Result<HashSet<McState>, String>
where
S: Into<String> + Clone,
{
let proc = proc.into();
let key = key.into();
let expected = expected.map(|s| s.into());
let msg = Message::new("GET", &format!(r#"{{"key": "{key}"}}"#));
let proc_name = proc.clone();
let invariant = boxed!(move |state: &McState| {
for entry in state.current_run_trace().iter() {
if let LogEntry::McLocalMessageSent {
msg: message,
proc: proc_tmp,
} = entry
{
if *proc_tmp != proc {
return Err("local message received on wrong process".to_string());
}
if message.tip != "GET_RESP" {
return Err(format!("wrong type {}", message.tip));
}
let data: GetRespMessage =
serde_json::from_str(&message.data).map_err(|err| err.to_string())?;
if data.key != key {
return Err(format!("wrong key {}", data.key));
}
if data.value.map(|s| s.to_string()) != expected {
return Err(format!("wrong value {:?}", data.value));
}
}
}
Ok(())
});
mc_check_query(mc, proc_name, invariant, msg, start_states, max_steps)
}
fn check_mc_put<S>(
mc: &mut ModelChecker,
proc: S,
key: S,
value: S,
max_steps: u64,
start_states: Option<HashSet<McState>>,
) -> Result<HashSet<McState>, String>
where
S: Into<String>,
{
let proc = proc.into();
let key = key.into();
let value = value.into();
let proc_name = proc.clone();
let msg = Message::new("PUT", &format!(r#"{{"key": "{key}", "value": "{value}"}}"#));
let invariant = Box::new(move |state: &McState| {
for entry in state.current_run_trace().iter() {
if let LogEntry::McLocalMessageSent {
msg: message,
proc: proc_tmp,
} = entry
{
if *proc_tmp != proc {
return Err("local message received on wrong process".to_string());
}
if message.tip != "PUT_RESP" {
return Err(format!("wrong type {}", message.tip));
}
let data: PutRespMessage =
serde_json::from_str(&message.data).map_err(|err| err.to_string())?;
if data.key != key {
return Err(format!("wrong key {}", data.key));
}
if data.value != value {
return Err(format!("wrong value {:?}", data.value));
}
}
}
Ok(())
});
mc_check_query(mc, proc_name, invariant, msg, start_states, max_steps)
}
fn check_mc_delete<S>(
mc: &mut ModelChecker,
proc: S,
key: S,
expected: Option<S>,
max_steps: u64,
start_states: Option<HashSet<McState>>,
) -> Result<HashSet<McState>, String>
where
S: Into<String> + Clone,
{
let proc = proc.into();
let key = key.into();
let expected = expected.map(|s| s.into());
let msg = Message::new("DELETE", &format!(r#"{{"key": "{key}"}}"#));
let proc_name = proc.clone();
let invariant = Box::new(move |state: &McState| {
for entry in state.current_run_trace().iter() {
if let LogEntry::McLocalMessageSent {
msg: message,
proc: proc_tmp,
} = entry
{
if *proc_tmp != proc {
return Err("local message received on wrong process".to_string());
}
if message.tip != "DELETE_RESP" {
return Err(format!("wrong type {}", message.tip));
}
let data: DeleteRespMessage =
serde_json::from_str(&message.data).map_err(|err| err.to_string())?;
if data.key != key {
return Err(format!("wrong key {}", data.key));
}
if data.value.map(|s| s.to_string()) != expected {
return Err(format!("wrong value {:?}", data.value));
}
}
}
Ok(())
});
mc_check_query(mc, proc_name, invariant, msg, start_states, max_steps)
}
fn check_mc_node_removed(
mc: &mut ModelChecker,
process_names: Vec<String>,
removed_proc: &str,
max_steps: u64,
start_states: HashSet<McState>,
) -> Result<HashSet<McState>, String> {
let strategy_config = StrategyConfig::default()
.invariant(invariants::state_depth_current_run(max_steps))
.goal(goals::no_events())
.collect(collects::no_events());
let res = mc.run_from_states_with_change::<Bfs>(strategy_config, start_states, move |sys| {
let msg = Message::new("NODE_REMOVED", &format!(r#"{{"id": "{removed_proc}"}}"#));
for proc in process_names.clone() {
sys.send_local_message(proc.clone(), proc, msg.clone());
}
});
match res {
Ok(stats) => Ok(stats
.collected_states
.into_iter()
.map(|mut state| {
state.network.disconnect_node(removed_proc);
state
})
.collect::<HashSet<McState>>()),
Err(err) => {
err.print_trace();
Err(err.message())
}
}
}
fn mc_check_query<S>(
mc: &mut ModelChecker,
proc: S,
invariant: InvariantFn,
msg: Message,
start_states: Option<HashSet<McState>>,
max_depth: u64,
) -> Result<HashSet<McState>, String>
where
S: Into<String>,
{
let proc = proc.into();
let strategy_config = StrategyConfig::default()
.goal(goals::all_goals(vec![
goals::event_happened_n_times_current_run(LogEntry::is_mc_local_message_sent, 1),
goals::no_events(),
]))
.invariant(invariants::all_invariants(vec![
invariant,
invariants::state_depth_current_run(max_depth),
]))
.collect(collects::event_happened_n_times_current_run(
LogEntry::is_mc_local_message_sent,
1,
));
let res = if let Some(start_states) = start_states {
mc.run_from_states_with_change::<Bfs>(strategy_config, start_states, |sys| {
sys.send_local_message(proc.clone(), proc.clone(), msg.clone());
})
} else {
mc.run_with_change::<Bfs>(strategy_config, |sys| {
sys.send_local_message(proc.clone(), proc.clone(), msg);
})
};
match res {
Ok(stats) => Ok(stats.collected_states),
Err(err) => {
err.print_trace();
Err(err.message())
}
}
}