Skip to content

Commit

Permalink
User online status caching (#94)
Browse files Browse the repository at this point in the history
* WIP on cron + Redis caching of connected users per locale

* Language support tweaks

* WIP on more logical locale support; fav users filtered by locale

* WIP on Redis caching of presence using sets; moved type definitions

* WIP on Redis caching of user presence data

* WIP on online user caching

* WIP but code is now consistent; needs test

* Cleanup of unused variables in firebase.py

* Added user parameter to render_template() calls; code formatting in cache.py

* Bug fixes in Redis online status handling code

* Added online user update job to cron.yaml

* Added TTL caching of online users

* Add attribution & copyright for Norwegian word frequency data

* Reduced in-memory caching of online users to 30 seconds

* Skip user online query if robots only

* Removed unused class
  • Loading branch information
vthorsteinsson authored Mar 6, 2024
1 parent c7bb16d commit aa66695
Show file tree
Hide file tree
Showing 18 changed files with 461 additions and 211 deletions.
7 changes: 7 additions & 0 deletions cron.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,10 @@ cron:
job_retry_limit: 3
min_backoff_seconds: 30.0
max_doublings: 3
- description: "Online users"
url: /connect/update
schedule: every 1 minutes
retry_parameters:
job_retry_limit: 3
min_backoff_seconds: 10.0
max_doublings: 3
107 changes: 64 additions & 43 deletions src/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"""

from __future__ import annotations
import functools

from typing import (
Optional,
Expand All @@ -38,7 +39,6 @@
import re
import logging
import threading
import random
from datetime import datetime, timedelta
import base64
import io
Expand All @@ -58,12 +58,12 @@
running_local,
PROJECT_ID,
DEFAULT_LOCALE,
ResponseType,
)
from basics import (
is_mobile_client,
jsonify,
auth_required,
ResponseType,
RequestData,
current_user,
current_user_id,
Expand All @@ -78,7 +78,7 @@
current_alphabet,
current_language,
to_supported_locale,
SUPPORTED_LOCALES,
RECOGNIZED_LOCALES,
)
from dawgdictionary import Wordbase
from skraflmechanics import (
Expand Down Expand Up @@ -241,6 +241,9 @@ class ChatHistoryDict(TypedDict):
disabled: bool # Chat disabled?


ChatHistoryList = List[ChatHistoryDict]


class MoveNotifyDict(TypedDict):
"""A notification sent via Firebase to clients when a move has been
processed"""
Expand Down Expand Up @@ -325,7 +328,7 @@ class RevenueCatEvent(TypedDict, total=False):
"NICK_TOO_LONG": "Ní mór d'ainm cleite a bheith níos lú ná {MAX_NICKNAME_LENGTH} carachtair",
"EMAIL_NO_AT": "Caithfidh seoladh ríomhphoist comhartha @ a áireamh",
"LOCALE_UNKNOWN": "Locale anaithnid",
}
},
}

PUSH_MESSAGES: Mapping[str, Mapping[str, str]] = {
Expand All @@ -334,28 +337,28 @@ class RevenueCatEvent(TypedDict, total=False):
"en": "Your turn in Explo 💥",
"pl": "Twoja kolej w Explo 💥",
"nb": "Din tur i Explo 💥",
"ga": "Do sheal i Explo 💥"
"ga": "Do sheal i Explo 💥",
},
"body": {
"is": "{player} hefur leikið í viðureign ykkar.",
"en": "{player} made a move in your game.",
"pl": "{player} wykonał ruch w Twojej grze.",
"nb": "{player} har gjort et trekk i spillet ditt.",
"ga": "Rinne {player} gluaiseacht i do chluiche."
"ga": "Rinne {player} gluaiseacht i do chluiche.",
},
"chall_title": {
"is": "Þú fékkst áskorun í Explo 💥",
"en": "You've been challenged in Explo 💥",
"pl": "Zostałeś wyzwany w Explo 💥",
"nb": "Du har blitt utfordret i Explo 💥",
"ga": "Tá dúshlán curtha ort i Explo 💥"
"ga": "Tá dúshlán curtha ort i Explo 💥",
},
"chall_body": {
"is": "{player} hefur skorað á þig í viðureign!",
"en": "{player} has challenged you to a game!",
"pl": "{player} wyzwał cię na pojedynek!",
"nb": "{player} har utfordret deg til en kamp!",
"ga": "Tá {player} tar éis dúshlán a thabhairt duit i gcluiche!"
"ga": "Tá {player} tar éis dúshlán a thabhairt duit i gcluiche!",
},
}

Expand Down Expand Up @@ -485,7 +488,7 @@ def validate(self) -> Dict[str, str]:
errors["nickname"] = self.error_msg("NICK_NOT_ALPHANUMERIC")
if self.email and "@" not in self.email:
errors["email"] = self.error_msg("EMAIL_NO_AT")
if self.locale not in SUPPORTED_LOCALES:
if self.locale not in RECOGNIZED_LOCALES:
errors["locale"] = self.error_msg("LOCALE_UNKNOWN")
return errors

Expand Down Expand Up @@ -773,6 +776,13 @@ def fetch_users(
return {uid: user for uid, user in zip(uids, user_objects)}


# Kludge to create reasonably type-safe functions for each type of
# dictionary that contains some kind of user id and has a 'live' property
set_online_status_for_users = functools.partial(firebase.set_online_status, "userid")
set_online_status_for_games = functools.partial(firebase.set_online_status, "oppid")
set_online_status_for_chats = functools.partial(firebase.set_online_status, "user")


def _userlist(query: str, spec: str) -> UserList:
"""Return a list of users matching the filter criteria"""

Expand Down Expand Up @@ -825,21 +835,22 @@ def elo_str(elo: Union[None, int, str]) -> str:

# Note that we only consider online users in the same locale
# as the requesting user
online = firebase.online_users(locale)
online = firebase.online_status(locale)

# Set of users blocked by the current user
blocked: Set[str] = cuser.blocked() if cuser else set()

if query == "live":
# Return a sample (no larger than MAX_ONLINE items) of online (live) users
func_online_status: Optional[firebase.OnlineStatusFunc] = None

iter_online: Iterable[str]
if len(online) > MAX_ONLINE:
iter_online = random.sample(list(online), MAX_ONLINE)
else:
iter_online = online
if query == "live":
# Return a sample (no larger than MAX_ONLINE items)
# of online (live) users. Note that these are always
# grouped by locale, so all returned users will be in
# the same locale as the current user.

iter_online = online.random_sample(MAX_ONLINE)
ousers = User.load_multi(iter_online)

for lu in ousers:
if (
lu
Expand Down Expand Up @@ -870,17 +881,19 @@ def elo_str(elo: Union[None, int, str]) -> str:
)

elif query == "fav":
# Return favorites of the current user
# Note: this is currently not locale-constrained,
# which may well turn out to be a bug
# Return favorites of the current user, filtered by
# the user's current locale
if cuid is not None:
i = set(FavoriteModel.list_favorites(cuid))
# Do a multi-get of the entire favorites list
fusers = User.load_multi(i)
# Look up users' online status later
func_online_status = online.users_online
for fu in fusers:
if (
fu
and fu.is_displayable()
and fu.locale == locale
and (favid := fu.id())
and favid not in blocked
):
Expand All @@ -897,7 +910,7 @@ def elo_str(elo: Union[None, int, str]) -> str:
chall=chall,
fairplay=fu.fairplay(),
newbag=True,
live=favid in online,
live=False, # Will be filled in later
ready=fu.is_ready(),
ready_timed=fu.is_ready_timed(),
image=fu.image(),
Expand All @@ -913,6 +926,8 @@ def elo_str(elo: Union[None, int, str]) -> str:
cuser.human_elo(), max_len=40, locale=locale
)
ausers = User.load_multi(ui)
# Look up users' online status later
func_online_status = online.users_online
for au in ausers:
if (
au
Expand All @@ -933,7 +948,7 @@ def elo_str(elo: Union[None, int, str]) -> str:
fav=cuser.has_favorite(uid),
chall=chall,
fairplay=au.fairplay(),
live=uid in online,
live=False, # Will be filled in later
newbag=True,
ready=au.is_ready(),
ready_timed=au.is_ready_timed(),
Expand All @@ -945,11 +960,7 @@ def elo_str(elo: Union[None, int, str]) -> str:
# Display users who are online and ready for a timed game.
# Note that the online list is already filtered by locale,
# so the result is also filtered by locale.
if len(online) > MAX_ONLINE:
iter_online = random.sample(list(online), MAX_ONLINE)
else:
iter_online = online

iter_online = online.random_sample(MAX_ONLINE)
online_users = User.load_multi(iter_online)

for user in online_users:
Expand Down Expand Up @@ -998,6 +1009,7 @@ def elo_str(elo: Union[None, int, str]) -> str:
# Store the result in the cache with a lifetime of 2 minutes
memcache.set(cache_range, si, time=2 * 60, namespace="userlist")

func_online_status = online.users_online
for ud in si:
if not (uid := ud.get("id")) or uid == cuid or uid in blocked:
continue
Expand All @@ -1012,7 +1024,7 @@ def elo_str(elo: Union[None, int, str]) -> str:
human_elo=elo_str(ud["human_elo"] or str(User.DEFAULT_ELO)),
fav=False if cuser is None else cuser.has_favorite(uid),
chall=chall,
live=uid in online,
live=False, # Will be filled in later
fairplay=User.fairplay_from_prefs(ud["prefs"]),
newbag=True,
ready=ud["ready"] or False,
Expand All @@ -1039,6 +1051,10 @@ def elo_str(elo: Union[None, int, str]) -> str:
current_alphabet().sortkey_nocase(x["nick"]),
)
)
# Assign the online status of the users in the list,
# if this assignment was postponed
if func_online_status is not None:
set_online_status_for_users(result, func_online_status)
return result


Expand All @@ -1050,9 +1066,8 @@ def _gamelist(cuid: str, include_zombies: bool = True) -> GameList:

now = datetime.utcnow()
cuser = current_user()
online = firebase.online_users(
cuser.locale if cuser and cuser.locale else DEFAULT_LOCALE
)
locale = cuser.locale if cuser and cuser.locale else DEFAULT_LOCALE
online = firebase.online_status(locale)
u: Optional[User] = None

# Place zombie games (recently finished games that this player
Expand Down Expand Up @@ -1093,7 +1108,7 @@ def _gamelist(cuid: str, include_zombies: bool = True) -> GameList:
"manual": manual,
},
timed=timed,
live=opp in online,
live=False, # Will be filled in later
image=u.image(),
fav=False if cuser is None else cuser.has_favorite(opp),
tile_count=100, # All tiles (100%) accounted for
Expand Down Expand Up @@ -1173,14 +1188,16 @@ def _gamelist(cuid: str, include_zombies: bool = True) -> GameList:
},
timed=timed,
tile_count=int(g["tile_count"] * 100 / tileset.num_tiles()),
live=opp in online,
live=False,
image="" if u is None else u.image(),
fav=False if cuser is None else cuser.has_favorite(opp),
robot_level=robot_level,
elo=0 if u is None else u.elo(),
human_elo=0 if u is None else u.human_elo(),
)
)
# Set the live status of the opponents in the list
set_online_status_for_games(result, online.users_online)
return result


Expand Down Expand Up @@ -1283,8 +1300,9 @@ def _recentlist(cuid: Optional[str], versus: Optional[str], max_len: int) -> Rec
rlist = GameModel.list_finished_games(cuid, versus=versus, max_len=max_len)
# Multi-fetch the opponents in the list into a dictionary
opponents = fetch_users(rlist, lambda g: g["opp"])
locale = cuser.locale if cuser and cuser.locale else DEFAULT_LOCALE

online = firebase.online_users(cuser.locale if cuser else DEFAULT_LOCALE)
online = firebase.online_status(locale)

u: Optional[User] = None

Expand Down Expand Up @@ -1341,13 +1359,14 @@ def _recentlist(cuid: Optional[str], versus: Optional[str], max_len: int) -> Rec
"duration": Game.get_duration_from_prefs(prefs),
"manual": Game.manual_wordcheck_from_prefs(prefs),
},
live=False if opp is None else opp in online,
live=False, # Will be filled in later
image="" if u is None else u.image(),
elo=0 if u is None else u.elo(),
human_elo=0 if u is None else u.human_elo(),
fav=False if cuser is None or opp is None else cuser.has_favorite(opp),
)
)
set_online_status_for_games(result, online.users_online)
return result


Expand Down Expand Up @@ -1383,9 +1402,8 @@ def opp_ready(c: ChallengeTuple):
return _opponent_waiting(cuid, c.opp, key=c.key)

blocked = cuser.blocked()
online = firebase.online_users(
cuser.locale if cuser and cuser.locale else DEFAULT_LOCALE
)
locale = cuser.locale if cuser and cuser.locale else DEFAULT_LOCALE
online = firebase.online_status(locale)
# List received challenges
received = list(ChallengeModel.list_received(cuid, max_len=20))
# List issued challenges
Expand Down Expand Up @@ -1415,7 +1433,7 @@ def opp_ready(c: ChallengeTuple):
prefs=c.prefs,
ts=Alphabet.format_timestamp_short(c.ts),
opp_ready=False,
live=oppid in online,
live=False, # Will be filled in later
image=u.image(),
fav=cuser.has_favorite(oppid),
elo=u.elo(),
Expand Down Expand Up @@ -1444,13 +1462,15 @@ def opp_ready(c: ChallengeTuple):
prefs=c.prefs,
ts=Alphabet.format_timestamp_short(c.ts),
opp_ready=opp_ready(c),
live=oppid in online,
live=False, # Will be filled in later
image=u.image(),
fav=cuser.has_favorite(oppid),
elo=u.elo(),
human_elo=u.human_elo(),
)
)
# Set the live status of the opponents in the list
set_online_status_for_users(result, online.users_online)
return result


Expand Down Expand Up @@ -2261,14 +2281,14 @@ def chathistory() -> ResponseType:
# By default, return a history of 20 conversations
count = rq.get_int("count", 20)

online = firebase.online_users(user.locale or DEFAULT_LOCALE)
online = firebase.online_status(user.locale or DEFAULT_LOCALE)
# We don't return chat conversations with users
# that this user has blocked
blocked = user.blocked()
uc = UserCache()
# The chat history is ordered in reverse timestamp
# order, i.e. the newest entry comes first
history: List[ChatHistoryDict] = [
history: ChatHistoryList = [
ChatHistoryDict(
user=(uid := cm["user"]),
name=uc.full_name(uid),
Expand All @@ -2277,13 +2297,14 @@ def chathistory() -> ResponseType:
last_msg=cm["last_msg"],
ts=Alphabet.format_timestamp(cm["ts"]),
unread=cm["unread"],
live=uid in online,
live=False, # Will be filled in later
fav=user.has_favorite(uid),
disabled=uc.chat_disabled(uid),
)
for cm in ChatModel.chat_history(user_id, blocked_users=blocked, maxlen=count)
]

set_online_status_for_chats(history, online.users_online)
return jsonify(ok=True, history=history)


Expand Down
Loading

0 comments on commit aa66695

Please sign in to comment.