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
1 change: 1 addition & 0 deletions packages/logger/lib/tags.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const TAGS = /** @type {const} */ ({
geocoder: chalk.hex('#ff5722')('[GEOCODER]'),
fetch: chalk.hex('#880e4f')('[FETCH]'),
scanner: chalk.hex('#b39ddb')('[SCANNER]'),
golbat: chalk.hex('#ffb300')('[GOLBAT]'),
build: chalk.hex('#ef6c00')('[BUILD]'),
ReactMap: chalk.hex('#ff3d00')('[ReactMap]'),
trial: chalk.hex('#fff320')('[TRIAL]'),
Expand Down
32 changes: 31 additions & 1 deletion packages/types/lib/server.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,13 @@ export interface AvailablePokestopShowcase {
}

export interface AvailablePokestops {
showcase_focus_filter: boolean
/**
* @deprecated Golbat advertises showcase focus support via
* GET /api/status `filters.showcase_focus` (see GolbatStatus). This
* per-response flag is only consulted for a Golbat whose status route
* reports no filters block, and will be dropped once such builds are gone.
*/
showcase_focus_filter?: boolean
quests: AvailablePokestopQuest[]
invasions: AvailablePokestopInvasion[]
lures: AvailablePokestopLure[]
Expand All @@ -125,6 +131,30 @@ export interface Available {
tappables: ModelReturn<typeof Tappable, 'getAvailable'>
}

/**
* Parsed GET /api/status from one Golbat instance. Every key a build defines
* is true; the signal is the key's presence. `filters` is null when the build
* predates the block (it then advertises no optional DNF filter fields), so
* consumers can fall back to older per-response flags for that build only.
*/
export interface GolbatStatus {
features: Record<string, boolean>
limits: Record<string, number>
filters: Record<string, boolean> | null
}

/** Registry entry kept by GolbatCapabilities, keyed by the endpoint base url. */
export interface GolbatInstance {
mem: string
secret: string
httpAuth: { username: string; password: string } | null
/** Last good status; null until the instance has answered once. */
status: GolbatStatus | null
/** Epoch ms of the last refresh start; drives the recheck debounce. */
lastFetchAt: number
inflight: Promise<void> | null
}

export interface ApiEndpoint {
type: string
endpoint: string
Expand Down
33 changes: 29 additions & 4 deletions server/src/models/Pokestop.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const { getCombinedFortAvailable } = require('../utils/fortAvailable')
const { buildPokestopDnfFilters } = require('../filters/fort/pokestop')
const { describeDnfNarrowing } = require('../filters/fort/describeDnfNarrowing')
const { state } = require('../services/state')
const { golbatCapabilities } = require('../services/GolbatCapabilities')
const {
isDualQuestLayerMode,
resolveQuestLayerSelection,
Expand All @@ -49,6 +50,21 @@ const TEMP_EVOLUTION_RESOURCE_REWARD_TYPES = [
TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE,
]

/**
* Whether the Golbat at `mem` applies contest_focus (showcase focus) inside
* its DNF matcher before the result cap. /api/status is the source of truth
* once a build advertises filters; an older build that does not is judged by
* the deprecated per-response `showcase_focus_filter` flag instead.
* @param {string} mem endpoint base url
* @param {{ showcase_focus_filter?: boolean }} payload /api/fort/available pokestops block
*/
function supportsShowcaseFocus(mem, payload) {
if (golbatCapabilities.advertisesFilters(mem)) {
return golbatCapabilities.supportsFilter(mem, 'showcase_focus')
}
return payload.showcase_focus_filter === true
}

/** @typedef {Partial<import('@rm/types').Quest>} QuestReward */

const QUEST_REWARD_FILTER_DEFINITIONS = {
Expand Down Expand Up @@ -1343,10 +1359,19 @@ class Pokestop extends Model {
// contract. Do not let an outdated endpoint masquerade as a transient
// failure and fall through to SQL.
const hasPayload = res && typeof res === 'object' && !Array.isArray(res)
if (hasPayload && res.showcase_focus_filter !== true) {
throw new Error(
'Golbat lacks the required showcase_focus_filter capability',
)
if (hasPayload && !supportsShowcaseFocus(mem, res)) {
// The registry can lag a Golbat upgrade that dropped the legacy flag
// (its status was last read before the upgrade). Re-read it before
// the verdict becomes a hard error. This is refresh(), not the
// debounced recheck(): the upgrade may land seconds after a periodic
// status fetch, and this path is already throttled by the
// availability refresh window, so it cannot hammer Golbat.
await golbatCapabilities.refresh(mem)
if (!supportsShowcaseFocus(mem, res)) {
throw new Error(
'Golbat lacks the required showcase_focus filter capability',
)
}
}
// Transport failures and malformed responses may use the source's
// normal SQL fallback. Keep only the mapper call inside this catch so
Expand Down
2 changes: 1 addition & 1 deletion server/src/models/pokestopAvailableMapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
* @property {Record<string, any>|null} showcase_focus
*
* @typedef {object} AvailablePokestops
* @property {boolean} showcase_focus_filter
* @property {boolean} [showcase_focus_filter] deprecated — see GolbatStatus.filters.showcase_focus
* @property {AvailablePokestopQuest[]} quests
* @property {AvailablePokestopInvasion[]} invasions
* @property {AvailablePokestopLure[]} lures
Expand Down
8 changes: 8 additions & 0 deletions server/src/services/DbManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const {
collapseRocketPokemonFilterKeys,
} = require('../utils/rocketPokemonFiltering')
const { getCache } = require('./cache')
const { golbatCapabilities } = require('./GolbatCapabilities')

const STATION_BATTLE_REQUIRED_COLUMNS = [
'station_id',
Expand Down Expand Up @@ -292,6 +293,12 @@ class DbManager extends Logger {
this.connections.length > 1 ? 's' : ''
}`,
)
// Endpoint capability discovery is the /api/status counterpart of
// schemaCheck: it runs here so the registry is populated before the first
// availability refresh (startup and config reload both await this).
const discovery = golbatCapabilities
.discover(Object.values(this.endpoints))
.catch((e) => this.log.error('Golbat capability discovery failed', e))
await Promise.all(
this.connections.map(async (schema, i) => {
try {
Expand Down Expand Up @@ -363,6 +370,7 @@ class DbManager extends Logger {
}
}),
)
await discovery
}

/**
Expand Down
252 changes: 252 additions & 0 deletions server/src/services/GolbatCapabilities.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
// @ts-check
const { default: fetch } = require('node-fetch')

const config = require('@rm/config')
const { Logger } = require('@rm/logger')

const { buildScannerHeaders } = require('../utils/scannerHeaders')
const { setLongTimeout } = require('../utils/setLongTimeout')

const STATUS_PATH = '/api/status'

/** @param {unknown} value */
const isPlainObject = (value) =>
!!value && typeof value === 'object' && !Array.isArray(value)

/** @returns {import('@rm/types').GolbatStatus} */
const legacyStatus = () => ({ features: {}, limits: {}, filters: null })

/**
* Normalises a decoded /api/status body. A body that is not an object (an
* HTML error page that happened to parse, a bare array) counts as legacy.
* `filters` stays null when the block is absent so consumers can tell "this
* build does not advertise filters" from "this build advertises none".
* @param {unknown} body
* @returns {import('@rm/types').GolbatStatus}
*/
function parseStatus(body) {
if (!isPlainObject(body)) return legacyStatus()
const { features, limits, filters } = /** @type {Record<string, any>} */ (
body
)
return {
features: isPlainObject(features) ? { ...features } : {},
limits: isPlainObject(limits) ? { ...limits } : {},
filters: isPlainObject(filters) ? { ...filters } : null,
}
}

/**
* Discovers what each configured Golbat instance supports by reading
* GET /api/status, and keeps the answer per instance so consumers can ask
* `supportsFilter(mem, 'showcase_focus')` instead of probing feature calls.
*
* Instances are keyed by their endpoint base URL — the `mem` every scanner
* model already carries in its DbContext.
*/
class GolbatCapabilities extends Logger {
/** Upgrades are rare and a slow reaction is fine; the recheck fast path covers downgrades. */
static REFRESH_MS = 5 * 60_000

/** Floor between immediate rechecks of one instance, so a burst of 4xx cannot hammer it. */
static RECHECK_DEBOUNCE_MS = 30_000

/**
* @param {{ fetch?: typeof fetch, timeoutMs?: number, now?: () => number }} [options]
*/
constructor(options = {}) {
super('golbat')
this.fetch = options.fetch ?? fetch
this.timeoutMs = options.timeoutMs
this.now = options.now ?? Date.now
/** @type {Map<string, import('@rm/types').GolbatInstance>} */
this.instances = new Map()
/** @type {NodeJS.Timeout | null} */
this.timer = null
}

/**
* Replaces the registry with the given endpoints and fetches each status
* once, resolving when every fetch has settled.
* @param {{ endpoint: string, secret?: string, httpAuth?: { username: string, password: string } | null }[]} endpoints
*/
async discover(endpoints) {
const next = new Map()
endpoints.forEach(({ endpoint, secret, httpAuth }) => {
if (!endpoint || next.has(endpoint)) return
next.set(endpoint, {
mem: endpoint,
secret: secret || '',
httpAuth: httpAuth || null,
status: this.instances.get(endpoint)?.status ?? null,
lastFetchAt: 0,
inflight: null,
})
})
this.instances = next
this.#ensureTimer()
await this.refreshAll()
}

/** Refreshes every registered instance, resolving once all have settled. */
async refreshAll() {
await Promise.allSettled(
[...this.instances.keys()].map((mem) => this.refresh(mem)),
)
}

#ensureTimer() {
if (this.timer) return
this.timer = setInterval(
() => this.refreshAll(),
GolbatCapabilities.REFRESH_MS,
)
this.timer.unref?.()
}

/**
* Fast path for a failed feature call: a 4xx from a filter field is the first
* sign that a Golbat was downgraded, so re-read its status now rather than
* on the next interval. Debounced per instance and shares any in-flight
* refresh, so it is safe to call from a hot request path.
* @param {string} mem
*/
async recheck(mem) {
const instance = this.instances.get(mem)
if (!instance) return
if (instance.inflight) {
await instance.inflight
return
}
if (
this.now() - instance.lastFetchAt <
GolbatCapabilities.RECHECK_DEBOUNCE_MS
) {
return
Comment on lines +121 to +125

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Allow the unsupported verdict to force a status recheck

When Golbat is upgraded within 30 seconds of the startup or periodic status fetch, Pokestop.getAvailable() receives the new payload without showcase_focus_filter and calls recheck(), but lastFetchAt was just set by the preceding fetch, so this branch returns without requesting the updated status. The availability call consequently throws instead of recovering on the same pass, leaving the previous or empty filter drawer until the next scheduled availability refresh. The unsupported-capability path needs a forced retry or debounce logic that still permits this first corrective recheck.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 4e0dbae. Confirmed the scenario: recheck() shared its 30 s floor with the periodic status fetch, so an upgrade landing inside that window left the pass throwing until the next availability refresh. The availability path now calls refresh() (still single-flight, no debounce); it is already throttled by the availability refresh window, so it cannot hammer Golbat. The debounce stays on the hot-path recheck triggered by 4xx scanner responses. The test for this path now drives the real registry with a fake transport, with the upgrade landing 10 s after discovery.

}
await this.refresh(mem)
}

/**
* Fetches /api/status for one instance and stores the parsed result.
*
* A 404 or a 2xx whose body is not JSON is an older Golbat and records a
* legacy (no capabilities) result. Any other outcome — a non-2xx such as
* 401/503, a network error, or the fetch timeout — is treated as transient:
* the last good result is kept so a Golbat blip does not churn behaviour.
* @param {string} mem
*/
async refresh(mem) {
const instance = this.instances.get(mem)
if (!instance) return
if (instance.inflight) {
await instance.inflight
return
}
instance.lastFetchAt = this.now()
instance.inflight = this.#fetchStatus(instance).finally(() => {
instance.inflight = null
})
await instance.inflight
}

/** @param {import('@rm/types').GolbatInstance} instance */
async #fetchStatus(instance) {
const { mem } = instance
const controller = new AbortController()
const clearFetchTimeout = setLongTimeout(
() => controller.abort(),
this.timeoutMs ?? config.getSafe('api.fetchTimeoutMs'),
)
try {
const response = await this.fetch(`${mem}${STATUS_PATH}`, {
method: 'GET',
headers: buildScannerHeaders(instance.secret, instance.httpAuth),
signal: controller.signal,
})
let status
if (response.status === 404) {
status = legacyStatus()
} else if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
} else {
let body
try {
body = await response.json()
} catch {
body = null
}
status = parseStatus(body)
}
this.#store(instance, status)
} catch (e) {
this.log.warn(
`${mem}${STATUS_PATH} unreachable (${e instanceof Error ? e.message : e}) — ${
instance.status
? 'keeping the last known capabilities'
: 'capabilities unknown until it answers'
}`,
)
} finally {
clearFetchTimeout()
}
}

/**
* Commits a freshly parsed status, logging at info only when it differs from
* the previous answer so the periodic refresh stays quiet.
* @param {import('@rm/types').GolbatInstance} instance
* @param {import('@rm/types').GolbatStatus} status
*/
#store(instance, status) {
const changed = JSON.stringify(instance.status) !== JSON.stringify(status)
instance.status = status
if (!changed) return
const filters = status.filters
? `filters: ${Object.keys(status.filters).join(', ') || 'none'}`
: 'no filters block (older Golbat)'
const features = Object.keys(status.features).length
? `features: ${Object.entries(status.features)
.map(([k, v]) => `${k}=${v}`)
.join(', ')}`
: 'no features block'
const limits = Object.keys(status.limits).length
? `limits: ${Object.entries(status.limits)
.map(([k, v]) => `${k}=${v}`)
.join(', ')}`
: 'no limits block'
this.log.info(`${instance.mem} — ${filters}; ${features}; ${limits}`)
}

/**
* @param {string} mem
* @returns {import('@rm/types').GolbatStatus | null}
*/
get(mem) {
return this.instances.get(mem)?.status ?? null
}

/** @param {string} mem */
advertisesFilters(mem) {
return !!this.get(mem)?.filters
}

/**
* @param {string} mem
* @param {string} key
*/
supportsFilter(mem, key) {
return this.get(mem)?.filters?.[key] === true
}

/** Cancels the periodic refresh. Discovery restarts it. */
stop() {
if (this.timer) clearInterval(this.timer)
this.timer = null
}
}

/** Process-wide registry; DbManager.getDbContext populates it at startup and on reload. */
const golbatCapabilities = new GolbatCapabilities()

module.exports = { GolbatCapabilities, golbatCapabilities }
Loading
Loading