mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
Mechanically re-derived: bin/debrand runs over a fresh import of posthog/posthog@fa1a9a60d4 (2026-08-06). The pipeline (.hanzo/workflows), the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md and the squashed initial migration are restored from the shipped tree, same as the previous import. The prune's import-closure guard kept 21 products the new core now imports; dropping them is a refactor now, recorded rather than forced.
547 lines
21 KiB
Python
547 lines
21 KiB
Python
import time
|
|
|
|
from django.conf import settings
|
|
from django.core.cache import cache as django_cache
|
|
from django.db.models import Count, F, Func, IntegerField, Max, Sum, TextField
|
|
from django.db.models.functions import Cast
|
|
|
|
import structlog
|
|
from celery import shared_task
|
|
from prometheus_client import Gauge
|
|
|
|
from insights.insightsql import ast
|
|
from insights.insightsql.constants import MAX_SELECT_RETURNED_ROWS, LimitContext
|
|
from insights.insightsql.query import execute_insightsql_query
|
|
|
|
from insights.api.capture import capture_batch_internal
|
|
from insights.models.team import Team
|
|
from insights.scoping_audit import skip_team_scope_audit
|
|
from insights.storage.hypercache_manager import HYPERCACHE_SIGNAL_UPDATE_COUNTER
|
|
from insights.tasks.utils import CeleryQueue, PushGatewayTask
|
|
|
|
from products.feature_flags.backend.canary import run_local_eval_canary
|
|
from products.feature_flags.backend.cross_region_flag_sync import sync_cross_region_flags
|
|
from products.feature_flags.backend.flags_cache import (
|
|
cleanup_stale_expiry_tracking,
|
|
clear_flags_cache,
|
|
get_cache_stats,
|
|
refresh_expiring_flags_caches,
|
|
update_flags_cache,
|
|
)
|
|
from products.feature_flags.backend.local_evaluation import (
|
|
FLAG_DEFINITIONS_HYPERCACHE_MANAGEMENT_CONFIG,
|
|
clear_flag_definition_caches,
|
|
update_flag_caches,
|
|
)
|
|
from products.feature_flags.backend.models.feature_flag import FeatureFlag
|
|
from products.feature_flags.backend.rebuild_queue import drain_rebuild_requests
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
# Matches the task's hard time_limit so a crashed run's lock expires before the next
|
|
# 5-minute schedule.
|
|
LOCAL_EVAL_CANARY_LOCK_TIMEOUT_SECONDS = 90
|
|
|
|
ENROLLMENT_MIGRATION_PAGE_SIZE = MAX_SELECT_RETURNED_ROWS
|
|
|
|
|
|
@shared_task(ignore_result=True, queue=CeleryQueue.FEATURE_FLAGS.value)
|
|
@skip_team_scope_audit
|
|
def update_team_flags_cache(team_id: int) -> None:
|
|
try:
|
|
team = Team.objects.get(id=team_id)
|
|
except Team.DoesNotExist:
|
|
logger.exception("Team does not exist", team_id=team_id)
|
|
return
|
|
|
|
update_flag_caches(team)
|
|
|
|
|
|
# Bounded below the 1-minute schedule so a slow drain (e.g. a large post-eviction
|
|
# backlog rebuilt inline) can't run past the next tick and pin a worker. Teams not
|
|
# reached before the limit stay missing and are re-enqueued by their next miss.
|
|
@shared_task(
|
|
ignore_result=True,
|
|
queue=CeleryQueue.FEATURE_FLAGS.value,
|
|
soft_time_limit=50,
|
|
time_limit=55,
|
|
)
|
|
def drain_flag_definitions_rebuild_requests() -> None:
|
|
"""Drain the flag-definitions self-heal queue, rebuilding caches the Rust
|
|
/flags/definitions endpoint reported missing. Scheduled every minute."""
|
|
drain_rebuild_requests()
|
|
|
|
|
|
@shared_task(ignore_result=True, queue=CeleryQueue.FEATURE_FLAGS_LONG_RUNNING.value)
|
|
def sync_cross_region_flags_task() -> None:
|
|
"""Celery entrypoint for sync_cross_region_flags.
|
|
|
|
On its own queue (not CeleryQueue.FEATURE_FLAGS) because it makes a blocking
|
|
cross-region HTTP call: a stalled upstream shouldn't be able to delay the
|
|
fast, sub-second signal-driven cache rebuilds sharing that queue.
|
|
"""
|
|
sync_cross_region_flags()
|
|
|
|
|
|
# Pinned: products.cohorts dispatches this by name (a static import would close a product-dependency
|
|
# cycle), so a module move must not silently rename the registration out from under that caller.
|
|
@shared_task(
|
|
name="products.feature_flags.backend.tasks.update_team_service_flags_cache",
|
|
ignore_result=True,
|
|
queue=CeleryQueue.FEATURE_FLAGS.value,
|
|
)
|
|
@skip_team_scope_audit
|
|
def update_team_service_flags_cache(team_id: int) -> None:
|
|
"""
|
|
Update the service flags cache for a specific team.
|
|
|
|
This task is triggered when feature flags change or when teams are created,
|
|
ensuring the feature-flags service has fresh data in HyperCache.
|
|
"""
|
|
try:
|
|
team = Team.objects.get(id=team_id)
|
|
except Team.DoesNotExist:
|
|
logger.debug("Team does not exist for service flags cache update", team_id=team_id)
|
|
HYPERCACHE_SIGNAL_UPDATE_COUNTER.labels(
|
|
namespace="feature_flags", cache_name="flags", operation="update", result="failure"
|
|
).inc()
|
|
return
|
|
|
|
success = update_flags_cache(team)
|
|
HYPERCACHE_SIGNAL_UPDATE_COUNTER.labels(
|
|
namespace="feature_flags", cache_name="flags", operation="update", result="success" if success else "failure"
|
|
).inc()
|
|
|
|
|
|
@shared_task(
|
|
ignore_result=True,
|
|
queue=CeleryQueue.FEATURE_FLAGS_LONG_RUNNING.value,
|
|
max_retries=3,
|
|
autoretry_for=(Exception,),
|
|
retry_backoff=True,
|
|
)
|
|
@skip_team_scope_audit
|
|
def migrate_feature_enrollment_on_key_change(team_id: int, old_key: str, flag_id: int) -> None:
|
|
"""
|
|
Copy `$feature_enrollment/<old_key>` person properties to the flag's key after a rename,
|
|
so existing early access opt-ins (and explicit opt-outs) keep applying — evaluation
|
|
derives the enrollment property name from the flag's current key.
|
|
|
|
The destination is the flag's key as of execution, not as of the rename, so a chain of
|
|
renames converges on the final key instead of stranding people on an intermediate one.
|
|
Writes use `$set_once`, so a person who makes a fresh choice under the new key during the
|
|
migration can't be clobbered, and retries are harmless. The old property is kept so
|
|
renaming back stays lossless. Enrollees are paged through by person id.
|
|
"""
|
|
try:
|
|
team = Team.objects.get(id=team_id)
|
|
except Team.DoesNotExist:
|
|
logger.exception("Team does not exist for enrollment migration", team_id=team_id)
|
|
return
|
|
|
|
flag = FeatureFlag.objects.filter(team_id=team_id, id=flag_id, deleted=False).first()
|
|
if flag is None:
|
|
return
|
|
|
|
new_key = flag.key
|
|
if new_key == old_key:
|
|
return
|
|
|
|
new_prop = f"$feature_enrollment/{new_key}"
|
|
cursor = ""
|
|
|
|
while True:
|
|
# Property access (rather than JSONExtractString) lets the InsightsQL printer use
|
|
# materialized person-property columns when available.
|
|
response = execute_insightsql_query(
|
|
"""
|
|
SELECT
|
|
toString(id) AS person_id,
|
|
argMax(pdi.distinct_id, created_at) AS distinct_id,
|
|
properties[{old_prop}] AS enrollment_value
|
|
FROM persons
|
|
WHERE properties[{old_prop}] IN ('true', 'false')
|
|
AND properties[{new_prop}] IS NULL
|
|
AND toString(id) > {cursor}
|
|
GROUP BY id, enrollment_value
|
|
ORDER BY person_id
|
|
LIMIT {limit}
|
|
""",
|
|
placeholders={
|
|
"old_prop": ast.Constant(value=f"$feature_enrollment/{old_key}"),
|
|
"new_prop": ast.Constant(value=new_prop),
|
|
"cursor": ast.Constant(value=cursor),
|
|
"limit": ast.Constant(value=ENROLLMENT_MIGRATION_PAGE_SIZE),
|
|
},
|
|
team=team,
|
|
limit_context=LimitContext.QUERY_ASYNC,
|
|
)
|
|
|
|
if not response.results:
|
|
return
|
|
|
|
# A person whose distinct ids all moved to another person in a merge joins to nothing
|
|
# and comes back blank; capture rejects the whole batch over one such event.
|
|
events = [
|
|
{
|
|
"event": "$set",
|
|
"distinct_id": distinct_id,
|
|
"properties": {"$set_once": {new_prop: enrollment_value == "true"}},
|
|
}
|
|
for _person_id, distinct_id, enrollment_value in response.results
|
|
if distinct_id
|
|
]
|
|
if events:
|
|
capture_batch_internal(
|
|
events=events,
|
|
token=team.api_token,
|
|
event_source="feature_flag_enrollment_key_migration",
|
|
process_person_profile=True,
|
|
).raise_for_status()
|
|
|
|
if len(response.results) < ENROLLMENT_MIGRATION_PAGE_SIZE:
|
|
return
|
|
|
|
cursor = response.results[-1][0]
|
|
|
|
|
|
@shared_task(ignore_result=True, queue=CeleryQueue.FEATURE_FLAGS.value)
|
|
@skip_team_scope_audit
|
|
def clear_team_evaluation_cache(team_id: int) -> None:
|
|
"""Clear the /flags evaluation cache for a specific team, enqueued by staff tooling."""
|
|
clear_flags_cache(team_id)
|
|
|
|
|
|
@shared_task(ignore_result=True, queue=CeleryQueue.FEATURE_FLAGS.value)
|
|
@skip_team_scope_audit
|
|
def clear_team_definitions_cache(team_id: int) -> None:
|
|
"""Clear the /flags/definitions local-eval cache for a specific team, enqueued by staff tooling."""
|
|
clear_flag_definition_caches(team_id)
|
|
|
|
|
|
@shared_task(bind=True, base=PushGatewayTask, ignore_result=True, queue=CeleryQueue.FEATURE_FLAGS_LONG_RUNNING.value)
|
|
def refresh_expiring_flags_cache_entries(self: PushGatewayTask) -> None:
|
|
"""
|
|
Periodic task to refresh flags caches before they expire.
|
|
|
|
This task runs hourly and refreshes caches with TTL < 24 hours to prevent cache misses.
|
|
|
|
Note: Most cache updates happen via Django signals when flags change.
|
|
This job just prevents expiration-related cache misses.
|
|
|
|
For initial cache build or schema migrations, use the management command:
|
|
python manage.py warm_flags_cache
|
|
"""
|
|
|
|
if not settings.FLAGS_REDIS_URL:
|
|
logger.info("Flags Redis URL not set, skipping flags cache refresh")
|
|
return
|
|
|
|
# Create metrics gauges for this task run
|
|
successful_gauge = Gauge(
|
|
"insights_flags_cache_refresh_successful_count",
|
|
"Number of flags caches successfully refreshed",
|
|
registry=self.metrics_registry,
|
|
)
|
|
failed_gauge = Gauge(
|
|
"insights_flags_cache_refresh_failed_count",
|
|
"Number of flags caches that failed to refresh",
|
|
registry=self.metrics_registry,
|
|
)
|
|
|
|
start_time = time.time()
|
|
logger.info(
|
|
"Starting flags cache sync",
|
|
ttl_threshold_hours=settings.FLAGS_CACHE_REFRESH_TTL_THRESHOLD_HOURS,
|
|
limit=settings.FLAGS_CACHE_REFRESH_LIMIT,
|
|
)
|
|
|
|
counts = refresh_expiring_flags_caches(
|
|
ttl_threshold_hours=settings.FLAGS_CACHE_REFRESH_TTL_THRESHOLD_HOURS,
|
|
limit=settings.FLAGS_CACHE_REFRESH_LIMIT,
|
|
)
|
|
|
|
# Record metrics
|
|
successful_gauge.set(counts.successful)
|
|
failed_gauge.set(counts.failed)
|
|
|
|
# Note: Teams processed metrics are pushed to Pushgateway by
|
|
# cache_expiry_manager.refresh_expiring_caches() via push_hypercache_teams_processed_metrics()
|
|
|
|
# Scan after refresh for metrics (pushes to Pushgateway via get_cache_stats)
|
|
stats_after = get_cache_stats()
|
|
|
|
duration = time.time() - start_time
|
|
|
|
logger.info(
|
|
"Completed flags cache refresh",
|
|
successful_refreshes=counts.successful,
|
|
failed_refreshes=counts.failed,
|
|
total_cached=stats_after.get("total_cached", 0),
|
|
total_teams=stats_after.get("total_teams", 0),
|
|
cache_coverage=stats_after.get("cache_coverage", "unknown"),
|
|
ttl_distribution=stats_after.get("ttl_distribution", {}),
|
|
duration_seconds=duration,
|
|
)
|
|
|
|
|
|
@shared_task(bind=True, base=PushGatewayTask, ignore_result=True, queue=CeleryQueue.FEATURE_FLAGS_LONG_RUNNING.value)
|
|
def cleanup_stale_flags_expiry_tracking_task(self: PushGatewayTask) -> None:
|
|
"""
|
|
Periodic task to clean up stale entries in the flags cache expiry tracking sorted set.
|
|
|
|
Removes entries for teams that no longer exist in the database.
|
|
Runs daily to prevent sorted set bloat from deleted teams.
|
|
"""
|
|
if not settings.FLAGS_REDIS_URL:
|
|
logger.info("Flags Redis URL not set, skipping flags expiry tracking cleanup")
|
|
return
|
|
|
|
entries_cleaned_gauge = Gauge(
|
|
"insights_cleanup_stale_flags_expiry_entries_cleaned",
|
|
"Number of stale expiry tracking entries cleaned up",
|
|
registry=self.metrics_registry,
|
|
)
|
|
|
|
removed_count = cleanup_stale_expiry_tracking()
|
|
entries_cleaned_gauge.set(removed_count)
|
|
logger.info("Completed flags expiry tracking cleanup", removed_count=removed_count)
|
|
|
|
|
|
def _set_ranked_team_gauge(gauge: Gauge, rows: list[dict], value_key: str) -> None:
|
|
"""Set gauge values for a ranked list of team metrics."""
|
|
for rank, row in enumerate(rows, start=1):
|
|
gauge.labels(
|
|
rank=str(rank),
|
|
team_id=str(row["team_id"]),
|
|
team_name=row["team__name"] or "Unknown",
|
|
).set(row[value_key] or 0)
|
|
|
|
|
|
@shared_task(bind=True, base=PushGatewayTask, ignore_result=True, queue=CeleryQueue.FEATURE_FLAGS_LONG_RUNNING.value)
|
|
@skip_team_scope_audit
|
|
def compute_feature_flag_metrics(self: PushGatewayTask) -> None:
|
|
"""
|
|
Compute and push feature flag metrics for Grafana dashboards.
|
|
|
|
Metrics:
|
|
- insights_feature_flag_team_flag_count: Top 5 teams by active flag count
|
|
- insights_feature_flag_team_largest_flag_bytes: OCTET_LENGTH for top 5 teams (ranked by pg_column_size)
|
|
- insights_feature_flag_team_largest_flag_pg_bytes: pg_column_size for top 5 teams
|
|
- insights_feature_flag_team_total_size_bytes: OCTET_LENGTH for top 5 teams (ranked by pg_column_size)
|
|
- insights_feature_flag_team_total_size_pg_bytes: pg_column_size for top 5 teams
|
|
|
|
Uses a two-phase query approach for size metrics:
|
|
- Phase 1: Fast ranking with pg_column_size to select and rank the top 5 teams
|
|
- Phase 2: Compute both OCTET_LENGTH and pg_column_size for those teams
|
|
|
|
Both metrics use pg_column_size ranking for consistency. Reports both values because:
|
|
- pg_column_size: PostgreSQL storage footprint (TOAST-compressed)
|
|
- OCTET_LENGTH: Text representation size of the filters field
|
|
"""
|
|
if not settings.PROM_PUSHGATEWAY_ADDRESS:
|
|
logger.debug("Pushgateway not configured, skipping feature flag metrics computation")
|
|
return
|
|
|
|
flag_count_gauge = Gauge(
|
|
"insights_feature_flag_team_flag_count",
|
|
"Number of active feature flags per team (top 5)",
|
|
labelnames=["rank", "team_id", "team_name"],
|
|
registry=self.metrics_registry,
|
|
)
|
|
|
|
largest_flag_gauge = Gauge(
|
|
"insights_feature_flag_team_largest_flag_bytes",
|
|
"Text representation size of the largest feature flag filter per team (top 5)",
|
|
labelnames=["rank", "team_id", "team_name"],
|
|
registry=self.metrics_registry,
|
|
)
|
|
|
|
largest_flag_pg_gauge = Gauge(
|
|
"insights_feature_flag_team_largest_flag_pg_bytes",
|
|
"PostgreSQL storage size of the largest feature flag filter per team (top 5, pg_column_size)",
|
|
labelnames=["rank", "team_id", "team_name"],
|
|
registry=self.metrics_registry,
|
|
)
|
|
|
|
total_size_gauge = Gauge(
|
|
"insights_feature_flag_team_total_size_bytes",
|
|
"Total text representation size of all feature flag filters per team (top 5)",
|
|
labelnames=["rank", "team_id", "team_name"],
|
|
registry=self.metrics_registry,
|
|
)
|
|
|
|
total_size_pg_gauge = Gauge(
|
|
"insights_feature_flag_team_total_size_pg_bytes",
|
|
"PostgreSQL total storage size of all feature flag filters per team (top 5, pg_column_size)",
|
|
labelnames=["rank", "team_id", "team_name"],
|
|
registry=self.metrics_registry,
|
|
)
|
|
|
|
base_qs = FeatureFlag.objects.filter(active=True)
|
|
|
|
# Top 5 by flag count (secondary sort by team_id for deterministic ordering on ties)
|
|
top_by_count = list(
|
|
base_qs.values("team_id", "team__name").annotate(flag_count=Count("id")).order_by("-flag_count", "team_id")[:5]
|
|
)
|
|
|
|
# Size expressions
|
|
pg_size = Func(F("filters"), function="pg_column_size", output_field=IntegerField())
|
|
octet_size = Func(Cast(F("filters"), TextField()), function="OCTET_LENGTH", output_field=IntegerField())
|
|
|
|
# Phase 1: Fast ranking with pg_column_size for "largest flag" metric
|
|
top_largest_ranking = list(
|
|
base_qs.annotate(pg_size=pg_size)
|
|
.values("team_id")
|
|
.annotate(max_pg_size=Max("pg_size"))
|
|
.order_by("-max_pg_size", "team_id")[:5]
|
|
)
|
|
top_largest_team_ids = [t["team_id"] for t in top_largest_ranking]
|
|
|
|
# Phase 2: Compute both metrics for top 5 teams, ordered by pg_column_size (Phase 1 ranking)
|
|
top_by_largest = list(
|
|
base_qs.filter(team_id__in=top_largest_team_ids)
|
|
.annotate(filters_size=octet_size, pg_size=pg_size)
|
|
.values("team_id", "team__name")
|
|
.annotate(largest_flag_size=Max("filters_size"), largest_flag_pg_size=Max("pg_size"))
|
|
.order_by("-largest_flag_pg_size", "team_id")
|
|
)
|
|
|
|
# Phase 1: Fast ranking with pg_column_size for "total size" metric
|
|
top_total_ranking = list(
|
|
base_qs.annotate(pg_size=pg_size)
|
|
.values("team_id")
|
|
.annotate(sum_pg_size=Sum("pg_size"))
|
|
.order_by("-sum_pg_size", "team_id")[:5]
|
|
)
|
|
top_total_team_ids = [t["team_id"] for t in top_total_ranking]
|
|
|
|
# Phase 2: Compute both metrics for top 5 teams, ordered by pg_column_size (Phase 1 ranking)
|
|
top_by_total = list(
|
|
base_qs.filter(team_id__in=top_total_team_ids)
|
|
.annotate(filters_size=octet_size, pg_size=pg_size)
|
|
.values("team_id", "team__name")
|
|
.annotate(total_size=Sum("filters_size"), total_pg_size=Sum("pg_size"))
|
|
.order_by("-total_pg_size", "team_id")
|
|
)
|
|
|
|
_set_ranked_team_gauge(flag_count_gauge, top_by_count, "flag_count")
|
|
_set_ranked_team_gauge(largest_flag_gauge, top_by_largest, "largest_flag_size")
|
|
_set_ranked_team_gauge(largest_flag_pg_gauge, top_by_largest, "largest_flag_pg_size")
|
|
_set_ranked_team_gauge(total_size_gauge, top_by_total, "total_size")
|
|
_set_ranked_team_gauge(total_size_pg_gauge, top_by_total, "total_pg_size")
|
|
|
|
logger.info(
|
|
"Computed feature flag metrics",
|
|
top_flag_count=top_by_count[0]["flag_count"] if top_by_count else 0,
|
|
top_largest_flag_bytes=top_by_largest[0]["largest_flag_size"] if top_by_largest else 0,
|
|
top_largest_flag_pg_bytes=top_by_largest[0]["largest_flag_pg_size"] if top_by_largest else 0,
|
|
top_total_size_bytes=top_by_total[0]["total_size"] if top_by_total else 0,
|
|
top_total_size_pg_bytes=top_by_total[0]["total_pg_size"] if top_by_total else 0,
|
|
)
|
|
|
|
|
|
@shared_task(bind=True, base=PushGatewayTask, ignore_result=True, queue=CeleryQueue.FEATURE_FLAGS_LONG_RUNNING.value)
|
|
def refresh_expiring_flag_definitions_cache_entries(self: PushGatewayTask) -> None:
|
|
"""
|
|
Periodic task to refresh the flag definitions cache before entries expire.
|
|
|
|
Runs hourly and refreshes caches with TTL < 24 hours to prevent cache misses.
|
|
|
|
Note: Most cache updates happen via Django signals when flags change.
|
|
This job just prevents expiration-related cache misses.
|
|
"""
|
|
|
|
from insights.storage.cache_expiry_manager import refresh_expiring_caches
|
|
|
|
successful_gauge = Gauge(
|
|
"insights_flag_definitions_cache_refresh_successful_count",
|
|
"Number of flag definitions caches successfully refreshed",
|
|
registry=self.metrics_registry,
|
|
)
|
|
failed_gauge = Gauge(
|
|
"insights_flag_definitions_cache_refresh_failed_count",
|
|
"Number of flag definitions caches that failed to refresh",
|
|
registry=self.metrics_registry,
|
|
)
|
|
|
|
start_time = time.time()
|
|
logger.info(
|
|
"Starting flag definitions cache sync",
|
|
ttl_threshold_hours=settings.FLAGS_CACHE_REFRESH_TTL_THRESHOLD_HOURS,
|
|
limit=settings.FLAGS_CACHE_REFRESH_LIMIT,
|
|
)
|
|
|
|
counts = refresh_expiring_caches(
|
|
config=FLAG_DEFINITIONS_HYPERCACHE_MANAGEMENT_CONFIG,
|
|
ttl_threshold_hours=settings.FLAGS_CACHE_REFRESH_TTL_THRESHOLD_HOURS,
|
|
limit=settings.FLAGS_CACHE_REFRESH_LIMIT,
|
|
)
|
|
|
|
successful_gauge.set(counts.successful)
|
|
failed_gauge.set(counts.failed)
|
|
|
|
duration = time.time() - start_time
|
|
logger.info(
|
|
"Completed flag definitions cache refresh",
|
|
successful_refreshes=counts.successful,
|
|
failed_refreshes=counts.failed,
|
|
duration_seconds=duration,
|
|
)
|
|
|
|
|
|
@shared_task(bind=True, base=PushGatewayTask, ignore_result=True, queue=CeleryQueue.FEATURE_FLAGS_LONG_RUNNING.value)
|
|
def cleanup_stale_flag_definitions_expiry_tracking_task(self: PushGatewayTask) -> None:
|
|
"""
|
|
Periodic task to clean up stale entries in the flag definitions cache expiry tracking sorted set.
|
|
|
|
Removes entries for teams that no longer exist in the database.
|
|
Runs daily to prevent sorted set bloat from deleted teams.
|
|
"""
|
|
|
|
from insights.storage.cache_expiry_manager import cleanup_stale_expiry_tracking
|
|
|
|
entries_cleaned_gauge = Gauge(
|
|
"insights_cleanup_stale_flag_definitions_expiry_entries_cleaned",
|
|
"Number of stale flag definitions expiry tracking entries cleaned up",
|
|
registry=self.metrics_registry,
|
|
)
|
|
|
|
removed_count = cleanup_stale_expiry_tracking(FLAG_DEFINITIONS_HYPERCACHE_MANAGEMENT_CONFIG)
|
|
|
|
entries_cleaned_gauge.set(removed_count)
|
|
logger.info("Completed flag definitions expiry tracking cleanup", removed_count=removed_count)
|
|
|
|
|
|
@shared_task(
|
|
bind=True,
|
|
base=PushGatewayTask,
|
|
ignore_result=True,
|
|
queue=CeleryQueue.FEATURE_FLAGS_LONG_RUNNING.value,
|
|
soft_time_limit=60,
|
|
time_limit=LOCAL_EVAL_CANARY_LOCK_TIMEOUT_SECONDS,
|
|
)
|
|
def feature_flags_local_eval_canary_task(self: PushGatewayTask) -> None:
|
|
"""Periodic canary for feature-flags local evaluation.
|
|
|
|
Builds the configured team's local-eval payload and checks its group_type_mapping
|
|
is non-empty. Does nothing unless FEATURE_FLAGS_CANARY_TEAM_ID is set. A
|
|
distributed lock skips overlapping runs.
|
|
|
|
Metrics:
|
|
- insights_feature_flags_local_eval_canary_group_mapping_present (gauge, 1/0)
|
|
- insights_feature_flags_local_eval_canary_failure_total (counter)
|
|
"""
|
|
if settings.FEATURE_FLAGS_CANARY_TEAM_ID is None:
|
|
return
|
|
|
|
lock_key = "insights:feature_flags_local_eval_canary:lock"
|
|
if not django_cache.add(lock_key, "locked", timeout=LOCAL_EVAL_CANARY_LOCK_TIMEOUT_SECONDS):
|
|
logger.info("Skipping feature flags local-eval canary - already running")
|
|
return
|
|
|
|
try:
|
|
run_local_eval_canary(self.metrics_registry)
|
|
finally:
|
|
django_cache.delete(lock_key)
|