Skip to content

feat/labs migration#1968

Open
alanpeixinho wants to merge 10 commits into
kernelci:mainfrom
profusion:feat/labs-migration
Open

feat/labs migration#1968
alanpeixinho wants to merge 10 commits into
kernelci:mainfrom
profusion:feat/labs-migration

Conversation

@alanpeixinho

@alanpeixinho alanpeixinho commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Migrates all read paths for lab information from JSONB misc fields (builds.misc->>'lab',
tests.misc->>'runtime') to the new labs table via lab_id foreign key, with JSONB fallback for rows not yet backfilled.
Every SQL query and Python helper that previously extracted lab names by parsing JSONB now uses COALESCE(labs.name, misc_fallback) through a LEFT JOIN on the labs table. This ensures:

  • New data (with lab_id populated by the ingester) reads from the structured FK
  • Historical data (without lab_id) still works via the JSONB fallback
  • After a full backfill, the JSONB fallbacks can be removed (all marked with TODO comments)

How to test

legacy database

  • Use a database where lab_id is NULL on existing rows
  • Verify all pages display lab names as before:
    • Tree details page (boots/tests tabs) — lab column in test history
    • Tree commits history page — build lab and test lab in filters and rows
    • Hardware details page — lab in test/build summaries, history, and filters
    • Build details page — lab column in test list
    • Issue details page — lab column in test list
    • Notifications/metrics endpoint — lab summary counts

Migrated database (test in a local database)

  • Run the migrations available
  • Run the ingester (which already fills lab_id)
    • Verify all pages display lab names as before:
    • Tree details page (boots/tests tabs) — lab column in test history
    • Tree commits history page — build lab and test lab in filters and rows
    • Hardware details page — lab in test/build summaries, history, and filters
    • Build details page — lab column in test list
    • Issue details page — lab column in test list
    • Notifications/metrics endpoint — lab summary counts

@alanpeixinho
alanpeixinho marked this pull request as draft July 1, 2026 20:59
@alanpeixinho alanpeixinho changed the title feat/labs migration {feat/labs migration Jul 1, 2026
@alanpeixinho alanpeixinho changed the title {feat/labs migration [WIP] feat/labs migration Jul 1, 2026
@alanpeixinho
alanpeixinho force-pushed the feat/labs-migration branch from 7ee4011 to 80d7ce6 Compare July 2, 2026 20:51
@alanpeixinho
alanpeixinho marked this pull request as ready for review July 2, 2026 20:53
@alanpeixinho
alanpeixinho force-pushed the feat/labs-migration branch from 80d7ce6 to 3404f84 Compare July 2, 2026 21:16
@alanpeixinho alanpeixinho changed the title [WIP] feat/labs migration feat/labs migration Jul 2, 2026


def assign_lab_ids(builds_buf: list[Builds], tests_buf: list[Tests]) -> None:
"""Resolve each instance's real lab name to a labs.id and set its lab_id FK.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If the lab name is unique in the database, why not use that as a primary key? In that case you'd make a first query to check which labs are not in the database yet, a second query to update the table if needed, but you wouldn't need the third query to get the id of the newly added labs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Mostly to avoid string keys, since this is an internal key, we might benefit more from integer indices.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Makes sense. I am worried that we are adding one query here, one query there and soon we will have a bloat of unnecessary queries in the ingester, but it's fine for now

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It is a valid concern. If we plain to move some operations to ingestion time, we certainly are going to end up increasing "ingestion complexity". But I believe that, as long as we keep the ingester fast enough, simplifying the analysis step should be the goal.

tests.number_value AS tests_number_value,
tests.misc AS tests_misc,
tests.environment_compatible AS tests_environment_compatible,
COALESCE(test_labs.name, tests.misc ->> 'runtime') AS tests_lab,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think you forgot a TODO here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

Comment thread backend/kernelCI_app/queries/build.py Outdated
)
return list(result)
tests = []
for test in result:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why not assign directly to lab like before?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Mostly because lab is now the column name, but thinking again. I might change the column name for lab_id, this way we keep the lab name free to use, and follow more closely the the foreign key nomenclature.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changed mind, to really avoid rename hacking on the ORM, it was easier to just add a proper sql query instead.

result = []
for row in rows:
build_misc = row[11]
sanitized_build_misc = sanitize_dict(build_misc)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why does this not use the fallback?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

Comment thread backend/kernelCI_app/models.py Outdated
log_excerpt = models.CharField(max_length=16384, blank=True, null=True)
misc = models.JSONField(blank=True, null=True)
lab = models.ForeignKey(
Labs, db_constraint=False, null=True, on_delete=models.DO_NOTHING

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldn't we use db_constraint=True?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ditto for Tests

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I see all other tables are using db_constraint=False, so there must be a good reason for this, but I do not see it documented

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For the kci keys, we unfortunately have some missing keys in a few rows. But since lab has only internal keys, I believe we can safely enforce constraints on them. Good point.

Comment on lines +454 to +457
build_lab_summary = builds_summary.labs.get(lab)
if not build_lab_summary:
build_lab_summary = StatusCount()
builds_summary.labs[lab] = build_lab_summary

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copy pattern from previous chunk for consistence and readability

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The point is, this is the original implementation (pre-refactor)

    misc = sanitize_dict(build.misc) or {}
    lab = misc.get("lab", UNKNOWN_STRING)
    if lab:
        build_lab_summary = builds_summary.labs.get(lab)
        if not build_lab_summary:
            build_lab_summary = StatusCount()
            builds_summary.labs[lab] = build_lab_summary
        setattr(
            builds_summary.labs[lab],
            status_key,
            getattr(builds_summary.labs[lab], status_key) + 1,
        )

The conditional for lab is never false, because we are assigning UNKNOWN_STRING for lab.
However, it seems that this function is no longer used, and might be a legacy function used before performance improvements on HardwareDetails endpoints.
I will confirm it, and if this is the case, remove it.
We might as well plan to perform some dead code elimination, and check for similar cases.

@MarceloRobert MarceloRobert left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Haven't tested yet, but looks good so far

Comment thread backend/kernelCI_app/queries/build.py Outdated
from typing import Optional

from django.db.models.expressions import F
from django.db import connection

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

very nit: prefer connections['default']

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

Part of kernelci#1948

Signed-off-by: Alan Peixinho <alan.peixinho@profusion.mobi>
Part of kernelci#1948

Signed-off-by: Alan Peixinho <alan.peixinho@profusion.mobi>
    * For analysis queries we are going for lab column information
      first, and coalescing to json misc information.
    *  All COALESCE expressions are marked with TODO comments for removal
      after the lab_id backfill is complete.

    Closes kernelci#1948

Signed-off-by: Alan Peixinho <alan.peixinho@profusion.mobi>
Signed-off-by: Alan Peixinho <alan.peixinho@profusion.mobi>
Signed-off-by: Alan Peixinho <alan.peixinho@profusion.mobi>
@alanpeixinho
alanpeixinho force-pushed the feat/labs-migration branch from e0d17ed to 3f30f62 Compare July 15, 2026 18:51
instance.unfiltered_labs["build"].add(build_misc.get("lab", UNKNOWN_STRING))
instance.unfiltered_labs["build"].add(
row_data.get("build_lab") or UNKNOWN_STRING
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

here the code we talked about. Not sure if this conditional should be removed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This one is tricky.
Post-migration we no longer read build_misc, so we can't distinguish "no misc at all" from "misc present but no lab" — we only have the lab field. And it's odd to branch on how lab is missing.
I think we should standardize: any missing lab as UNKNOWN.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If we do find a reason to treat them differently, we should include an explicit rule on this.

SELECT
t.misc->>'runtime' AS lab,
-- TODO remove misc->>'runtime' fallback after lab backfill
COALESCE(l.name, t.misc->>'runtime', t.origin) AS lab,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why t.origin here ?
If we have neither a laboratory nor a runtime, won't the WHERE clause you wrote will filter out the record?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Nice catch. Missed removing some test here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed

Rename Builds/Tests FK field from lab to lab_id so lab is free for
annotated lab names without conflicting with the ORM relation.

Signed-off-by: Alan Peixinho <alan.peixinho@profusion.mobi>
Keep lab as the standard Django FK field and resolve build test lab names
via raw SQL, matching the hardware query pattern.

Signed-off-by: Alan Peixinho <alan.peixinho@profusion.mobi>
Enforce the lab FK db_constraint on builds and tests, drop the unused
lab_id from the build tests response, and fix the tree query lab TODO.

Signed-off-by: Alan Peixinho <alan.peixinho@profusion.mobi>
Signed-off-by: Alan Peixinho <alan.peixinho@profusion.mobi>
Signed-off-by: Alan Peixinho <alan.peixinho@profusion.mobi>
Comment on lines +194 to +224
def assign_lab_ids(builds_buf: list[Builds], tests_buf: list[Tests]) -> None:
"""Resolve each instance's real lab name to a labs.id and set its lab_id FK.

Select-first so we only INSERT genuinely new labs (avoids burning the id
sequence on every flush). New labs are committed outside the fact-insert
transaction (autocommit).
"""
objs = [*builds_buf, *tests_buf]
names = {name for obj in objs if (name := obj._lab_name)}

id_map: dict[str, int] = {}
if names:
with connections["default"].cursor() as cursor:
cursor.execute(
"SELECT id, name FROM labs WHERE name = ANY(%s)", [list(names)]
)
id_map = {name: lab_id for lab_id, name in cursor.fetchall()}

missing = [name for name in names if name not in id_map]
if missing:
cursor.executemany(
"INSERT INTO labs (name) VALUES (%s) ON CONFLICT (name) DO NOTHING",
[(name,) for name in missing],
)
cursor.execute(
"SELECT id, name FROM labs WHERE name = ANY(%s)", [missing]
)
id_map.update({name: lab_id for lab_id, name in cursor.fetchall()})

for obj in objs:
obj.lab_id = id_map.get(obj._lab_name) if obj._lab_name else None

@tales-aparecida tales-aparecida Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I was a bit concerned with the 3 queries, so I've asked gemini to try to fold them into a single query, please review

Suggested change
def assign_lab_ids(builds_buf: list[Builds], tests_buf: list[Tests]) -> None:
"""Resolve each instance's real lab name to a labs.id and set its lab_id FK.
Select-first so we only INSERT genuinely new labs (avoids burning the id
sequence on every flush). New labs are committed outside the fact-insert
transaction (autocommit).
"""
objs = [*builds_buf, *tests_buf]
names = {name for obj in objs if (name := obj._lab_name)}
id_map: dict[str, int] = {}
if names:
with connections["default"].cursor() as cursor:
cursor.execute(
"SELECT id, name FROM labs WHERE name = ANY(%s)", [list(names)]
)
id_map = {name: lab_id for lab_id, name in cursor.fetchall()}
missing = [name for name in names if name not in id_map]
if missing:
cursor.executemany(
"INSERT INTO labs (name) VALUES (%s) ON CONFLICT (name) DO NOTHING",
[(name,) for name in missing],
)
cursor.execute(
"SELECT id, name FROM labs WHERE name = ANY(%s)", [missing]
)
id_map.update({name: lab_id for lab_id, name in cursor.fetchall()})
for obj in objs:
obj.lab_id = id_map.get(obj._lab_name) if obj._lab_name else None
def assign_lab_ids(builds_buf: list[Builds], tests_buf: list[Tests]) -> None:
"""Resolve each instance's real lab name to a labs.id and set its lab_id FK.
Uses a single optimized CTE join to insert missing records and fetch all IDs
in 1 round-trip without sequence waste or ORM overhead.
"""
objs = [*builds_buf, *tests_buf]
names = list({obj._lab_name for obj in objs if getattr(obj, "_lab_name", None)})
if not names:
for obj in objs:
obj.lab_id = None
return
query = """
WITH input_names AS (
-- 1. Unnest and deduplicate inputs in Postgres C-memory
SELECT DISTINCT unnest(%s::text[]) AS name
),
inserted AS (
-- 2. Anti-join via LEFT JOIN / IS NULL (Faster than NOT EXISTS)
INSERT INTO labs (name)
SELECT i.name
FROM input_names i
LEFT JOIN labs l ON l.name = i.name
WHERE l.name IS NULL
RETURNING id, name
)
-- 3. Combine newly inserted IDs with existing IDs
SELECT id, name FROM inserted
UNION ALL
SELECT l.id, l.name
FROM labs l
JOIN input_names i ON l.name = i.name;
"""
with connections["default"].cursor() as cursor:
cursor.execute(query, [names])
# Unpacks (id, name) tuples into a {name: id} dictionary directly
id_map = dict(cursor.fetchall())
for obj in objs:
lab_name = getattr(obj, "_lab_name", None)
obj.lab_id = id_map.get(lab_name) if lab_name else None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

great optimization but I think it might burn the id sequence in some cases, right? which is the concern of the original approach.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actually, it doesn't...

but it's great that you mentioned that! We still need need ON CONFLICT (name) DO NOTHING like we have in the original code to avoid the race condition.

Only when the race condition happens there will be ID burning on both the original and using the single query

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is tricky, because in the original solution, despite having tree queries, only the first simpler query would hit most of the time (since we expect to have the number of labs being significantly smaller than the number of builds or tests ).
With the unified query we are running 100% of the time a more complex query (I will take a look how this impact performance).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

On the topic of race condition (thanks for catching that), I believe this can be solved with a lock in the solution with split queries.

@tales-aparecida

Copy link
Copy Markdown

I'm concerned with how much overhead the labs will bring to the kcidb ingestion. either put it under a feature flag, so we can toggle it off quickly in case it's catastrophic, or show some metrics comparing the time to consume a few hundred payloads in a local development, before and after this MR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants