Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions lib/Bracket/Controller/Admin.pm
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,16 @@ sub equity_report : Global {
$seed = 1 if $seed < 1;
$seed = MAX_SEED if $seed > MAX_SEED;

my $projection = Bracket::Service::EquityProjection->project(
$c->model('DBIC')->schema,
my $schema = $c->model('DBIC')->schema;
my $use_cached_default = $iterations == Bracket::Service::EquityProjection::DEFAULT_ITERATIONS()
&& $seed == Bracket::Service::EquityProjection::DEFAULT_SEED();

my $projection = $use_cached_default
? Bracket::Service::EquityProjection->load_default_cache($schema)
: undef;

$projection ||= Bracket::Service::EquityProjection->project(
$schema,
{
iterations => $iterations,
seed => $seed,
Expand Down
4 changes: 3 additions & 1 deletion lib/Bracket/Controller/Player.pm
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ sub all : Global {
$projection_metrics->{act_by_player} = $c->stash->{teams_left_per_player} || {};
$projection_metrics->{fi4_by_player} = $c->stash->{final4_teams_left_per_player} || {};

my $projection = Bracket::Service::EquityProjection->project(
my $projection = Bracket::Service::EquityProjection->load_default_cache(
$c->model('DBIC')->schema
) || Bracket::Service::EquityProjection->project(
$c->model('DBIC')->schema,
{
iterations => 2000,
Expand Down
22 changes: 21 additions & 1 deletion lib/Bracket/Model/DBIC.pm
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use base 'Catalyst::Model::DBIC::Schema';
use List::Util qw( first max sum );
use Time::HiRes qw/ time /;
use Bracket::Service::BracketStructure;
use Bracket::Service::EquityProjection;

__PACKAGE__->config(schema_class => 'Bracket::Schema',);

Expand Down Expand Up @@ -33,9 +34,15 @@ sub update_points {
sub _update_points_for_schema {
my ($schema) = @_;
my $driver = lc($schema->storage->dbh->{Driver}->{Name} || '');
return $driver eq 'mysql'
my $stats = $driver eq 'mysql'
? _update_points_mysql($schema)
: _update_points_portable($schema);

my $cache_started = time();
Bracket::Service::EquityProjection->refresh_default_cache($schema);
my $cache_elapsed = time() - $cache_started;

return _append_update_stat($stats, equity_cache => $cache_elapsed);

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_update_points_for_schema now always runs refresh_default_cache, and any exception from cache table creation or the projection itself will abort update_points entirely. Since update_points is an admin-critical scoring path, consider wrapping the cache refresh in eval/try so point updates still succeed (and report cache refresh failure separately) rather than failing the whole operation.

Suggested change
Bracket::Service::EquityProjection->refresh_default_cache($schema);
my $cache_elapsed = time() - $cache_started;
return _append_update_stat($stats, equity_cache => $cache_elapsed);
my $cache_error;
eval {
Bracket::Service::EquityProjection->refresh_default_cache($schema);
1;
} or do {
$cache_error = "$@";
chomp $cache_error if defined $cache_error;
};
my $cache_elapsed = time() - $cache_started;
$stats = _append_update_stat($stats, equity_cache => $cache_elapsed);
return defined $cache_error && length $cache_error
? _append_update_stat($stats, equity_cache_error => $cache_error)
: $stats;

Copilot uses AI. Check for mistakes.
}

sub _update_points_mysql {
Expand Down Expand Up @@ -306,6 +313,19 @@ sub _format_update_stats {
return join('<br>', @stats);
}

sub _append_update_stat {
my ($stats, $label, $seconds) = @_;
$stats ||= _format_update_stats({});
$seconds ||= 0;

my $new_ms = sprintf('%.1f', 1000 * $seconds);
if ($stats =~ s{<u>total time: ([0-9.]+)</u>}{'<u>total time: ' . sprintf('%.1f', $1 + $new_ms) . '</u>'}e) {
return $stats . '<br>' . $label . ': ' . $new_ms;
}

return $stats . '<br>' . $label . ': ' . $new_ms;
}

=heads2 count_region_picks

Count up how many picks a player has made for each region.
Expand Down
84 changes: 84 additions & 0 deletions lib/Bracket/Schema/Result/EquityCache.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package Bracket::Schema::Result::EquityCache;

use strict;
use warnings;

use base 'DBIx::Class::Core';

=head1 NAME

Bracket::Schema::Result::EquityCache - Per-player default equity projection cache

=cut

__PACKAGE__->table("equity_cache");

=head1 ACCESSORS

=head2 player_id

data_type: INT
is_nullable: 0

=head2 cache_key

data_type: VARCHAR
size: 32
is_nullable: 0
default: 'default'

=head2 current_points

data_type: INT
is_nullable: 0
default: 0

=head2 max_possible_points

data_type: INT
is_nullable: 0
default: 0

=head2 projected_first_pct

data_type: VARCHAR
size: 16
is_nullable: 0
default: '0.00'

=head2 projected_podium_pct

data_type: VARCHAR
size: 16
is_nullable: 0
default: '0.00'

=head2 projected_score_avg

data_type: VARCHAR
size: 16
is_nullable: 0
default: '0.00'

=cut

__PACKAGE__->add_columns(
"player_id",
{ data_type => "INT", is_nullable => 0 },
"cache_key",
{ data_type => "VARCHAR", size => 32, is_nullable => 0, default_value => 'default' },
"current_points",
{ data_type => "INT", is_nullable => 0, default_value => 0 },
"max_possible_points",
{ data_type => "INT", is_nullable => 0, default_value => 0 },
"projected_first_pct",
{ data_type => "VARCHAR", size => 16, is_nullable => 0, default_value => '0.00' },
"projected_podium_pct",
{ data_type => "VARCHAR", size => 16, is_nullable => 0, default_value => '0.00' },
"projected_score_avg",
{ data_type => "VARCHAR", size => 16, is_nullable => 0, default_value => '0.00' },
);

__PACKAGE__->set_primary_key("player_id", "cache_key");

1;
126 changes: 126 additions & 0 deletions lib/Bracket/Service/EquityProjection.pm
Original file line number Diff line number Diff line change
Expand Up @@ -510,4 +510,130 @@ sub _dedupe {
return grep { defined $_ && !$seen{$_}++ } @values;
}

use constant DEFAULT_CACHE_KEY => 'default';
use constant DEFAULT_ITERATIONS => 2000;
use constant DEFAULT_SEED => 17;

=head2 refresh_default_cache

Bracket::Service::EquityProjection->refresh_default_cache($schema);

Runs the default equity projection (iterations=2000, seed=17) and stores the
per-player results in the C<equity_cache> table under cache_key C<'default'>.
Any previous rows for that cache_key are replaced atomically.

=cut

sub refresh_default_cache {
my ($class, $schema) = @_;
return if !$schema;

_ensure_cache_table($schema);

my $projection = $class->project($schema, {
iterations => DEFAULT_ITERATIONS,
seed => DEFAULT_SEED,
});

$schema->txn_do(sub {
$schema->resultset('EquityCache')->search({ cache_key => DEFAULT_CACHE_KEY })->delete;
for my $row (@{$projection->{player_projections} || []}) {
next if !$row->{player_id};
$schema->resultset('EquityCache')->create({
player_id => $row->{player_id},
cache_key => DEFAULT_CACHE_KEY,
current_points => $row->{current_points} // 0,
max_possible_points => $row->{max_possible_points} // 0,
projected_first_pct => $row->{projected_first_pct} // '0.00',
projected_podium_pct => $row->{projected_podium_pct} // '0.00',
projected_score_avg => $row->{projected_score_avg} // '0.00',
});
}
});
return;
}

=head2 load_default_cache

my $projection = Bracket::Service::EquityProjection->load_default_cache($schema);

Loads the cached default equity projection from the database. Returns a
hashref with a C<player_projections> key in the same format as C<project()>,
or C<undef> if the cache is empty.

=cut

sub load_default_cache {
my ($class, $schema) = @_;
return undef if !$schema;

_ensure_cache_table($schema);

my @rows = $schema->resultset('EquityCache')
->search({ cache_key => DEFAULT_CACHE_KEY })
->all;
return undef if !@rows;

my @player_projections = map {
{
player_id => $_->get_column('player_id'),
current_points => $_->get_column('current_points'),
max_possible_points => $_->get_column('max_possible_points'),
projected_first_pct => $_->get_column('projected_first_pct'),
projected_podium_pct => $_->get_column('projected_podium_pct'),
projected_score_avg => $_->get_column('projected_score_avg'),
}
} @rows;

return { player_projections => \@player_projections };
}

sub _ensure_cache_table {
my ($schema) = @_;
return if !$schema;

my $storage = $schema->storage;
my $dbh = $storage->dbh;
my $driver = lc($dbh->{Driver}{Name} || '');

if ($driver eq 'mysql') {
$dbh->do(q{
create table if not exists equity_cache (
player_id int not null,
cache_key varchar(32) not null default 'default',
current_points int not null default 0,
max_possible_points int not null default 0,
projected_first_pct varchar(16) not null default '0.00',
projected_podium_pct varchar(16) not null default '0.00',
projected_score_avg varchar(16) not null default '0.00',
primary key (player_id, cache_key)
)
});
return;
}

if ($driver eq 'sqlite') {
$dbh->do(q{
create table if not exists equity_cache (
player_id integer not null,
cache_key varchar(32) not null default 'default',
current_points integer not null default 0,
max_possible_points integer not null default 0,
projected_first_pct varchar(16) not null default '0.00',
projected_podium_pct varchar(16) not null default '0.00',
projected_score_avg varchar(16) not null default '0.00',
primary key (player_id, cache_key)
)
});
return;
}

my $table = eval { $schema->storage->dbh->selectrow_arrayref(q{
select 1 from equity_cache where 1 = 0
}) };
return if $table || !$@;

die 'equity_cache table is missing and automatic creation is only implemented for MySQL/SQLite';
}

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_ensure_cache_table dies when equity_cache is missing on non-MySQL/SQLite drivers. bracket.conf indicates PostgreSQL is a supported option, so a fresh PG deployment (or a restricted DB user without CREATE TABLE privilege) would cause /all and admin update_points to fail hard. Consider either (a) adding PostgreSQL table creation here, or (b) making the cache optional by returning undef/no-op when the table is missing and leaving callers to fall back to live projection (and separately documenting/providing a migration for creating equity_cache).

Copilot uses AI. Check for mistakes.

1;
Loading
Loading