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

source

LEGACY — the campaign dictionary, not the measurement one. See Two Different source Dictionaries.

state

ARCHIVED. Unreachable through the ordinary state machine; this import is the only writer of it. It describes something that finished years ago, and the lifecycle the other states belong to — schedule it, activate it, stop it — has no meaning for it.

legacy_ug_id

update_group.id, which together with type is the key the import finds its own anchor by on the next pass.

reactivation_days

0. An imported campaign never reactivates; its runs are reconstructed, not planned.

command_seq

0. No ACTIVATE was ever sent for it, there is no fcc_acs_outbox row and no ack, and giving it a sequence would put an imported campaign into the numbering the live protocol depends on.

fcc_test_config, fcc_test_setting_speed / _latency

Written, but mostly null. threshold_dl/ul, tier_dl/ul and hubb_id come from ug_fcc; only_online and push from update_group; the durations and retry counts from fcc_properties when it has a row. test_mode = FILE_BASED and wan_sample_mode = TWO_SESSIONS are the only two columns that cannot be null, and they are set to what the legacy ACS actually did rather than to a placeholder.

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_fcc says would undo that correction every time it ran.

  • An anchor created with no fcc_properties row is counted, not guessed at. fcc_properties.ug_id is 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 increments properties_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 fcc_test.type and is now fcc_test.legacy_result_kind. type was the campaign’s diagnostics type and is gone — a campaign holds several configurations, of possibly different kinds, so it has none (fcc_test_config). The index still needs a column on fcc_test, and renaming type to what it actually meant is what that column is: it says WHICH HALF OF A LEGACY GROUP an anchor is, and nothing else. The anchor’s single fcc_test_config row carries what the imported campaign actually measured.

ONE CAMPAIGN WITH TWO CONFIGURATIONS WOULD NOW BE EXPRESSIBLE — it is exactly the shape a native campaign has, and the correspondence between a legacy update_group and a multi-configuration campaign is where that shape came from. The ETL deliberately still makes two anchors: the import is keyed, counted, watermarked and resumed per result kind (fcc_legacy_import holds one row per kind, and a pass reads one kind’s result table), so merging the two halves into one campaign is a change to the whole ETL rather than to its anchor creation. It is a follow-up, not a consequence of the configuration level.

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

fcc_cpe

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.

cpe_diagnostic*

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 * * ? or 0 M H ? * *, the two spellings Quartz accepts for "every day at H:M" — gives the boundary H: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>"

windowIndex is an ABSOLUTE epoch-day index, never a 1..N ordinal over the windows that happen to exist.

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 idempotency_key changes, and the next pass creates a second activation for a day it has already imported, under a key the unique index cannot recognise as a duplicate.

The same value is stored as fcc_activation.seq, for the same reason: two passes over the same data have to produce the same run for the same day, and an ordinal that depends on which windows were imported first does not. seq on an imported run is therefore a large number (an epoch day, not a run counter) and is not comparable with the 1, 2, 3 … of a live campaign.

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.

CAST(x AS DATETIME) is not a truncation on MySQL, and is deliberately not used.

Narrowing the fractional seconds rounds there, so 23:59:59.700 becomes the next day — a different day-window, a different synthetic activation and a different iteration than the same source row produces on Oracle.

The MySQL arm is DATE_SUB(x, INTERVAL MICROSECOND(x) MICROSECOND), which removes the fraction without touching anything else. Oracle’s CAST(x AS DATE) genuinely truncates — narrowing a TIMESTAMP to a DATE discards the fractional seconds, and a DATE’s resolution is one second — so that arm stands as it is. The two spellings are not interchangeable in either direction: `DATE on MySQL would drop the time of day entirely and collapse a whole day of measurements onto one rank.

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 ROW_NUMBER() would be recomputed over the whole campaign by every batch, because the batch predicate cuts the window from outside — on a ten-million-row group that is quadratic work, of the order of 1010 row operations. The rows arrive in precisely the order the rank is defined over, so a running counter reset whenever (windowIndex, serial, direction) changes produces the same value.

Two details of that counter are not optional. cursor_iteration carries it across a batch boundary, so a group split between two batches continues at N+1 instead of restarting at 1 and claiming two first measurements. And the rank is advanced for a skipped row too — a row the crash recovery found already present in ClickHouse was written with a rank of its own, and letting the next row inherit it would renumber the rest of the group.

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

ug_fcc_speed_result

One string for both directions, "dl/ul", in Mbps, repeated verbatim on the download row and on the upload row alike. The direction of the row being imported selects the half.

ug_fcc_latency_result

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 ftacs.ug_fcc_speed_result.tier, permanently, because the import never touches the source. That is precisely why no second tier column was added to ClickHouse to hold it: the lossless value has a home already, and an ALTER TABLE …​ ADD COLUMN over tens of millions of rows is the operation the result-table comments exist to avoid.

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 tier / 1024 with integer division before comparing, so a low tier collapses towards zero and the measurement passes trivially — the same defect The Pass/Fail Formula tells this service’s own write path never to copy.

The import reproduces it anyway, because the import reproduces history.

Recomputing would contradict every report already issued. Those reports were computed out of ftacs with that formula, by the customer, and they have been filed. A service that silently disagrees with all of them is worse than one that preserves a known defect visibly — at least the second one can be explained.

And above 2 GiB it is not even possible. The formula needs total_bytes, and the legacy code destroyed it with an intValue() narrowing for any transfer past the signed 32-bit boundary. The input is gone; there is nothing to recompute from. See total_bytes: Two Signatures of One Overflow, Both Stored as NULL.

So the two verdict-bearing columns are mapped and nothing more:

Column Mapping

speed_kbps

ug_fcc_speed_result.speed x 1000. A unit conversion only — the legacy column is Mbps and the ClickHouse column is Kbps. An absent value becomes 0.0; the column is not Nullable.

status

The legacy integer, mapped constant for constant: 0 → FAILED, 1 → SUCCESS, 2 → CROSS_TALK, 3 → OTHER. Anything outside those four — including a NULL — becomes OTHER and is tallied as status_unknown_count. It is never dropped: the measurement happened, and the only thing not understood is the label on it.

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

total_bytes < 0

A signed 32-bit counter that wrapped. This is the Oracle signature: the legacy column is signed there, so the wrapped value goes negative.

total_bytes = 0 with speed > 0

The same overflow landing exactly on the boundary — and the MySQL signature, where the column is INT UNSIGNED and a negative clamps to 0 instead. A transfer that moved no bytes cannot have had a rate, so the pair is impossible rather than merely suspicious.

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 idempotency_key of the activation it is imported into. Finishing a pass under a different zone would create a second activation for a day that already has one and split that day’s measurements between them. The mismatch is logged at WARN — it is not an error, and the run continues under its original zone.

bom and eom Are the Only Genuine Device Timestamps

Column Provenance

bom, eom

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. ug_fcc_latency_result has neither column, and fcc_result_latency itself has no bom / eom columns to import into: the two are part of the speed-specific tail of fcc_result_speed (V1__create-fcc-result-speed-table.sql), not of the common block the two result tables share, so FccLegacyResultMapper.latencyRow passes nothing for them at all rather than passing a NULL.

started_at

start_date truncated to the second, and nothing added. The truncated value is used, not the raw column, so that the instant stored agrees with the value the iteration rank was computed over — a row ranked as the third measurement of its second must not be stored as having happened in a different second.

finished_at

end_date truncated to the second, by the same rule. ug_fcc_latency_result has no end_date at all, so a latency row imports NULL — not the start, and not the start plus a guess.

The sub-second parts of started_at and finished_at are DROPPED, and that is what makes two imports of one source row byte-comparable.

The legacy fraction was never a measurement. The legacy flow stamped start_date with setNanos(1e8 + rnd(9e8)) and end_date with System.nanoTime() % 1e9, so the digits below the second say nothing about when anything happened.

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:

  • it made the import irreproducible. Two imports of the same source row produced two different instants, so the only way to compare them was to agree in advance to ignore a column. There is no such caveat now: re-import the same row and every column matches, sub-second included, because there is none.

  • it could invert the pair. The two fractions came from unrelated sources, so finished_at landed before started_at for any row whose start and end fell in the same second.

bom and eom are the exception and keep their stored milliseconds, because they are the only genuine device-side timestamps in the row.

The Fleet Snapshot Is Mandatory, Not an Enrichment

A report filtered by model or by manufacturer resolves ONLY through fcc_test_device.

Neither ClickHouse result table carries a model, a manufacturer or a cpe_id column, and none is being added (Model and Manufacturer Are Resolved Through the Frozen Fleet). FccReportScopeResolver goes model ids → fcc_test_device → campaigns and serials, and that is the whole of the path.

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 ftacs.cpe is case-insensitive on MySQL and case-sensitive on Oracle, so two legacy spellings of one serial are one device on one vendor and two on the other. Upper-casing the key here makes the snapshot the same on both.

The insert is chunked, and a duplicate key is tolerated rather than prevented

The ftfcc MySQL URL sets rewriteBatchedStatements=true, which collapses a JDBC batch into a single multi-row INSERT — an installation-sized fleet in one statement is a packet nobody chose the size of, so it goes in chunks of the same bound every other multi-row statement of this service uses. The read of the known cpe_id`s and the insert are two statements with no lock between them, so the `UNIQUE (test_id, cpe_id) index is what actually decides the question; losing that race means the device is already recorded, which is not a reason to take a pass over tens of millions of measurements down. The chunk is left to the run that won it, and devices_snapshotted counts what this run actually inserted.

Where several serials collapse onto one cpe_id, the serial of the LATEST measurement wins

(test_id, cpe_id) is unique and a fleet read by serial can legitimately produce the same CPE twice. Choosing the newest means the snapshot carries the serial the campaign’s most recent measurements are recorded under — which is the one a device filter will be asked about. Recency is MAX(start_date) with MAX(id) breaking the tie.

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 MergeTree has no unique constraint, no upsert and no delete. A measurement inserted twice stays in the table twice, forever, and there is no statement that can remove it. In a regulatory report that is not a cosmetic defect: it silently inflates the sample the compliance percentage is computed over.

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:

  1. savePending(ugId, minId, maxId) — the legacy id range of the batch, and the campaign it was read for, are written to fcc_legacy_import.pending_from_id / pending_result_id / pending_ug_id and committed before ClickHouse is touched at all.

  2. The ClickHouse insert.

  3. commitCursor(…​) — the cursor moves, the counters are added with column = column + ?, and the pending range is cleared to NULL.

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_id the 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 null ugId; 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 for cursorResultId != null or pendingResultId != 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 clears pending_*, which is the second lock on the same door.

  • No method on FccLegacyImportDao is 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..MAX of 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

POST /FCC/admin/legacy-import/stop

state = IDLE on a committed cursor. The stop is cooperative: the flag is recorded and the call returns at once; the running import notices it between two batches. There is deliberately no forced variant — being killed mid-batch is what the pending range exists to survive, not something to offer a button for.

The result store going DEGRADED

state = IDLE, last_error naming the gate’s reason. The import stops on its last committed cursor and resumes from there once the store recovers.

A JVM shutdown

@PreDestroy asks every claimed kind to stop and waits up to 10 seconds for the worker to reach the end of its batch. Giving up on that wait loses nothing — the next start re-checks the pending range.

A source row that cannot become a cursor

state = IDLE, last_error naming the campaign and the row id, and rows_invalid incremented. The cursor and the watermark are both left exactly where they were — see A Null Cursor Component Halts the Pass.

An exception inside the pass

state = FAILED, last_error carrying the exception class and message. The cursor is still intact.

Eviction by a stale-heartbeat takeover

Nothing at all. The evicted run raises FccLegacyImportEvictedException and does not touch the row again — it belongs to the worker that took it. Writing FAILED on it would stop a healthy import. See One Worker, and a Claim That Holds Across Instances.

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 window-settle, so with the default 1h a measurement taken now is first eligible for a pass started about 25 hours from now. Raising window-settle raises the lag by the same amount.

The extra day is what makes the cutoff boundary-independent. A day-window is at most 24 hours long, so any row whose start_date is below passStartedAt - settle - 24h sits in a window that closed before passStartedAt - settle whatever hour that campaign’s day begins at — and campaigns' ug_reactivate hours differ by up to 24 hours across an installation.

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

cursor_ug_id, cursor_serial, cursor_direction, cursor_started_at, cursor_result_id, cursor_iteration

Where an interrupted pass resumes. A position in the traversal order (ug_id, serial, direction, start_date-to-the-second, id), plus the running rank inside the group it sits in. cursor_result_id on its own says nothing about progress, because that order is deliberately not id order. The four values that come from a measurement — serial, direction, second and id — are all taken from the same row, together with the rank that row was given; a cursor assembled from two rows names a position that never existed. And cursor_direction holds the raw legacy type as text, never a direction name: type 0, 1, 3 and null all map to DOWNLOAD, so a name cannot be turned back into the number the legacy table is compared on.

watermark_started_before

Which measurements are complete — every row of this kind whose start_date is below it has been imported. A local wall clock, because that is what the legacy column stores. Written only when a full pass completes, never mid-pass, and set to exactly the cutoff that pass covered up to. Null means no pass has ever completed and the next one has no lower bound.

watermark_result_id

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 start_date >= watermark_started_before AND start_date < cutoff, and on completion sets watermark_started_before = cutoff. Because the mark and the inclusion predicate are the same column, the intervals of successive passes tile the timeline with no gap and no overlap. Completeness is then true by construction rather than by argument.

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 start_date is whenever a measurement happened to be taken:

  • a device that buffers a result and flushes it late gets a high id and an old start_date;

  • a device reporting in real time gets a lower id and a start_date inside a window that is still open.

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 (activation, serial, direction, iteration) position in a store from which nothing can be deleted — and in a regulatory sample a duplicate is indistinguishable from a real measurement, while a missing one at least does not inflate the percentage it is computed over.

A non-zero rowsLateSkipped therefore needs no action during the run. It is the loss made visible instead of silent, and re-running the import will not change it.

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_invalid is incremented, an ERROR naming the campaign and the row id is logged, and last_error carries the same text;

  • state is IDLE and 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 NULL is part of the staleness arm, not a redundancy: a row that says RUNNING and has never been heartbeated is exactly what a crash between the claim and the first batch produces, and NULL < ? 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 outlast stale-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

source

LEGACY_ETL — the measurement dictionary. The run above it is LEGACY. See Two Different source Dictionaries.

legacy_ug_id, legacy_result_id

update_group.id and the source row’s own id. legacy_result_id is what the crash-recovery probe reads back, so it is never decorative.

connections, wan_usage_dl_kbps, wan_usage_ul_kbps, after_stop

0. The legacy flow has no source for any of them and the columns are not Nullable. after_stop = 0 is also simply true: an imported run was never stopped.

test_bytes

NULL. Same reason, with a Nullable column to say so honestly.

repetitions, data_block_size (latency only)

NULL. The legacy latency table reports neither, and a source that does not report a figure must stay distinguishable from one that reported zero — see the contract 1.5 note.

url, tier_kbps on a latency row

Filled, unlike a live latency row. ug_fcc_latency_result carries both a url and a tier, so they are carried across rather than written empty. A live IPPing iteration has neither and writes "" / 0.

domain_id

The anchor’s domain, which is update_group.location_id. 0 means the group belonged to the super domain — see domain_id = 0 Means the Group Belonged to the Super Domain.

created

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, created and started_at differ by years.

The Pipeline

fcc-legacy-import-pipeline

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.