Skip to content

Commit

Permalink
Rewrite scoring without Materialized View
Browse files Browse the repository at this point in the history
Closes: #28
Closes: #49

Co-authored-by: Simon Ruderich <simon@ruderich.org>
  • Loading branch information
F30 and rudis committed Jul 20, 2024
1 parent d76ce2b commit 61636d6
Show file tree
Hide file tree
Showing 12 changed files with 298 additions and 133 deletions.
76 changes: 0 additions & 76 deletions conf/controller/scoring.sql

This file was deleted.

1 change: 0 additions & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,6 @@ If you are **not using our Ansible roles**, you need to manually install Postgre
3. `PYTHONPATH=/etc/ctf-gameserver/web DJANGO_SETTINGS_MODULE=prod_settings django-admin migrate`
4. To create an initial admin user for the Web component, run:
`PYTHONPATH=/etc/ctf-gameserver/web DJANGO_SETTINGS_MODULE=prod_settings django-admin createsuperuser`
5. Create the Materialized View for scoring, apply [scoring.sql](https://github.com/fausecteam/ctf-gameserver/blob/master/conf/controller/scoring.sql): `psql < scoring.sql`

If you want to restrict database access for the individual roles to what is actually required, create
additional Postgres users with the respective database grants. For details, see the [tasks from the
Expand Down
15 changes: 11 additions & 4 deletions src/ctf_gameserver/controller/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from ctf_gameserver.lib.exceptions import DBDataError
from ctf_gameserver.lib.metrics import start_metrics_server

from . import database
from . import database, scoring


def main():
Expand Down Expand Up @@ -50,6 +50,7 @@ def main():
logging.warning('Invalid database state: %s', e)

database.increase_tick(db_conn, prohibit_changes=True)
scoring.calculate_scoreboard(db_conn, prohibit_changes=True)
except psycopg2.ProgrammingError as e:
if e.pgcode == postgres_errors.INSUFFICIENT_PRIVILEGE:
# Log full exception because only the backtrace will tell which kind of permission is missing
Expand Down Expand Up @@ -90,7 +91,9 @@ def make_metrics(db_conn, registry=prometheus_client.REGISTRY):

histograms = [
('tick_change_delay_seconds', 'Differences between supposed and actual tick change times',
(1, 3, 5, 10, 30, 60, float('inf')))
(1, 3, 5, 10, 30, 60, float('inf'))),
('scoreboard_update_seconds', 'Time spent calculating the scoreboard',
(0.1, 0.5, 1, 3, 5, 10, 30, 60, 120, 180, 240, float('inf')))
]
for name, doc, buckets in histograms:
metrics[name] = prometheus_client.Histogram(metric_prefix+name, doc, buckets=buckets,
Expand Down Expand Up @@ -184,7 +187,7 @@ def sleep(duration):
database.cancel_checks(db_conn)

# Update scoring for last tick of game
database.update_scoring(db_conn)
scoring.calculate_scoreboard(db_conn)

# Do not stop the program because a daemon might get restarted if it exits
# Prevent a busy loop in case we have not slept above as the hypothetic next tick would be overdue
Expand All @@ -196,7 +199,11 @@ def sleep(duration):
if get_sleep_seconds(control_info, metrics, now) <= 0:
logging.info('After tick %d, increasing tick to the next one', control_info['current_tick'])
database.increase_tick(db_conn)
database.update_scoring(db_conn)

scoring_start_time = time.monotonic()
scoring.calculate_scoreboard(db_conn)
metrics['scoreboard_update_seconds'].observe(time.monotonic() - scoring_start_time)
logging.info('New scoreboard calculated')


def get_sleep_seconds(control_info, metrics, now=None):
Expand Down
16 changes: 0 additions & 16 deletions src/ctf_gameserver/controller/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,6 @@ def cancel_checks(db_conn, prohibit_changes=False):
cursor.execute('UPDATE scoring_gamecontrol SET cancel_checks = true')


def update_scoring(db_conn):

with transaction_cursor(db_conn) as cursor:
cursor.execute('UPDATE scoring_flag as outerflag'
' SET bonus = 1.0 / ('
' SELECT greatest(1, count(*))'
' FROM scoring_flag'
' LEFT OUTER JOIN scoring_capture ON scoring_capture.flag_id = scoring_flag.id'
' WHERE scoring_capture.flag_id = outerflag.id)'
' FROM scoring_gamecontrol'
' WHERE outerflag.tick >='
' scoring_gamecontrol.current_tick - scoring_gamecontrol.valid_ticks'
' OR outerflag.bonus IS NULL')
cursor.execute('REFRESH MATERIALIZED VIEW "scoring_scoreboard"')


def get_exploiting_teams_counts(db_conn, prohibit_changes=False):

with transaction_cursor(db_conn, prohibit_changes) as cursor:
Expand Down
92 changes: 92 additions & 0 deletions src/ctf_gameserver/controller/scoring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from collections import defaultdict
import math

from ctf_gameserver.lib.checkresult import CheckResult
from ctf_gameserver.lib.database import transaction_cursor


def calculate_scoreboard(db_conn, prohibit_changes=False):

# Attack/offensive scores by team ID and then service ID
team_attack = {}
# Defense scores by team ID and then service ID
team_defense = {}
# SLA scores by team ID and then service ID
team_sla = {}

# Total number of captures by flag ID
flag_capture_counts = defaultdict(lambda: 0)

with transaction_cursor(db_conn, prohibit_changes) as cursor:
cursor.execute('SELECT user_id FROM registration_team WHERE nop_team = true')
nop_team_ids = set(t[0] for t in cursor.fetchall())

cursor.execute('SELECT f.service_id, c.capturing_team_id, f.protecting_team_id, f.id'
' FROM scoring_capture c, scoring_flag f WHERE c.flag_id = f.id')
# The submission server does not prevent NOP teams from submitting flags, even though it usually
# shouldn't happen
captures = [c for c in cursor.fetchall() if c[1] not in nop_team_ids]

cursor.execute('SELECT id, service_id, protecting_team_id FROM scoring_flag')
flags = [f for f in cursor.fetchall() if f[2] not in nop_team_ids]

service_ids = set(f[1] for f in flags)
team_ids = set(f[2] for f in flags)

# Pre-fill the dicts (instead of using defaultdicts) to have values wherever they are required
for team_id in team_ids:
team_attack[team_id] = {i: 0.0 for i in service_ids}
team_defense[team_id] = {i: 0.0 for i in service_ids}
team_sla[team_id] = {i: 0.0 for i in service_ids}

# Attack scoring
for service_id, team_id, _, flag_id in captures:
flag_capture_counts[flag_id] += 1
team_attack[team_id][service_id] += 1

for service_id, capturing_team_id, protecting_team_id, flag_id in captures:
team_attack[capturing_team_id][service_id] += 1.0 / flag_capture_counts[flag_id]

# Defense scoring
for flag_id, service_id, protecting_team_id in flags:
# The submission server *does* prevent submitting flags protected by NOP teams
team_defense[protecting_team_id][service_id] -= flag_capture_counts[flag_id] ** 0.75

# SLA scoring
cursor.execute('SELECT COUNT(*) FROM registration_team t, auth_user u'
' WHERE t.user_id = u.id AND u.is_active = true AND t.nop_team = false')
team_count = cursor.fetchone()[0]

checks_select = ('SELECT team_id, service_id, COUNT(*) FROM scoring_statuscheck'
' WHERE status = %s GROUP BY team_id, service_id')
cursor.execute(checks_select, (CheckResult.OK.value,))
ok_checks = [c for c in cursor.fetchall() if c[0] not in nop_team_ids]
cursor.execute(checks_select, (CheckResult.RECOVERING.value,))
recovering_checks = [c for c in cursor.fetchall() if c[0] not in nop_team_ids]

for team_id, service_id, tick_count in ok_checks:
team_sla[team_id][service_id] += tick_count
for team_id, service_id, tick_count in recovering_checks:
team_sla[team_id][service_id] += 0.5 * tick_count

sla_factor = math.sqrt(team_count)

for team_id, service_sla in team_sla.items():
for service_id in service_sla:
service_sla[service_id] *= sla_factor

row_values = []

for team_id, service_attack in team_attack.items():
for service_id in service_attack:
# pylint: disable=unnecessary-dict-index-lookup
attack = team_attack[team_id][service_id]
defense = team_defense[team_id][service_id]
sla = team_sla[team_id][service_id]
total = attack + defense + sla
row_values.append((team_id, service_id, attack, defense, sla, total))

cursor.execute('DELETE FROM scoring_scoreboard')
cursor.executemany('INSERT INTO scoring_scoreboard'
' (team_id, service_id, attack, defense, sla, total)'
' VALUES (%s, %s, %s, %s, %s, %s)', row_values)
12 changes: 4 additions & 8 deletions src/ctf_gameserver/web/scoring/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ class Flag(models.Model):
placement_end = models.DateTimeField(null=True, blank=True, default=None)
# Optional identifier to help Teams retrieve the Flag, we don't enforce this to uniquely identify a Flag
flagid = models.CharField(max_length=100, null=True, blank=True, default=None)
# Bonus points for capturing this flag
bonus = models.FloatField(null=True, blank=True, default=None)

class Meta:
unique_together = ('service', 'protecting_team', 'tick')
Expand Down Expand Up @@ -108,14 +106,12 @@ def __str__(self):

class ScoreBoard(models.Model):
"""
Scoreboard as calculated by external, asyncron helper. May be a
(materialized) view or a real table and should just be handled
read-only from within the website.
Calculated current state of the scoreboard.
Can be recreated from other data at any point, but persisted for performance reasons.
"""
team = models.OneToOneField(Team, editable=False, primary_key=True, on_delete=models.PROTECT)
service = models.OneToOneField(Service, editable=False, on_delete=models.PROTECT)
team = models.ForeignKey(Team, editable=False, on_delete=models.PROTECT)
service = models.ForeignKey(Service, editable=False, on_delete=models.PROTECT)
attack = models.FloatField(editable=False)
bonus = models.FloatField(editable=False)
defense = models.FloatField(editable=False)
sla = models.FloatField(editable=False)
total = models.FloatField(editable=False)
Expand Down
9 changes: 3 additions & 6 deletions tests/checker/fixtures/master.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@
"tick": 1,
"placement_start": null,
"placement_end": null,
"flagid": null,
"bonus": null
"flagid": null
}
},
{
Expand All @@ -59,8 +58,7 @@
"tick": 2,
"placement_start": null,
"placement_end": null,
"flagid": null,
"bonus": null
"flagid": null
}
},
{
Expand All @@ -72,8 +70,7 @@
"tick": 3,
"placement_start": null,
"placement_end": null,
"flagid": null,
"bonus": null
"flagid": null
}
},
{
Expand Down
Binary file added tests/controller/fixtures/scoring.json.xz
Binary file not shown.
Loading

0 comments on commit 61636d6

Please sign in to comment.