Togli dal percorso caldo la rilettura dell'intero storico

Sei modifiche chieste in questa sessione, di cui una pesa piu' di tutte le altre.

CompletedAuctionsStore rileggeva e rideserializzava l'intero file a ogni chiamata:
misurati 72 ms su 5876 record, 3,9 MB. Append pagava quel prezzo piu' la
riserializzazione e la riscrittura completa per cambiare un record solo, 120 ms
in tutto, e la chiamata arriva da un gestore di eventi di MainWindow, quindi dal
thread dell'interfaccia, che restava fermo a ogni asta conclusa. Il recupero
delle puntate dei vincitori lo faceva in un ciclo: 4675 aste erano 7,2 minuti di
solo rimescolamento JSON e 17,8 GB scritti sul disco.

Adesso c'e' una copia in memoria invalidata sulla data di modifica del file, cosi'
resta giusta anche se il file viene sostituito da fuori (ripristino di un backup),
e un AppendMany che applica molti record con un salvataggio solo, con un indice
per id perche' il caso per cui esiste non torni quadratico. Il recupero salva a
blocchi di cinquanta e deposita comunque quello che ha in mano quando esce, anche
per annullamento: le richieste gia' fatte sono state pagate al server.

Misurato sullo stesso file: 95 volte piu' veloce, 7,2 minuti diventano 4,5
secondi, 17,8 GB diventano 0,36 GB.

Le altre cinque:

- Il setup si chiama AutoBidder_<versione>.exe.
- I pulsanti di stop sono rossi, sia quello globale sia quelli per asta nella
  griglia: erano gli unici tre senza colore mentre avvia e osserva ce l'avevano.
- La pulizia dello storico sa togliere le aste seguite solo in parte. Non serve
  nessun campo nuovo: il prezzo finale in centesimi e' il totale delle puntate
  dell'asta, quindi il rapporto con i reset osservati dice quanta asta si e'
  vista, e la regola vale anche sullo storico gia' raccolto. Verificata sui dati
  veri: su 5876 aste nessuna supera copertura 1,02, che e' quanto ci si aspetta
  se l'identita' e' giusta.
- La griglia ricicla i contenitori di riga invece di ricrearli.
- Il costo puntata gia' esisteva col predefinito giusto: al suggerimento e' stato
  aggiunto perche' 0,20 e' il numero da tenere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 14:48:54 +02:00
co-authored by Claude Opus 5
parent 5adc4a6526
commit 1d578debe2
14 changed files with 448 additions and 50 deletions
@@ -60,6 +60,7 @@
<TextBlock Style="{StaticResource Glyph}" Text="&#xE769;"/> <TextBlock Style="{StaticResource Glyph}" Text="&#xE769;"/>
</Button> </Button>
<Button x:Name="StopButton" Style="{StaticResource IconButton}" <Button x:Name="StopButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Ferma tutte: nessuna richiesta" ToolTip="Ferma tutte: nessuna richiesta"
Click="StopButton_Click"> Click="StopButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE71A;"/> <TextBlock Style="{StaticResource Glyph}" Text="&#xE71A;"/>
@@ -390,6 +391,7 @@
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE769;"/> <TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE769;"/>
</Button> </Button>
<Button Style="{StaticResource MiniIconButton}" <Button Style="{StaticResource MiniIconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Ferma" ToolTip="Ferma"
IsEnabled="{Binding CanStop}" IsEnabled="{Binding CanStop}"
Command="{Binding DataContext.GridStopCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" Command="{Binding DataContext.GridStopCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
+1 -1
View File
@@ -158,7 +158,7 @@
<TextBlock Grid.Row="1" Grid.Column="0" Text="Costo medio di una puntata (€)" <TextBlock Grid.Row="1" Grid.Column="0" Text="Costo medio di una puntata (€)"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="Quanto ti costa una puntata. Serve a calcolare il tetto di spesa: valore del prodotto meno le puntate massime moltiplicate per questo costo."/> ToolTip="Quanto ti costa una puntata. Serve a calcolare il tetto di spesa: valore del prodotto meno le puntate massime moltiplicate per questo costo.&#10;&#10;Il valore predefinito è 0,20 €, cioè il pacchetto meno vantaggioso in vendita su Bidoo: tenerlo così fa stimare per eccesso, e una stima per eccesso al massimo ti fa rinunciare a un affare, mentre una per difetto ti fa spendere più del valore del prodotto.&#10;&#10;Se compri pacchetti più grandi, abbassalo al costo che paghi davvero."/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="AverageBidCostTextBox" <TextBox Grid.Row="1" Grid.Column="1" x:Name="AverageBidCostTextBox"
Text="0,20" Margin="10,10"/> Text="0,20" Margin="10,10"/>
</Grid> </Grid>
+11
View File
@@ -46,6 +46,17 @@
ToolTip="Senza il «Compra Subito» non si può calcolare il risparmio, ma il prezzo e le puntate restano validi." ToolTip="Senza il «Compra Subito» non si può calcolare il risparmio, ma il prezzo e le puntate restano validi."
Checked="Option_Changed" Unchecked="Option_Changed"/> Checked="Option_Changed" Unchecked="Option_Changed"/>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<CheckBox x:Name="ChkCoverage" VerticalAlignment="Center"
Content="Aste seguite per meno del"
ToolTip="Toglie le aste agganciate a metà. Sono il caso peggiore: i numeri sono veri ma parziali, quindi sembrano buone e abbassano le medie senza dare segnali.&#10;&#10;Quanta asta si è vista si ricava dal prezzo finale, senza bisogno di alcun dato in più: ogni puntata vale un centesimo, quindi il prezzo in centesimi è il totale delle puntate dell'asta, e i reset osservati dicono quanti se ne sono visti.&#10;&#10;Sul tuo storico, su 5876 aste: al 75% ne resterebbero circa 78 su 100, al 90% circa 64, al 95% circa 54, al 99% circa 34."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<TextBox x:Name="CoverageBox" Text="90" Width="50" Margin="8,0"
VerticalAlignment="Center" TextChanged="Option_Changed"/>
<TextBlock Text="% della loro durata" VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0"> <StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<CheckBox x:Name="ChkOlderThan" VerticalAlignment="Center" <CheckBox x:Name="ChkOlderThan" VerticalAlignment="Center"
Content="Aste chiuse da più di" Content="Aste chiuse da più di"
@@ -45,6 +45,17 @@ namespace AutoBidder.Dialogs
RemoveWithoutValue = ChkNoValue.IsChecked == true RemoveWithoutValue = ChkNoValue.IsChecked == true
}; };
// Una soglia illeggibile o fuori scala non deve diventare "togli tutto":
// il filtro resta spento finché il numero non ha senso.
if (ChkCoverage.IsChecked == true &&
double.TryParse(CoverageBox.Text.Trim().Replace(',', '.'),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var percento) &&
percento is > 0 and <= 100)
{
options.MinObservedCoverage = percento / 100.0;
}
if (ChkOlderThan.IsChecked == true && if (ChkOlderThan.IsChecked == true &&
int.TryParse(DaysBox.Text.Trim(), out var days) && days > 0) int.TryParse(DaysBox.Text.Trim(), out var days) && days > 0)
{ {
+7 -1
View File
@@ -1,2 +1,8 @@
Cose da fare: Cose da fare:
- Nulla per ora - Non caricare i sorgenti del programma nella release. Deve solo essere presente la versione portable e la versione installabile.
- Il nome del setup deve essere AutoBidder_Versione.exe (ad esempio AutoBidder_4.14.0.exe)
- Colora il pulsante stop di rosso (così come quello verde è verde) per quanto riguarda i globali. Colora anche quelli relativi alla singola asta presenti sulla griglia di monitoraggio AutoBidder_Versione
- Verifica tutta l'applicazione e tutto il codice per cercare di ottimizzare l'applicazione per renderla più veloce e meno bisognosa di risorse
- Aggiungi la possibilità di pulire le aste non valide o con non abbastanza dati, come per esempio quelle non seguite completamente dall'inizio alla fine. In generale devo poterle pulire
- Agiungi una impostazione per indicare il costo di una puntata (fisso a 20 centesimi, che corrisponde al piano meno vantaggioso per l'acquisto di puntate dal sito di Bidoo)
+37 -5
View File
@@ -56,6 +56,26 @@ namespace AutoBidder.Services
var failed = 0; var failed = 0;
var skipped = 0; var skipped = 0;
// ── Perché si salva a blocchi ────────────────────────────────
// Un Append per asta rilegge e riscrive l'intero storico ogni volta:
// su un recupero completo (4675 aste) erano minuti di solo rimescolamento
// JSON e svariati gigabyte scritti sul disco. Un blocco ogni cinquanta
// taglia i salvataggi di cinquanta volte senza mettere a rischio più di
// cinquanta richieste già pagate: quello che non è ancora stato salvato
// viene comunque scritto all'uscita, annullamento compreso.
const int DimensioneBlocco = 50;
var daSalvare = new List<CompletedAuctionRecord>(DimensioneBlocco);
void Deposita()
{
if (daSalvare.Count == 0) return;
CompletedAuctionsStore.AppendMany(daSalvare);
daSalvare.Clear();
}
try
{
for (var i = 0; i < missing.Count; i++) for (var i = 0; i < missing.Count; i++)
{ {
if (ct.IsCancellationRequested) break; if (ct.IsCancellationRequested) break;
@@ -78,15 +98,19 @@ namespace AutoBidder.Services
var check = AuctionIntegrity.CheckWinnerBids(record); var check = AuctionIntegrity.CheckWinnerBids(record);
if (check.IsTrustworthy) if (check.IsTrustworthy)
{ {
// Si riscrive solo il record toccato: Append sostituisce // Va nel blocco: AppendMany sostituisce i record con lo
// quello con lo stesso id. // stesso id, quindi il risultato è identico a un Append
CompletedAuctionsStore.Append(record); // per ciascuno, con un salvataggio invece di cinquanta.
daSalvare.Add(record);
if (daSalvare.Count >= DimensioneBlocco) Deposita();
updated++; updated++;
} }
else else
{ {
// Il valore c'è ma non regge il confronto col prezzo: non si // Il valore c'è ma non regge il confronto col prezzo: non
// salva, e si dice perché. Meglio un buco che una media storta. // si salva, e si dice perché. Meglio un buco che una media
// storta.
record.WinnerBidsPaid = null; record.WinnerBidsPaid = null;
record.WinnerBidsFree = null; record.WinnerBidsFree = null;
skipped++; skipped++;
@@ -108,6 +132,14 @@ namespace AutoBidder.Services
if (!await Wait.DelayAsync(DelayBetweenRequestsMs, ct).ConfigureAwait(false)) break; if (!await Wait.DelayAsync(DelayBetweenRequestsMs, ct).ConfigureAwait(false)) break;
} }
}
finally
{
// Anche se si esce per annullamento o per un errore: le richieste già
// fatte sono state pagate al server, buttarne i risultati significa
// rifarle al giro dopo.
Deposita();
}
return new Result(missing.Count, updated, failed, skipped); return new Result(missing.Count, updated, failed, skipped);
} }
+69
View File
@@ -309,4 +309,73 @@ public class StatsMaintenanceTests
Assert.Equal(report.Kept, kept.Count); Assert.Equal(report.Kept, kept.Count);
Assert.Equal(report.Removed, list.Count - kept.Count); Assert.Equal(report.Removed, list.Count - kept.Count);
} }
// ── Aste seguite solo in parte ───────────────────────────────────────
//
// Il prezzo finale in centesimi e' il totale delle puntate dell'asta: confrontarlo
// con i reset osservati dice quanta asta si e' vista, senza bisogno di registrare
// nulla di nuovo. Vale quindi anche sullo storico raccolto prima della regola.
private static CompletedAuctionRecord Seguita(string id, double price, int resets)
{
var r = R(id: id, price: price, bidsFree: 1);
r.TotalResets = resets;
return r;
}
[Theory]
[InlineData(1.00, 100, 1.0)] // 1,00 EUR = 100 puntate, viste tutte
[InlineData(1.00, 50, 0.5)] // agganciata a meta'
[InlineData(1.00, 0, 0.0)] // vista solo la fine
public void La_copertura_e_i_reset_sul_totale_di_puntate(
double price, int resets, double attesa)
{
Assert.Equal(attesa, AuctionIntegrity.ObservedCoverage(price, resets)!.Value, 3);
}
[Fact]
public void Senza_prezzo_la_copertura_non_si_puo_dire()
{
Assert.Null(AuctionIntegrity.ObservedCoverage(0, 42));
}
[Fact]
public void La_copertura_non_supera_mai_uno()
{
// L'arrotondamento del prezzo puo' produrre qualche reset di troppo: non e'
// un'asta osservata piu' del possibile, e non deve diventare 103%.
Assert.Equal(1.0, AuctionIntegrity.ObservedCoverage(1.00, 103)!.Value, 3);
}
[Fact]
public void Toglie_le_aste_seguite_sotto_la_soglia()
{
var list = new List<CompletedAuctionRecord>
{
Seguita("intera", 1.00, 100),
Seguita("quasi", 1.00, 95),
Seguita("meta", 1.00, 50),
};
var options = new StatsMaintenance.CleanupOptions
{
MinObservedCoverage = 0.90
};
var kept = StatsMaintenance.Apply(list, options);
Assert.Equal(2, kept.Count);
Assert.DoesNotContain(kept, r => r.AuctionId == "meta");
}
[Fact]
public void Spenta_la_soglia_non_toglie_nulla_per_copertura()
{
var list = new List<CompletedAuctionRecord> { Seguita("meta", 1.00, 1) };
var report = StatsMaintenance.Preview(list,
new StatsMaintenance.CleanupOptions { MinObservedCoverage = null });
Assert.DoesNotContain("seguita solo in parte", report.ByReason.Keys);
}
} }
@@ -0,0 +1,137 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using AutoBidder.Models;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Archivio delle aste concluse: copia in memoria e salvataggio a blocchi.
///
/// <para>Sono le due modifiche che hanno tolto dal percorso caldo la rilettura dell'intero
/// file — ~72 ms su 5876 record, pagati sul thread dell'interfaccia a ogni asta che finisce.
/// La proprieta' da difendere e' che andare piu' veloce non cambi <b>cosa</b> viene salvato:
/// AppendMany deve dare lo stesso risultato di tanti Append, e la copia in memoria non deve
/// mai servire un contenuto che sul disco non c'e' piu'.</para>
/// </summary>
public class CompletedAuctionsStoreTests
{
private static CompletedAuctionRecord R(string id, double price = 1.00, string? nome = null) =>
new()
{
AuctionId = id,
Name = nome ?? $"Asta {id}",
ProductKey = "prova",
FinalPrice = price,
Winner = "tizio",
EndedAt = DateTime.Now
};
private static void Svuota() => CompletedAuctionsStore.ReplaceAll(new List<CompletedAuctionRecord>());
[Fact]
public void Append_sostituisce_quella_con_lo_stesso_id()
{
Svuota();
CompletedAuctionsStore.Append(R("100", price: 1.00));
CompletedAuctionsStore.Append(R("100", price: 2.50));
var tutte = CompletedAuctionsStore.LoadAll();
Assert.Single(tutte);
Assert.Equal(2.50, tutte[0].FinalPrice);
}
[Fact]
public void AppendMany_da_lo_stesso_risultato_di_tanti_Append()
{
var lotto = new[] { R("a", 1), R("b", 2), R("c", 3) };
Svuota();
foreach (var r in lotto) CompletedAuctionsStore.Append(r);
var unoAllaVolta = CompletedAuctionsStore.LoadAll()
.OrderBy(r => r.AuctionId).Select(r => (r.AuctionId, r.FinalPrice)).ToList();
Svuota();
CompletedAuctionsStore.AppendMany(lotto);
var inBlocco = CompletedAuctionsStore.LoadAll()
.OrderBy(r => r.AuctionId).Select(r => (r.AuctionId, r.FinalPrice)).ToList();
Assert.Equal(unoAllaVolta, inBlocco);
}
[Fact]
public void AppendMany_sostituisce_e_aggiunge_nello_stesso_giro()
{
Svuota();
CompletedAuctionsStore.AppendMany(new[] { R("x", 1), R("y", 2) });
CompletedAuctionsStore.AppendMany(new[] { R("y", 9), R("z", 3) });
var tutte = CompletedAuctionsStore.LoadAll().ToDictionary(r => r.AuctionId, r => r.FinalPrice);
Assert.Equal(3, tutte.Count);
Assert.Equal(9, tutte["y"]);
Assert.Equal(1, tutte["x"]);
Assert.Equal(3, tutte["z"]);
}
[Fact]
public void AppendMany_senza_niente_da_fare_non_esplode()
{
Svuota();
CompletedAuctionsStore.AppendMany(Array.Empty<CompletedAuctionRecord>());
Assert.Empty(CompletedAuctionsStore.LoadAll());
}
[Fact]
public void I_record_senza_id_restano_tutti()
{
// Un id vuoto non identifica niente: due record cosi' non sono lo stesso record,
// e accorparli farebbe sparire un'asta davvero conclusa.
Svuota();
CompletedAuctionsStore.AppendMany(new[] { R(""), R("") });
Assert.Equal(2, CompletedAuctionsStore.LoadAll().Count);
}
[Fact]
public void La_copia_in_memoria_si_accorge_se_il_file_cambia_da_fuori()
{
Svuota();
CompletedAuctionsStore.Append(R("uno"));
Assert.Single(CompletedAuctionsStore.LoadAll());
// Ripristino di un backup o modifica a mano: la copia non deve continuare a
// servire il contenuto vecchio. La data si forza per non dipendere dalla
// granularita' con cui il file system aggiorna l'ora di scrittura.
File.WriteAllText(CompletedAuctionsStore.StorePath,
JsonSerializer.Serialize(new List<CompletedAuctionRecord> { R("due"), R("tre") }));
File.SetLastWriteTimeUtc(CompletedAuctionsStore.StorePath, DateTime.UtcNow.AddSeconds(5));
var dopo = CompletedAuctionsStore.LoadAll();
Assert.Equal(2, dopo.Count);
Assert.DoesNotContain(dopo, r => r.AuctionId == "uno");
}
[Fact]
public void Dopo_ReplaceAll_si_legge_quello_nuovo()
{
Svuota();
CompletedAuctionsStore.AppendMany(new[] { R("vecchia") });
CompletedAuctionsStore.ReplaceAll(new List<CompletedAuctionRecord> { R("nuova") });
var tutte = CompletedAuctionsStore.LoadAll();
Assert.Single(tutte);
Assert.Equal("nuova", tutte[0].AuctionId);
}
}
+6
View File
@@ -457,6 +457,12 @@
<Setter Property="AutoGenerateColumns" Value="False"/> <Setter Property="AutoGenerateColumns" Value="False"/>
<Setter Property="SelectionMode" Value="Single"/> <Setter Property="SelectionMode" Value="Single"/>
<Setter Property="EnableRowVirtualization" Value="True"/> <Setter Property="EnableRowVirtualization" Value="True"/>
<!-- Il riciclo riusa i contenitori di riga invece di ricrearli a ogni giro di
scorrimento. Con la griglia del monitor, che si aggiorna piu' volte al
secondo mentre le aste corrono, e' la differenza fra allocare righe nuove
di continuo e non allocarne quasi nessuna. Il valore predefinito di WPF e'
"Standard", cioe' senza riciclo. -->
<Setter Property="VirtualizingPanel.VirtualizationMode" Value="Recycling"/>
<Setter Property="VerticalGridLinesBrush" Value="Transparent"/> <Setter Property="VerticalGridLinesBrush" Value="Transparent"/>
<Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource Brush.Border}"/> <Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource Brush.Border}"/>
</Style> </Style>
+28
View File
@@ -63,5 +63,33 @@ namespace AutoBidder.Utilities
/// </summary> /// </summary>
public static int? TrustedWinnerBids(CompletedAuctionRecord record) => public static int? TrustedWinnerBids(CompletedAuctionRecord record) =>
CheckWinnerBids(record).IsTrustworthy ? record.WinnerBidsUsed : null; CheckWinnerBids(record).IsTrustworthy ? record.WinnerBidsUsed : null;
/// <summary>
/// Quanta parte dell'asta è stata davvero osservata, da 0 a 1. <c>null</c> se non
/// si può dire (prezzo finale mancante).
///
/// <para>Sfrutta la stessa identità del controllo qui sopra: il prezzo finale in
/// centesimi è il numero totale di puntate dell'asta. Confrontarlo con i reset
/// visti dice, senza bisogno di registrare nient'altro, se l'asta è stata seguita
/// dall'inizio o presa per strada. Un'asta agganciata a metà ha numeri veri ma
/// parziali — è il caso peggiore, perché sembra buona.</para>
///
/// <para>Il valore è tagliato a 1: sui dati reali (5876 aste) nessun record supera
/// 1,02, e il poco che sfora è arrotondamento del prezzo, non un'asta osservata
/// più del possibile.</para>
/// </summary>
public static double? ObservedCoverage(double finalPrice, int totalResets)
{
if (finalPrice <= 0) return null;
var totalBids = Math.Round(finalPrice * 100);
if (totalBids <= 0) return null;
return Math.Min(1.0, Math.Max(0, totalResets) / totalBids);
}
/// <summary>Comodità: la copertura di un'asta già registrata.</summary>
public static double? ObservedCoverage(CompletedAuctionRecord record) =>
record == null ? null : ObservedCoverage(record.FinalPrice, record.TotalResets);
} }
} }
+83 -7
View File
@@ -23,6 +23,25 @@ namespace AutoBidder.Utilities
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
// ── Copia in memoria ─────────────────────────────────────────────
//
// Il file cresce di un record per asta e arriva in fretta a qualche megabyte:
// a 5876 aste, leggerlo e deserializzarlo costa ~72 ms misurati. Senza copia
// in memoria quel prezzo si paga a ogni apertura delle statistiche e a ogni
// asta che finisce — e quella chiamata arriva da un gestore di eventi, cioe'
// dal thread dell'interfaccia, che resta fermo per tutto il tempo.
//
// L'invalidazione guarda la data di modifica del file, non un tempo di
// scadenza: cosi' la copia resta giusta anche se il file viene sostituito da
// fuori — ripristino di un backup, modifica a mano — che qui capita davvero.
//
// Il prezzo da sapere: i record restituiti sono gli STESSI oggetti che stanno
// nella copia. Chi ne modifica uno sta modificando anche la copia, prima e
// indipendentemente dal salvataggio. Repair e il recupero puntate contano su
// questo; chi non lo vuole si faccia una copia del record.
private static List<CompletedAuctionRecord>? _cache;
private static DateTime _cacheStamp;
/// <summary>Percorso del file storico (per mostrarlo/aprirlo nella UI).</summary> /// <summary>Percorso del file storico (per mostrarlo/aprirlo nella UI).</summary>
public static string StorePath => FilePath; public static string StorePath => FilePath;
@@ -34,16 +53,50 @@ namespace AutoBidder.Utilities
public static void Append(CompletedAuctionRecord record) public static void Append(CompletedAuctionRecord record)
{ {
if (record == null) return; if (record == null) return;
AppendMany(new[] { record });
}
/// <summary>
/// Aggiunge o sostituisce piu' aste con <b>un solo</b> salvataggio.
///
/// <para>Serve a chi ne tocca tante di fila, come il recupero delle puntate dei
/// vincitori. Chiamare <see cref="Append"/> in un ciclo rilegge e riscrive tutto
/// il file a ogni giro: sulle 4675 aste di un recupero completo erano minuti di
/// solo rimescolamento JSON e svariati gigabyte scritti sul disco per nulla.</para>
/// </summary>
public static void AppendMany(IReadOnlyCollection<CompletedAuctionRecord> records)
{
if (records == null || records.Count == 0) return;
lock (_lock) lock (_lock)
{ {
var list = LoadInternal(); var list = LoadInternal();
var existing = list.FindIndex(r => // Indice per id: cercare ogni record con un FindIndex renderebbe
!string.IsNullOrEmpty(r.AuctionId) && r.AuctionId == record.AuctionId); // quadratico proprio il caso per cui questo metodo esiste.
var byId = new Dictionary<string, int>(list.Count, StringComparer.Ordinal);
for (var i = 0; i < list.Count; i++)
{
var id = list[i].AuctionId;
if (!string.IsNullOrEmpty(id)) byId[id] = i;
}
if (existing >= 0) list[existing] = record; foreach (var record in records)
else list.Add(record); {
if (record == null) continue;
var id = record.AuctionId;
if (!string.IsNullOrEmpty(id) && byId.TryGetValue(id, out var at))
{
list[at] = record;
}
else
{
if (!string.IsNullOrEmpty(id)) byId[id] = list.Count;
list.Add(record);
}
}
SaveInternal(list); SaveInternal(list);
} }
@@ -119,10 +172,12 @@ namespace AutoBidder.Utilities
try try
{ {
if (File.Exists(FilePath)) File.Delete(FilePath); if (File.Exists(FilePath)) File.Delete(FilePath);
_cache = null;
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"[STATS] Errore pulizia storico: {ex.Message}"); Console.WriteLine($"[STATS] Errore pulizia storico: {ex.Message}");
_cache = null;
} }
} }
} }
@@ -221,18 +276,33 @@ namespace AutoBidder.Utilities
try try
{ {
if (!File.Exists(FilePath)) if (!File.Exists(FilePath))
{
_cache = null;
return new List<CompletedAuctionRecord>(); return new List<CompletedAuctionRecord>();
}
// Una lettura di data di modifica costa microsecondi; la lettura piu'
// la deserializzazione che evita ne costa decine di millisecondi.
var stamp = File.GetLastWriteTimeUtc(FilePath);
if (_cache != null && stamp == _cacheStamp) return _cache;
var json = File.ReadAllText(FilePath); var json = File.ReadAllText(FilePath);
if (string.IsNullOrWhiteSpace(json))
return new List<CompletedAuctionRecord>();
return JsonSerializer.Deserialize<List<CompletedAuctionRecord>>(json) var list = string.IsNullOrWhiteSpace(json)
? new List<CompletedAuctionRecord>()
: JsonSerializer.Deserialize<List<CompletedAuctionRecord>>(json)
?? new List<CompletedAuctionRecord>(); ?? new List<CompletedAuctionRecord>();
_cache = list;
_cacheStamp = stamp;
return list;
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"[STATS] Errore lettura storico: {ex.Message}"); Console.WriteLine($"[STATS] Errore lettura storico: {ex.Message}");
// Non si sa cosa ci sia sul disco: la prossima volta si rilegge.
_cache = null;
return new List<CompletedAuctionRecord>(); return new List<CompletedAuctionRecord>();
} }
} }
@@ -246,10 +316,16 @@ namespace AutoBidder.Utilities
var json = JsonSerializer.Serialize(list, JsonOptions); var json = JsonSerializer.Serialize(list, JsonOptions);
File.WriteAllText(FilePath, json); File.WriteAllText(FilePath, json);
// La copia in memoria e' esattamente quello che si e' appena scritto:
// tenerla evita di rileggere il file al primo uso successivo.
_cache = list;
_cacheStamp = File.GetLastWriteTimeUtc(FilePath);
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"[STATS] Errore salvataggio storico: {ex.Message}"); Console.WriteLine($"[STATS] Errore salvataggio storico: {ex.Message}");
_cache = null;
} }
} }
} }
+20
View File
@@ -100,6 +100,17 @@ namespace AutoBidder.Utilities
/// <summary>Doppioni con lo stesso id: si tiene il più recente.</summary> /// <summary>Doppioni con lo stesso id: si tiene il più recente.</summary>
public bool RemoveDuplicates { get; set; } = true; public bool RemoveDuplicates { get; set; } = true;
/// <summary>
/// Copertura minima richiesta, da 0 a 1: sotto questa soglia l'asta è stata
/// agganciata a metà e va tolta. Null = non filtrare sulla copertura.
///
/// <para>È il criterio per «non seguita dall'inizio alla fine». Non serve
/// nessun campo nuovo nei record: la copertura si ricava dal prezzo finale,
/// vedi <see cref="AuctionIntegrity.ObservedCoverage(double,int)"/>. Vale
/// quindi anche per lo storico raccolto prima che questa regola esistesse.</para>
/// </summary>
public double? MinObservedCoverage { get; set; }
/// <summary>Aste chiuse prima di questa data. Null = nessun limite.</summary> /// <summary>Aste chiuse prima di questa data. Null = nessun limite.</summary>
public DateTime? OlderThan { get; set; } public DateTime? OlderThan { get; set; }
} }
@@ -161,6 +172,15 @@ namespace AutoBidder.Utilities
if (options.RemoveWithoutValue && r.BuyNowPrice is not > 0) if (options.RemoveWithoutValue && r.BuyNowPrice is not > 0)
Mark(r, "senza valore del prodotto"); Mark(r, "senza valore del prodotto");
if (options.MinObservedCoverage is { } soglia)
{
// Copertura non calcolabile = asta senza prezzo finale: qui vale come
// "non seguita", perché non c'è modo di dire il contrario.
var copertura = AuctionIntegrity.ObservedCoverage(r);
if (copertura is null || copertura.Value < soglia)
Mark(r, "seguita solo in parte");
}
if (options.OlderThan is { } limite && r.EndedAt < limite) if (options.OlderThan is { } limite && r.EndedAt < limite)
Mark(r, "più vecchia del limite"); Mark(r, "più vecchia del limite");
} }
+1 -1
View File
@@ -50,7 +50,7 @@ DisableProgramGroupPage=yes
DisableDirPage=auto DisableDirPage=auto
OutputDir={#OutputDir} OutputDir={#OutputDir}
OutputBaseFilename=AutoBidder-{#AppVersion}-setup OutputBaseFilename=AutoBidder_{#AppVersion}
SetupIconFile=..\Icon\favicon.ico SetupIconFile=..\Icon\favicon.ico
UninstallDisplayIcon={app}\{#AppExeName} UninstallDisplayIcon={app}\{#AppExeName}
UninstallDisplayName={#AppName} {#AppVersion} UninstallDisplayName={#AppName} {#AppVersion}
+2 -2
View File
@@ -465,7 +465,7 @@
Command="&quot;$(Iscc)&quot; /Qp &quot;/DAppVersion=$(V)&quot; &quot;/DSourceExe=$(CartellaPubblicazione)\AutoBidder.exe&quot; &quot;/DOutputDir=$(CartellaPacchetti)&quot; &quot;$(Iss)&quot;" /> Command="&quot;$(Iscc)&quot; /Qp &quot;/DAppVersion=$(V)&quot; &quot;/DSourceExe=$(CartellaPubblicazione)\AutoBidder.exe&quot; &quot;/DOutputDir=$(CartellaPacchetti)&quot; &quot;$(Iss)&quot;" />
<PropertyGroup> <PropertyGroup>
<Setup>$(CartellaPacchetti)\AutoBidder-$(V)-setup.exe</Setup> <Setup>$(CartellaPacchetti)\AutoBidder_$(V).exe</Setup>
</PropertyGroup> </PropertyGroup>
<Error Condition="!Exists('$(Setup)')" Text="Installatore non trovato: $(Setup)" /> <Error Condition="!Exists('$(Setup)')" Text="Installatore non trovato: $(Setup)" />
@@ -540,7 +540,7 @@
<CurlCfg>$(Tmp)\curl.cfg</CurlCfg> <CurlCfg>$(Tmp)\curl.cfg</CurlCfg>
<CorpoJson>$(Tmp)\release.json</CorpoJson> <CorpoJson>$(Tmp)\release.json</CorpoJson>
<RispostaJson>$(Tmp)\risposta.json</RispostaJson> <RispostaJson>$(Tmp)\risposta.json</RispostaJson>
<Setup>$(CartellaPacchetti)\AutoBidder-$(V)-setup.exe</Setup> <Setup>$(CartellaPacchetti)\AutoBidder_$(V).exe</Setup>
</PropertyGroup> </PropertyGroup>
<MakeDir Directories="$(Tmp)" /> <MakeDir Directories="$(Tmp)" />