Files
Mimante/Mimante/Data/AuctionRecorder.cs
T
Alby96andClaude Fable 5.1 9177b31bd5 Due database, interruttore dell'apprendimento, barre a icone; via kill-switch, esportazioni e voci deprecate
Tutto ciò che l'applicazione registra sta ora in due file SQLite in
%LocalAppData%\AutoBidder\Database: autobidder.sqlite per le osservazioni e
esercizio.sqlite per i documenti d'esercizio (aste nel monitor, prodotti, promozioni,
modelli appresi con prefisso ml/) e per i registri applicativo e del riscatto puntate.
Il motore comune è SqliteDatabase; i file JSON e la cartella Apprendimento delle
versioni precedenti vengono importati la prima volta e lasciati dove sono. La cartella
si cambia dalle Impostazioni: al salvataggio si chiede se spostare i file, e si riavvia.
Le cartelle Dati/Statistiche/Registri, i registri su file, le misure dell'anticipo e
le impostazioni morte (LogBids, AutoApplyProductDefaults, NewAuctionLimitsPriority…)
non esistono più.

Il kill-switch se ne va: al suo posto un solo interruttore in barra, accanto ad
Avvia/Osserva/Ferma, che spegne l'apprendimento. Spento, il motore punta solo entro i
limiti dell'utente (prezzo, puntate, budget, fascia oraria, rischio) con l'anticipo
fisso: niente valore atteso, regime, duello, bandit, anticipo adattivo. Le aste vengono
comunque registrate e studiate.

Tre difetti visti osservando il motore dal vivo per un'ora, in Osserva:
- la copertura «Compralo Ora» con V lasciato al listino azzerava la perdita coperta e
  il motore diceva BID a ogni scadenza con P dell'1%: ora vale solo per i prodotti con
  «Valore reale €» scritto, altrimenti la puntata si conta persa e P decide;
- la chiusura di un'asta arrivava due volte (fine, poi rimozione dal monitor) e di
  nuovo al riavvio: modello, profilo, bandit e statistiche per prodotto contavano ogni
  asta due volte. Il monitor la comunica una volta sola, l'apprendimento salta le aste
  già apprese, e le schede prodotto vengono ricostruite una volta dallo storico;
- la pagina delle ricompense veniva scambiata per la pagina di accesso perché porta un
  collegamento a /login.php: prima si guarda se è la pagina delle ricompense.

Interfaccia: la scheda Esporta e tutto il suo codice sono tolti; le esportazioni
chiedono sempre dove salvare. Prodotti, Storico e Apprendimento hanno barre a sole
icone con tooltip che dicono esattamente cosa succede, colorate come il monitor, con
i nuovi pulsanti di pulizia (spegni tutte le stelline, togli i non seguiti, pulizia
completa; seleziona tutte / elimina le selezionate / svuota lo storico). Impostazioni:
sezione Database, Manutenzione con «Elimina tutti i dati (tranne la login)» e
«Impostazioni di fabbrica», esportazione dei registri in testo. Modifiche.txt, il
prompt ormai realizzato, esce dal repository.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 13:58:08 +02:00

522 lines
27 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Data
{
/// <summary>
/// Registra nel database tutto ciò che succede a un'asta mentre la si segue: ogni
/// interrogazione, ogni puntata di ogni utente con l'ora al millisecondo, ogni reset
/// con la profondità del ciclo, ogni nostra puntata con anticipo voluto ed effettivo,
/// ogni riga di registro, e alla fine il riepilogo.
///
/// <para>È l'erede del dossier JSON per asta, con la stessa superficie: il motore
/// chiama gli stessi metodi di prima e non sa che dietro c'è SQLite. Cambia dove
/// finiscono i dati — una riga per tabella invece di una riga per file — e il fatto
/// che da lì si possano interrogare.</para>
///
/// <para>Perché tanto dettaglio: questi dati <b>non esistono a posteriori</b>. Bidoo
/// non espone lo storico dei prezzi né i tempi delle puntate di un'asta conclusa. O li
/// si raccoglie mentre l'asta va avanti, o sono persi — e sono esattamente quelli su
/// cui il modello impara.</para>
///
/// <para>Nessuna scrittura tocca il disco sul thread che segue l'asta: ogni riga va
/// nella coda del database e prosegue.</para>
/// </summary>
public sealed class AuctionRecorder
{
private static readonly ConcurrentDictionary<string, AuctionRecorder> Open = new(StringComparer.Ordinal);
private static readonly JsonSerializerOptions Json = new()
{
WriteIndented = false,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Il nostro nome utente su Bidoo. Lo imposta il motore appena conosce la
/// sessione; finisce sull'asta, così chi rilegge sa quali puntate erano nostre.
/// </summary>
public static volatile string CurrentUsername = "";
private static AuctionDatabase Db => AuctionDatabase.Instance;
private readonly DateTime _startedAt;
private readonly List<int> _pings = new();
private readonly object _sync = new();
private long _polls;
private long _events;
private bool _closed;
// Coalescenza dei poll: si scrive quando cambia qualcosa, e comunque una volta
// al secondo. Misurato sui dossier: l'88,8% delle interrogazioni ha la stessa
// faccia della precedente.
private string? _lastPollSignature;
private DateTime _lastPollWrittenAt = DateTime.MinValue;
// Del ciclo in corso: fin dove è sceso il cronometro e quando è cominciato.
private double _cycleMinTimer = double.MaxValue;
private double _cycleStartedElapsed = -1;
private double _completedCycleMinTimer = double.NaN;
private double _completedCycleSeconds = double.NaN;
private double _lastPollPrice = double.NaN;
private const int MaxPingSamples = 50_000;
public string AuctionId { get; }
/// <summary>Righe scritte finora per quest'asta.</summary>
public long EventCount => _events;
private AuctionRecorder(string auctionId, DateTime startedAt)
{
AuctionId = auctionId;
_startedAt = startedAt;
}
// ── Apertura e chiusura ──────────────────────────────────────────
/// <summary>
/// Apre (o riapre) la registrazione di quest'asta. Riaprendo l'applicazione
/// mentre l'asta è ancora viva si <b>riprende la stessa riga</b>: un'asta seguita
/// a cavallo di un riavvio deve restare una storia sola.
/// </summary>
public static AuctionRecorder? OpenFor(AuctionInfo auction, AppSettings settings)
{
if (auction == null || !settings.RecordAuctions) return null;
if (string.IsNullOrEmpty(auction.AuctionId)) return null;
return Open.GetOrAdd(auction.AuctionId, _ => Create(auction, settings));
}
/// <summary>La registrazione già aperta per quest'asta, se c'è.</summary>
public static AuctionRecorder? For(string auctionId) =>
auctionId != null && Open.TryGetValue(auctionId, out var r) ? r : null;
/// <summary>Fa confluire nel database tutte le righe di registro delle aste. Una volta all'avvio.</summary>
public static void CaptureAuctionLogs()
{
AuctionInfo.LogSink = (auction, entry) =>
For(auction.AuctionId)?.Log(
entry.Level.ToString().ToLowerInvariant(),
entry.Category.ToString(),
entry.Message);
}
/// <summary>
/// L'asta è stata tolta dal monitor prima della fine: la riga resta, marcata
/// come interrotta, perché una serie di prezzi che si ferma a metà sembra
/// un'asta finita a quel prezzo.
/// </summary>
public static void Abandon(string auctionId, string reason)
{
if (auctionId == null) return;
if (!Open.TryRemove(auctionId, out var rec)) return;
rec.LogRow("info", "State", $"asta tolta dal monitor prima della conclusione: {reason}");
Db.Enqueue(
"UPDATE auctions SET status = CASE WHEN outcome IS NULL THEN 'abandoned' ELSE status END, " +
"abandon_reason = ?2, closed_at = COALESCE(closed_at, ?3), polls = ?4 WHERE auction_id = ?1",
auctionId, reason, AuctionDatabase.Now(), rec._polls);
}
/// <summary>Aspetta che tutto ciò che è in coda sia sul disco.</summary>
public static void FlushAll() => Db.Flush();
/// <summary>
/// Una decisione del motore, in Osserva (shadow) come in Attiva (live): cosa
/// avrebbe fatto o ha fatto, con che stima e per quale motivo. Restituisce l'id,
/// che la puntata porta con sé. L'esito si scrive alla chiusura dell'asta.
/// </summary>
public static long RecordDecision(
AuctionInfo auction, AuctionState state, string action, double? pWin, double? ev,
string? reason, string mode, string? regime, string? policy, double? leadMs,
string? contextKey = null, string? altAction = null, double? altP = null)
{
var id = Db.NextDecisionId();
var rec = For(auction.AuctionId);
var stateJson = JsonSerializer.Serialize(new
{
price = state.Price,
timer = Math.Round(state.Timer, 2),
lastBidder = string.IsNullOrEmpty(state.LastBidder) ? null : state.LastBidder,
pingMs = state.PollingLatencyMs,
bidsUsed = auction.BidsUsedOnThisAuction,
resets = auction.ResetCount,
leadMs = leadMs ?? auction.BidBeforeDeadlineMs,
duel = auction.AutoBidDuelDetected,
autoResponses = auction.AutoResponsesInARow,
recentBidders = auction.SnapshotRecentBids(10).Select(b => b.Username).Distinct(StringComparer.OrdinalIgnoreCase).Count()
}, Json);
Db.Enqueue(
"INSERT INTO bot_decisions(decision_id, auction_id, ts, t, price, timer, action, p_win, ev_estimate, model_version, policy, regime, mode, reason_detail, state_json, context_key, alt_policy, alt_action, alt_p) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
id, auction.AuctionId, AuctionDatabase.Now(), rec?.Elapsed(), state.Price, Math.Round(state.Timer, 2), action,
pWin, ev, Ml.LearningService.ModelVersion, policy, regime, mode, reason, stateJson,
contextKey, altAction == null ? null : "bandit", altAction, altP);
return id;
}
public static int OpenCount => Open.Count;
private static AuctionRecorder Create(AuctionInfo auction, AppSettings settings)
{
var rec = new AuctionRecorder(auction.AuctionId, DateTime.Now);
var now = AuctionDatabase.Now();
var productKey = ProductKeyHelper.GenerateProductKey(auction.Name);
var config = JsonSerializer.Serialize(new
{
state = auction.State.ToString(),
bidBeforeDeadlineMs = auction.BidBeforeDeadlineMs > 0 ? auction.BidBeforeDeadlineMs : settings.DefaultBidBeforeDeadlineMs,
minPrice = auction.MinPrice,
maxPrice = auction.MaxPrice,
maxBids = auction.MaxClicks,
minResets = auction.MinResets,
maxResets = auction.MaxResets,
bidLeadIsManual = auction.BidLeadIsManual,
adaptiveLead = settings.AdaptiveLeadEnabled,
leadMinMs = settings.LeadMinMs,
leadMaxMs = settings.LeadMaxMs,
valueCheckEnabled = settings.ValueCheckEnabled,
minSavingsPercentage = settings.MinSavingsPercentage,
rawPollsIncluded = settings.RecordPolls
}, Json);
var me = string.IsNullOrEmpty(CurrentUsername) ? null : CurrentUsername;
Db.EnqueueBatch(new (string, object?[])[]
{
("INSERT INTO items(product_key, title, retail_price_bidoo, shipping, first_seen_at, updated_at) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?5) " +
"ON CONFLICT(product_key) DO UPDATE SET " +
" title = CASE WHEN excluded.title != '' THEN excluded.title ELSE title END, " +
" retail_price_bidoo = COALESCE(excluded.retail_price_bidoo, retail_price_bidoo), " +
" shipping = COALESCE(excluded.shipping, shipping), updated_at = excluded.updated_at",
new object?[] { productKey, auction.Name ?? "", auction.BuyNowPrice, auction.ShippingCost, now }),
("INSERT INTO auctions(auction_id, item_id, product_key, name, url, status, me, app_version, " +
" added_at, first_seen_at, opened_at, bid_fee, shipping, buy_now_price, market_value, has_win_limit, win_limit, " +
" configured_lead_ms, lead_is_manual, config_json) " +
"VALUES(?1, (SELECT item_id FROM items WHERE product_key = ?2), ?2, ?3, ?4, 'open', ?5, ?6, ?7, ?8, ?8, ?9, ?10, ?11, " +
" (SELECT market_price_est FROM items WHERE product_key = ?2), ?12, ?13, ?14, ?15, ?16) " +
"ON CONFLICT(auction_id) DO UPDATE SET " +
" me = COALESCE(excluded.me, me), app_version = excluded.app_version, " +
" buy_now_price = COALESCE(excluded.buy_now_price, buy_now_price), " +
" shipping = COALESCE(excluded.shipping, shipping), " +
" configured_lead_ms = excluded.configured_lead_ms, lead_is_manual = excluded.lead_is_manual, " +
" config_json = excluded.config_json",
new object?[]
{
auction.AuctionId, productKey, auction.Name ?? "", auction.OriginalUrl ?? "", me, AppInfo.Version,
AuctionDatabase.Iso(auction.AddedAt), now, auction.BidCost, auction.ShippingCost, auction.BuyNowPrice,
auction.HasWinLimit, auction.WinLimitDescription,
auction.BidBeforeDeadlineMs > 0 ? auction.BidBeforeDeadlineMs : settings.DefaultBidBeforeDeadlineMs,
auction.BidLeadIsManual, config
})
});
rec.LogRow("info", "State", "registrazione aperta");
return rec;
}
/// <summary>
/// Chiude la registrazione con il riepilogo. Da qui in avanti la riga non cambia
/// più: è ciò che dice a chi analizza che la storia è completa.
/// </summary>
public static void Close(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
{
if (auction == null) return;
if (!Open.TryRemove(auction.AuctionId, out var rec)) return;
rec.WriteSummary(auction, detail, finalState);
}
private void WriteSummary(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
{
lock (_sync)
{
if (_closed) return;
_closed = true;
}
var pings = PingStats();
var series = JsonSerializer.Serialize(detail.PriceSeries.Select(p => new { t = Math.Round(p.T, 2), price = p.Price }), Json);
var byUser = JsonSerializer.Serialize(detail.BidsByUser, Json);
Db.EnqueueBatch(new (string, object?[])[]
{
("UPDATE auctions SET status = 'closed', closed_at = ?2, end_time = ?3, outcome = ?4, won_by_me = ?5, winner_user = ?6, " +
" final_price = ?7, final_status = ?8, buy_now_price = COALESCE(?9, buy_now_price), shipping = COALESCE(?10, shipping), " +
" bid_fee = ?11, my_bids = ?12, resets = ?13, distinct_bidders = ?14, total_observed_bids = ?15, top_bidder_share = ?16, " +
" observed_from_start = ?17, observed_to_end = ?18, observed_minutes = ?19, first_seen_at = ?20, " +
" avg_ping_ms = ?21, p95_ping_ms = ?22, polls = ?23, poll_errors = ?24, configured_lead_ms = ?25, lead_is_manual = ?26, " +
" timer_expired = ?27, successful_bids = ?28, failed_bids = ?29, price_velocity_per_minute = ?30, " +
" price_series_json = ?31, bids_by_user_json = ?32 " +
"WHERE auction_id = ?1",
new object?[]
{
AuctionId, AuctionDatabase.Now(), AuctionDatabase.Iso(detail.EndedAt), detail.Outcome, detail.WonByMe, detail.Winner,
detail.FinalPrice, finalState?.Status.ToString(), detail.BuyNowPrice, detail.ShippingCost,
auction.BidCost, detail.MyBids, detail.Resets, detail.DistinctBidders, detail.TotalObservedBids, Math.Round(detail.TopBidderShare, 4),
auction.ObservedFromStart, auction.ObservedToEnd, Math.Round(detail.ObservedMinutes, 2), AuctionDatabase.Iso(detail.FirstSeenAt),
pings.Avg, pings.P95, _polls, detail.PollErrors, detail.ConfiguredLeadMs, auction.BidLeadIsManual,
auction.TimerExpiredCount, auction.SuccessfulBidCount, auction.FailedBidCount, Math.Round(detail.PriceVelocityPerMinute, 4),
series, byUser
}),
// Chi ha puntato in quest'asta: aggregato una volta, alla chiusura.
("INSERT INTO bidders(username, first_seen, last_seen, n_auctions, n_bids, n_auto, n_manual, win_count) " +
"SELECT username, MIN(local_ts), MAX(local_ts), 1, COUNT(*), " +
" SUM(CASE WHEN bid_type = 'Auto' THEN 1 ELSE 0 END), SUM(CASE WHEN bid_type = 'Manuale' THEN 1 ELSE 0 END), 0 " +
"FROM bids WHERE auction_id = ?1 GROUP BY username " +
"ON CONFLICT(username) DO UPDATE SET last_seen = excluded.last_seen, first_seen = COALESCE(first_seen, excluded.first_seen), " +
" n_auctions = n_auctions + 1, n_bids = n_bids + excluded.n_bids, n_auto = n_auto + excluded.n_auto, n_manual = n_manual + excluded.n_manual",
new object?[] { AuctionId }),
("UPDATE bidders SET win_count = win_count + 1 WHERE username = ?1 AND ?1 != ''",
new object?[] { detail.Winner ?? "" }),
("UPDATE items SET retail_price_bidoo = COALESCE(?2, retail_price_bidoo), shipping = COALESCE(?3, shipping), updated_at = ?4 " +
"WHERE product_key = ?1",
new object?[] { detail.ProductKey, detail.BuyNowPrice, detail.ShippingCost, AuctionDatabase.Now() }),
// L'esito di ogni decisione, ora che si sa come è finita. Per una puntata
// partita davvero il prezzo dopo di lei era price + 0,01; per una
// decisione shadow o un NO-OP il prezzo è quello del momento. Se il prezzo
// finale non l'ha superato, nessuno ha più puntato: quella sarebbe stata
// (o è stata) la puntata vincente.
("UPDATE bot_decisions SET outcome = CASE " +
" WHEN price + 0.01 >= ?2 - 0.005 THEN 'won' ELSE 'answered' END " +
"WHERE auction_id = ?1 AND executed = 1 AND action = 'BID' AND (outcome IS NULL OR outcome = 'sent')",
new object?[] { AuctionId, detail.FinalPrice }),
("UPDATE bot_decisions SET outcome = CASE " +
" WHEN price >= ?2 - 0.005 THEN (CASE WHEN action = 'BID' THEN 'would_win' ELSE 'missed' END) " +
" ELSE (CASE WHEN action = 'BID' THEN 'would_be_answered' ELSE 'right_skip' END) END " +
"WHERE auction_id = ?1 AND executed = 0 AND outcome IS NULL",
new object?[] { AuctionId, detail.FinalPrice })
});
_events++;
}
// ── Eventi ───────────────────────────────────────────────────────
/// <summary>Una singola interrogazione a Bidoo.</summary>
public void Poll(AuctionState state, bool includeRaw)
{
_polls++;
lock (_sync)
{
if (_pings.Count < MaxPingSamples) _pings.Add(state.PollingLatencyMs);
var now = Elapsed();
if (!double.IsNaN(_lastPollPrice) && state.Price > _lastPollPrice)
{
_completedCycleMinTimer = _cycleMinTimer == double.MaxValue ? double.NaN : _cycleMinTimer;
_completedCycleSeconds = _cycleStartedElapsed >= 0 ? now - _cycleStartedElapsed : double.NaN;
_cycleMinTimer = double.MaxValue;
_cycleStartedElapsed = now;
}
else if (_cycleStartedElapsed < 0)
{
_cycleStartedElapsed = now;
}
_lastPollPrice = state.Price;
if (state.Timer > 0 && state.Timer < _cycleMinTimer) _cycleMinTimer = state.Timer;
}
NetworkSampler.Note(state);
if (!includeRaw) return;
var signature = string.Concat(
state.Price.ToString("F2"), '|', state.LastBidder, '|', state.Status, '|',
(int)state.Timer, '|', state.IsMyBid ? '1' : '0');
var wall = DateTime.Now;
if (signature == _lastPollSignature && (wall - _lastPollWrittenAt).TotalMilliseconds < 1000)
return;
_lastPollSignature = signature;
_lastPollWrittenAt = wall;
Write("INSERT INTO polls(auction_id, t, local_ts, price, timer, status, last_bidder, is_mine, ping_ms, expiry_unix, server_unix, clock_offset_ms) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
AuctionId, Elapsed(), AuctionDatabase.Now(), state.Price, Math.Round(state.Timer, 2), state.Status.ToString(),
string.IsNullOrEmpty(state.LastBidder) ? null : state.LastBidder, state.IsMyBid, state.PollingLatencyMs,
state.ExpiryUnixSeconds, state.ServerUnixSeconds, NetworkSampler.ClockOffsetMs(state));
}
/// <summary>Una puntata altrui (o nostra, vista dallo storico), come l'ha riportata Bidoo.</summary>
public void ForeignBid(BidHistoryEntry entry, double price)
{
double? timerBefore;
lock (_sync) timerBefore = _cycleMinTimer == double.MaxValue ? null : Math.Round(_cycleMinTimer, 2);
// Il tipo dichiarato dal server vince su quello ignoto di una riga già presente;
// una puntata ricostruita da un cambio di prezzo non sovrascrive mai un tipo vero.
Write("INSERT INTO bids(auction_id, username, price_after, bid_type, is_mine, server_ts, seen_t, local_ts, timer_before) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) " +
"ON CONFLICT(auction_id, price_after) DO UPDATE SET " +
" bid_type = CASE WHEN excluded.bid_type != '—' THEN excluded.bid_type ELSE bid_type END, " +
" server_ts = COALESCE(NULLIF(excluded.server_ts, 0), server_ts), " +
" is_mine = MAX(is_mine, excluded.is_mine), " +
" username = CASE WHEN excluded.username != '' THEN excluded.username ELSE username END",
AuctionId, entry.Username ?? "", price, entry.BidType ?? BidHistoryEntry.TipoSconosciuto, entry.IsMyBid,
entry.Timestamp > 0 ? entry.Timestamp : (object?)null, Elapsed(), AuctionDatabase.Now(), timerBefore);
}
/// <summary>Una mia puntata, con tutto ciò che serve a giudicarla.</summary>
public void MyBid(
double price,
int plannedLeadMs,
double actualLeadMs,
int pingMs,
bool success,
string? error,
int? bidsUsed,
int? remainingBids,
int rttMs = 0,
long? decisionId = null)
{
Write("INSERT INTO my_bids(auction_id, t, local_ts, price, planned_lead_ms, actual_lead_ms, ping_ms, rtt_ms, success, error, bids_used, remaining_bids, decision_id) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
AuctionId, Elapsed(), AuctionDatabase.Now(), price, plannedLeadMs, Math.Round(actualLeadMs, 1), pingMs, rttMs,
success, error, bidsUsed, remainingBids, decisionId);
if (decisionId is { } id)
Db.Enqueue("UPDATE bot_decisions SET executed = 1, outcome = ?2 WHERE decision_id = ?1", id, success ? "sent" : "failed:" + (error ?? "?"));
}
/// <summary>Il timer è stato azzerato da una puntata: l'asta continua.</summary>
public void Reset(int resetCount, double price, string? bidder)
{
double? minTimer, cycleSeconds;
lock (_sync)
{
minTimer = double.IsNaN(_completedCycleMinTimer) ? null : Math.Round(_completedCycleMinTimer, 2);
cycleSeconds = double.IsNaN(_completedCycleSeconds) ? null : Math.Round(_completedCycleSeconds, 2);
_completedCycleMinTimer = double.NaN;
_completedCycleSeconds = double.NaN;
}
Write("INSERT INTO resets(auction_id, t, local_ts, reset_count, price, bidder, min_timer, cycle_seconds) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
AuctionId, Elapsed(), AuctionDatabase.Now(), resetCount, price, bidder, minTimer, cycleSeconds);
}
/// <summary>Una riga del registro dell'asta.</summary>
public void Log(string level, string category, string message)
{
// Le righe "[RESET #n]" ripetono l'evento reset che sta già nella sua tabella.
if (message != null && message.StartsWith("[RESET #", StringComparison.Ordinal)) return;
LogRow(level, category, message ?? "");
}
/// <summary>Cambio di modo: Ferma, Osserva, Attiva.</summary>
public void StateChanged(string from, string to) => LogRow("info", "State", $"{from} → {to}");
private void LogRow(string level, string category, string message)
{
Write("INSERT INTO auction_log(auction_id, t, local_ts, level, category, msg) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
AuctionId, Elapsed(), AuctionDatabase.Now(), level, category, message);
}
// ── Interno ──────────────────────────────────────────────────────
private void Write(string sql, params object?[] args)
{
if (_closed) return;
Db.Enqueue(sql, args);
_events++;
}
internal double Elapsed() => Math.Round((DateTime.Now - _startedAt).TotalSeconds, 3);
private (int Count, double Avg, int P95) PingStats()
{
int[] copy;
lock (_sync) copy = _pings.Where(p => p > 0).ToArray();
if (copy.Length == 0) return (0, 0, 0);
Array.Sort(copy);
return (copy.Length, Math.Round(copy.Average(), 1), copy[Math.Min(copy.Length - 1, (int)(copy.Length * 0.95))]);
}
}
/// <summary>
/// Misure di rete aggregate: una riga ogni dieci secondi con mediana, p95, jitter e
/// scarto dell'orologio, su tutte le interrogazioni di tutte le aste. Una riga per
/// poll sarebbero milioni di righe uguali; una ogni dieci secondi racconta la stessa
/// storia in un decimillesimo dello spazio.
/// </summary>
public static class NetworkSampler
{
private static readonly object Sync = new();
private static readonly List<int> _pings = new(512);
private static long _windowStartedTicks = Environment.TickCount64;
private static int _minOffset = int.MaxValue;
private const int WindowMs = 10_000;
/// <summary>
/// Scarto fra il nostro orologio e quello del server, in ms, per questa risposta.
/// Bidoo dichiara i secondi interi, quindi il valore è quantizzato: il minimo su
/// una finestra converge al confine reale del secondo.
/// </summary>
public static int ClockOffsetMs(AuctionState state)
{
if (state.ServerUnixSeconds <= 0) return 0;
var localMs = new DateTimeOffset(state.SnapshotTime.Kind == DateTimeKind.Utc ? state.SnapshotTime : state.SnapshotTime.ToUniversalTime())
.ToUnixTimeMilliseconds() - state.PollingLatencyMs / 2;
return (int)Math.Clamp(localMs - state.ServerUnixSeconds * 1000L, int.MinValue / 2, int.MaxValue / 2);
}
public static void Note(AuctionState state)
{
if (state.PollingLatencyMs <= 0) return;
List<int>? flush = null;
int offset = 0;
lock (Sync)
{
_pings.Add(state.PollingLatencyMs);
var o = ClockOffsetMs(state);
if (state.ServerUnixSeconds > 0 && o < _minOffset) _minOffset = o;
if (Environment.TickCount64 - _windowStartedTicks >= WindowMs)
{
flush = new List<int>(_pings);
offset = _minOffset == int.MaxValue ? 0 : _minOffset;
_pings.Clear();
_minOffset = int.MaxValue;
_windowStartedTicks = Environment.TickCount64;
}
}
if (flush == null || flush.Count == 0) return;
flush.Sort();
var p50 = flush[flush.Count / 2];
var p95 = flush[Math.Min(flush.Count - 1, (int)(flush.Count * 0.95))];
AuctionDatabase.Instance.Enqueue(
"INSERT INTO network_metrics(ts, rtt_ms, jitter_ms, p95_ms, clock_offset_ms, samples, source) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'poll')",
AuctionDatabase.Now(), p50, p95 - p50, p95, offset, flush.Count);
}
}
}