Skip to content

Commit b57e9cb

Browse files
committed
feat(history): record first registrant as default
With no default in settings, the registry resolves one from registration order and never records it, so the choice is remade on every start. startPlugins does not await plugin.start(), so a provider that registers once its database answers loses the slot to one that registers straight from start(), and the switch is silent because the unavailable warning covers configured providers only. Record the first provider to register, through the same settings write the POST route uses, so a provider installed later cannot take the default from the one already serving. The resources API does this for resource providers. Nothing is recorded while settings do not represent the configured state. A failed write is reported once per run rather than per attempt: the retry stays, but console.error reaches the log ring the Admin UI subscribes to, and a plugin reconnecting in a loop would push the errors explaining the loop out of it.
1 parent e059928 commit b57e9cb

5 files changed

Lines changed: 264 additions & 7 deletions

File tree

docs/develop/rest-api/history_api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ Returns an array of path strings.
110110

111111
The History API supports the registration of multiple history provider plugins.
112112

113-
The first plugin registered is set as the _default_ provider and all requests will be directed to it.
113+
The first plugin to register becomes the _default_ provider and all requests are directed to it. When settings name no default, and the settings the server started with are safe to save, it records that first provider — so the choice holds across restarts and a plugin installed later does not take it over. A default already named in settings is never replaced, and a server started with `--data` or sample data records nothing, keeping the first provider for that run only. Change the default in the Admin UI under Apps & Plugins -> Configuration.
114114

115115
Requests can be directed to a specific provider by using the `provider` parameter in the request with the _id_ of the provider plugin.
116116

src/api/history/index.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ export class HistoryApiHttpRegistry {
6666
* the HISTORYPROVIDERS serverevent. */
6767
private unavailableGraceExpired = false
6868
private unavailableGraceTimer: ReturnType<typeof setTimeout> | null = null
69+
/** True once a failed recording has been reported for this run. */
70+
private recordFailureReported = false
71+
/** The provider chosen as default whose write has not landed yet.
72+
* Held across later registrations so a failed write is retried by the
73+
* next one: without it the arrival of a second provider ends the
74+
* attempts, and the default reverts to registration order on every
75+
* start. Cleared when that provider unregisters, since recording a
76+
* provider that has gone would be worse than the fallback. */
77+
private pendingDefaultProviderId?: string
6978
proxy: HistoryApi
7079

7180
/** The configured provider when it is registered, otherwise the first
@@ -136,6 +145,7 @@ export class HistoryApiHttpRegistry {
136145
this.unavailableGraceExpired = false
137146
this.notifyConfiguredAvailable()
138147
}
148+
this.recordDefaultIfUnconfigured(pluginId)
139149
this.emitProvidersState()
140150
debug(
141151
`Registered history api provider ${pluginId},`,
@@ -144,11 +154,66 @@ export class HistoryApiHttpRegistry {
144154
)
145155
}
146156

157+
/** Persist the first provider to register when settings name none.
158+
*
159+
* Unrecorded, the default is resolved from registration order on
160+
* every start, and `startPlugins` does not await `start()`: a plugin
161+
* that registers once its database answers loses the slot to one that
162+
* registers from `start()` itself. Recording it makes the first
163+
* outcome the lasting one, so a provider installed later cannot take
164+
* the default from the one already serving.
165+
*/
166+
private recordDefaultIfUnconfigured(pluginId: string): void {
167+
if (this.configuredProviderId !== undefined) {
168+
return
169+
}
170+
if (!this.app.config.safeToPersistSettings) {
171+
debug.enabled &&
172+
debug(
173+
`Not recording ${pluginId}: settings are not the configured state`
174+
)
175+
return
176+
}
177+
if (this.pendingDefaultProviderId === undefined) {
178+
// Only the first provider claims the slot; later ones just give a
179+
// failed write another chance at the one already claimed.
180+
if (this.historyProviders.size !== 1) {
181+
return
182+
}
183+
this.pendingDefaultProviderId = pluginId
184+
}
185+
const candidate = this.pendingDefaultProviderId
186+
this.saveConfiguredProvider(candidate, (err?: Error) => {
187+
if (!err) {
188+
this.pendingDefaultProviderId = undefined
189+
return
190+
}
191+
// A plugin that reconnects in a loop registers each time, and
192+
// console.error reaches the 100-entry log ring the admin UI
193+
// subscribes to. Reporting every attempt would push out the
194+
// plugin errors that explain the loop, so the retry stays and
195+
// only the first report does.
196+
if (!this.recordFailureReported) {
197+
this.recordFailureReported = true
198+
console.error(
199+
`Failed to record ${candidate} as the default history provider:`,
200+
err.message
201+
)
202+
} else {
203+
debug.enabled &&
204+
debug(`Failed to record ${candidate} again: ${err.message}`)
205+
}
206+
})
207+
}
208+
147209
unregisterHistoryApiProvider(pluginId: string): void {
148210
if (!pluginId || !this.historyProviders.has(pluginId)) {
149211
return
150212
}
151213
this.historyProviders.delete(pluginId)
214+
if (pluginId === this.pendingDefaultProviderId) {
215+
this.pendingDefaultProviderId = undefined
216+
}
152217
if (pluginId === this.configuredProviderId) {
153218
this.armUnavailableGrace()
154219
}

src/api/history/openApi.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ historyApiDoc.paths = {
356356
configured: {
357357
type: 'string',
358358
description:
359-
'Provider identifier persisted in server settings. May differ from `id` when the configured provider is not currently registered.'
359+
'Provider identifier persisted in server settings, set either by a client through this API or by the server itself, which records the first provider to register when no default is configured and its settings are safe to save. May differ from `id` when the configured provider is not currently registered, and is absent on a server that has recorded nothing.'
360360
}
361361
},
362362
example: { id: 'signalk-to-influxdb2' }

src/config/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,9 @@ export interface Config {
191191
* Applied whenever the provider is registered, so the default does
192192
* not depend on plugin load order. When the configured provider is
193193
* not registered (e.g. plugin disabled), the first registered
194-
* provider serves as fallback. */
194+
* provider serves as fallback. Set through the History API, or by
195+
* the server itself when a provider registers while this key is
196+
* absent and safeToPersistSettings is set. */
195197
defaultProvider?: string
196198
}
197199
notifications?: {

test/history-api.ts

Lines changed: 194 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,14 @@ describe('History API v2', () => {
297297
}
298298

299299
interface TestApp extends WithHistoryApi {
300-
config: { settings: { historyApi?: { defaultProvider?: string } } }
300+
config: {
301+
safeToPersistSettings: boolean
302+
settings: {
303+
historyApi?: { defaultProvider?: string }
304+
pipedProviders?: unknown[]
305+
interfaces?: Record<string, boolean>
306+
}
307+
}
301308
handleMessage: (id: string, delta: unknown) => void
302309
/** Notification values captured from handleMessage */
303310
notifications: NotificationValue[]
@@ -311,7 +318,12 @@ describe('History API v2', () => {
311318
const serverEvents: HistoryProvidersEventData[] = []
312319
return {
313320
config: {
321+
safeToPersistSettings: true,
314322
settings: {
323+
// Unrelated keys so the assertions can show the recorded
324+
// write carries the rest of the user's settings with it.
325+
pipedProviders: [{ id: 'gps' }],
326+
interfaces: { nmea0183: true },
315327
historyApi: configuredDefault
316328
? { defaultProvider: configuredDefault }
317329
: undefined
@@ -354,10 +366,47 @@ describe('History API v2', () => {
354366
return registry
355367
}
356368

369+
// Recording the first provider writes settings, so every test in
370+
// this block goes through a stubbed writeSettingsFile: it captures
371+
// what would be persisted, and its callback runs inline so the
372+
// assertions need no timing guess. Stubbing also keeps the block
373+
// independent of whether an earlier suite built a Server, which
374+
// disables settings writes for the whole process.
375+
let settingsWrites: Array<{
376+
historyApi?: { defaultProvider?: string }
377+
pipedProviders?: unknown[]
378+
interfaces?: Record<string, boolean>
379+
}>
380+
let writeOutcome: (cb: (err?: Error) => void) => void
381+
let restoreWriteSettings: () => void
382+
383+
beforeEach(() => {
384+
settingsWrites = []
385+
writeOutcome = (cb) => cb()
386+
// eslint-disable-next-line @typescript-eslint/no-require-imports
387+
const config = require('../dist/config/config')
388+
const original = config.writeSettingsFile
389+
config.writeSettingsFile = (
390+
_app: unknown,
391+
settings: (typeof settingsWrites)[number],
392+
cb: (err?: Error) => void
393+
) => {
394+
settingsWrites.push(settings)
395+
writeOutcome(cb)
396+
}
397+
restoreWriteSettings = () => {
398+
config.writeSettingsFile = original
399+
}
400+
})
401+
357402
afterEach(() => {
403+
restoreWriteSettings()
358404
registries.splice(0).forEach((r) => r.stop())
359405
})
360406

407+
const recordedDefault = (app: TestApp) =>
408+
app.config.settings.historyApi?.defaultProvider
409+
361410
const VALUES_QUERY: ValuesRequest = {
362411
duration: Temporal.Duration.from({ minutes: 15 }),
363412
pathSpecs: []
@@ -403,14 +452,142 @@ describe('History API v2', () => {
403452
;(await defaultOf(app)).should.equal(providerContext('kip'))
404453
})
405454

406-
it('defaults to the first registered provider without configuration', async function () {
455+
it('serves the first registered provider when settings name none', async function () {
407456
const app = makeApp()
408457
const registry = makeRegistry(app)
409458
registry.registerHistoryApiProvider('kip', provider('kip'))
410459
registry.registerHistoryApiProvider('questdb', provider('questdb'))
411460
;(await defaultOf(app)).should.equal(providerContext('kip'))
412461
})
413462

463+
it('records the first registered provider as the configured default', function () {
464+
const app = makeApp()
465+
const registry = makeRegistry(app)
466+
registry.registerHistoryApiProvider('questdb', provider('questdb'))
467+
settingsWrites.length.should.equal(1)
468+
settingsWrites[0].historyApi!.defaultProvider!.should.equal('questdb')
469+
recordedDefault(app)!.should.equal('questdb')
470+
})
471+
472+
it('records without dropping the rest of the settings', function () {
473+
const app = makeApp()
474+
const registry = makeRegistry(app)
475+
registry.registerHistoryApiProvider('questdb', provider('questdb'))
476+
settingsWrites[0].should.deep.equal({
477+
pipedProviders: [{ id: 'gps' }],
478+
interfaces: { nmea0183: true },
479+
historyApi: { defaultProvider: 'questdb' }
480+
})
481+
})
482+
483+
it('keeps the recorded default when a second provider registers', async function () {
484+
const app = makeApp()
485+
const registry = makeRegistry(app)
486+
registry.registerHistoryApiProvider('questdb', provider('questdb'))
487+
registry.registerHistoryApiProvider('kip', provider('kip'))
488+
settingsWrites.length.should.equal(1)
489+
recordedDefault(app)!.should.equal('questdb')
490+
;(await defaultOf(app)).should.equal(providerContext('questdb'))
491+
})
492+
493+
it('honours the recorded default on the next start', async function () {
494+
const app = makeApp()
495+
const first = makeRegistry(app)
496+
first.registerHistoryApiProvider('questdb', provider('questdb'))
497+
498+
// A fresh registry over the settings the first one wrote, with the
499+
// providers registering in the order that used to decide it.
500+
const restarted = makeRegistry(app)
501+
restarted.registerHistoryApiProvider('kip', provider('kip'))
502+
restarted.registerHistoryApiProvider('questdb', provider('questdb'))
503+
;(await defaultOf(app)).should.equal(providerContext('questdb'))
504+
})
505+
506+
it('leaves a configured default alone', function () {
507+
const app = makeApp('kip')
508+
const registry = makeRegistry(app)
509+
registry.registerHistoryApiProvider('questdb', provider('questdb'))
510+
settingsWrites.length.should.equal(0)
511+
recordedDefault(app)!.should.equal('kip')
512+
})
513+
514+
it('treats an empty configured default as none', function () {
515+
const app = makeApp()
516+
app.config.settings.historyApi = { defaultProvider: '' }
517+
const registry = makeRegistry(app)
518+
registry.registerHistoryApiProvider('questdb', provider('questdb'))
519+
recordedDefault(app)!.should.equal('questdb')
520+
})
521+
522+
it('records nothing while settings hold runtime overrides', async function () {
523+
const app = makeApp()
524+
app.config.safeToPersistSettings = false
525+
const registry = makeRegistry(app)
526+
registry.registerHistoryApiProvider('questdb', provider('questdb'))
527+
settingsWrites.length.should.equal(0)
528+
chai.expect(recordedDefault(app)).to.equal(undefined)
529+
;(await defaultOf(app)).should.equal(providerContext('questdb'))
530+
})
531+
532+
it('serves the provider even when recording it fails', async function () {
533+
const app = makeApp()
534+
writeOutcome = (cb) => cb(new Error('disk full'))
535+
const registry = makeRegistry(app)
536+
registry.registerHistoryApiProvider('questdb', provider('questdb'))
537+
settingsWrites.length.should.equal(1)
538+
chai.expect(recordedDefault(app)).to.equal(undefined)
539+
;(await defaultOf(app)).should.equal(providerContext('questdb'))
540+
})
541+
542+
it('retries the first provider when a later one registers', async function () {
543+
const app = makeApp()
544+
writeOutcome = (cb) => cb(new Error('disk full'))
545+
const registry = makeRegistry(app)
546+
registry.registerHistoryApiProvider('questdb', provider('questdb'))
547+
// The slot belongs to questdb even though its write failed, so the
548+
// arrival of kip must retry questdb rather than end the attempts.
549+
writeOutcome = (cb) => cb()
550+
registry.registerHistoryApiProvider('kip', provider('kip'))
551+
settingsWrites.length.should.equal(2)
552+
settingsWrites[1].historyApi!.defaultProvider!.should.equal('questdb')
553+
recordedDefault(app)!.should.equal('questdb')
554+
;(await defaultOf(app)).should.equal(providerContext('questdb'))
555+
})
556+
557+
it('drops the pending default when that provider unregisters', function () {
558+
const app = makeApp()
559+
writeOutcome = (cb) => cb(new Error('disk full'))
560+
const registry = makeRegistry(app)
561+
registry.registerHistoryApiProvider('questdb', provider('questdb'))
562+
registry.unregisterHistoryApiProvider('questdb')
563+
// questdb is gone, so nothing may record it. kip arrives as the
564+
// only provider and claims the slot itself.
565+
writeOutcome = (cb) => cb()
566+
registry.registerHistoryApiProvider('kip', provider('kip'))
567+
recordedDefault(app)!.should.equal('kip')
568+
})
569+
570+
it('reports a failed recording once per run', function () {
571+
const app = makeApp()
572+
writeOutcome = (cb) => cb(new Error('disk full'))
573+
const registry = makeRegistry(app)
574+
const reported: unknown[][] = []
575+
const originalError = console.error
576+
console.error = (...args: unknown[]) => {
577+
reported.push(args)
578+
}
579+
try {
580+
// A plugin that reconnects registers again; the retry must not
581+
// report again, or it floods the log ring the admin UI reads.
582+
registry.registerHistoryApiProvider('questdb', provider('questdb'))
583+
registry.registerHistoryApiProvider('questdb', provider('questdb'))
584+
} finally {
585+
console.error = originalError
586+
}
587+
settingsWrites.length.should.equal(2)
588+
reported.length.should.equal(1)
589+
})
590+
414591
it('rejects when no provider is registered', async function () {
415592
const app = makeApp('questdb')
416593
makeRegistry(app)
@@ -451,14 +628,14 @@ describe('History API v2', () => {
451628
app.notifications.length.should.equal(0)
452629
})
453630

454-
it('reports no configured provider for an empty configured id', function () {
631+
it('records over an empty configured id', function () {
455632
const app = makeApp()
456633
// makeApp takes a truthy id, so the empty value goes in directly.
457634
app.config.settings.historyApi = { defaultProvider: '' }
458635
const registry = makeRegistry(app)
459636
registry.registerHistoryApiProvider('questdb', provider('questdb'))
460637
const event = app.serverEvents[app.serverEvents.length - 1]
461-
chai.expect(event.configuredId).to.equal(undefined)
638+
event.configuredId!.should.equal('questdb')
462639
event.defaultId!.should.equal('questdb')
463640
})
464641

@@ -472,6 +649,19 @@ describe('History API v2', () => {
472649
const lastEvent = (app: TestApp) =>
473650
app.serverEvents[app.serverEvents.length - 1]
474651

652+
it('reports the recorded provider as configured on an unconfigured server', function () {
653+
const app = makeApp()
654+
const registry = makeRegistry(app)
655+
656+
registry.registerHistoryApiProvider('questdb', provider('questdb'))
657+
lastEvent(app).should.deep.equal({
658+
ids: ['questdb'],
659+
defaultId: 'questdb',
660+
configuredId: 'questdb',
661+
configuredAvailable: true
662+
})
663+
})
664+
475665
it('emits full state on register and unregister', function () {
476666
const app = makeApp('questdb')
477667
const registry = makeRegistry(app)

0 commit comments

Comments
 (0)