chore(): added async decision persistence

This commit is contained in:
ITQ
2026-02-24 17:55:14 +03:00
parent 740fd2d7bd
commit cda60bb057
8 changed files with 515 additions and 108 deletions
+293 -100
View File
@@ -1,9 +1,11 @@
import hashlib
import json
import logging
import uuid
from datetime import timedelta
from decimal import Decimal
from django.conf import settings
from django.core.cache import cache
from django.utils import timezone
from prometheus_client import Counter
@@ -12,6 +14,7 @@ from apps.conflicts.models import ExperimentConflictDomain
from apps.conflicts.services import resolve_domain_conflict
from apps.events.models import Decision
from apps.events.services import decision_create
from apps.events.tasks import persist_decision_task
from apps.experiments.models import (
ACTIVE_STATUSES,
Experiment,
@@ -36,6 +39,25 @@ FLAG_CACHE_TTL = 300
EXPERIMENT_CACHE_TTL = 60
MAX_CONCURRENT_EXPERIMENTS = 3
COOLDOWN_DAYS = 7
DECISION_WRITE_MODE_SYNC = "sync"
DECISION_WRITE_MODE_ASYNC = "async"
DECISION_WRITE_MODE_DISABLED = "disabled"
DECISION_WRITE_MODE_VALUES = {
DECISION_WRITE_MODE_SYNC,
DECISION_WRITE_MODE_ASYNC,
DECISION_WRITE_MODE_DISABLED,
}
DECISION_FORCE_SYNC_REASON = "experiment_assigned"
DECISION_CACHEABLE_REASONS = frozenset(
{
"flag_not_found",
"no_active_experiment",
"targeting_mismatch",
"outside_traffic_allocation",
"no_variants",
"experiment_assigned",
}
)
def _hash_subject(subject_id: str, experiment_id: str, salt: str) -> Decimal:
@@ -56,16 +78,156 @@ def _select_variant(
return variants[-1] if variants else None
def _persist_decision(result: dict, subject_id: str) -> None:
decision_create(
decision_id=result["decision_id"],
flag_key=result["flag"],
subject_id=subject_id,
experiment_id=result.get("experiment_id"),
variant_id=result.get("variant_id"),
value=str(result["value"]) if result["value"] is not None else "",
reason=result["reason"],
def _decision_result_cache_ttl() -> int:
ttl = int(
getattr(settings, "DECISION_RESULT_CACHE_TTL_SECONDS", 60),
)
return max(ttl, 0)
def _decision_write_mode() -> str:
mode = str(
getattr(
settings,
"DECISION_WRITE_MODE",
DECISION_WRITE_MODE_SYNC,
)
).lower()
if mode in DECISION_WRITE_MODE_VALUES:
return mode
return DECISION_WRITE_MODE_SYNC
def _subject_attributes_digest(subject_attributes: dict) -> str:
encoded = json.dumps(
subject_attributes,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode()
return hashlib.sha256(encoded).hexdigest()
def _decision_cache_key(
*,
flag_key: str,
subject_id: str,
subject_attributes_digest: str,
flag: FeatureFlag | None,
experiment: Experiment | None,
) -> str:
flag_revision = flag.updated_at.isoformat() if flag else "missing"
experiment_id = str(experiment.pk) if experiment else "none"
experiment_status = experiment.status if experiment else "none"
experiment_version = str(experiment.version) if experiment else "none"
experiment_revision = (
experiment.updated_at.isoformat() if experiment else "none"
)
return (
"decide_result:"
f"{flag_key}:"
f"{subject_id}:"
f"{subject_attributes_digest}:"
f"{flag_revision}:"
f"{experiment_id}:"
f"{experiment_status}:"
f"{experiment_version}:"
f"{experiment_revision}"
)
def _decision_template(result: dict) -> dict:
return {
"flag": result["flag"],
"value": result["value"],
"experiment_id": result.get("experiment_id"),
"variant_id": result.get("variant_id"),
"reason": result["reason"],
}
def _decision_from_template(template: dict) -> dict:
return {
"flag": template["flag"],
"value": template["value"],
"decision_id": str(uuid.uuid4()),
"experiment_id": template.get("experiment_id"),
"variant_id": template.get("variant_id"),
"reason": template["reason"],
}
def _cache_get_decision(cache_key: str) -> dict | None:
cached = cache.get(cache_key)
if not isinstance(cached, dict):
return None
required = {"flag", "value", "reason"}
if not required.issubset(cached.keys()):
return None
return _decision_from_template(cached)
def _cache_set_decision(cache_key: str, result: dict) -> None:
if result["reason"] not in DECISION_CACHEABLE_REASONS:
return
ttl = _decision_result_cache_ttl()
if ttl == 0:
return
cache.set(cache_key, _decision_template(result), ttl)
def _build_result(
*,
flag_key: str,
value: str | int | float | bool | None,
reason: str,
experiment_id: str | None = None,
variant_id: str | None = None,
) -> dict:
return {
"flag": flag_key,
"value": value,
"decision_id": str(uuid.uuid4()),
"experiment_id": experiment_id,
"variant_id": variant_id,
"reason": reason,
}
def _decision_payload(result: dict, subject_id: str) -> dict:
return {
"decision_id": result["decision_id"],
"flag_key": result["flag"],
"subject_id": subject_id,
"experiment_id": result.get("experiment_id"),
"variant_id": result.get("variant_id"),
"value": str(result["value"])
if result["value"] is not None
else "",
"reason": result["reason"],
}
def _persist_decision(result: dict, subject_id: str) -> None:
payload = _decision_payload(result, subject_id)
mode = _decision_write_mode()
is_force_sync = result["reason"] == DECISION_FORCE_SYNC_REASON
if mode == DECISION_WRITE_MODE_DISABLED and not is_force_sync:
return
if mode == DECISION_WRITE_MODE_ASYNC and not is_force_sync:
try:
persist_decision_task.delay(**payload)
except Exception:
logger.exception(
"decision_async_persist_failed",
extra={"reason": result["reason"]},
)
else:
return
decision_create(**payload)
def _cached_flag_get(flag_key: str) -> FeatureFlag | None:
@@ -171,77 +333,107 @@ def _check_domain_conflicts(
return True
def _finalize_result(
*,
result: dict,
subject_id: str,
cache_key: str,
) -> dict:
DECIDE_REQUESTS.labels(reason=result["reason"]).inc()
_cache_set_decision(cache_key, result)
_persist_decision(result, subject_id)
return result
def decide_for_flag(
flag_key: str,
subject_id: str,
subject_attributes: dict,
) -> dict:
flag = _cached_flag_get(flag_key)
if not flag:
DECIDE_REQUESTS.labels(reason="flag_not_found").inc()
result = {
"flag": flag_key,
"value": None,
"decision_id": str(uuid.uuid4()),
"experiment_id": None,
"variant_id": None,
"reason": "flag_not_found",
}
_persist_decision(result, subject_id)
return result
subject_attributes_digest = _subject_attributes_digest(
subject_attributes,
)
flag = _cached_flag_get(flag_key)
experiment = _cached_active_experiment(flag.pk) if flag else None
cache_key = _decision_cache_key(
flag_key=flag_key,
subject_id=subject_id,
subject_attributes_digest=subject_attributes_digest,
flag=flag,
experiment=experiment,
)
cached_result = _cache_get_decision(cache_key)
if cached_result is not None:
return _finalize_result(
result=cached_result,
subject_id=subject_id,
cache_key=cache_key,
)
if not flag:
result = _build_result(
flag_key=flag_key,
value=None,
reason="flag_not_found",
)
return _finalize_result(
result=result,
subject_id=subject_id,
cache_key=cache_key,
)
experiment = _cached_active_experiment(flag.pk)
if not experiment or experiment.status != ExperimentStatus.RUNNING:
DECIDE_REQUESTS.labels(reason="no_active_experiment").inc()
result = {
"flag": flag_key,
"value": flag.default_value,
"decision_id": str(uuid.uuid4()),
"experiment_id": None,
"variant_id": None,
"reason": "no_active_experiment",
}
_persist_decision(result, subject_id)
return result
result = _build_result(
flag_key=flag_key,
value=flag.default_value,
reason="no_active_experiment",
)
return _finalize_result(
result=result,
subject_id=subject_id,
cache_key=cache_key,
)
if not _check_targeting(experiment.targeting_rules, subject_attributes):
DECIDE_REQUESTS.labels(reason="targeting_mismatch").inc()
result = {
"flag": flag_key,
"value": flag.default_value,
"decision_id": str(uuid.uuid4()),
"experiment_id": str(experiment.pk),
"variant_id": None,
"reason": "targeting_mismatch",
}
_persist_decision(result, subject_id)
return result
result = _build_result(
flag_key=flag_key,
value=flag.default_value,
reason="targeting_mismatch",
experiment_id=str(experiment.pk),
)
return _finalize_result(
result=result,
subject_id=subject_id,
cache_key=cache_key,
)
if not _check_participation_limits(subject_id, experiment.pk):
DECIDE_REQUESTS.labels(reason="participation_limit").inc()
result = {
"flag": flag_key,
"value": flag.default_value,
"decision_id": str(uuid.uuid4()),
"experiment_id": str(experiment.pk),
"variant_id": None,
"reason": "participation_limit",
}
_persist_decision(result, subject_id)
return result
result = _build_result(
flag_key=flag_key,
value=flag.default_value,
reason="participation_limit",
experiment_id=str(experiment.pk),
)
return _finalize_result(
result=result,
subject_id=subject_id,
cache_key=cache_key,
)
if not _check_domain_conflicts(experiment, subject_id):
DECIDE_REQUESTS.labels(reason="domain_conflict").inc()
result = {
"flag": flag_key,
"value": flag.default_value,
"decision_id": str(uuid.uuid4()),
"experiment_id": str(experiment.pk),
"variant_id": None,
"reason": "domain_conflict",
}
_persist_decision(result, subject_id)
return result
result = _build_result(
flag_key=flag_key,
value=flag.default_value,
reason="domain_conflict",
experiment_id=str(experiment.pk),
)
return _finalize_result(
result=result,
subject_id=subject_id,
cache_key=cache_key,
)
allocation_hash = _hash_subject(
subject_id,
@@ -249,31 +441,31 @@ def decide_for_flag(
"allocation",
)
if allocation_hash >= experiment.traffic_allocation:
DECIDE_REQUESTS.labels(reason="outside_traffic_allocation").inc()
result = {
"flag": flag_key,
"value": flag.default_value,
"decision_id": str(uuid.uuid4()),
"experiment_id": str(experiment.pk),
"variant_id": None,
"reason": "outside_traffic_allocation",
}
_persist_decision(result, subject_id)
return result
result = _build_result(
flag_key=flag_key,
value=flag.default_value,
reason="outside_traffic_allocation",
experiment_id=str(experiment.pk),
)
return _finalize_result(
result=result,
subject_id=subject_id,
cache_key=cache_key,
)
variants = list(experiment.variants.all())
if not variants:
DECIDE_REQUESTS.labels(reason="no_variants").inc()
result = {
"flag": flag_key,
"value": flag.default_value,
"decision_id": str(uuid.uuid4()),
"experiment_id": str(experiment.pk),
"variant_id": None,
"reason": "no_variants",
}
_persist_decision(result, subject_id)
return result
result = _build_result(
flag_key=flag_key,
value=flag.default_value,
reason="no_variants",
experiment_id=str(experiment.pk),
)
return _finalize_result(
result=result,
subject_id=subject_id,
cache_key=cache_key,
)
variant_hash = _hash_subject(
subject_id,
@@ -284,14 +476,15 @@ def decide_for_flag(
normalized_hash = variant_hash * total_weight / Decimal(100)
selected = _select_variant(variants, normalized_hash)
DECIDE_REQUESTS.labels(reason="experiment_assigned").inc()
result = {
"flag": flag_key,
"value": selected.value if selected else flag.default_value,
"decision_id": str(uuid.uuid4()),
"experiment_id": str(experiment.pk),
"variant_id": str(selected.pk) if selected else None,
"reason": "experiment_assigned",
}
_persist_decision(result, subject_id)
return result
result = _build_result(
flag_key=flag_key,
value=selected.value if selected else flag.default_value,
reason="experiment_assigned",
experiment_id=str(experiment.pk),
variant_id=str(selected.pk) if selected else None,
)
return _finalize_result(
result=result,
subject_id=subject_id,
cache_key=cache_key,
)