Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
26 changes: 14 additions & 12 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type cleanup struct {
Quests bool `koanf:"quests"`
Incidents bool `koanf:"incidents"`
Tappables bool `koanf:"tappables"`
Hyperlocals bool `koanf:"hyperlocals"`
Stats bool `koanf:"stats"`
StatsDays int `koanf:"stats_days"`
DeviceHours int `koanf:"device_hours"`
Expand Down Expand Up @@ -124,18 +125,19 @@ type tuning struct {
}

type scanRule struct {
Areas []string `koanf:"areas"`
AreaNames []geo.AreaName `koanf:"-"`
ScanContext []string `koanf:"context"`
ProcessPokemon *bool `koanf:"pokemon"`
ProcessWilds *bool `koanf:"wild_pokemon"`
ProcessNearby *bool `koanf:"nearby_pokemon"`
ProcessWeather *bool `koanf:"weather"`
ProcessCells *bool `koanf:"cells"`
ProcessPokestops *bool `koanf:"pokestops"`
ProcessGyms *bool `koanf:"gyms"`
ProcessStations *bool `koanf:"stations"`
ProcessTappables *bool `koanf:"tappables"`
Areas []string `koanf:"areas"`
AreaNames []geo.AreaName `koanf:"-"`
ScanContext []string `koanf:"context"`
ProcessPokemon *bool `koanf:"pokemon"`
ProcessWilds *bool `koanf:"wild_pokemon"`
ProcessNearby *bool `koanf:"nearby_pokemon"`
ProcessWeather *bool `koanf:"weather"`
ProcessCells *bool `koanf:"cells"`
ProcessPokestops *bool `koanf:"pokestops"`
ProcessGyms *bool `koanf:"gyms"`
ProcessStations *bool `koanf:"stations"`
ProcessTappables *bool `koanf:"tappables"`
ProcessHyperlocals *bool `koanf:"hyperlocals"`
}

var Config configDefinition
1 change: 1 addition & 0 deletions config/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func ReadConfig() (configDefinition, error) {
Quests: true,
Incidents: true,
Tappables: true,
Hyperlocals: true,
StatsDays: 7,
DeviceHours: 24,
},
Expand Down
127 changes: 127 additions & 0 deletions decoder/hyperlocal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package decoder

import (
"context"
"database/sql"
"errors"
"golbat/db"
"golbat/pogo"

"github.com/jellydator/ttlcache/v3"
log "github.com/sirupsen/logrus"
)

// Hyperlocal struct for hyperlocal experiment data
type Hyperlocal struct {
ExperimentId int32 `db:"experiment_id" json:"experiment_id"`
StartMs int64 `db:"start_ms" json:"start_ms"`
EndMs int64 `db:"end_ms" json:"end_ms"`
Lat float64 `db:"lat" json:"lat"`
Lon float64 `db:"lon" json:"lon"`
RadiusM float64 `db:"radius_m" json:"radius_m"`
ChallengeBonusKey string `db:"challenge_bonus_key" json:"challenge_bonus_key"`
UpdatedMs int64 `db:"updated_ms" json:"updated_ms"`
Copy link
Collaborator

Choose a reason for hiding this comment

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

Interested in others thoughts about moving updated to a ms field (where others are not). Consistency might be more important than other concerns (I'd be willing to consider a PR moving to a virtual updated and every other table being updated to ms as the per second resolution annoys me too)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think it is fine to just do this instead to keep compatibility. I imagine this would break compatibility for a lot of other things if we introduce this to older tables though?

Copy link
Collaborator

Choose a reason for hiding this comment

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

You would use a virtual column (as a calculated field) to maintain the old name. Anyway, for the time being we should change this to updated

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Why? This is a new table and RM PR is already implemented for this. Do you mean we should add a virtual updated column for this instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also since the column explicitly gives the unit (which is actually a convenient consequence of following the same naming convention as proto), I think it's fine to just keep this as is.

}

// HyperlocalKey represents the composite primary key for hyperlocal records
type HyperlocalKey struct {
ExperimentId int32 `json:"experiment_id"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}

func (h *Hyperlocal) getKey() HyperlocalKey {
return HyperlocalKey{
ExperimentId: h.ExperimentId,
Lat: h.Lat,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Urgh. I understand there may be no unique identifier (unusual for niantic though this is!) but I wonder if lat/lon as a float works reliably given the round-trip to database (I doubt it will be guaranteed byte-exact). We may be forced for the key to move to an integer representing a fixed precision - but I haven't seen the resolution of the data that comes from niantic for this - how many decimal places is it?

Copy link
Contributor Author

@Mygod Mygod Jul 15, 2025

Choose a reason for hiding this comment

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

I think it should be exact. From my data gathered three months ago, these fields are always truncated to 1e-6 (ignoring floating point rounding), e.g. 20.681438,-156.442944.

Full row: 3578,1743375172270,1743376072270,20.681438,-156.442944,100.00000000000001,spawn_wild_bug_types,1743376070600

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I guess if testing turns out to be problematic, we can store (int)(1e6*lat) instead. (It didn't seem problematic in the data I collected.)

Lon: h.Lon,
}
}

func (h *Hyperlocal) updateFromHyperlocalProto(data *pogo.HyperlocalExperimentClientProto, timestampMs int64) {
h.ExperimentId = data.ExperimentId
h.StartMs = data.StartMs
h.EndMs = data.EndMs
h.Lat = data.LatDegrees
h.Lon = data.LngDegrees
h.RadiusM = data.EventRadiusM
h.ChallengeBonusKey = data.ChallengeBonusKey
h.UpdatedMs = timestampMs
}

func getHyperlocalRecord(ctx context.Context, db db.DbDetails, key HyperlocalKey) (*Hyperlocal, error) {
// Check cache first using HyperlocalKey directly
if cachedItem := hyperlocalCache.Get(key); cachedItem != nil {
hyperlocal := cachedItem.Value()
return &hyperlocal, nil
}

hyperlocal := Hyperlocal{}
err := db.GeneralDb.GetContext(ctx, &hyperlocal,
`SELECT experiment_id, start_ms, end_ms, lat, lon, radius_m, challenge_bonus_key, updated_ms
FROM hyperlocal
WHERE experiment_id = ? AND lat = ? AND lon = ?`, key.ExperimentId, key.Lat, key.Lon)
statsCollector.IncDbQuery("select hyperlocal", err)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}

if err != nil {
return nil, err
}
return &hyperlocal, nil
}

func saveHyperlocalRecord(ctx context.Context, details db.DbDetails, hyperlocal *Hyperlocal) {
key := hyperlocal.getKey()
oldHyperlocal, _ := getHyperlocalRecord(ctx, details, key)

if oldHyperlocal != nil && !hasChangesHyperlocal(oldHyperlocal, hyperlocal) {
return
}

if oldHyperlocal == nil {
res, err := details.GeneralDb.NamedExecContext(ctx, `
INSERT INTO hyperlocal (
experiment_id, start_ms, end_ms, lat, lon, radius_m, challenge_bonus_key, updated_ms
) VALUES (
:experiment_id, :start_ms, :end_ms, :lat, :lon, :radius_m, :challenge_bonus_key, :updated_ms
)
`, hyperlocal)
statsCollector.IncDbQuery("insert hyperlocal", err)
if err != nil {
log.Errorf("insert hyperlocal %+v: %s", key, err)
return
}
_ = res
} else {
res, err := details.GeneralDb.NamedExecContext(ctx, `
UPDATE hyperlocal SET
start_ms = :start_ms,
end_ms = :end_ms,
radius_m = :radius_m,
challenge_bonus_key = :challenge_bonus_key,
updated_ms = :updated_ms
WHERE experiment_id = :experiment_id AND lat = :lat AND lon = :lon
`, hyperlocal)
statsCollector.IncDbQuery("update hyperlocal", err)
if err != nil {
log.Errorf("update hyperlocal %+v: %s", key, err)
return
}
_ = res
}
hyperlocalCache.Set(key, *hyperlocal, ttlcache.DefaultTTL)
}

func hasChangesHyperlocal(old *Hyperlocal, new *Hyperlocal) bool {
return old.StartMs != new.StartMs ||
old.EndMs != new.EndMs ||
old.RadiusM != new.RadiusM ||
old.ChallengeBonusKey != new.ChallengeBonusKey ||
old.UpdatedMs < new.UpdatedMs
Copy link
Collaborator

Choose a reason for hiding this comment

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

this is not considering lat/lon - there is a method for nearby precision comparison of floats
updated wouldn't normally be considered here I don't think

Copy link
Contributor Author

Choose a reason for hiding this comment

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

They aren't considered because they are primary keys. :)

}

func ClearHyperlocalCache() {
hyperlocalCache.DeleteAll()
}
45 changes: 45 additions & 0 deletions decoder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ type RawMapPokemonData struct {
Timestamp int64
}

type RawHyperlocalData struct {
Data *pogo.HyperlocalExperimentClientProto
Timestamp int64
}

type webhooksSenderInterface interface {
AddMessage(whType webhooks.WebhookType, message any, areas []geo.AreaName)
}
Expand All @@ -72,6 +77,7 @@ var playerCache *ttlcache.Cache[string, Player]
var routeCache *ttlcache.Cache[string, Route]
var diskEncounterCache *ttlcache.Cache[uint64, *pogo.DiskEncounterOutProto]
var getMapFortsCache *ttlcache.Cache[string, *pogo.GetMapFortsOutProto_FortProto]
var hyperlocalCache *ttlcache.Cache[HyperlocalKey, Hyperlocal]

var gymStripedMutex = stripedmutex.New(128)
var pokestopStripedMutex = stripedmutex.New(128)
Expand All @@ -82,6 +88,7 @@ var pokemonStripedMutex = intstripedmutex.New(1024)
var weatherStripedMutex = intstripedmutex.New(128)
var s2cellStripedMutex = stripedmutex.New(1024)
var routeStripedMutex = stripedmutex.New(128)
var hyperlocalStripedMutex = intstripedmutex.New(128)

var s2CellLookup = sync.Map{}

Expand Down Expand Up @@ -168,6 +175,11 @@ func initDataCache() {
ttlcache.WithTTL[string, Route](60 * time.Minute),
)
go routeCache.Start()

hyperlocalCache = ttlcache.New[HyperlocalKey, Hyperlocal](
ttlcache.WithTTL[HyperlocalKey, Hyperlocal](60 * time.Minute),
)
go hyperlocalCache.Start()
}

func InitialiseOhbem() {
Expand Down Expand Up @@ -346,6 +358,39 @@ func UpdateStationBatch(ctx context.Context, db db.DbDetails, scanParameters Sca
}
}

func UpdateHyperlocalBatch(ctx context.Context, db db.DbDetails, scanParameters ScanParameters, p []RawHyperlocalData) {
if len(p) <= 0 {
return
}

for _, raw := range p {
key := HyperlocalKey{
ExperimentId: raw.Data.GetExperimentId(),
Lat: raw.Data.GetLatDegrees(),
Lon: raw.Data.GetLngDegrees(),
}

hyperlocalMutex, _ := hyperlocalStripedMutex.GetLock(uint64(key.ExperimentId) ^ math.Float64bits(key.Lat) ^ math.Float64bits(key.Lon))
Copy link
Collaborator

Choose a reason for hiding this comment

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

I wonder if the hash would be better as a method on the key - ... I think the stripedmutex just calls the standard hashing interface from memory

Copy link
Contributor Author

Choose a reason for hiding this comment

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

stripedmutex takes a string key so this is more performant.

hyperlocalMutex.Lock()

hyperlocal, err := getHyperlocalRecord(ctx, db, key)
if err != nil {
log.Errorf("getHyperlocalRecord: %s", err)
hyperlocalMutex.Unlock()
continue
}

if hyperlocal == nil {
hyperlocal = &Hyperlocal{}
}

hyperlocal.updateFromHyperlocalProto(raw.Data, raw.Timestamp)
saveHyperlocalRecord(ctx, db, hyperlocal)

hyperlocalMutex.Unlock()
}
}

func UpdatePokemonBatch(ctx context.Context, db db.DbDetails, scanParameters ScanParameters, wildPokemonList []RawWildPokemonData, nearbyPokemonList []RawNearbyPokemonData, mapPokemonList []RawMapPokemonData, weather []*pogo.ClientWeatherProto, username string) {
weatherLookup := make(map[int64]pogo.GameplayWeatherProto_WeatherCondition)
for _, weatherProto := range weather {
Expand Down
2 changes: 1 addition & 1 deletion decoder/pokestop.go
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ func (stop *Pokestop) updatePokestopFromQuestProto(questProto *pogo.FortSearchOu
} else {
infoData["pokemon_id"] = int(info.GetPokemonId())
}
if info.ShinyProbability > 0.0 {
if info.ShinyProbability != 0.0 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

why? equality is generally best avoided with floats (though 0 generally works as is a special float)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

These are unlikely to be a result of a floating point math calculation so I think precise comparison is fine. Also the old code is comparing with 0 as well instead of something like > 1e-12.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

And yes 0.0 is a special float in proto, indicating default value.

infoData["shiny_probability"] = info.ShinyProbability
}
if display := info.PokemonDisplay; display != nil {
Expand Down
57 changes: 30 additions & 27 deletions decoder/scanarea.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@ import (
)

type ScanParameters struct {
ProcessPokemon bool
ProcessWild bool
ProcessNearby bool
ProcessWeather bool
ProcessPokestops bool
ProcessGyms bool
ProcessStations bool
ProcessCells bool
ProcessTappables bool
ProcessPokemon bool
ProcessWild bool
ProcessNearby bool
ProcessWeather bool
ProcessPokestops bool
ProcessGyms bool
ProcessStations bool
ProcessHyperlocals bool
ProcessCells bool
ProcessTappables bool
}

func FindScanConfiguration(scanContext string, lat, lon float64) ScanParameters {
Expand Down Expand Up @@ -54,27 +55,29 @@ func FindScanConfiguration(scanContext string, lat, lon float64) ScanParameters
return *value
}
return ScanParameters{
ProcessPokemon: defaultTrue(rule.ProcessPokemon),
ProcessWild: defaultTrue(rule.ProcessWilds),
ProcessNearby: defaultTrue(rule.ProcessNearby),
ProcessCells: defaultTrue(rule.ProcessCells),
ProcessWeather: defaultTrue(rule.ProcessWeather),
ProcessPokestops: defaultTrue(rule.ProcessPokestops),
ProcessGyms: defaultTrue(rule.ProcessGyms),
ProcessStations: defaultTrue(rule.ProcessStations),
ProcessTappables: defaultTrue(rule.ProcessTappables),
ProcessPokemon: defaultTrue(rule.ProcessPokemon),
ProcessWild: defaultTrue(rule.ProcessWilds),
ProcessNearby: defaultTrue(rule.ProcessNearby),
ProcessCells: defaultTrue(rule.ProcessCells),
ProcessWeather: defaultTrue(rule.ProcessWeather),
ProcessPokestops: defaultTrue(rule.ProcessPokestops),
ProcessGyms: defaultTrue(rule.ProcessGyms),
ProcessStations: defaultTrue(rule.ProcessStations),
ProcessTappables: defaultTrue(rule.ProcessTappables),
ProcessHyperlocals: defaultTrue(rule.ProcessHyperlocals),
}
}

return ScanParameters{
ProcessPokemon: true,
ProcessWild: true,
ProcessNearby: true,
ProcessCells: true,
ProcessWeather: true,
ProcessGyms: true,
ProcessPokestops: true,
ProcessStations: true,
ProcessTappables: true,
ProcessPokemon: true,
ProcessWild: true,
ProcessNearby: true,
ProcessCells: true,
ProcessWeather: true,
ProcessGyms: true,
ProcessPokestops: true,
ProcessStations: true,
ProcessTappables: true,
ProcessHyperlocals: true,
}
}
11 changes: 11 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ func main() {
StartTappableExpiry(db)
}

if cfg.Cleanup.Hyperlocals == true {
StartHyperlocalExpiry(db)
}

if cfg.Cleanup.Quests == true {
StartQuestExpiry(db)
}
Expand Down Expand Up @@ -825,6 +829,7 @@ func decodeGMO(ctx context.Context, protoData *ProtoData, scanParameters decoder
var newMapPokemon []decoder.RawMapPokemonData
var newMapCells []uint64
var cellsToBeCleaned []uint64
var newHyperlocals []decoder.RawHyperlocalData

for _, mapCell := range decodedGmo.MapCell {
if isCellNotEmpty(mapCell) {
Expand All @@ -849,6 +854,9 @@ func decodeGMO(ctx context.Context, protoData *ProtoData, scanParameters decoder
for _, station := range mapCell.Stations {
newStations = append(newStations, decoder.RawStationData{Cell: mapCell.S2CellId, Data: station})
}
for _, hyperlocal := range mapCell.HyperlocalExperiment {
newHyperlocals = append(newHyperlocals, decoder.RawHyperlocalData{Data: hyperlocal, Timestamp: mapCell.AsOfTimeMs})
}
}

if scanParameters.ProcessGyms || scanParameters.ProcessPokestops {
Expand All @@ -863,6 +871,9 @@ func decodeGMO(ctx context.Context, protoData *ProtoData, scanParameters decoder
if scanParameters.ProcessStations {
decoder.UpdateStationBatch(ctx, dbDetails, scanParameters, newStations)
}
if scanParameters.ProcessHyperlocals {
decoder.UpdateHyperlocalBatch(ctx, dbDetails, scanParameters, newHyperlocals)
}

if scanParameters.ProcessCells {
decoder.UpdateClientMapS2CellBatch(ctx, dbDetails, newMapCells)
Expand Down
14 changes: 14 additions & 0 deletions sql/48_hyperlocal.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
CREATE TABLE `hyperlocal` (
`experiment_id` INT NOT NULL,
`start_ms` BIGINT NOT NULL,
`end_ms` BIGINT NOT NULL,
`lat` DOUBLE(18,14) NOT NULL,
`lon` DOUBLE(18,14) NOT NULL,
`radius_m` DOUBLE(18,14) NOT NULL,
`challenge_bonus_key` VARCHAR(255) NOT NULL,
`updated_ms` BIGINT NOT NULL,
PRIMARY KEY(`experiment_id`,`lat`,`lon`),
KEY `ix_end_ms` (`end_ms`,`lat`,`lon`)
) ENGINE = InnoDB
Copy link
Collaborator

Choose a reason for hiding this comment

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

We don't normally force engine (some people use alternates), charset or collate (forcing and not using database default gives problems on a join)
Suggest that you probably need an index on lat,lon,end_ms for the standard map type query
and don't need ix_updated_ms as this won't be used anywhere
the end_ms is likely used to expire

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I copied this ENGINE part from a similar sql migration script.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

image Just following existing codebase.

Updated keys/indices as suggested.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I will PR the removal of these, this is a mistake

DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
Loading