Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
88 commits
Select commit Hold shift + click to select a range
215fcb2
Webhooks to structures
jfberry Jan 25, 2026
f8b2eb1
Replace per-pokemon goroutines with pending queue
jfberry Jan 11, 2026
22afd74
Remove comment
jfberry Jan 28, 2026
3de2ece
Example dirty flag implementation
jfberry Jan 26, 2026
d238589
Update pokemon and gym to use new pattern
jfberry Jan 28, 2026
80e1826
Update isNewRecord logic
jfberry Jan 28, 2026
8226a5f
Merge branch 'json_structure' into dirtyflag_prototype
jfberry Jan 28, 2026
c502d6d
Merge remote-tracking branch 'origin/feature/pokemon-pending-queue' i…
jfberry Jan 28, 2026
2b5b7fb
Additional types converted to new model
jfberry Jan 28, 2026
a985123
Generic sharding introduced for pokestop/gym
jfberry Jan 28, 2026
bb8210c
Database change tracing
jfberry Jan 28, 2026
33e6e2e
Add to cache on get
jfberry Jan 28, 2026
7db7d36
Add to cache on get incident
jfberry Jan 28, 2026
af8b3ef
Add weather to cache on read
jfberry Jan 29, 2026
f89eab3
copilot comments
jfberry Jan 30, 2026
f4b3714
switch to internal pokemon lock
jfberry Jan 30, 2026
9660a71
optimise locking in getOrCreatePokemonRecord
jfberry Jan 30, 2026
9e24751
optimise locking in getOrCreatePokemonRecord
jfberry Jan 30, 2026
ca4f0d6
optimise locking in getOrCreatePokemonRecord
jfberry Jan 30, 2026
0825445
Pokestop to new locking model
jfberry Jan 30, 2026
642eb32
Gym locking model changes
jfberry Jan 30, 2026
c56b3ba
copilot review suggestion fixes
jfberry Jan 30, 2026
a28a039
update modules
jfberry Jan 30, 2026
03c897a
fix: increase spawnpoint updates
Fabio1988 Jan 29, 2026
d2ba3b7
feat: reduce updates config
Fabio1988 Jan 30, 2026
0a3cee9
Merge pull request #334 from UnownHash/db_update_frequ
jfberry Jan 30, 2026
f4976ac
more locking update
jfberry Jan 30, 2026
0f2bd79
spawnpoint locking
jfberry Jan 30, 2026
07efbe6
Tidy source
jfberry Jan 31, 2026
58c22db
Remove json fields, these should not be directly serialised
jfberry Jan 31, 2026
b69b5ac
Claude review items
jfberry Jan 31, 2026
1452c1f
Move config options into tuning
jfberry Feb 1, 2026
edf6d76
Some fields weren't being updated through SetXX
jfberry Feb 1, 2026
140e36a
Improved dbupdate logging to show before/after
jfberry Feb 1, 2026
ba42ef9
Ability to turn off nearby cell pokemon
jfberry Feb 1, 2026
dba646a
API documentation
jfberry Feb 1, 2026
c02ade8
Change null debug output so it is more readable
jfberry Feb 2, 2026
a724af6
Initial merge of DNF api and pre-load
jfberry Feb 3, 2026
5e4697b
Use correct result buffer
jfberry Feb 3, 2026
66d87ee
Get it working
jfberry Feb 3, 2026
882f1b0
Convert station/gym to sharded cache
jfberry Feb 3, 2026
81c0107
preload more things
jfberry Feb 3, 2026
6255688
Preload incidents
jfberry Feb 3, 2026
f70e5b5
Write behind database queue implementation
jfberry Feb 3, 2026
f1b2889
Switch to a concurrent writer model
jfberry Feb 4, 2026
9c6159c
Add stats to log entry
jfberry Feb 4, 2026
75f9c51
RaidSeed/QuestSeed as internal fields
jfberry Feb 4, 2026
8407bb1
Amend default in comment
jfberry Feb 4, 2026
f89d449
Fort tracker goes through write behind
jfberry Feb 4, 2026
2c0ade6
implementation of batch writer
jfberry Feb 5, 2026
314cc37
Fix spawnpoint upsert query
jfberry Feb 5, 2026
1d66812
Improve logging
jfberry Feb 5, 2026
dce8b1d
Update spawnpoint through write behind
jfberry Feb 5, 2026
895be01
Remove debug log
jfberry Feb 5, 2026
675fc05
Avoid deadlock
jfberry Feb 6, 2026
bea539e
Ensure we can collect blocked and mutex details
jfberry Feb 6, 2026
a4af10d
Potentially save deadlock on gym/pokestop interaction
jfberry Feb 6, 2026
eb238e2
Introduce a deadlock detector
jfberry Feb 6, 2026
339c0bf
Squash also in batch writer
jfberry Feb 6, 2026
4defe14
Refactor to use data copies in the queue to avoid deadlocking
jfberry Feb 7, 2026
7a16db0
Remove deadlock library
jfberry Feb 7, 2026
3c33579
Update config example
jfberry Feb 7, 2026
735fa2c
No need for removed forts to spin off a go-routine
jfberry Feb 7, 2026
2e32bee
Pass correct context on shutdown
jfberry Feb 7, 2026
526420c
Option to store pokemon to disk on shutdown
jfberry Feb 7, 2026
c398dd8
Fix quest seed in webhook
jfberry Feb 7, 2026
619b628
Skip preserve pokemon api call
jfberry Feb 7, 2026
b0b0948
Fix overlength gym/pokestop names
jfberry Feb 7, 2026
c15470c
Make cleanup pokemon work with preserver
jfberry Feb 7, 2026
2964f70
Max length for routes
jfberry Feb 8, 2026
28e6885
Try to avoid deadlocks through sorting, and also retry mechanism
jfberry Feb 8, 2026
92f6a4b
Improve GetAvailablePokemon performance
jfberry Feb 8, 2026
2837b15
Fort rtree improvements
jfberry Feb 9, 2026
364a57e
Merge branch 'main' into optimise-coalesce
jfberry Feb 20, 2026
220d522
fix: unused/wrong imports after merge
Fabio1988 Feb 21, 2026
05776a1
feat: area pre-rtree lookup table
lenisko Mar 8, 2026
e70ed93
fix: fortwebhook pass cellid
lenisko Mar 8, 2026
df97a18
fix: ignore geography_test in build
lenisko Mar 8, 2026
38fd0c4
feat: speed up processing areas at startup
lenisko Mar 9, 2026
f45eca9
fix: race condition on reload
lenisko Mar 9, 2026
0d31848
Merge branch 'main' into len-optmize-match-stats
lenisko Mar 9, 2026
9bf73e8
Remove duplicated FortChangeWebhook struct
lenisko Mar 9, 2026
896a53e
Merge branch 'main' into len-optmize-match-stats
lenisko Mar 9, 2026
93b987c
Merge branch 'main' into len-optmize-match-stats
lenisko Mar 9, 2026
605f142
Merge branch 'main' into len-optmize-match-stats
lenisko Mar 9, 2026
323e5d3
Merge branch 'main' into len-optmize-match-stats
lenisko Mar 18, 2026
0c9be2e
Merge branch 'main' into len-optmize-match-stats
lenisko Mar 18, 2026
65dfb56
fix: small changes to please jabes claude
lenisko Mar 18, 2026
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
1 change: 1 addition & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ write_behind_batch_size = 50 # Number of entries per batch write
write_behind_batch_timeout = 100 # Max wait time in ms before flushing partial batch
profile_routes = false # Turn on debugging endpoints
profile_contention = false # Collect data for contention (use with above) - has a perf impact
s2_cell_lookup = false # Pre-compute S2 cell lookup for faster geofence matching. Trades memory (~60x geofence file size) for ~7x faster lookups. (default: false)

# When enabled, reduce_updates will make fort update debounce windows much longer
# to reduce database churn. Specifically, gym/pokestop/station debounce will be
Expand Down
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ type tuning struct {
WriteBehindWorkerCount int `koanf:"write_behind_worker_count"` // concurrent writers, default: 50
WriteBehindBatchSize int `koanf:"write_behind_batch_size"` // entries per batch, default: 50
WriteBehindBatchTimeoutMs int `koanf:"write_behind_batch_timeout"` // max wait for batch in ms, default: 100
S2CellLookup bool `koanf:"s2_cell_lookup"` // Pre-compute S2 cell lookup for faster geofence matching. Trades memory (~60x geofence file size) for ~7x faster lookups, default: false
}

type scanRule struct {
Expand Down
9 changes: 6 additions & 3 deletions decoder/fort.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type FortWebhook struct {
Description *string `json:"description"`
ImageUrl *string `json:"image_url"`
Location Location `json:"location"`
CellId uint64 `json:"-"` // internal use only, not sent in webhook
}

type FortChangeWebhook struct {
Expand Down Expand Up @@ -83,6 +84,7 @@ func InitWebHookFortFromGym(gym *Gym) *FortWebhook {
fort.ImageUrl = gym.Url.Ptr()
fort.Description = gym.Description.Ptr()
fort.Location = Location{Latitude: gym.Lat, Longitude: gym.Lon}
fort.CellId = uint64(gym.CellId.ValueOrZero())
return fort
}

Expand All @@ -97,6 +99,7 @@ func InitWebHookFortFromPokestop(stop *Pokestop) *FortWebhook {
fort.ImageUrl = stop.Url.Ptr()
fort.Description = stop.Description.Ptr()
fort.Location = Location{Latitude: stop.Lat, Longitude: stop.Lon}
fort.CellId = uint64(stop.CellId.ValueOrZero())
return fort
}

Expand All @@ -111,23 +114,23 @@ func CreateFortChangeWebhooks(fort *FortWebhook, change FortChange) {

func CreateFortWebHooks(old *FortWebhook, new *FortWebhook, change FortChange) {
if change == NEW {
areas := MatchStatsGeofence(new.Location.Latitude, new.Location.Longitude)
areas := MatchStatsGeofenceWithCell(new.Location.Latitude, new.Location.Longitude, new.CellId)
hook := FortChangeWebhook{
ChangeType: change.String(),
New: new,
}
webhooksSender.AddMessage(webhooks.FortUpdate, hook, areas)
statsCollector.UpdateFortCount(areas, new.Type, "addition")
} else if change == REMOVAL {
areas := MatchStatsGeofence(old.Location.Latitude, old.Location.Longitude)
areas := MatchStatsGeofenceWithCell(old.Location.Latitude, old.Location.Longitude, old.CellId)
hook := FortChangeWebhook{
ChangeType: change.String(),
Old: old,
}
webhooksSender.AddMessage(webhooks.FortUpdate, hook, areas)
statsCollector.UpdateFortCount(areas, old.Type, "removal")
} else if change == EDIT {
areas := MatchStatsGeofence(new.Location.Latitude, new.Location.Longitude)
areas := MatchStatsGeofenceWithCell(new.Location.Latitude, new.Location.Longitude, new.CellId)
var editTypes []string

// Check if Name has changed
Expand Down
38 changes: 31 additions & 7 deletions decoder/geography.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ package decoder

import (
"encoding/json"
"github.com/tidwall/rtree"
"golbat/geo"
"io/ioutil"
"net/http"
"sync/atomic"

"golbat/config"
"golbat/geo"

"github.com/golang/geo/s2"
"github.com/paulmach/orb/geojson"
log "github.com/sirupsen/logrus"
"github.com/tidwall/rtree"
)

type KojiResponse struct {
Expand All @@ -19,8 +23,9 @@ type KojiResponse struct {
// Stats KojiStats `json:"stats"`
}

var statsTree *rtree.RTreeG[*geojson.Feature]
var nestTree *rtree.RTreeG[*geojson.Feature]
var statsTree atomic.Value
var nestTree atomic.Value
var statsS2Lookup atomic.Value
var kojiUrl = ""
var kojiBearerToken = ""

Expand Down Expand Up @@ -105,15 +110,34 @@ func ReadGeofences() error {
statsFeatureCollection = fc
}

statsTree = geo.LoadRtree(statsFeatureCollection)
newStatsTree := geo.LoadRtree(statsFeatureCollection)
var newStatsS2Lookup *geo.S2CellLookup
if config.Config.Tuning.S2CellLookup {
newStatsS2Lookup = geo.BuildS2LookupFromFeatures(statsFeatureCollection)
}

statsTree.Store(newStatsTree)
statsS2Lookup.Store(newStatsS2Lookup)

return nil
}

func MatchStatsGeofence(lat, lon float64) []geo.AreaName {
return geo.MatchGeofencesRtree(statsTree, lat, lon)
return MatchStatsGeofenceWithCell(lat, lon, 0)
}

func MatchStatsGeofenceWithCell(lat, lon float64, cellId uint64) []geo.AreaName {
lookup, _ := statsS2Lookup.Load().(*geo.S2CellLookup)
if cellId != 0 && lookup != nil {
if areas := lookup.Lookup(s2.CellID(cellId)); len(areas) > 0 {
return areas
}
}
tree, _ := statsTree.Load().(*rtree.RTreeG[*geojson.Feature])
return geo.MatchGeofencesRtree(tree, lat, lon)
}

func MatchNestGeofence(lat, lon float64) []geo.AreaName {
return geo.MatchGeofencesRtree(nestTree, lat, lon)
tree, _ := nestTree.Load().(*rtree.RTreeG[*geojson.Feature])
return geo.MatchGeofencesRtree(tree, lat, lon)
}
129 changes: 129 additions & 0 deletions decoder/geography_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//go:build ignore

package decoder

import (
"encoding/json"
"math/rand"
"os"
"reflect"
"sort"
"testing"
"time"

"golbat/geo"

"github.com/golang/geo/s2"
"github.com/paulmach/orb/geojson"
)

type KojiTestResponse struct {
Data geojson.FeatureCollection `json:"data"`
}

func TestMatchStatsGeofenceWithCellVsRtree(t *testing.T) {
data, err := os.ReadFile("../cache/geofences.txt")
if err != nil {
t.Fatalf("Failed to read geofences.txt: %v", err)
}

var response KojiTestResponse
if err := json.Unmarshal(data, &response); err != nil {
t.Fatalf("Failed to parse geofences.txt: %v", err)
}

fc := &response.Data
t.Logf("Loaded %d features from geofences.txt", len(fc.Features))

testStatsTree := geo.LoadRtree(fc)
t.Logf("Built rtree")
testS2Lookup := geo.BuildS2LookupFromFeatures(fc)
t.Logf("Built S2 lookup with %d cells, size: %.2f MB", testS2Lookup.CellCount(), float64(testS2Lookup.SizeBytes())/(1024*1024))

minLat, maxLat := 49.002043, 54.854478
minLon, maxLon := 14.120178, 24.145783

const numPoints = 50000
rng := rand.New(rand.NewSource(42))

var mismatches int
var s2Hits int
var rtreeHits int
var rtreeTime time.Duration
var lookupTime time.Duration

for i := 0; i < numPoints; i++ {
lat := minLat + rng.Float64()*(maxLat-minLat)
lon := minLon + rng.Float64()*(maxLon-minLon)

cellID := s2.CellIDFromLatLng(s2.LatLngFromDegrees(lat, lon)).Parent(geo.S2LookupLevel)

start := time.Now()
areasRtree := geo.MatchGeofencesRtree(testStatsTree, lat, lon)
rtreeTime += time.Since(start)

start = time.Now()
areasS2 := testS2Lookup.Lookup(cellID)
var areasWithCell []geo.AreaName
if len(areasS2) > 0 {
areasWithCell = areasS2
s2Hits++
} else {
areasWithCell = geo.MatchGeofencesRtree(testStatsTree, lat, lon)
rtreeHits++
}
lookupTime += time.Since(start)

if !areasEqual(areasRtree, areasWithCell) {
mismatches++
if mismatches <= 10 {
t.Logf("Mismatch at point %d (%.6f, %.6f): rtree=%v, withCell=%v",
i, lat, lon, areasRtree, areasWithCell)
}
}
}

t.Logf("Results: %d points tested", numPoints)
t.Logf("S2 lookup hits: %d (%.2f%%)", s2Hits, float64(s2Hits)/float64(numPoints)*100)
t.Logf("Rtree fallback: %d (%.2f%%)", rtreeHits, float64(rtreeHits)/float64(numPoints)*100)
t.Logf("Mismatches: %d (%.2f%%)", mismatches, float64(mismatches)/float64(numPoints)*100)

// Mismatches should be 0 — edge cells are excluded from the S2 lookup,
// so the rtree fallback always provides the complete result.
if mismatches > 0 {
t.Errorf("Expected 0 mismatches, got %d. Edge cells should be excluded from S2 lookup.", mismatches)
}

t.Logf("Timing: rtree only: %v (%.2f µs/call)", rtreeTime, float64(rtreeTime.Microseconds())/float64(numPoints))
t.Logf("Timing: lookup+fallback: %v (%.2f µs/call)", lookupTime, float64(lookupTime.Microseconds())/float64(numPoints))
t.Logf("Speedup: %.2fx", float64(rtreeTime)/float64(lookupTime))
}

func areasEqual(a, b []geo.AreaName) bool {
if len(a) != len(b) {
return false
}
if len(a) == 0 {
return true
}

aCopy := make([]geo.AreaName, len(a))
bCopy := make([]geo.AreaName, len(b))
copy(aCopy, a)
copy(bCopy, b)

sort.Slice(aCopy, func(i, j int) bool {
if aCopy[i].Parent != aCopy[j].Parent {
return aCopy[i].Parent < aCopy[j].Parent
}
return aCopy[i].Name < aCopy[j].Name
})
sort.Slice(bCopy, func(i, j int) bool {
if bCopy[i].Parent != bCopy[j].Parent {
return bCopy[i].Parent < bCopy[j].Parent
}
return bCopy[i].Name < bCopy[j].Name
})

return reflect.DeepEqual(aCopy, bCopy)
}
2 changes: 1 addition & 1 deletion decoder/gym_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ func saveGymRecord(ctx context.Context, db db.DbDetails, gym *Gym) {
fortRtreeUpdateGymOnSave(gym)
}

areas := MatchStatsGeofence(gym.Lat, gym.Lon)
areas := MatchStatsGeofenceWithCell(gym.Lat, gym.Lon, uint64(gym.CellId.ValueOrZero()))
createGymWebhooks(gym, areas)
createGymFortWebhooks(gym)
updateRaidStats(gym, areas)
Expand Down
8 changes: 6 additions & 2 deletions decoder/incident_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,15 @@ func saveIncidentRecord(ctx context.Context, db db.DbDetails, incident *Incident
createIncidentWebhooks(ctx, db, incident)

var stopLat, stopLon float64
var stopCellId uint64
stop, unlock, _ := getPokestopRecordReadOnly(ctx, db, incident.PokestopId)
if stop != nil {
stopLat, stopLon = stop.Lat, stop.Lon
stopCellId = uint64(stop.CellId.ValueOrZero())
unlock()
}

areas := MatchStatsGeofence(stopLat, stopLon)
areas := MatchStatsGeofenceWithCell(stopLat, stopLon, stopCellId)
updateIncidentStats(incident, areas)

if config.Config.FortInMemory {
Expand Down Expand Up @@ -217,12 +219,14 @@ func createIncidentWebhooks(ctx context.Context, db db.DbDetails, incident *Inci
var pokestopName, stopUrl string
var stopLat, stopLon float64
var stopEnabled bool
var stopCellId uint64
stop, unlock, _ := getPokestopRecordReadOnly(ctx, db, incident.PokestopId)
if stop != nil {
pokestopName = stop.Name.ValueOrZero()
stopLat, stopLon = stop.Lat, stop.Lon
stopUrl = stop.Url.ValueOrZero()
stopEnabled = stop.Enabled.ValueOrZero()
stopCellId = uint64(stop.CellId.ValueOrZero())
unlock()
}
if pokestopName == "" {
Expand Down Expand Up @@ -270,7 +274,7 @@ func createIncidentWebhooks(ctx context.Context, db db.DbDetails, incident *Inci
Lineup: lineup,
}

areas := MatchStatsGeofence(stopLat, stopLon)
areas := MatchStatsGeofenceWithCell(stopLat, stopLon, stopCellId)
webhooksSender.AddMessage(webhooks.Invasion, incidentHook, areas)
statsCollector.UpdateIncidentCount(areas)
}
Expand Down
2 changes: 1 addition & 1 deletion decoder/pokemon_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ func savePokemonRecordAsAtTime(ctx context.Context, db db.DbDetails, pokemon *Po
updatePokemonLookup(pokemon, changePvpField, pvpResults)

// Webhooks and stats happen immediately (not queued)
areas := MatchStatsGeofence(pokemon.Lat, pokemon.Lon)
areas := MatchStatsGeofenceWithCell(pokemon.Lat, pokemon.Lon, uint64(pokemon.CellId.ValueOrZero()))
if webhook {
createPokemonWebhooks(ctx, db, pokemon, areas)
}
Expand Down
2 changes: 1 addition & 1 deletion decoder/pokestop_process.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func UpdatePokestopWithQuest(ctx context.Context, db db.DbDetails, quest *pogo.F
updatePokestopGetMapFortCache(pokestop)
savePokestopRecord(ctx, db, pokestop)

areas := MatchStatsGeofence(pokestop.Lat, pokestop.Lon)
areas := MatchStatsGeofenceWithCell(pokestop.Lat, pokestop.Lon, uint64(pokestop.CellId.ValueOrZero()))
updateQuestStats(pokestop, haveAr, areas)

return fmt.Sprintf("%s %s %s", quest.FortId, haveArStr, questTitle)
Expand Down
2 changes: 1 addition & 1 deletion decoder/pokestop_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ func createPokestopFortWebhooks(stop *Pokestop) {

func createPokestopWebhooks(stop *Pokestop) {

areas := MatchStatsGeofence(stop.Lat, stop.Lon)
areas := MatchStatsGeofenceWithCell(stop.Lat, stop.Lon, uint64(stop.CellId.ValueOrZero()))

pokestopName := "Unknown"
if stop.Name.Valid {
Expand Down
2 changes: 1 addition & 1 deletion decoder/station_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ func createStationWebhooks(station *Station) {
TotalStationedGmax: station.TotalStationedGmax,
Updated: station.Updated,
}
areas := MatchStatsGeofence(station.Lat, station.Lon)
areas := MatchStatsGeofenceWithCell(station.Lat, station.Lon, uint64(station.CellId))
webhooksSender.AddMessage(webhooks.MaxBattle, stationHook, areas)
statsCollector.UpdateMaxBattleCount(areas, station.BattleLevel.ValueOrZero())
}
Expand Down
Loading
Loading