Legacy FCC Import
What It Is
The FCC compliance measurements an installation took before this service existed are still there.
They live in ftacs.ug_fcc_speed_result and ftacs.ug_fcc_latency_result, written by the legacy Update-Group flow — which keeps running and keeps writing into them.
A regulatory filing covering a period that predates this service has to be assembled out of those rows, and the reporting endpoints of M4 read ClickHouse and nothing else.
The legacy import is what closes that gap: one pass reads a legacy result table, reconstructs the campaign structure the measurements were produced under, and writes them into fcc_result_speed / fcc_result_latency with source = LEGACY_ETL, so a report over them is the same query as a report over anything else.
It Copies, and Never Moves
ug_fcc_speed_result and ug_fcc_latency_result are not touched.
Not updated, not flagged as imported, not truncated afterwards — the ftacs account this service holds is SELECT-only, the pool is opened read-only in code, and every statement in FtacsLegacyGroupDao and FtacsLegacyResultDao is a SELECT (app.ds.* Datasources).
That is a product decision and not only a permissions accident. Customers run their own SQL reports directly against those tables, and an ETL that marked or moved rows would silently change the answers those reports give — from an operation nobody outside this service asked for. The legacy tables stay exactly what they were; ClickHouse gains a second, queryable copy.
The consequence is the one every idempotency rule on this page exists for: the source can be read again, so the import must be safe to read again.
It Is a Product Feature, Not a Migration Script
There is no poller and no schedule.
The import is started by POST /FCC/admin/legacy-import/start and by nothing else, watched through /status, and asked to stop through /stop — see The Legacy Import Endpoints.
It is also re-run on purpose.
The legacy flow keeps writing while this service runs, so one pass is never the last one: a completed pass means "every measurement whose start_date is below watermark_started_before is in ClickHouse", and the next start opens a new pass from that date to catch up.
FccLegacyImportStateEnum.COMPLETED is therefore not a terminal state, and ft.fcc.legacy-import.enabled exists so an installation that has genuinely finished migrating can switch the start path off while /status keeps answering.
What Is Not Transferred, and Why
Legacy Configurations Are Not Migrated — Each Group Gets a Read-Only Anchor Instead
A legacy campaign cannot be correctly continued by this service, and continuing it is the only thing migrating its configuration would buy.
It has no activations to continue from, frequently no domain, and a reactivation expressed as an arbitrary Quartz cron that this service’s daily-reactivation model cannot express.
An imported campaign that offered an Activate button would publish an ACTIVATE command naming an update_group that no longer exists on an ACS that has since been upgraded.
So each legacy update group that has results becomes an anchor: a fcc_test row whose entire job is to be something the imported measurements and the reconstructed runs can hang from.
| Column | Value on an anchor |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
Written, but mostly null. |
No URLs, no ping host, no connection counts, no upload file size, and no reactivation.
Those lived in ug_set_param_value, a generic name/value table whose contents have to be interpreted as parameter paths per ACS version, and that is deliberately out of scope: every one of those columns is nullable precisely so that an archived campaign can decline to answer.
A null configuration is written as nulls and never as defaults — a plausible duration filled in here would be a number nobody configured, sitting in a regulatory record, indistinguishable from one that was.
Two further consequences worth stating plainly:
-
A re-run never rewrites an anchor that already exists. The legacy configuration is read only on the create path. An operator may have corrected something on the anchor, and a catch-up pass that restored what
ug_fccsays would undo that correction every time it ran. -
An anchor created with no
fcc_propertiesrow is counted, not guessed at.fcc_properties.ug_idis ambiguous — it holds the parent group in some ACS builds and the child in others, and there is no foreign key to settle it — so both are tried, parent first. An empty answer leaves every timing column null and incrementsproperties_missing_count.
One Update Group Can Become Two Anchors
A single legacy update_group can carry both an FCC speed task and an FCC latency task: ug_task.task_type = 103 and 104 are separate ug_task rows, with separate ug_fcc settings rows underneath them.
The ETL makes two anchors out of such a group, one per result kind — which is exactly why the unique index is on (legacy_ug_id, legacy_result_kind) and not on legacy_ug_id alone.
A unique index on the group id by itself would make the second anchor impossible to insert and the entire latency half of that group unimportable.
|
The discriminator used to be ONE CAMPAIGN WITH TWO CONFIGURATIONS WOULD NOW BE EXPRESSIBLE — it is exactly the shape a native campaign has, and the correspondence between a legacy |
Ordinary campaigns are unaffected: source = ACS_STREAM tests carry legacy_ug_id IS NULL, and both MySQL and Oracle allow unlimited rows with a NULL in a unique index, so the constraint is only ever asserted between imported anchors.
fcc_cpe and cpe_diagnostic* Are Never Read at All
Two more legacy tables are deliberately out of the import entirely.
| Table | Why it is not imported |
|---|---|
|
Ephemeral per-iteration state, overwritten on every iteration. It is the legacy equivalent of fcc_device_state: current state, not history. There is nothing in it to preserve — whatever it held about a run that finished years ago was overwritten by the next iteration of that run. |
|
Shared diagnostic tables, not FCC-owned. They are written by every diagnostic the ACS runs, for every product, and an FCC import has no way to tell which rows belong to a compliance campaign and no business claiming the ones that do not. |
The Join Chain Is One Hop Longer Than the ER Diagrams Say
Neither result table carries a ug_id column.
Both carry action_id, and action_id references ug_fcc.id, not ug_diagnostic.id — the existing ER diagrams say otherwise and are wrong on exactly this edge, which is worth knowing before writing an ad-hoc query against these tables.
ug_fcc_*_result.action_id -> ug_fcc.id
ug_fcc.ug_task_id -> ug_task.id
ug_task.ug_id -> update_group_child.id
update_group_child.parent_id -> update_group.id
FtacsLegacyGroupDao writes the chain from ug_fcc outwards rather than from update_group inwards, because ug_fcc is what the measurements point at: a group with no FCC task of the kind being imported has nothing to import and must not appear in the traversal at all.
ug_reactivate is LEFT-joined — most legacy campaigns have no schedule row, and its expression is consulted for one thing only, the hour a day-window starts at.
The group list is de-duplicated on ug_id, keeping the lowest ug_fcc.id, and that is a safety property rather than tidiness: the ug_reactivate join can multiply a row, and a group could in principle carry two tasks of the same type. Either would import one campaign’s measurements twice, and on a ClickHouse MergeTree a duplicate can never be removed afterwards. The statement’s ORDER BY ug.id, f.id, rea.id is what makes "the first row" a fact about the data rather than about the optimizer.
domain_id = 0 Means the Group Belonged to the Super Domain
An anchor takes domain_id from update_group.location_id, which is NOT NULL DEFAULT 0, and stores it as it stands.
0 is the super domain, not "no domain". That is the platform’s convention and not a reading this service chose: ui-backend’s CommonUtils.isSuperDomain treats null, 0 and -1 alike, and the comment beside it gives the reason — a super-domain record has no row in isp at all, which is how Group Update, monitoring and automation have always stored theirs. A legacy group created by a super-domain operator therefore reads back as 0, and so does every run and measurement under its anchor. The three spellings are normalised to 0 on the way in, because a stored -1 would be invisible to a predicate written against 0.
The access-control consequence is unchanged, and it is the right one.
0 is in nobody’s X-FT-Domain-Ids list — ui-backend strips the sentinels before they reach the header — so such an anchor is visible only to a caller with X-FT-Domain-Scope: ALL, which is precisely the operator who owns it. A scope-LIST operator does not get a partial view; they get nothing, exactly as The Empty Intersection Selects Nothing describes for every other read.
It does mean "the import ran and I still cannot see anything" has a second explanation besides a missing fleet snapshot: the campaign may be the super domain’s.
The Three Reconstructed Fields
Three columns that every FCC measurement in ClickHouse carries have no counterpart at all in the legacy schema.
They are not copied, not approximated and not defaulted: they are computed by a rule that is deterministic, so that two passes over the same source rows produce the same values.
That determinism is the whole contract — a MergeTree has no unique constraint and no delete, so a value that changed between passes would leave two rows claiming to be the same measurement, forever.
activation_id — One Synthetic Run per Calendar Day Window
The legacy schema has no "activation" entity whatsoever. A legacy campaign ran for months, reactivating itself, and the only record that a particular run happened is the measurements it produced.
The import reconstructs one fcc_activation per day-window, because a day is what a legacy reactivation was.
The window a measurement belongs to is decided by its start_date alone, and the time of day the window starts at is read from ug_reactivate.expression:
-
A recognisably daily cron —
0 M H * * ?or0 M H ? * *, the two spellings Quartz accepts for "every day at H:M" — gives the boundaryH:M. -
Anything else falls back to midnight: a weekly schedule, a list of hours, a malformed string. It is not half-interpreted, because a boundary guessed from an expression that does not mean what it looks like would split one legacy run across two activations, and the split would be invisible in the report. The fallback is not a degradation either — the boundary only decides where a day is cut; every measurement still lands in exactly one window and the windows are still stable across re-runs, which is the property that matters.
Each window becomes one fcc_activation with source = LEGACY, state = FINISHED (only closed windows are imported, so the run is over by definition), command_seq = 0, and:
idempotency_key = "legacy:<ugId>:<SPEED|LATENCY>:<windowIndex>"
|
An ordinal is a function of the data present at import time.
A campaign whose older measurements are archived in later, or whose first window turns out to be empty, would have every subsequent window renumbered — which means every The same value is stored as |
The activation is created lazily, the first time a measurement of that window is seen, and never up front from the calendar. Which days a campaign actually produced measurements on is not knowable without reading the measurements: a campaign scheduled daily for a year may have run on forty days, and a row per calendar day would fill the report with runs that never happened.
window_start_at / window_end_at are facts about the window and are written at creation.
started_at, finished_at and device_count are facts about the measurements and are applied monotonically once the campaign’s rows have been read, so a campaign imported in two halves converges on the true envelope instead of recording whichever half finished last.
A pass that halts part way deliberately does not finalise its windows at all — it has seen a fragment, and the envelope of a window must not be narrowed to it.
iteration — A Rank Over (Activation, Serial, Direction)
iteration is the position of a measurement among the measurements of the same device, in the same direction, in the same window. Stated as SQL, it is exactly:
ROW_NUMBER() OVER (
PARTITION BY activation, serial, direction
ORDER BY start_date truncated to the second, id
)
Two halves of that expression are load-bearing.
The truncation to the second.
It is what makes rows written within one second rank deterministically instead of by insertion accident, and it is computed by the database (FtacsSqlDialect.startSecondExpression) so that the ordering the rows arrive in is the ordering the rank is defined over.
|
Narrowing the fractional seconds rounds there, so The MySQL arm is |
The id tiebreaker, which is mandatory.
The legacy start_date carries a random sub-second fraction — between 0.100 s and 0.999 s — which the legacy code set deliberately to make rows unique.
Being random, it carries no ordering information at all: without the id tiebreaker the order of rows inside one second is undefined, and a re-import would produce different iteration numbers for the same measurements.
In a store where nothing can be deleted, that is the same measurement appearing twice under two identities.
|
The rank is counted in Java, not by a window function, and the result is identical. A Two details of that counter are not optional.
|
tier_kbps — Recovered by Multiplying by 1024 and Taking the Ceiling
The two legacy tables spell the tier differently.
| Source | What the tier column holds |
|---|---|
|
One string for both directions, |
|
A plain integer, already in Kbps. It is parsed as it stands; dividing it would be a unit conversion that never happened. |
The legacy ACS produced the speed string by dividing the configured Kbps tier by 1024 and truncating to three decimals with RoundingMode.DOWN.
So the true value lies in [x, x + 1.024), and multiplying back by 1024 and taking the ceiling lands on it — while a round does not:
1000000 Kbps -> written as "976.562"
976.562 x 1024 = 999999.488
round -> 999999 (wrong)
ceil -> 1000000 (correct)
The multiplier is 1024 and not 1000, which looks wrong next to this product’s own Kbps/Mbps convention and is exactly right here: it undoes the division the legacy code actually performed, whatever that division ought to have been.
The string goes straight into a BigDecimal and never through a double — a ceiling is the one operation that turns a floating-point representation error into a whole unit, in both directions and silently.
|
The residual precision loss is under 1.024 Kbps, and the lossless original is kept forever. The truncation destroyed everything below 1.024 Kbps, and no arithmetic can bring it back — the reconstruction recovers the interval, not the digit. On a tier of any realistic size that is immaterial; it is stated here so nobody reconstructs it a second time and expects a different answer. The original string is still in |
An unreadable tier does not drop the measurement.
The row is imported with tier_kbps = 0 and tallied as tier_parse_failed.
The measurement itself — the speed, the timestamps, the device — is a fact that happened, and losing it because the contract line it was judged against cannot be recovered would take real data out of a regulatory report to hide a metadata defect.
A speed string with no / separator is treated as unreadable in both directions rather than read as the download half: that is a shape this service has never seen written, and picking a half would be a guess with no evidence behind it.
A latency tier has no separator by design — it is a plain Kbps integer and goes through a different path entirely, so this rule does not touch it.
A half that is present but unreadable fails only its own direction: "12/" gives a download tier and an unreadable upload one, "/34" and "abc/12" the reverse.
A value above Integer.MAX_VALUE is clamped and counted with the unreadable ones — the ClickHouse column is an Int32, and two petabits per second is a corrupt string that happens to parse.
tier_parse_failed is counted once per imported row, not once per measurement. A speed measurement is two rows — one download, one upload — and each is tallied independently. Since a no-separator string is now unreadable in both directions, such a string is counted twice, once on each row, where before the fix it was counted once, on the upload row only (the download half read a real, if arbitrary, tier under the old behaviour). An operator comparing tierParseFailed across a re-run of the same source data should expect it to roughly double for campaigns whose legacy tier strings never carried a separator, and that is the counter behaving correctly rather than a regression in the source data.
speed and status Are Carried Across Verbatim and Are Deliberately NOT Recalculated
|
This is the single most surprising rule on this page, and it is deliberate. The legacy pass/fail formula is known to be wrong. It truncates The import reproduces it anyway, because the import reproduces history. Recomputing would contradict every report already issued. Those reports were computed out of And above 2 GiB it is not even possible. The formula needs |
So the two verdict-bearing columns are mapped and nothing more:
| Column | Mapping |
|---|---|
|
|
|
The legacy integer, mapped constant for constant: |
CROSS_TALK therefore arrives in a report as its own status and not as a kind of FAILED, exactly as the reporting rules require of live measurements.
total_bytes: Two Signatures of One Overflow, Both Stored as NULL
fcc_result_speed.total_bytes is Nullable(Int64) for this (fcc_result_speed).
A legacy value is refused, mapped to NULL and counted as bytes_overflow_count when either of the following holds:
| Condition | What it is |
|---|---|
|
A signed 32-bit counter that wrapped. This is the Oracle signature: the legacy column is signed there, so the wrapped value goes negative. |
|
The same overflow landing exactly on the boundary — and the MySQL signature, where the column is |
Both checks run on both vendors, deliberately: the import must catch the overflow whichever way the storage engine expressed it, and an installation may well have been migrated between the two.
Storing the wrapped number instead would put a byte count into a regulatory report that is off by four gigabytes and looks perfectly plausible.
A NULL plus a tally says the only true thing available: the input was destroyed, and here is how often.
Time Zone Is a Run Parameter, Not a Property of the Data
Every legacy timestamp — start_date, end_date, bom, eom — is naive local wall clock, with the offset explicitly stripped and stored nowhere.
There is no zone to recover from the row, so turning one into an instant requires a zone supplied from outside.
That zone is ft.fcc.legacy-import.zone (empty means the JVM’s own zone, never UTC by default), it is recorded on the row in fcc_legacy_import.zone_id, and /status returns both it and the zone a new pass would use.
Without it every imported instant is displaced by the ACS server’s UTC offset — silently, uniformly, and invisibly in the resulting report.
The columns are read as LocalDateTime through getObject(label, LocalDateTime.class) and never as Timestamp, because the JDBC 4.2 type carries no zone, which is the only honest reading of a column that stores none; a Timestamp would be produced in the driver’s zone and converted back through the JVM’s, and in a container those are not the same.
|
A resumed pass keeps the zone it began with, even if the property has changed since. The day-window a measurement belongs to is a function of the zone, and so is the |
bom and eom Are the Only Genuine Device Timestamps
| Column | Provenance |
|---|---|
|
Beginning and end of measurement, as the device reported them. Kept with their stored milliseconds — these are the only real sub-second figures in the row. |
|
|
|
|
|
The sub-second parts of The legacy fraction was never a measurement. The legacy flow stamped Reproducing a fraction here — with a fresh random draw, which is the only thing a re-import could do — would have re-created the very hack the import exists to undo, and it did two concrete kinds of damage while it was in the code:
|
The Fleet Snapshot Is Mandatory, Not an Enrichment
|
A report filtered by model or by manufacturer resolves ONLY through Neither ClickHouse result table carries a model, a manufacturer or a An anchor with no snapshot therefore returns nothing at all under that filter — not less, nothing. To an operator that reads as "this campaign measured no devices of this model" rather than as "this campaign has no fleet recorded", which is the one statement a compliance report must never make by accident. |
The serials come from the measurements themselves (SELECT r.serial, MAX(r.start_date), MAX(r.id) … GROUP BY r.serial), not from the legacy group’s current membership.
A device that has been deleted from the ACS since the test ran is absent from that membership and present in the measurements, and it is the measurements that describe what the campaign actually did.
model_id Is the Model at IMPORT Time, Not at Measurement Time
Stated plainly because it changes how a model-filtered report over imported data should be read.
The legacy result rows carry a serial and nothing else. The only place a model can come from is the live ftacs.cpe row of that serial today, so a CPE that has been re-provisioned into another product class since the campaign ran is snapshotted under its current model.
There is no way around it and no approximation that would be better: this is a genuine limitation of the source data, not of the resolver.
It is the one respect in which an imported fleet differs from a live one, where the snapshot is frozen at save time and therefore does describe the fleet as it was.
The /start endpoint states it in its own description so an operator reading a model-filtered report knows what they are filtering by.
A Serial With No Live cpe Row Is Not Snapshotted — and Its Measurements Still Are
fcc_test_device.cpe_id is NOT NULL and stays so.
There is no id to record for a serial the ACS no longer knows, and inventing one — a zero, a negative sentinel — would make the (test_id, cpe_id) unique index meaningless and put a device into the fleet that nothing can ever resolve.
So the device is left out of the snapshot and counted as devices_unresolved (logged at WARN), while:
-
its measurements are imported normally and are returned by every unfiltered report;
-
they are unreachable only by the model and manufacturer filter, because the model they would be filtered by no longer exists anywhere in the installation.
Four more rules of the snapshot, each of which is there for a concrete failure:
| Rule | Why |
|---|---|
Top up, never replace |
Existing rows are read first and only new `cpe_id`s are inserted. A delete-then-insert would destroy the snapshot of a device that has since disappeared from the ACS — which is exactly the device a historical report needs the snapshot for. |
Serials are folded case-insensitively |
The comparison against |
The insert is chunked, and a duplicate key is tolerated rather than prevented |
The |
Where several serials collapse onto one |
|
The snapshot scan is restricted to the interval this pass covers — the same [watermark_started_before, cutoff) predicate its batches are read under — so an installation that has been importing for months does not re-resolve a fleet it recorded long ago; a device that only ever appears below the watermark was snapshotted by the pass that imported it.
The rows a pass reads, the fleet it snapshots and the id high-water mark it reports are all written from one predicate in FtacsLegacyResultDao, because the watermark is only sound while the three describe exactly the same set.
The resolve is not narrowed by domain: the import runs with scope ALL by contract, an anchor takes the domain of the legacy group rather than of the caller, and narrowing here would silently drop the devices of every other domain from the fleet of a campaign that measured them.
Idempotency and Restartability
|
Why this matters more here than anywhere else in the service. A ClickHouse Every mechanism below exists to make "the import was interrupted and started again" produce exactly the rows one uninterrupted run would have produced. |
The Two-Phase Cursor Commit Around Each Batch
One batch is one unit of work in three steps, and the order is the design:
-
savePending(ugId, minId, maxId)— the legacy id range of the batch, and the campaign it was read for, are written tofcc_legacy_import.pending_from_id/pending_result_id/pending_ug_idand committed before ClickHouse is touched at all. -
The ClickHouse insert.
-
commitCursor(…)— the cursor moves, the counters are added withcolumn = column + ?, and the pending range is cleared toNULL.
A non-null pending_result_id found at startup therefore means the previous run died inside exactly that window and the batch’s fate is unknown.
The recovery is a single bounded read — SELECT DISTINCT legacy_result_id FROM fcc_result_* WHERE source = 'LEGACY_ETL' AND legacy_result_id BETWEEN ? AND ? — which runs at most once per resumed run and tells the importer precisely which ids to skip (counted as rows_skipped).
Because the range is one batch, the import never has to reconcile itself against the whole table.
Four supporting details, each of which is there for a crash that actually reaches it:
-
The range names its campaign, and the recovery set is offered to that campaign and to no other. A run that died in the first batch of a campaign committed no cursor for it, so the stored cursor still names the previous campaign — which has nothing left to skip. Without
pending_ug_idthe recovery set is handed to that previous campaign, cleared there, and the batch that genuinely is in doubt is inserted a second time. (A range written before the column existed carries a nullugId; it is applied to every campaign until the pass ends, because skipping an id that is genuinely in ClickHouse is correct wherever it is met.) -
A pending range on its own makes a pass resumable, even with no cursor behind it.
FccLegacyImportRow.isResumable()is true forcursorResultId != nullorpendingResultId != null: a crash inside the very first batch of a pass has committed no cursor at all, and treating that as "not started" would take the fresh-claim path — which is exactly how the only record of which ids are in doubt gets erased before the recovery probe can read it. The fresh claim additionally never clearspending_*, which is the second lock on the same door. -
No method on
FccLegacyImportDaois transactional, and that is what the recovery contract rests on. Each statement runs on autocommit, so the pending marker is durable the instant the call returns. Wrapping the import in a transaction would defer every one of these commits to the end of a run that may take hours — and lose all of them to the crash they exist to survive. -
The range is
MIN..MAXof the batch, not first..last. The traversal order is(serial, direction, second, id), deliberately not id order, so the ids of one batch are scattered and only their extremes bracket it.
Everything That Ends a Run Ends It Between Batches
The stop flag, the result-store health and the shutdown signal are all checked between batches, never inside one. Whatever ends a run therefore leaves a committed cursor the next one resumes from exactly.
| What ends the run | What it leaves behind |
|---|---|
|
|
The result store going |
|
A JVM shutdown |
|
A source row that cannot become a cursor |
|
An exception inside the pass |
|
Eviction by a stale-heartbeat takeover |
Nothing at all. The evicted run raises |
IDLE and FAILED differ in exactly one thing: IDLE means interrupted, FAILED means broken.
The cursor is committed in both cases, which is why a clean stop is IDLE even when it carries a last_error.
Only Closed Day-Windows Are Imported
A pass covers one half-open interval of start_date and nothing else:
[ watermark_started_before , cutoff )
cutoff = passStartedAt - ft.fcc.legacy-import.window-settle - 24h
Rows at or after the cutoff are not read at all.
Because the iteration rank of a window still receiving rows would be computed over a partial set, and a later pass would renumber it.
A renumbered rank means the same measurement appearing twice under two identities in a table where nothing can be deleted — the exact failure this whole section exists to prevent.
|
The newest measurements become importable roughly 25 hours after they are taken, and an operator has to plan around that. The cutoff subtracts a full day on top of The extra day is what makes the cutoff boundary-independent. A day-window is at most 24 hours long, so any row whose It has to be boundary-independent because the cutoff is one value for the whole pass and not one per campaign. A completed pass writes a single date as its watermark, so a pass has to have a single coherent covered interval; per-campaign cutoffs would differ by up to a day, and one campaign’s watermark would then exclude another campaign’s importable rows. The trade is stated plainly because it is a real one: a day of extra lag on the newest data, in exchange for a watermark that cannot skip a row. On a store with no delete and no back-fill that is not a close call — a row skipped there is skipped permanently. |
The cutoff is derived from the pass’s own started_at and never from now.
A pass interrupted at campaign 50 and resumed an hour later must import exactly the windows it set out to: deriving the cutoff from now on resume would let windows that closed in the meantime be imported for the campaigns after the cursor and never for the ones before it — while the watermark written at the end declared all of them done.
The one place now is used instead is totalRowEstimate, which is measured before the claim decides whether the start is a fresh pass or a resumed one. A resumed pass’s denominator is therefore a few hours wider than the interval it will actually cover; it is explicitly a rough denominator and nothing decides that the import is finished from it.
The Cursor and the Watermark Answer Two Different Questions
| Column(s) | Question it answers |
|---|---|
|
Where an interrupted pass resumes. A position in the traversal order |
|
Which measurements are complete — every row of this kind whose |
|
A diagnostic high-water mark over legacy ids, and nothing else. It records the highest id any completed pass has covered. It bounds no read; its one remaining use is recognising a late arrival. |
The cursor and the watermark cannot be collapsed into one column: a position in an order that is not id order says nothing about which rows are complete, and a completeness mark says nothing about where to resume a ranking.
|
The completeness mark is a DATE because the predicate that decides what a pass includes is a date. An id could not be one, and the earlier design that made it one was unsound. A pass selects Recording completeness as an id while selecting by date is not sound, because the two do not order the same way. Legacy ids come from one auto-increment sequence shared by every campaign and every device of the installation, while
A pass imports the first, writes its high id as the watermark — and the second row, lower id and not yet importable, is below the watermark forever and can never be picked up again. That is a permanent hole in a store where nothing can be back-filled. The per-campaign cutoff made it worse still, which is why the cutoff is now pass-wide: see [cutoff-lag]. |
watermark_result_id is built from MAX(r.id) read per campaign, under the pass’s own predicate, and is folded in for every campaign of the pass — including the ones a resumed pass skips because the run it continues already imported them.
The stored value only ever moves up: a later pass covers a later date interval whose highest id is not necessarily higher, and letting the mark fall would over-report rows_late_skipped.
Late Arrivals Are Counted and Deliberately Not Back-Filled
A measurement can reach ftacs after the day-window it belongs to has already been imported: it matches start_date < watermark_started_before AND id > watermark_result_id, which is exactly what the two marks together are able to recognise.
FtacsLegacyResultDao.countLateArrivals counts them per campaign, the runner tallies them as rows_late_skipped and logs a WARN naming the campaign, and /status reports the total.
|
They are not imported, and that is a known limitation with a stated reason rather than an oversight. Their window is closed and every measurement in it has already been ranked. Inserting into it now would give two measurements the same A non-zero |
The count is answered only when both marks are known. Before the first completed pass there is no "after its window was imported" for anything to be after, so the count is skipped entirely.
A Null Cursor Component Halts the Pass
The read position is the tuple (serial, direction, start_date-to-the-second, id), and every arm of the resume predicate compares those columns with > and =.
In SQL both x > NULL and x = NULL are UNKNOWN, so a cursor carrying a null in any component matches no row at all — the next batch comes back empty, and an empty batch is indistinguishable from the end of a campaign.
So a row with a null serial, a null start_date, or (for a speed row) a null type cannot become a cursor, and the pass stops there:
-
the whole batch is discarded — nothing has been written yet, because the pending marker and the insert both come after the row loop — so the cursor stays exactly where the last committed batch left it;
-
rows_invalidis incremented, anERRORnaming the campaign and the row id is logged, andlast_errorcarries the same text; -
stateisIDLEand the watermark is not advanced.
It deliberately does not skip the campaign and carry on. Doing that would move the cursor past the campaign, and the pass after it would resume above it, complete, and advance the watermark over rows nothing ever imported — the same permanent hole, reached by a different route.
serial and start_date are NOT NULL in the ACS DDL and a speed row’s type has only ever been written as a number, so such a row means the source is not what this import was specified against. Fix the source row and start again.
One Worker, and a Claim That Holds Across Instances
A single-threaded worker runs one kind at a time. Two importers would contend for the same ftacs read capacity and the same ClickHouse ingest — but the thing that must never happen, two passes over the same kind, is prevented by the claim and not by the executor.
The claim is one conditional UPDATE on fcc_legacy_import: a run may take the row when nobody holds it, or when the holder has written no heartbeat within ft.fcc.legacy-import.stale-after (default 5m), which is what a process killed mid-import leaves behind.
Zero rows updated is the 409.
Being a database statement, it holds across two service instances, which a thread pool never could.
Two properties of it are easy to miss:
-
heartbeat_at IS NULLis part of the staleness arm, not a redundancy: a row that saysRUNNINGand has never been heartbeated is exactly what a crash between the claim and the first batch produces, andNULL < ?is unknown rather than true. -
The claim is all-or-nothing across the requested kinds. If the second kind cannot be claimed, the first is released again — a start that answers 409 must not leave half an import running, or the operator retries and finds that half of it is now "already running".
The two kinds are otherwise independent by design, which is why fcc_legacy_import holds exactly two rows: an unreadable row or a stalled insert on one table is no reason to abandon a pass over the other, and either kind can be started, stopped or restarted alone.
The Claim Is Enforced on Every Write, Not Only at the Start
Every mutating statement of FccLegacyImportDao that belongs to a run carries WHERE result_kind = ? AND run_id = ?, and a zero-row update means EVICTED, not "nothing to do": it raises FccLegacyImportEvictedException and the pass aborts without touching the row again.
That is not belt-and-braces. Taking the claim is not the same as keeping it — a run whose heartbeat goes stale can be taken over while it is still alive — and an evicted run without a fence keeps writing: it commits its cursor over the live worker’s position, and its markIdle flips a RUNNING row to IDLE. Two workers ranking the same campaign write duplicate iteration positions into a store from which nothing can be deleted.
The row for a kind always exists (ensureRow creates it before anything else touches it), so with the fence in place zero rows updated can have only the one meaning.
Two consequences of the fence worth stating:
-
The heartbeat covers the slow parts of a pass, not only the batch loop. It is written at the top of every campaign iteration — the
MAX(id)prologue runs for every campaign, including the ones a resumed pass skips, and on a ten-million-row group can on its own outlaststale-after— and again after each half of phase A, the anchor resolve and the fleet top-up. Unbounded gaps there are what made eviction likely in the first place. -
The heartbeat refreshes every kind this run holds, including the ones still waiting for the single worker, so a claimed kind that has not started yet cannot go stale and be claimed by a second start. Losing a kind this run has not reached yet is logged and nothing more; only the kind being imported right now aborts the pass when its fence misses.
Three statements sit outside the fence, and each is outside it for a different, deliberate reason.
ensureRow and the two claim statements (SQL_CLAIM_FRESH / SQL_CLAIM_RESUME) run with no run_id because they are what establishes one: there is nothing to fence a run’s own birth against, and the claim’s UPDATE … WHERE (holder is absent or stale) is itself the mechanism that decides which run_id gets to write next.
saveEstimate runs before the claim, for the same reason — it has no run_id yet either — so it is fenced on the row’s state instead: a start that is about to answer 409 must not first overwrite the denominator of the run that is already importing that kind.
requestStop (SQL_REQUEST_STOP) is a second, and different, deliberate exception: it carries no run_id and no state fence at all, only WHERE result_kind = ?. This is not an oversight the two statements above could be mistaken for — POST /FCC/admin/legacy-import/stop is a request about the kind, made by an HTTP caller who does not know and has no way to know the run_id of whichever worker currently holds it, so there is no run id it could present to be fenced on. Stopping is cooperative by design (Everything That Ends a Run Ends It Between Batches): the flag is set unconditionally, the currently-running worker notices it between batches and closes cleanly on its own committed cursor, and a stop request that arrived just as one worker was evicted and a second one claimed the kind simply asks the new holder to stop too, which is the correct outcome either way.
What an Imported Measurement Looks Like in ClickHouse
Beyond the reconstructed and mapped columns above, an imported row differs from a live one in a few fixed ways.
| Column | Value on a LEGACY_ETL row |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
Filled, unlike a live latency row. |
|
The anchor’s domain, which is |
|
The instant the import wrote the row, not when the measurement was taken. It is the partitioning key, which is why the report period predicate is two predicates: for an imported row, |
Operating It
The endpoints, their authorization rule and their status codes are in The Legacy Import Endpoints. The properties, the grant it needs, the ACS version it requires and the start / watch / stop / restart runbook are in The Legacy Import Runbook. The table that holds its cursor is fcc_legacy_import.