Operations
Configuration Reference
ft.fcc.* Properties
Bound by FccProperties (@ConfigurationProperties("ft.fcc")), all overridable via environment variables in application.yaml.
| Property | Default | Meaning |
|---|---|---|
|
|
How long the dispatcher waits for a |
|
|
Beyond this, a late-arriving ack no longer starts the run; the activation fails with |
|
|
Three uses, one knob. (1) Maximum time an activation stays |
|
|
Hard cap enforced by |
|
|
How many configurations one campaign may carry — |
|
|
Rows the dispatcher pulls per tick. Flat spelling kept alongside |
|
|
Attempts (initial publish plus every ack-timeout republish) before |
|
|
|
|
(empty) |
Fallback list, only consulted when the |
|
|
|
|
|
Disabled in the |
|
|
Gates a feature, not a check. With it off, a campaign configuration naming a |
|
|
Checks the two |
|
|
Gates both |
|
|
|
|
|
|
|
|
M4. How often a |
|
|
M4. How often |
|
|
|
|
|
Never the Flyway default — see Flyway History Table. |
|
|
M4. Not an |
ft.fcc.outbox.* / ft.fcc.result.* / ft.fcc.dedup.* — Nested Scheduling and Buffering Properties
Bound by the nested records of FccProperties (Outbox, Result, Dedup). outboxBatchSize and outboxMaxAttempts above keep their original flat spelling because an installation may already set them; these nested blocks were added later for the scheduling and buffering knobs that did not exist yet.
| Property | Default | Meaning |
|---|---|---|
|
|
How often |
|
|
How often |
|
|
Same environment variable as the flat |
|
|
Rows per ClickHouse |
|
|
The result buffer flushes at |
|
|
Bound of the in-memory result buffer, which also serves as the ClickHouse retry queue. Exhausting it flips the service to |
|
|
Lifetime of one entry in the |
ft.fcc.scheduler.* / ft.fcc.watchdog.* / ft.fcc.dead-letter.* — M2
Bound by the nested Scheduler, Watchdog and DeadLetter records of FccProperties.
| Property | Default | Meaning |
|---|---|---|
|
|
How often |
|
|
Rows claimed per tick, per pass. It bounds the work of one tick, not the backlog: whatever is left over is claimed next tick, and a claim that comes back full is the signal that the interval is too long for the installation rather than a reason to raise this. Capped internally at |
|
|
How often |
|
|
Runs claimed per tick, per pass (expired |
|
|
How long past |
|
|
Parked measurements read per |
|
There is no |
ft.fcc.resolve.* and the Multipart Limits — M3, extended M5
The device-resolution endpoints of the campaign wizard. See Device Resolution for what they bound.
| Property | Default | Meaning |
|---|---|---|
|
|
Page size of |
|
|
Largest |
|
|
Largest whole multipart request: the |
|
There is deliberately no maximum-page-size knob for device resolution. The ceiling on a page is The same reasoning is why |
|
Exceeding the limit answers HTTP 413 with FT code
413 and not 400, deliberately: the upload was well formed and there is nothing in it for the caller to correct except its size, so the limit is what the answer has to name.
The figure comes from the configuration, not from the exception — and that is not a detail.
The handler used to render
|
A limit neither source knows is left out of the message rather than printed.
The exception’s own figure is kept as a fallback for the case where the property is unset, and if that is missing too the sentence is emitted without any number at all: "the upload exceeds the configured maximum; send a shorter serial list…".
A remedy with no number beats a remedy with a wrong one — the whole -1 incident was a wrong number being more convincing than no number.
In this service the fallback is unreachable in practice, because application.yaml binds the property to ${FCC_SERIAL_FILE_MAX_SIZE:10MB} and a default is therefore always present; it exists for the deployment that overrides the property with an empty value.
A client must not parse the byte figure out of the description. It is prose, it is not guaranteed to be there, and the machine-readable part of the answer is code: 9413.
Three Spring-MVC handlers on the multipart path, and none of them is redundant.
Spring resolves each of these into an exception whose default status it knows perfectly well, but DefaultHandlerExceptionResolver is the last resolver in the chain while an @ExceptionHandler(Exception.class) in an advice runs before it — so the catch-all masks Spring’s own answer and turns every one of them into a 500 with no hint of what the caller sent wrong.
| Mistake | Answer | Note |
|---|---|---|
Upload larger than the limit |
413 / |
Names the configured maximum in bytes, read from |
No |
400 / |
|
A part offered with a content type nothing accepts |
415 / |
|
9413 and 9415 follow the catalog’s own convention: the last three digits are the HTTP status the failure is answered with. See the one exception to that rule.
|
Tying the two together would mean that making stops more patient also delays every watchdog close.
They both default to |
ft.fcc.legacy-import.* — M4
Bound by the nested LegacyImport record of FccProperties. They tune the legacy ETL — see Legacy FCC Import for what it does and The Legacy Import Runbook for how to drive it.
None of these makes the import run. There is no poller and no schedule: the import starts only from POST /FCC/admin/legacy-import/start, which is why it does not appear in The Five Pollers.
| Property | Default | Meaning |
|---|---|---|
|
|
Whether |
|
|
Rows per |
|
(empty) |
The time zone the naive legacy timestamps are read in. Empty means the JVM’s own zone, never UTC by default. |
|
|
Grace after a day-window closes before its measurements are believed complete. Only closed windows are imported: the |
|
|
How long a |
The Five Pollers
| Poller | Every | What it claims and does |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Four more scheduled methods exist that are not pollers over a claim: FccResultIngestService.flushTick (ft.fcc.result.flush-interval, 5s), the ClickHouseHealthIndicator refresh (ft.fcc.clickhouse.health.cache-ttl, 10s), ClickHouseMigrationRunner.retry (ft.fcc.clickhouse.migration.retry-interval, 60s — see [clickhouse-migration-retry]; a no-op the moment the status is UP, so it costs a pool slot only during a ClickHouse outage), and, since M5, the metrics sampler FccMetricsSampler.sample (ft.fcc.metrics.sample-interval, FCC_METRICS_SAMPLE_INTERVAL, default 15s), which refreshes every fcc_queue_* / fcc_outbox_* gauge /actuator/prometheus reads from — see why the gauges are sampled rather than computed on scrape.
|
There is no scheduler thread pool in this service, and
The eight-tick inventory this note used to justify a pool size for is still accurate and still worth keeping, because it is what makes "unbounded" the right answer rather than a risk: the outbox dispatcher (5s), the result flush (5s), the ClickHouse health probe (10s), the metrics sampler ( |
Every one of these claims with the two-step FOR UPDATE SKIP LOCKED pattern of Vendor-Specific Claim SQL, holds the claim and the writes in one transaction, and lives in a bean separate from its @Scheduled tick — see The Scheduler / Transaction Split for why both of those are load-bearing rather than stylistic.
A failing tick is caught, counted and throttled (FccFailureThrottle): the first occurrence of a failure signature is logged at WARN in full, repetitions of the same signature at DEBUG, and the signature is re-logged at WARN once REPEAT_AFTER (5 minutes) has elapsed since the last WARN — a recovery resets the throttle outright, so the next outage is loud again from its own first tick.
An unreachable database during a scheduler tick therefore produces one WARN every five minutes for the length of the outage, not one per 30-second tick — an hour-long outage writes about twelve WARN lines, not a hundred and twenty.
There is no poller for fcc_result_dead_letter: draining it is operator-triggered, through POST /FCC/admin/result-dead-letter/resend.
HZ_MEMBERS (the raw environment variable, read directly, not through Spring binding) wins over ft.fcc.hazelcast.members — this is the docker-compose convention shared with the ACS and ui-backend. A bare host with no port gets :5701 appended (HazelcastClientConfig.resolveMembers). If neither is set, the client falls back to 127.0.0.1:5701.
Hazelcast Structures This Service Uses
Beyond the general client configuration above, this service touches a fixed, small set of Hazelcast structures on the shared hazelcast:ft cluster — see External Dependencies — Exactly Five and Hazelcast Queues for the full queue contract.
| Structure | Kind | Role |
|---|---|---|
|
Queue (produced to) |
|
|
Queue (consumed) |
Dedicated |
|
Queue (consumed) |
Dedicated |
|
Queue (consumed) |
Dedicated |
|
Queue (consumed) |
Dedicated |
|
|
Result de-duplication key, see De-Duplication Is One Atomic putIfAbsent. Unlike the queues, this map is internal to |
|
|
app.ds.* Datasources
Four Hikari-backed DataSources, each configured under app.ds.<name> with its Hikari block at app.ds.<name>.hikari (do not cross-wire these prefixes — a documented live bug in a sibling service’s DataSourcesConfig is exactly this kind of mismatch, and this service’s DataSourcesConfig javadoc calls it out explicitly so it is not repeated here).
| DataSource | Access | Pool notes |
|---|---|---|
|
Read-write, |
|
|
Read-only |
|
|
Read-write, but the bean is |
|
|
Read-only, and the bean is |
The portal’s schema, two tables, SELECT only (the grant). |
An Oracle URL on any of these DataSources gets ALTER SESSION SET TIME_ZONE='UTC' and oracle.jdbc.timezoneAsRegion=false applied automatically (DataSourcesConfig.initDataSource).
app.ds.iotw additionally gets ALTER SESSION SET CURRENT_SCHEMA=<schema> on Oracle, because the two iotw tables belong to their own user there and every query in IotwConditionDao is unqualified.
mysql / oracle / local Profile Groups
application.yaml defines three Spring profile groups:
| Group | Composition |
|---|---|
|
|
|
|
|
|
ClickHouse Flyway Properties
Flyway for ClickHouse is built programmatically in ClickHouseFlywayConfig, with Boot’s own Flyway autoconfiguration switched off (spring.flyway.enabled: false). This is because the community ClickHouse Flyway plugin (flyway-database-clickhouse:10.24.0) is built against flyway-parent 11.9.0, while Spring Boot 3.5.6 manages a different flyway-core line — pom.xml pins flyway-core to 11.7.2 and the ClickHouse plugin to 10.24.0 explicitly rather than letting Boot’s dependency management pick a mismatched pair. The Flyway instance’s baselineOnMigrate(true), validateOnMigrate(false) and outOfOrder(true) are all set explicitly in code, not through properties.
Health
GET /actuator/health exposes health, info and prometheus. The health status order is explicitly restated in application.yaml as DOWN, OUT_OF_SERVICE, DEGRADED, UP, UNKNOWN with an HTTP mapping that replaces Boot’s default:
| Status | HTTP |
|---|---|
|
503 |
|
200 |
|
200 |
|
|
|
Since M5, the aggregate |
DEGRADED means FccDegradedState.clickhouseStatus() is not UP. Through M3 that state was a boolean; M4 replaced it with FccClickHouseStatusEnum, three values, because "the result store cannot be used" has always had two different causes with two different remedies, and a boolean could only ever say one of them:
FccClickHouseStatusEnum |
What it means | Operator’s next action |
|---|---|---|
|
Reachable, and every ClickHouse migration this build carries has been applied. |
None. |
|
Reachable, but migrations are pending — the connecting user has no |
Grant the missing privilege, or investigate why the retry keeps failing. Restarting the instance does not help by itself: the migration is already retried on a timer regardless — see ClickHouse Migration at Startup. |
|
Not reachable at all: the connectivity probe, or a ClickHouse batch insert, failed. |
Check that ClickHouse itself is up and reachable from this service’s network. The ordinary "the database is down" case. |
Both non-UP states report the same /actuator/health status (DEGRADED, HTTP 200, per the mapping above) and close the same gate — see [gate-refuses]. They are told apart only by details.reason, and it is worth reading before reaching for a fix: restarting a database that is merely SCHEMA_BEHIND does nothing, and granting a privilege to a ClickHouse that is UNAVAILABLE cannot even connect to apply it.
|
|
{"status": "DEGRADED",
"components": {"clickhouse": {"status": "DEGRADED",
"details": {"reason": "1 clickhouse migration(s) pending: 3"}}}}
FccDegradedState is written from three independent places: ClickHouseHealthIndicator (a cached connectivity probe — SELECT 1, TTL ft.fcc.clickhouse.health.cache-ttl — followed, once connectivity is confirmed, by a look at Flyway’s pending migrations, cached separately and far longer at ft.fcc.clickhouse.health.schema-check-interval), ClickHouseMigrationRunner at startup and on its own retry timer, and FccResultIngestService itself when a ClickHouse batch insert fails or the in-memory result buffer is exhausted — see Result Ingest. The ingest path and the migration runner only ever set UNAVAILABLE, never SCHEMA_BEHIND: neither knows why an insert or a migration failed, and guessing a classification from driver wording would be wrong the first time the wording changes. Only the health indicator’s own probe — the one place that can actually ask Flyway info().pending() — is entitled to report SCHEMA_BEHIND, and it refines a plain UNAVAILABLE into that reading on its next probe once connectivity turns out to be fine.
The operational consequence: CRUD keeps working (creating, editing and listing tests touches only ftfcc), but every operation that depends on the result store is refused with 503 / FT code 9503 — FccResultStorageGate.require(…) checks FccDegradedState.clickhouseStatus() before it starts anything and throws FccDegradedException whenever it is not UP. Since M4 that is not only POST /FCC/activate and the dead-letter resend, but all four /FCC/reports/** endpoints too: a result that cannot ultimately be written to ClickHouse should not be allowed to start, and a report should not be produced as though the store held everything it should. See POST /FCC/activate and Endpoints Implemented in M4.
The db health contributor is replaced by DataSourceHealthConfig so that only ftfcc and ftacs are probed under it; ClickHouse is deliberately excluded from the generic db contributor and reported only through the dedicated DEGRADED indicator, so a ClickHouse outage does not drag the aggregate status to DOWN. Hazelcast’s own health indicator is disabled (management.health.hazelcast.enabled: false): the client starts asynchronously by design (ClientConnectionStrategyConfig.setAsyncStart(true)), so "not connected yet" is an expected transient state during startup, not a reason to report DOWN.
Startup Behaviour
ftacs Read-Only Schema Check
FtacsSchemaCheckRunner runs as an ApplicationRunner (gated by ft.fcc.ftacs.schema-check.enabled, on by default, off in localdev). It detects the vendor from DatabaseMetaData.getDatabaseProductName() and queries information_schema.columns (MySQL) or ALL_TAB_COLUMNS (Oracle, resolved against app.ds.ftacs.schema or the connected user) for every column this service reads. A missing table or column throws IllegalStateException and fails startup with the full list of missing table.column entries, rather than surfacing as a broken query mid-campaign.
Only columns some statement actually names are listed, and that is a rule rather than an accident of maintenance. This check aborts startup, so every entry is a reason the service refuses to come up at an installation; requiring a column no query reads turns a harmless schema difference into an outage. The M4 review found several such entries and removed them — see [schema-check-trimmed].
The exact columns checked today (FtacsSchemaCheckRunner.REQUIRED_COLUMNS), with the M3 additions marked in bold and the M4 ones — everything the legacy import reads — marked as such:
| Table | Read by | Columns |
|---|---|---|
|
Device resolution, legacy fleet snapshot |
|
|
Device resolution, catalog |
|
|
Device resolution, catalog |
|
|
Catalog, device grid |
|
|
Legacy import |
|
|
Legacy import |
|
|
Legacy import |
|
|
Legacy import |
|
|
Legacy import |
|
|
Legacy import |
|
|
Legacy import |
|
|
Legacy import |
|
The four whole tables marked M4 are all in the contract’s §8 grant list already, so unlike the M3 manufacturer addition they need no grant change on a correctly provisioned account — see [legacy-import-grant].
|
The M4 review removed ten columns from this set, none of which any statement reads.
They were listed on the reasonable-looking principle that the import "reads those tables", but the gate is not about tables — it is about the exact Two are worth naming individually. The complement of this rule is |
|
The rule above has a live exception, and it is worth stating rather than restating a rule that has a hole in it. |
With one exception this is a subset of the full read-only table list the read-only account is granted SELECT on (00-common-contract.md §8): cpe, product_class, product_class_group, view, isp, cpe_parameter_name, ug_fcc, ug_fcc_speed_result, ug_fcc_latency_result, fcc_properties, update_group, update_group_child, ug_task, ug_diagnostic, ug_get_param, ug_set_param_value, ug_reactivate, ug_cpe_completed. The columns checked at startup reflect what the service actually reads today — the four-table device-resolution read added in M3 (The ACS Schema This Service Reads), plus, since M4, every table the legacy import reads (Legacy FCC Import). ug_diagnostic, ug_get_param, ug_set_param_value, ug_cpe_completed, view, isp and cpe_parameter_name are in the grant list and are not read by anything in this service today — ug_set_param_value in particular is deliberately out of scope for the import, which is why an imported campaign carries no URLs and no ping host.
The exception is manufacturer, and it is the whole point of the warning below: it is required by the startup check and is not in the contract’s §8 grant list.
An account provisioned strictly from that list fails the check and the service does not start.
The grant list is the document that has to catch up — the read is real, deliberate and load-bearing ([m3-grant-change]) — so treat §8 as one item short rather than treating the check as over-strict.
|
An existing installation’s read-only grant set now needs
The failure is at startup, loud, and complete:
The runner cannot tell a missing grant from a missing table — both simply fail to appear in The three new columns ( Upgrade order therefore matters: extend the grant, then deploy. Deploying first produces an instance that will not start, and the M3 additions are the first time this check has ever demanded a grant an earlier release did not. The check is skipped entirely in the |
|
This service requires ACS 6.4.12 or later, and the startup check is where an older ACS is discovered. Know this before installing, not from a failed startup.
It is a real requirement and not an over-strict gate. As with every other finding of this runner, the message cannot tell a missing column from a missing grant. Here it is almost always a missing column, because
The lever of last resort is the same one: |
The iotw Grant Check: The One That Warns Instead of Failing
IotwSchemaCheckRunner is the second ApplicationRunner over somebody else’s schema, and it is deliberately the opposite of the first in exactly one respect: it does not abort startup.
ftacs is where every campaign’s devices and results live, so a service that cannot read it can do nothing and ftacs Read-Only Schema Check refuses to come up.
iotw is borrowed for one of four device source types (sourceType=Condition); an installation that never uses saved conditions is fully functional without the grant, and taking the service down over it would turn an unused feature into an outage.
So why check at all? Because the iotw datasource is lazy, and the alternative is silence: without this, the first person to learn the grant is missing is an operator half-way through a campaign wizard, from a 400. The warning is in the startup output an installer already reads, it names every missing table.column at once rather than one per failed save, and it tells apart "no grant" from "wrong schema" from "unreachable" — three different tickets.
WARN c.f.fcc.startup.IotwSchemaCheckRunner : iotw schema check FAILED: missing columns
[iotw_view_column_condition.logic]. Campaign configurations that name a conditionId
(configs[].sourceType=Condition) will be refused with a 400; every other device source
type is unaffected. Grant this service SELECT on the two iotw tables, or set
ft.fcc.iotw.enabled=false to refuse the source type by configuration and silence this check.
| Situation | What the runner does |
|---|---|
Grant complete |
|
A column or table missing |
The warning above, with the full list. The service starts; |
Schema unreachable (database down, wrong credentials, no route) |
The same warning, carrying the connection failure’s message. This is the one place in the service that opens the lazy |
|
|
|
The runner is not registered. The feature still works: an installation that wants the pool left closed until the first condition read — or whose portal database starts after this service — turns the check off and keeps the source type. |
The required set is the two tables of the grant and nothing more: iotw_view(id, name, domain_id, view_type) and iotw_view_column_condition(id, view_id, parent_id, column_key, type, string_value, date_value, logic).
The same rule as for ftacs applies — only columns some statement actually names — for a different reason here: a grant narrowed to exactly what this service needs should pass, not warn.
ClickHouse Migration at Startup
ClickHouseMigrationRunner runs the Flyway migration in a @PostConstruct method, not as part of Boot’s own startup sequence, specifically so a failed migration sets FccDegradedState to UNAVAILABLE and lets the service start DEGRADED instead of refusing to start at all.
Three migrations ship today: V1 creates fcc_result_speed, V2create-fcc-result-latency-table.sql (M2) creates fcc_result_latency, and V3add-ping-actuals-to-fcc-result-latency.sql (contract 1.5) adds its two nullable ping-actual columns. All three are recorded in flyway_schema_history_fcc, never the Flyway default — see Flyway History Table.
|
The M4 baseline fix, and how to confirm an installation has it. Confirm a given installation has the fix by reading the history table directly:
A healthy, up-to-date node shows a baseline row at version |
Since M4 the migration is retried on a timer (ft.fcc.clickhouse.migration.retry-interval, default 60s) for as long as the status is not UP, in addition to the @PostConstruct attempt. Without it, an instance that started during a ClickHouse outage stayed schema-less for the life of the process — nothing else ever ran the migration, so the outage would outlive itself and only a manual restart would end it. The tick is a no-op the moment the status is UP, so it costs nothing outside an outage.
Two instances retrying at the same moment is safe and deliberately unguarded: the ClickHouse Flyway plugin cannot take the history-table lock the other databases use (ClickHouse has none), so the safety comes from the migrations themselves, all three of which are idempotent (CREATE TABLE IF NOT EXISTS, ADD COLUMN IF NOT EXISTS). The worst outcome of a race is a duplicated history row and a second, redundant run of statements that change nothing.
A failed attempt — startup or retry — reports UNAVAILABLE without inspecting the exception: driver wording is not a classification, and ClickHouseHealthIndicator refines it to SCHEMA_BEHIND on its own next probe if connectivity turns out to be fine and migrations are merely pending — see [clickhouse-three-states].
Because a migration failure degrades rather than kills startup, a node that missed V2 or V3 runs the campaigns those migrations enable perfectly well and only fails on the INSERT, which surfaces as fcc.clickhouse.result.failed and rows parked in the dead letter — see Troubleshooting. Since M4 that node also keeps retrying the migration on its own, so the failure is expected to self-heal once its cause (usually a missing grant) is fixed, without a restart.
The Legacy Import Runbook
What the legacy ETL is, and every rule about what it reconstructs, is Legacy FCC Import; its endpoints are The Legacy Import Endpoints; its properties are ft.fcc.legacy-import.* — M4. This section is the operator’s sequence.
Before the First Start
| Check | Why, and what goes wrong without it |
|---|---|
ACS 6.4.12 or later |
|
|
|
|
|
|
Unless this service runs in that zone already, in which case the empty default is correct. Getting it wrong displaces every imported measurement by a fixed offset, silently. Changing it afterwards does not move rows that are already imported, and a resumed pass will not even adopt the new value — so this is the one property to get right first. |
Expect Roughly 25 Hours of Lag on the Newest Measurements
A pass does not read up to the present, and an operator planning a cut-over has to know by how much it falls short.
One pass covers the half-open start_date interval [watermark_started_before, cutoff), where
cutoff = the pass's started_at - ft.fcc.legacy-import.window-settle - 24h
so with the default window-settle of 1h a measurement taken now is first eligible for a pass started about 25 hours from now. Raising window-settle raises the lag by exactly that much.
The extra day is not padding. A day-window is at most 24 hours long, and the hour a window starts at differs from campaign to campaign — it comes from each group’s ug_reactivate cron. Subtracting a full day makes the cutoff correct whatever hour a campaign’s day begins at, which is what lets one pass-wide cutoff serve every campaign; a completed pass writes a single date as its watermark, so it has to have a single coherent covered interval.
The trade is a day of lag in exchange for a watermark that cannot skip a row, and on a store with no delete and no back-fill a skipped row is skipped permanently. See Only Closed Day-Windows Are Imported.
Two operational consequences follow:
-
"The import has completed but yesterday’s measurements are missing" is normal, not a fault. They become importable on a later pass;
COMPLETEDhas never meant "caught up to now". -
The final catch-up pass of a migration has to be run at least 25 hours after the legacy flow is switched off, or it will not cover the last day the legacy flow wrote.
Start
Measure first. dryRun claims nothing, writes nothing and is safe to call at any time, including while an import is running:
POST /FCC/admin/legacy-import/start
X-FT-Domain-Scope: ALL
{"kinds": ["SPEED"], "dryRun": true}
The answer is the ordinary status body, with totalRowEstimate filled in for the named kinds. Then start for real:
POST /FCC/admin/legacy-import/start
X-FT-Domain-Scope: ALL
{"kinds": ["SPEED", "LATENCY"]}
Omitting kinds means both. They are imported one after the other by a single worker and each carries its own cursor, so starting one alone is a legitimate way to retry a kind that failed without disturbing the one that is fine.
batchSize overrides ft.fcc.legacy-import.batch-size for this run only; lower it on a slow ClickHouse.
The call returns as soon as the kinds are claimed — the import itself runs on the fcc-legacy-import worker thread and is watched through /status.
Watch Progress
POST /FCC/admin/legacy-import/status (scope ALL, like the other two) returns one entry per kind.
| Field | How to read it |
|---|---|
|
|
|
The progress fraction, and only that. The denominator was taken when the pass started and the source keeps growing, so it is stale by design; nothing decides that the import is finished from it. |
|
Refreshed between batches, and over the slow work that is not a batch: the per-campaign |
|
One position, read together. |
|
What is complete: every measurement whose |
|
The highest legacy id a completed pass has covered — a diagnostic high-water mark that bounds no read. It is half of the late-arrival predicate and nothing else. |
|
Non-null means a batch is with ClickHouse right now, or a previous run died inside one. Both are normal; the next start re-checks exactly that id range, for the campaign |
|
Measurements that reached |
|
Source rows that cannot be expressed as a cursor position — a null |
|
Data-quality tallies. None of them is an error to act on during the run — every one of those rows was imported anyway — but a large |
|
The zone this run is using, which for a resumed pass may differ from the configured one. The response’s top-level |
|
Why the run stopped, when it did not stop because it was asked to. |
The counters describe the current or last run: a fresh pass zeroes them, a resumed one continues them.
Micrometer counters under /actuator/prometheus carry the same figures across restarts of the pass: fcc.legacy.import.rows.read, .rows.written, .rows.skipped, .batches, .anchors.created, .activations.created, .devices.snapshotted, .devices.unresolved, .passes.completed, .passes.failed.
Log lines worth grepping: fcc legacy import pass started / pass completed / pass halted / pass failed / pass evicted, fcc legacy import group done (one per campaign), fcc legacy anchor created, fcc legacy fleet snapshot, fcc legacy import late arrivals skipped (a WARN naming the campaign, one per campaign that has any), fcc legacy import row cannot be a cursor position (the ERROR that halts a pass), and fcc legacy import batch at DEBUG.
Note that rowsLateSkipped and rowsInvalid have no Micrometer counter; they are visible on /status and in the log, and nowhere else.
Stop
POST /FCC/admin/legacy-import/stop
X-FT-Domain-Scope: ALL
{"kinds": ["SPEED"]}
The stop is cooperative and returns immediately: the flag is recorded, the running import notices it between two batches, and the row ends at IDLE on a committed cursor.
Expect state = STOPPING in between; that state exists so an operator can see the request was recorded rather than pressing stop again.
There is deliberately no forced variant. Being killed mid-batch is exactly what the pending id range exists to survive, not something to offer a button for. Stopping an import that is not running is not an error — the flag is simply cleared by the next start.
A JVM shutdown does the same thing by itself: @PreDestroy requests a stop on every claimed kind and waits up to 10 seconds for the worker to finish its batch.
Restart After an Interruption
Call /start again with the same kinds. That is the whole procedure; nothing has to be reset, cleaned or reconciled by hand.
What happens under it:
-
The kind is resumed rather than begun afresh when its cursor is intact or a pending range is outstanding — the counters continue,
started_atis kept (so the window cutoff is still the one that pass set out with), and the traversal picks up strictly after the committed position. A pending range counts on its own because a crash in the very first batch of a pass leaves no cursor at all, and treating that as a fresh start is how the record of which ids are in doubt gets erased. -
If
pendingResultIdis non-null, the run first re-reads exactly that id range from ClickHouse and skips whatever is already there, counting it asrowsSkipped. This read happens at most once per resumed run, and the ids it finds are skipped for the campaignpendingUgIdnames and for no other. -
A kind whose row says
RUNNINGbut whose heartbeat is older thanft.fcc.legacy-import.stale-afteris taken over — that is what a killed process leaves behind. One whose heartbeat is fresh is refused; see What a 409 Means. -
If the old worker turns out to be alive after all, the takeover is still safe: every mutating statement is fenced on
run_id, so the evicted run’s next write matches no row and it aborts without touching the row again (fcc legacy import pass evictedin the log). It does not get markedFAILED— the row belongs to the new worker, and writing a failure on it would stop a healthy import.
Never edit fcc_legacy_import by hand to "unstick" an import. The cursor, the watermark and the pending range are the only record of what has already been written into a store from which nothing can be deleted; clearing watermark_started_before re-imports every measurement below it, and every duplicated measurement is permanent.
What a 409 Means
FT code 9409. The kind is already being imported by a run whose heartbeat is still fresh — possibly on another service instance, since the claim is a database statement and not a process-local lock.
It is a 409 and not a 423 because the request is perfectly valid and will succeed as soon as the other run ends. The message names the holding runId and its last heartbeat.
Two remedies, in order: stop the other run and retry, or wait for ft.fcc.legacy-import.stale-after to elapse without a heartbeat if the holder is genuinely dead.
A start that names two kinds and can claim only one answers 409 and releases the one it took — a refused start must not leave half an import running.
What a 9503 Means
The same FccResultStorageGate every activation and every report goes through: the ClickHouse result store is not UP. Read details.reason on /actuator/health to tell UNAVAILABLE from SCHEMA_BEHIND — see [clickhouse-three-states].
It appears in two places for this feature, and they are different events:
-
on
/start, refusing to begin at all. Beginning would mean reading millions of rows, failing every insert, and leaving a cursor that has moved past rows nothing wrote; -
mid-run, where a store that goes down ends the pass cleanly at
IDLEon its last committed cursor, withlastErrornaming the gate’s reason. Nothing is lost and nothing is duplicated; start it again once the store recovers.
No restart of the service is needed in either case. dryRun is not gated and keeps working during an outage, because it writes nothing.
Deployment
The image, the Helm chart, and — the most important part of it — why liveness and readiness are two different probes hitting two different endpoints, are on their own page: Deployment. It also covers the two ACS-side Hazelcast startup failures this service’s own init container cannot fix, and the minimum ACS version ([acs-minimum-version]).
|
The container’s port 8080 must never be published outside the docker network. This service has no authentication of its own (see No Authentication by Design); its only access control is that it is unreachable except from inside the compose/Kubernetes network, where |
Troubleshooting
| Symptom | Likely Cause / Action |
|---|---|
Activation stuck in |
The outbox row was marked |
Outbox rows stuck in |
Hazelcast is unreachable from this service, or |
Outbox rows reaching |
|
An activation is refused with 503 ( |
|
Same gate as activation, since M4 — all four reporting endpoints call |
|
|
Almost always |
|
Confirm first that the download is actually complete — see the row above; a truncated ZIP is the most common cause of "will not open" and is a timeout, not a format defect. If the file is complete and still will not open, check the row count against |
Results are arriving but no rows show up in |
Two distinct, non-error causes, both normal: (1) the row is genuinely still buffered — it flushes at |
|
ClickHouse ( |
After upgrading, a TIME_BASED run reports a speed but no interval breakdown, or |
The ClickHouse THE INTERVALS ARE ARRAYS IN THE SAME ROW AND NOT EXTRA ROWS, which is the one thing to know before querying them. The de-duplication key is A time-based run with a speed but no breakdown is not a fault. The device reported |
After upgrading, a campaign’s settings or fleet are gone, or |
The configurations migration moves every row that describes "what is measured" from the campaign down to the configuration — see fcc_test_config. It is the one changelog in this schema that is not purely additive, so it is worth knowing what it does before running it on a database you care about: * A FRESH DATABASE NEEDS NOTHING AND IS NOT AFFECTED: it creates the old shape, migrates it in the same startup and ends in the new one, with every table empty. Every changeSet carries |
A save or the legacy import fails with |
A development database that ran [source,sql] ---- — MySQL ALTER TABLE fcc_test MODIFY reactivation_repeat_every INT NULL; — Oracle ALTER TABLE fcc_test MODIFY (reactivation_repeat_every NUMBER NULL); ---- A FRESH DATABASE NEEDS NOTHING: it creates the column nullable from the start. Only the development environments that migrated between the two versions of the changeSet are affected, and no release ever carried the |
The service will not start: |
M3 upgrade. The read-only |
Every request returns 403 |
|
The wizard’s device count is smaller than the operator expects, with no error |
Work through the three silent narrowings in order. (1) Domain scope: a scope- |
A campaign refuses to save with |
Not a bug and not clampable: |
|
Benign, and it means the retry did its job. Two writers saved the same model or the same domain at the same moment, both found no row, both inserted, and the unique index refused the loser. The admin service catches the violation and calls the handler a second time, where the row now exists and the UPDATE branch cannot violate anything — see Both Admin Upserts Retry Once. The caller saw a normal |
|
Deliberate, and do not raise it back. That line is emitted by |
A per-domain default was changed but existing campaigns still use the old one |
Working as designed. The settings chain is walked once, when a test is saved, never at activation — see It Runs Once, at Save Time. A campaign with |
A campaign is stuck in |
|
Measurements are landing in |
Read |
A reactivation did not fire |
Work from the campaign outward. (1) Does the campaign owe one — |
A |
Most likely the ClickHouse |
Latency results stop landing in ClickHouse while speed results keep landing, with ClickHouse errors naming |
|
The legacy import says |
The worker is gone — the process was killed, or the pass threw somewhere the handler did not catch. Do not edit |
|
The kind is held by a run whose heartbeat is still fresh — possibly on another service instance, since the claim is a conditional |
|
Every one of the three requires |
A report filtered by model or manufacturer returns nothing for an imported campaign |
Two causes, in this order. (1) No fleet snapshot, or a thin one: that filter resolves only through |
An imported campaign is invisible to an operator, and visible with |
Working as designed. Its anchor carries |
The legacy import reports a large |
Neither drops a measurement and neither needs action during the run. |
The legacy import stopped at |
A source row whose |
A pass completed but |
Measurements reached |
The legacy import completed, but the last day or two of measurements is not in a report |
Expected. A pass reads only up to |
A Prometheus dashboard shows |
Read Where NaN Appears, and What It Means before assuming a bug: several of these are structural (the command-queue and command-ack ages are never observable from inside this service) rather than a scrape or a sampler problem. A genuinely missing series — as opposed to a present series reading |
|
No longer possible. |
|
A code defect to report, not a document to fix. |