From fe09e1dd1fbb26f7a2e7444ef36e87ce1e9bb98d Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sat, 16 Aug 2025 02:40:38 +0200 Subject: [PATCH] 0.4.1.0.a5 - Yet another patch for the exectuion counter.. - *When stuff won't play nice, you brute-force it* --- bot.py | 2 +- data_manager.py | 52 ++++++++++++++++++------------------ modules/usage/usage_stats.py | 43 +++++++---------------------- 3 files changed, 36 insertions(+), 61 deletions(-) diff --git a/bot.py b/bot.py index 74c4857..73afaaf 100644 --- a/bot.py +++ b/bot.py @@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice # Version consists of: # Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesn’t trigger auto update) -VERSION = "0.4.1.0.a4" +VERSION = "0.4.1.0.a5" # ---------- Env loading ---------- diff --git a/data_manager.py b/data_manager.py index 6a6b556..d37b378 100644 --- a/data_manager.py +++ b/data_manager.py @@ -32,8 +32,9 @@ class DataManager: 'nick_claim_pending': [], 'nick_reviews': [], 'rr_msg_channels': [], - '_counters': {}, # key -> int (metrics) - '_events_seen': {}, # stamp-key -> unix_ts (dedup window) + '_counters': {}, # key -> int (metrics) + '_events_seen': {}, # optional (kept for other uses) + '_counter_last_ts': {}, # key -> last increment unix_ts (timelock) } def _load(self): @@ -44,6 +45,7 @@ class DataManager: raise ValueError("root is not an object") data.setdefault('_counters', {}) data.setdefault('_events_seen', {}) + data.setdefault('_counter_last_ts', {}) return data except FileNotFoundError: default = self._default_payload() @@ -104,7 +106,7 @@ class DataManager: return True return False - # ------------- counters ------------- + # ------------- counters (plain) ------------- def incr_counter(self, key: str, by: int = 1) -> int: with self.lock: c = self._data.setdefault('_counters', {}) @@ -121,36 +123,34 @@ class DataManager: c = dict(self._data.get('_counters', {})) return {k: v for k, v in c.items() if (not prefix or k.startswith(prefix))} - # ------------- NEW: once-per-event increment with TTL dedup ------------- - def incr_counter_once(self, counter_key: str, event_key: str, window_sec: float = 3.0, namespace: str = "cmd") -> int | None: + # ------------- counters (timelocked) ------------- + def incr_counter_timelocked(self, counter_key: str, window_sec: float = 1.0) -> int | None: """ - Atomically: if `event_key` wasn't seen within `window_sec`, mark it seen and increment `counter_key`. - Returns new counter value, or None if this event is a duplicate. + Increment `counter_key` at most once per `window_sec`. + Returns the new value if incremented, or None if suppressed by the timelock. """ now = time.time() - stamp = f"{namespace}:{event_key}" with self.lock: - seen = self._data.setdefault('_events_seen', {}) - last = float(seen.get(stamp, 0.0)) - if now - last <= float(window_sec): - # duplicate; ignore + last_map = self._data.setdefault('_counter_last_ts', {}) + last = float(last_map.get(counter_key, 0.0)) + if now - last < float(window_sec): + # within lock window -> ignore return None - # mark seen - seen[stamp] = now - - # opportunistic pruning to keep file small - if len(seen) > 5000: - cutoff = now - (window_sec * 2) - for k in list(seen.keys()): - try: - if float(seen.get(k, 0.0)) < cutoff: - seen.pop(k, None) - except Exception: - seen.pop(k, None) - - # increment + # increment and stamp counters = self._data.setdefault('_counters', {}) counters[counter_key] = int(counters.get(counter_key, 0)) + 1 + last_map[counter_key] = now + + # opportunistic pruning for very old stamps (keeps file smaller) + if len(last_map) > 5000: + cutoff = now - (window_sec * 60) + for k in list(last_map.keys()): + try: + if float(last_map.get(k, 0.0)) < cutoff: + last_map.pop(k, None) + except Exception: + last_map.pop(k, None) + self._save(self._data) return counters[counter_key] diff --git a/modules/usage/usage_stats.py b/modules/usage/usage_stats.py index 7b25fb1..1ce5053 100644 --- a/modules/usage/usage_stats.py +++ b/modules/usage/usage_stats.py @@ -4,6 +4,7 @@ from discord.ext import commands import discord COUNTER_KEY_PREFIX = "cmd::" +LOCK_WINDOW_SEC = 1.0 # timelock window; change if you want stricter/looser locking def _key_from_app(cmd: discord.app_commands.Command) -> str: name = getattr(cmd, "qualified_name", None) or getattr(cmd, "name", "unknown") @@ -15,72 +16,46 @@ def _key_from_ctx(ctx: commands.Context) -> str: return f"{COUNTER_KEY_PREFIX}{name}" class UsageStatsCog(commands.Cog): - """Lightweight command run counters with storage-level dedup.""" + """Command run counters with a per-command timelock.""" def __init__(self, bot: commands.Bot): self.bot = bot - print("[usage] UsageStatsCog init") - - # ---- successful completions ---- + print("[usage] UsageStatsCog init (timelock)") @commands.Cog.listener() async def on_app_command_completion(self, interaction: discord.Interaction, command: discord.app_commands.Command): dm = getattr(self.bot, "data_manager", None) if not dm: - print("[usage] app ~~ skip (no data_manager)") return try: - # Use the interaction id as the dedup event key - event_key = str(getattr(interaction, "id", None) or "no-iid") counter_key = _key_from_app(command) - newv = dm.incr_counter_once(counter_key=counter_key, event_key=event_key, window_sec=3.0, namespace="app") + newv = dm.incr_counter_timelocked(counter_key, window_sec=LOCK_WINDOW_SEC) if newv is not None: print(f"[usage] app ++ {counter_key} -> {newv}") else: - print(f"[usage] app ~~ dup ignored ({event_key})") + print(f"[usage] app ~~ timelocked {counter_key}") except Exception as e: print("[usage] app !! incr failed:", repr(e)) @commands.Cog.listener() async def on_command_completion(self, ctx: commands.Context): - # If a HybridCommand was invoked via slash, skip here (the app listener handled it). + # If a HybridCommand was invoked as a slash interaction, let the app listener count it. if isinstance(getattr(ctx, "command", None), commands.HybridCommand) and getattr(ctx, "interaction", None): - print("[usage] px ~~ hybrid-as-slash; ignore here") return dm = getattr(self.bot, "data_manager", None) if not dm: - print("[usage] px ~~ skip (no data_manager)") return try: - # Prefer interaction id when present (hybrid-as-slash defense), else message id - event_key = None - if getattr(ctx, "interaction", None): - event_key = str(getattr(ctx.interaction, "id", None) or "") - if not event_key: - msg = getattr(ctx, "message", None) - event_key = str(getattr(msg, "id", None) or "no-mid") - counter_key = _key_from_ctx(ctx) - newv = dm.incr_counter_once(counter_key=counter_key, event_key=event_key, window_sec=3.0, namespace="px") + newv = dm.incr_counter_timelocked(counter_key, window_sec=LOCK_WINDOW_SEC) if newv is not None: print(f"[usage] px ++ {counter_key} -> {newv}") else: - print(f"[usage] px ~~ dup ignored ({event_key})") + print(f"[usage] px ~~ timelocked {counter_key}") except Exception as e: print("[usage] px !! incr failed:", repr(e)) - # ---- error visibility (optional) ---- - @commands.Cog.listener() - async def on_app_command_error(self, interaction: discord.Interaction, error: Exception): - iid = getattr(interaction, "id", None) - print(f"[usage] app ** error ({type(error).__name__}) iid={iid}") - - @commands.Cog.listener() - async def on_command_error(self, ctx: commands.Context, error: Exception): - mid = getattr(getattr(ctx, "message", None), "id", None) - print(f"[usage] px ** error ({type(error).__name__}) mid={mid}") - async def setup(bot: commands.Bot): # Prevent duplicate registration if extensions are reloaded / auto-discovered twice if getattr(bot, "_usage_stats_loaded", False): @@ -88,4 +63,4 @@ async def setup(bot: commands.Bot): return await bot.add_cog(UsageStatsCog(bot)) bot._usage_stats_loaded = True - print("[usage] UsageStatsCog loaded") + print("[usage] UsageStatsCog loaded (timelock)")