All of lore.kernel.org
 help / color / mirror / Atom feed
* [PATCH] toaster: Add recipe variables view
@ 2026-08-12 15:04 Paolo Wattebled
  2026-08-12 20:34 ` [bitbake-devel] " Richard Purdie
  0 siblings, 1 reply; 4+ messages in thread
From: Paolo Wattebled @ 2026-08-12 15:04 UTC (permalink / raw)
  To: bitbake-devel; +Cc: Paolo Wattebled

Toaster records global build variables but does not expose the final
values associated with individual recipes.

Collect variables defined or modified by recipes and their bbappends,
resolve active overrides and expansions, and store one compressed JSON
snapshot in the database for each build and recipe. Keep the variable
count separately to avoid decompressing snapshots in recipe-list
queries.

Expose the values through a searchable and paginated Variables tab and
show their counts in the built-recipes table and recipe package view.
Preserve compatibility with older dependency payloads and invalidate
stale BitBake caches after extending the cached recipe information.

Tests cover variable collection, cache transport, database persistence,
backward compatibility, searching, pagination, escaping, and counts.

AI-Generated: Uses GitHub Copilot and OpenCode with GPT-5.6 Sol
Signed-off-by: Paolo Wattebled <paolo.wattebled@savoirfairelinux.com>
---
 bin/bitbake-selftest                          |   1 +
 lib/bb/cache.py                               |   2 +-
 lib/bb/cache_extra.py                         |  66 +++++++-
 lib/bb/cooker.py                              |   6 +-
 lib/bb/tests/cache_extra.py                   | 141 ++++++++++++++++
 lib/bb/ui/buildinfohelper.py                  |  21 ++-
 .../orm/migrations/0022_recipevariable.py     |  27 +++
 lib/toaster/orm/models.py                     |  10 ++
 lib/toaster/tests/db/test_db.py               | 101 ++++++++++++
 lib/toaster/tests/views/test_views.py         | 155 +++++++++++++++++-
 lib/toaster/toastergui/buildtables.py         |  11 +-
 .../templates/detail_pagination_bottom.html   |   4 +-
 .../templates/detail_search_header.html       |   2 +-
 lib/toaster/toastergui/templates/recipe.html  |  32 ++++
 .../toastergui/templates/recipe_packages.html |   7 +
 lib/toaster/toastergui/views.py               |  68 +++++++-
 16 files changed, 642 insertions(+), 12 deletions(-)
 create mode 100644 lib/bb/tests/cache_extra.py
 create mode 100644 lib/toaster/orm/migrations/0022_recipevariable.py

diff --git a/bin/bitbake-selftest b/bin/bitbake-selftest
index fb7c57dd8..1b09815cd 100755
--- a/bin/bitbake-selftest
+++ b/bin/bitbake-selftest
@@ -21,6 +21,7 @@ except RuntimeError as exc:
     sys.exit(str(exc))
 
 tests = ["bb.tests.codeparser",
+         "bb.tests.cache_extra",
          "bb.tests.color",
          "bb.tests.cooker",
          "bb.tests.cow",
diff --git a/lib/bb/cache.py b/lib/bb/cache.py
index 2361c5684..33fa61936 100644
--- a/lib/bb/cache.py
+++ b/lib/bb/cache.py
@@ -28,7 +28,7 @@ import shutil
 
 logger = logging.getLogger("BitBake.Cache")
 
-__cache_version__ = "156"
+__cache_version__ = "157"
 
 def getCacheFile(path, filename, mc, data_hash):
     mcspec = ''
diff --git a/lib/bb/cache_extra.py b/lib/bb/cache_extra.py
index bf4226d16..95a13c402 100644
--- a/lib/bb/cache_extra.py
+++ b/lib/bb/cache_extra.py
@@ -13,7 +13,47 @@
 # SPDX-License-Identifier: GPL-2.0-only
 #
 
+import logging
+import json
+import zlib
+
 from bb.cache import RecipeInfoCommon
+logger = logging.getLogger("BitBake.CacheExtra")
+
+
+def _winning_override(metadata, variable_name):
+    active = {}
+    metadata.need_overrides()
+    for override_variable, override in metadata.overridedata.get(variable_name, ()):
+        if (override in metadata.overridesset or
+                ':' in override and
+                set(override.split(':')).issubset(metadata.overridesset)):
+            active[override] = override_variable
+
+    match = None
+    modified = True
+    while modified:
+        modified = False
+        for override in metadata.overrides:
+            for candidate in active.copy():
+                if candidate.endswith(':' + override):
+                    active[candidate.removesuffix(':' + override)] = active.pop(candidate)
+                    modified = True
+                elif candidate == override:
+                    match = active.pop(candidate)
+    return match
+
+
+def _recipe_value_event(metadata, event):
+    if not event.get('file', '').endswith(('.bb', '.bbappend')):
+        return False
+    if event.get('flag') not in (None, '_defaultval'):
+        return False
+    operation = event.get('op', '')
+    if '[' not in operation:
+        return True
+    override = operation.rsplit('[', 1)[1].removesuffix(']')
+    return set(override.split(':')).issubset(metadata.overridesset)
 
 class HobRecipeInfo(RecipeInfoCommon):
     __slots__ = ()
@@ -27,7 +67,7 @@ class HobRecipeInfo(RecipeInfoCommon):
     # that this class will provide
     cachefields = ['summary', 'license', 'section',
             'description', 'homepage', 'bugtracker',
-            'prevision', 'files_info']
+            'prevision', 'files_info', 'recipe_variables']
 
     def __init__(self, filename, metadata):
 
@@ -39,6 +79,28 @@ class HobRecipeInfo(RecipeInfoCommon):
         self.bugtracker = self.getvar('BUGTRACKER', metadata)
         self.prevision = self.getvar('PR', metadata)
         self.files_info = self.getvar('FILES_INFO', metadata)
+        recipe_variables = {}
+        for variable_name in metadata:
+            try:
+                if ':' in variable_name:
+                    continue
+                history = metadata.varhistory.variable(variable_name)
+                winning_override = _winning_override(metadata, variable_name)
+                if winning_override:
+                    history += metadata.varhistory.variable(winning_override)
+                if not any(_recipe_value_event(metadata, event) for event in history):
+                    continue
+                if (metadata.getVarFlag(variable_name, 'func', False) or
+                        winning_override and
+                        metadata.getVarFlag(winning_override, 'func', False)):
+                    continue
+                value = metadata.getVar(variable_name, True)
+                recipe_variables[variable_name] = '' if value is None else str(value)
+            except Exception as exc:
+                logger.debug("Omitting recipe variable %s from %s after %s",
+                             variable_name, filename, type(exc).__name__)
+        self.recipe_variables = zlib.compress(
+            json.dumps(recipe_variables, separators=(',', ':')).encode('utf-8'))
 
     @classmethod
     def init_cacheData(cls, cachedata):
@@ -51,6 +113,7 @@ class HobRecipeInfo(RecipeInfoCommon):
         cachedata.bugtracker = {}
         cachedata.prevision = {}
         cachedata.files_info = {}
+        cachedata.recipe_variables = {}
 
     def add_cacheData(self, cachedata, fn):
         cachedata.summary[fn] = self.summary
@@ -61,3 +124,4 @@ class HobRecipeInfo(RecipeInfoCommon):
         cachedata.bugtracker[fn] = self.bugtracker
         cachedata.prevision[fn] = self.prevision
         cachedata.files_info[fn] = self.files_info
+        cachedata.recipe_variables[fn] = self.recipe_variables
diff --git a/lib/bb/cooker.py b/lib/bb/cooker.py
index 4b6ba3196..fe80af22d 100644
--- a/lib/bb/cooker.py
+++ b/lib/bb/cooker.py
@@ -270,7 +270,8 @@ class BBCooker:
         if hasattr(self, "data"):
             consolelog = self.data.getVar("BB_CONSOLELOG")
 
-        if CookerFeatures.BASEDATASTORE_TRACKING in self.featureset:
+        if (CookerFeatures.BASEDATASTORE_TRACKING in self.featureset or
+                CookerFeatures.HOB_EXTRA_CACHES in self.featureset):
             self.enableDataTracking()
 
         caches_name_array = ['bb.cache:CoreRecipeInfo']
@@ -310,7 +311,8 @@ class BBCooker:
 
         self.data.setVar('BB_CMDLINE', self.ui_cmdline)
 
-        if CookerFeatures.BASEDATASTORE_TRACKING in self.featureset:
+        if (CookerFeatures.BASEDATASTORE_TRACKING in self.featureset and
+                CookerFeatures.HOB_EXTRA_CACHES not in self.featureset):
             self.disableDataTracking()
 
         for mc in self.databuilder.mcdata.values():
diff --git a/lib/bb/tests/cache_extra.py b/lib/bb/tests/cache_extra.py
new file mode 100644
index 000000000..12a82f56a
--- /dev/null
+++ b/lib/bb/tests/cache_extra.py
@@ -0,0 +1,141 @@
+#
+# BitBake Tests for extra cache data
+#
+# SPDX-License-Identifier: GPL-2.0-only
+#
+
+import unittest
+import json
+import zlib
+
+import bb.data
+import bb.parse
+from bb.cache_extra import HobRecipeInfo
+
+
+class HobRecipeInfoTest(unittest.TestCase):
+
+    @staticmethod
+    def metadata():
+        metadata = bb.data.init()
+        metadata.enableTracking()
+        return metadata
+
+    def test_recipe_variables(self):
+        metadata = self.metadata()
+        metadata.setVar('TEXT', '${VALUE}', file='test.bb', line=1)
+        metadata.setVar('VALUE', 'expanded', file='test.bb', line=2)
+        metadata.setVar('EMPTY', '', file='test.bb', line=3)
+        metadata.setVar('NUMBER', 7, file='test.bbappend', line=1)
+        metadata.setVar('SPECIAL', 'café\nline\x00end',
+                        file='test.bb', line=4)
+        metadata.setVar('INHERITED', 'global', file='conf/bitbake.conf', line=1)
+        metadata.setVar('do_function', 'echo test', file='test.bb', line=5)
+        metadata.setVarFlag('do_function', 'func', True)
+
+        info = HobRecipeInfo('test.bb', metadata)
+        variables = json.loads(zlib.decompress(info.recipe_variables))
+
+        self.assertEqual(variables['TEXT'], 'expanded')
+        self.assertEqual(variables['EMPTY'], '')
+        self.assertEqual(variables['NUMBER'], '7')
+        self.assertEqual(variables['SPECIAL'], 'café\nline\x00end')
+        self.assertNotIn('INHERITED', variables)
+        self.assertNotIn('do_function', variables)
+
+    def test_recipe_variable_operations_are_included(self):
+        metadata = self.metadata()
+        metadata.setVar('TEXT', 'global', file='conf/bitbake.conf', line=1)
+        metadata.setVar('TEXT:append', ' recipe', file='test.bb', line=1)
+        metadata.setVar('TEXT:remove', 'global', file='test.bbappend', line=1)
+
+        info = HobRecipeInfo('test.bb', metadata)
+        variables = json.loads(zlib.decompress(info.recipe_variables))
+
+        self.assertEqual(variables['TEXT'], ' recipe')
+
+    def test_only_effective_overrides_are_included(self):
+        metadata = self.metadata()
+        metadata.setVar('OVERRIDES', 'machine', file='conf/bitbake.conf', line=1)
+        metadata.setVar('ACTIVE:machine', 'recipe', file='test.bb', line=1)
+        metadata.setVar('INACTIVE:other', 'recipe', file='test.bb', line=2)
+        metadata.setVar('GLOBAL', 'global', file='conf/bitbake.conf', line=2)
+        metadata.setVar('GLOBAL:append:other', ' recipe', file='test.bb', line=3)
+
+        info = HobRecipeInfo('test.bb', metadata)
+        variables = json.loads(zlib.decompress(info.recipe_variables))
+
+        self.assertEqual(variables['ACTIVE'], 'recipe')
+        self.assertNotIn('ACTIVE:machine', variables)
+        self.assertNotIn('INACTIVE', variables)
+        self.assertNotIn('INACTIVE:other', variables)
+        self.assertNotIn('GLOBAL', variables)
+
+    def test_weak_default_is_included(self):
+        metadata = self.metadata()
+        metadata.setVarFlag('PACKAGECONFIG', '_defaultval', 'feature',
+                            file='test.bb', line=1)
+
+        info = HobRecipeInfo('test.bb', metadata)
+        variables = json.loads(zlib.decompress(info.recipe_variables))
+
+        self.assertEqual(variables['PACKAGECONFIG'], 'feature')
+
+    def test_combined_override_is_included_under_logical_name(self):
+        metadata = self.metadata()
+        metadata.setVar('OVERRIDES', 'foo:bar:local',
+                        file='conf/bitbake.conf', line=1)
+        metadata.setVar('COMBINED:local:foo:bar', 'recipe',
+                        file='test.bb', line=1)
+
+        info = HobRecipeInfo('test.bb', metadata)
+        variables = json.loads(zlib.decompress(info.recipe_variables))
+
+        self.assertEqual(variables, {'COMBINED': 'recipe'})
+
+    def test_active_override_function_is_omitted(self):
+        metadata = self.metadata()
+        metadata.setVar('OVERRIDES', 'machine', file='conf/bitbake.conf', line=1)
+        metadata.setVar('do_work:machine', 'echo test', file='test.bb', line=1)
+        metadata.setVarFlag('do_work:machine', 'func', True,
+                            file='test.bb', line=1)
+
+        info = HobRecipeInfo('test.bb', metadata)
+        variables = json.loads(zlib.decompress(info.recipe_variables))
+
+        self.assertNotIn('do_work', variables)
+
+    def test_recipe_variables_survive_extra_cache_mapping(self):
+        metadata = self.metadata()
+        metadata.setVar('EMPTY', '', file='test.bb', line=1)
+        info = HobRecipeInfo('test.bb', metadata)
+        cachedata = type('CacheData', (), {})()
+
+        HobRecipeInfo.init_cacheData(cachedata)
+        info.add_cacheData(cachedata, 'test.bb')
+
+        self.assertIn('recipe_variables', HobRecipeInfo.cachefields)
+        variables = json.loads(zlib.decompress(
+            cachedata.recipe_variables['test.bb']))
+        self.assertEqual(variables['EMPTY'], '')
+
+    def test_recipe_variables_are_compressed_before_caching(self):
+        metadata = self.metadata()
+        metadata.setVar('LARGE', 'repeated-value-' * 1000,
+                        file='test.bb', line=1)
+
+        info = HobRecipeInfo('test.bb', metadata)
+
+        self.assertLess(len(info.recipe_variables), len(metadata.getVar('LARGE')))
+
+    def test_unexpandable_variable_is_omitted(self):
+        metadata = self.metadata()
+        metadata.setVar('BROKEN', '${BROKEN}', file='test.bb', line=1)
+
+        info = HobRecipeInfo('test.bb', metadata)
+
+        variables = json.loads(zlib.decompress(info.recipe_variables))
+        self.assertNotIn('BROKEN', variables)
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/lib/bb/ui/buildinfohelper.py b/lib/bb/ui/buildinfohelper.py
index 4ee45d67a..679f016bd 100644
--- a/lib/bb/ui/buildinfohelper.py
+++ b/lib/bb/ui/buildinfohelper.py
@@ -8,8 +8,10 @@
 
 import sys
 import bb
+import json
 import re
 import os
+import zlib
 
 import django
 from django.utils import timezone
@@ -31,7 +33,7 @@ from orm.models import Target_Image_File, TargetKernelFile, TargetSDKFile
 from orm.models import Variable, VariableHistory
 from orm.models import Package, Package_File, Target_Installed_Package, Target_File
 from orm.models import Task_Dependency, Package_Dependency
-from orm.models import Recipe_Dependency, Provides
+from orm.models import Recipe_Dependency, RecipeVariable, Provides
 from orm.models import Project, CustomImagePackage
 from orm.models import signal_runbuilds
 
@@ -1459,6 +1461,23 @@ class BuildInfoHelper(object):
                         t.save()
             self.internal_state['recipes'][pn] = recipe
 
+        if any('recipe_variables' in recipe_data
+               for recipe_data in event._depgraph['pn'].values()):
+            recipe_variables = []
+            for pn, recipe_data in event._depgraph['pn'].items():
+                variables = recipe_data.get('recipe_variables')
+                if variables is None:
+                    continue
+                recipe_variables.append(RecipeVariable(
+                    build=self.internal_state['build'],
+                    recipe=self.internal_state['recipes'][pn],
+                    variables=variables,
+                    variable_count=len(json.loads(zlib.decompress(variables)))))
+            with transaction.atomic():
+                RecipeVariable.objects.filter(
+                    build=self.internal_state['build']).delete()
+                RecipeVariable.objects.bulk_create(recipe_variables)
+
         # we'll not get recipes for key w/ values listed in ASSUME_PROVIDED
 
         assume_provided = self.server.runCommand(["getVariable", "ASSUME_PROVIDED"])[0].split()
diff --git a/lib/toaster/orm/migrations/0022_recipevariable.py b/lib/toaster/orm/migrations/0022_recipevariable.py
new file mode 100644
index 000000000..455a8ce23
--- /dev/null
+++ b/lib/toaster/orm/migrations/0022_recipevariable.py
@@ -0,0 +1,27 @@
+# Generated by Django 4.2.5 on 2026-08-11 00:00
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('orm', '0021_eventlogsimports'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='RecipeVariable',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('variables', models.BinaryField()),
+                ('variable_count', models.PositiveIntegerField(default=0)),
+                ('build', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='orm.build')),
+                ('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='orm.recipe')),
+            ],
+            options={
+                'unique_together': {('build', 'recipe')},
+            },
+        ),
+    ]
diff --git a/lib/toaster/orm/models.py b/lib/toaster/orm/models.py
index e2f488ed8..c240836cc 100644
--- a/lib/toaster/orm/models.py
+++ b/lib/toaster/orm/models.py
@@ -1346,6 +1346,16 @@ class Recipe(models.Model):
         unique_together = (("layer_version", "file_path", "pathflags"), )
 
 
+class RecipeVariable(models.Model):
+    build = models.ForeignKey(Build, on_delete=models.CASCADE)
+    recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
+    variables = models.BinaryField()
+    variable_count = models.PositiveIntegerField(default=0)
+
+    class Meta:
+        unique_together = (("build", "recipe"), )
+
+
 class Recipe_DependencyManager(models.Manager):
     use_for_related_fields = True
 
diff --git a/lib/toaster/tests/db/test_db.py b/lib/toaster/tests/db/test_db.py
index 072ab9436..7c48b1de3 100644
--- a/lib/toaster/tests/db/test_db.py
+++ b/lib/toaster/tests/db/test_db.py
@@ -24,6 +24,10 @@
 
 import sys
 import pytest
+from types import SimpleNamespace
+from unittest.mock import Mock, patch
+import json
+import zlib
 
 try:
     from StringIO import StringIO
@@ -34,6 +38,11 @@ from contextlib import contextmanager
 
 from django.core import management
 from django.test import TestCase
+from django.utils import timezone
+
+from bb.ui.buildinfohelper import BuildInfoHelper, ORMWrapper
+from orm.models import Build, Layer, Layer_Version, Project, Recipe
+from orm.models import RecipeVariable
 
 
 @contextmanager
@@ -56,3 +65,95 @@ class MigrationTest(TestCase):
 
         with capture(makemigrations) as output:
             self.assertEqual(output, "No changes detected\n")
+
+
+class RecipeVariableTest(TestCase):
+
+    def setUp(self):
+        now = timezone.now()
+        project = Project.objects.get_or_create_default_project()
+        self.build = Build.objects.create(
+            project=project, machine='', distro='', distro_version='',
+            started_on=now, completed_on=now, cooker_log_path='',
+            bitbake_version='', progress_item='')
+        layer = Layer.objects.create(name='test', layer_index_url='')
+        self.layer_version = Layer_Version.objects.create(
+            build=self.build, layer=layer, branch='', commit='',
+            local_path='/layer')
+
+    def _store(self, recipe_data):
+        if 'recipe_variables' in recipe_data:
+            recipe_data = dict(recipe_data)
+            recipe_data['recipe_variables'] = zlib.compress(json.dumps(
+                recipe_data['recipe_variables'], separators=(',', ':')
+            ).encode('utf-8'))
+        helper = BuildInfoHelper.__new__(BuildInfoHelper)
+        helper.internal_state = {'build': self.build, 'targets': []}
+        helper.orm_wrapper = ORMWrapper()
+        helper.server = Mock()
+        helper.server.runCommand.return_value = ['', None]
+        helper._get_layer_version_for_path = Mock(return_value=self.layer_version)
+        event = SimpleNamespace(_depgraph={
+            'layer-priorities': [],
+            'pn': {'test': dict({'filename': '/layer/test.bb'}, **recipe_data)},
+            'depends': {'test': []},
+            'tdepends': {},
+        })
+
+        helper.store_dependency_information(event)
+
+        return helper.internal_state['recipes']['test']
+
+    def test_dependency_information_stores_recipe_variables(self):
+        recipe = self._store({'recipe_variables': {'EMPTY': '', 'FOO': 'bar'}})
+
+        snapshot = RecipeVariable.objects.get(build=self.build, recipe=recipe)
+        self.assertEqual(
+            json.loads(zlib.decompress(snapshot.variables)),
+            {'EMPTY': '', 'FOO': 'bar'})
+        self.assertEqual(snapshot.variable_count, 2)
+
+    def test_old_payload_does_not_delete_recipe_variables(self):
+        recipe = self._store({'recipe_variables': {'FOO': 'bar'}})
+        self._store({})
+
+        snapshot = RecipeVariable.objects.get(build=self.build, recipe=recipe)
+        self.assertEqual(
+            json.loads(zlib.decompress(snapshot.variables)), {'FOO': 'bar'})
+
+    def test_dependency_information_replaces_recipe_variables(self):
+        recipe = self._store({'recipe_variables': {'FOO': 'old'}})
+        self._store({'recipe_variables': {'BAR': 'new'}})
+
+        snapshot = RecipeVariable.objects.get(build=self.build, recipe=recipe)
+        self.assertEqual(
+            json.loads(zlib.decompress(snapshot.variables)), {'BAR': 'new'})
+        self.assertEqual(snapshot.variable_count, 1)
+
+    def test_dependency_information_removes_stale_recipe_snapshot(self):
+        stale_recipe = Recipe.objects.create(
+            name='stale', version='', layer_version=self.layer_version,
+            file_path='stale.bb')
+        RecipeVariable.objects.create(
+            build=self.build, recipe=stale_recipe,
+            variables=zlib.compress(b'{"STALE":"value"}'))
+
+        current_recipe = self._store({'recipe_variables': {'FOO': 'bar'}})
+
+        self.assertFalse(RecipeVariable.objects.filter(
+            build=self.build, recipe=stale_recipe).exists())
+        self.assertTrue(RecipeVariable.objects.filter(
+            build=self.build, recipe=current_recipe).exists())
+
+    def test_dependency_information_rolls_back_failed_bulk_create(self):
+        recipe = self._store({'recipe_variables': {'FOO': 'old'}})
+        with patch.object(RecipeVariable.objects, 'bulk_create',
+                          side_effect=RuntimeError('injected failure')):
+            with self.assertRaisesRegex(RuntimeError, 'injected failure'):
+                self._store({'recipe_variables': {'FOO': 'new'}})
+
+        snapshots = RecipeVariable.objects.filter(build=self.build)
+        self.assertEqual(snapshots.count(), 1)
+        snapshot = snapshots.get(recipe=recipe)
+        self.assertEqual(
+            json.loads(zlib.decompress(snapshot.variables)), {'FOO': 'old'})
diff --git a/lib/toaster/tests/views/test_views.py b/lib/toaster/tests/views/test_views.py
index e1adfcf86..78695a151 100644
--- a/lib/toaster/tests/views/test_views.py
+++ b/lib/toaster/tests/views/test_views.py
@@ -10,14 +10,16 @@
 """Test cases for Toaster GUI and ReST."""
 
 import os
+import zlib
 import pytest
 from django.test import TestCase
 from django.test.client import RequestFactory
 from django.urls import reverse
 from django.db.models import Q
 
-from orm.models import Project, Package
+from orm.models import Build, Project, Package
 from orm.models import Layer_Version, Recipe
+from orm.models import RecipeVariable
 from orm.models import CustomImageRecipe
 from orm.models import CustomImagePackage
 
@@ -61,6 +63,14 @@ class ViewTests(TestCase):
         if BuildEnvironment.objects.count() == 0:
             BuildEnvironment.objects.create(betype=BuildEnvironment.TYPE_LOCAL)
 
+    @staticmethod
+    def _recipe_variables(build, recipe, values):
+        return RecipeVariable(
+            build=build, recipe=recipe,
+            variable_count=len(values),
+            variables=zlib.compress(json.dumps(
+                values, separators=(',', ':')).encode('utf-8')))
+
 
     def test_get_base_call_returns_html(self):
         """Basic test for all-projects view"""
@@ -90,6 +100,149 @@ class ViewTests(TestCase):
         self.assertTrue(name_found,
                         "project name not found in projects table")
 
+    def test_recipe_variables_tab_scopes_searches_and_escapes(self):
+        build = Build.objects.get(pk=1)
+        other_build = Build.objects.get(pk=2)
+        other_recipe = Recipe.objects.exclude(pk=self.recipe1.pk).first()
+        RecipeVariable.objects.bulk_create([
+            self._recipe_variables(build, self.recipe1, {
+                'SRC_URI': 'git://example.invalid/src',
+                'SPECIAL': '<script>alert(1)</script>',
+            }),
+            self._recipe_variables(other_build, self.recipe1, {
+                'OTHER_BUILD': 'hidden',
+            }),
+            self._recipe_variables(build, other_recipe, {
+                'OTHER_RECIPE': 'hidden',
+            }),
+        ])
+        url = reverse('recipe', args=(build.pk, self.recipe1.pk, '5'))
+
+        response = self.client.get(url, {
+            'count': 100,
+            'page': 1,
+            'orderby': 'variable_name:+',
+            'search': 'SRC_URI',
+        })
+
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'recipe.html')
+        self.assertContains(response, 'SRC_URI')
+        self.assertContains(response, 'git://example.invalid/src')
+        self.assertNotContains(response, 'OTHER_BUILD')
+        self.assertNotContains(response, 'OTHER_RECIPE')
+
+        response = self.client.get(url, {
+            'count': 100,
+            'page': 1,
+            'orderby': 'variable_name:+',
+        })
+        self.assertContains(response, 'Variables (2)')
+        self.assertContains(response, '&lt;script&gt;alert(1)&lt;/script&gt;')
+        self.assertNotContains(response, '<script>alert(1)</script>')
+
+    def test_recipe_variables_tab_redirects_paginates_and_handles_empty(self):
+        build = Build.objects.get(pk=1)
+        url = reverse('recipe', args=(build.pk, self.recipe1.pk, '5'))
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 302)
+        self.assertIn('count=100', response.url)
+        self.assertIn('orderby=variable_name%3A%2B', response.url)
+
+        RecipeVariable.objects.bulk_create([
+            self._recipe_variables(build, self.recipe1, {
+                'VAR_%02d' % index: str(index) for index in range(12)
+            })
+        ])
+        response = self.client.get(url, {
+            'count': 10,
+            'page': 2,
+            'orderby': 'variable_name:+',
+        })
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.context['objects'].paginator.count, 12)
+        self.assertEqual(response.context['objects'].number, 2)
+        self.assertContains(response, 'VAR_10')
+        self.assertContains(response, 'VAR_11')
+        self.assertNotContains(response, 'VAR_00')
+
+        RecipeVariable.objects.filter(build=build, recipe=self.recipe1).delete()
+        response = self.client.get(url, {
+            'count': 100,
+            'page': 1,
+            'orderby': 'variable_name:+',
+        })
+        self.assertContains(response, 'defines or modifies no variables')
+
+    def test_recipe_variables_tab_bounds_query_parameters(self):
+        build = Build.objects.get(pk=1)
+        RecipeVariable.objects.bulk_create([
+            self._recipe_variables(build, self.recipe1, {'SRC_URI': 'value'})
+        ])
+        url = reverse('recipe', args=(build.pk, self.recipe1.pk, '5'))
+
+        response = self.client.get(url, {
+            'count': 'alert(document.domain)',
+            'page': 1,
+            'orderby': 'variable_value:+',
+            'filter': 'variable_value__regex:(a+)+$',
+        })
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.context['objects'].paginator.per_page, 100)
+        self.assertNotContains(response, 'alert(document.domain)')
+        self.assertEqual(
+            [variable['variable_name']
+             for variable in response.context['objects'].object_list],
+            ['SRC_URI'])
+
+        response = self.client.get(url, {
+            'count': 100,
+            'page': 1,
+            'orderby': 'variable_name:+',
+            'search': ('SRC_URI ' * 20) + ('x' * 300),
+        })
+        self.assertLessEqual(len(response.context['search_term']), 256)
+        self.assertLessEqual(len(response.context['search_term'].split()), 16)
+
+    def test_built_recipes_table_shows_variable_count(self):
+        build = Build.objects.get(pk=1)
+        self._recipe_variables(build, self.recipe1, {
+            'FOO': 'one',
+            'BAR': 'two',
+        }).save()
+        url = reverse('recipes', args=(build.pk,))
+
+        response = self.client.get(url, {
+            'format': 'json',
+            'limit': 25,
+            'page': 1,
+        })
+        data = json.loads(response.content)
+        row = next(row for row in data['rows']
+                   if self.recipe1.name in row['name'])
+
+        self.assertIn('Variables', [column['title'] for column in data['columns']])
+        self.assertIn('>2</a>', row['variable_count'])
+
+    def test_recipe_packages_tab_shows_variable_count(self):
+        build = Build.objects.get(pk=1)
+        self._recipe_variables(build, self.recipe1, {
+            'FOO': 'one',
+            'BAR': 'two',
+        }).save()
+
+        response = self.client.get(reverse(
+            'recipe_packages', args=(build.pk, self.recipe1.pk)), {
+                'count': 10,
+                'page': 1,
+                'orderby': 'name:+',
+            })
+
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, 'Variables (2)')
+
     def test_typeaheads(self):
         """Test typeahead ReST API"""
         layers_url = reverse('xhr_layerstypeahead', args=(self.project.id,))
diff --git a/lib/toaster/toastergui/buildtables.py b/lib/toaster/toastergui/buildtables.py
index 327059d00..52c3dd779 100644
--- a/lib/toaster/toastergui/buildtables.py
+++ b/lib/toaster/toastergui/buildtables.py
@@ -239,7 +239,10 @@ class BuiltRecipesTable(BuildTablesMixin):
     def setup_queryset(self, *args, **kwargs):
         build = Build.objects.get(pk=kwargs['build_id'])
         self.static_context_extra['build'] = build
-        self.queryset = build.get_recipes()
+        self.queryset = build.get_recipes().annotate(
+            variable_count=Sum(
+                'recipevariable__variable_count',
+                filter=Q(recipevariable__build=build), default=0))
         self.queryset = self.queryset.order_by(self.default_orderby)
 
     def setup_columns(self, *args, **kwargs):
@@ -323,6 +326,12 @@ class BuiltRecipesTable(BuildTablesMixin):
                         hideable=False,
                         field_name="version")
 
+        self.add_column(
+            title="Variables",
+            field_name="variable_count",
+            static_data_name="variable_count",
+            static_data_template='<a href="{% url "recipe" extra.build.pk data.pk "5" %}">{{data.variable_count|default:0}}</a>')
+
         self.add_column(title="Dependencies",
                         static_data_name="dependencies",
                         static_data_template=depends_on_tmpl)
diff --git a/lib/toaster/toastergui/templates/detail_pagination_bottom.html b/lib/toaster/toastergui/templates/detail_pagination_bottom.html
index 15adfbc91..ddcba133e 100644
--- a/lib/toaster/toastergui/templates/detail_pagination_bottom.html
+++ b/lib/toaster/toastergui/templates/detail_pagination_bottom.html
@@ -41,8 +41,8 @@
 <script>
  $(document).ready(function() {
     // load data for number of entries to be displayed on page
-    if ({{request.GET.count}} != "") {
-      pagesize = {{request.GET.count}};
+    if ({{validated_pagesize|default:request.GET.count}} != "") {
+      pagesize = {{validated_pagesize|default:request.GET.count}};
     }
     $('.pagesize option').prop('selected', false)
                          .filter('[value="' + pagesize + '"]')
diff --git a/lib/toaster/toastergui/templates/detail_search_header.html b/lib/toaster/toastergui/templates/detail_search_header.html
index 7a9865908..0205b88f6 100644
--- a/lib/toaster/toastergui/templates/detail_search_header.html
+++ b/lib/toaster/toastergui/templates/detail_search_header.html
@@ -31,7 +31,7 @@ $(document).ready(function() {
         <div class="form-group">
           <div class="btn-group">
             <input id="search" class="form-control" type="text" placeholder="Search {{search_what}}" name="search" value="{% if request.GET.search %}{{request.GET.search}}{% endif %}">
-            <input type="hidden" value="name:+" name="orderby">
+            <input type="hidden" value="{{search_orderby|default:'name:+'}}" name="orderby">
             <input type="hidden" value="l" name="page">
             {% if request.GET.search %}
             <span class="remove-search-btn-detail-search search-clear glyphicon glyphicon-remove-circle"></span>
diff --git a/lib/toaster/toastergui/templates/recipe.html b/lib/toaster/toastergui/templates/recipe.html
index 4b5301b54..be40b58d4 100644
--- a/lib/toaster/toastergui/templates/recipe.html
+++ b/lib/toaster/toastergui/templates/recipe.html
@@ -52,6 +52,13 @@
                 Reverse build dependencies ({{object.r_dependencies_depends.all.count}})
             </a>
         </li>
+        <li class="{{tab_states.5}}">
+            <a href="{% url "recipe" build.pk object.id "5" %}">
+                <span class="glyphicon glyphicon-question-sign get-help" title="Final values
+                    defined or modified by this recipe and its bbappends"></span>
+                Variables ({{recipe_variable_count}})
+            </a>
+        </li>
     </ul>
     <div class="tab-content">
         <div class="tab-pane {{tab_states.1}}" id="information">
@@ -277,6 +284,31 @@
             {% endif %}
 
         </div>
+        <div class="tab-pane {{tab_states.5}}" id="variables">
+            {% if not objects and not request.GET.search %}
+            <div class="alert alert-info">
+                <strong>{{object.name}}_{{object.version}}</strong> defines or modifies no variables.
+            </div>
+            {% else %}
+                {% with "variables" as search_what %}
+                {% include "detail_search_header.html" %}
+                {% endwith %}
+                {% if objects %}
+                <table class="table table-bordered table-hover tablesorter" id="otable">
+                    {% include "detail_sorted_header.html" %}
+                    <tbody>
+                    {% for variable in objects %}
+                        <tr>
+                            <td>{{variable.variable_name}}</td>
+                            <td>{{variable.variable_value}}</td>
+                        </tr>
+                    {% endfor %}
+                    </tbody>
+                </table>
+                {% include "detail_pagination_bottom.html" %}
+                {% endif %}
+            {% endif %}
+        </div>
     </div>
 </div>
 
diff --git a/lib/toaster/toastergui/templates/recipe_packages.html b/lib/toaster/toastergui/templates/recipe_packages.html
index 37a586f38..c39ae6b5e 100644
--- a/lib/toaster/toastergui/templates/recipe_packages.html
+++ b/lib/toaster/toastergui/templates/recipe_packages.html
@@ -51,6 +51,13 @@
                 Reverse build dependencies ({{recipe.r_dependencies_depends.all.count}})
             </a>
         </li>
+        <li>
+            <a href="{% url "recipe" build.pk recipe.id "5" %}">
+                <span class="glyphicon glyphicon-question-sign get-help" title="Final values
+                    defined or modified by this recipe and its bbappends"></span>
+                Variables ({{variable_count}})
+            </a>
+        </li>
     </ul>
     <div class="tab-content">
 {#        <div class="tab-pane active" id="packages-built" name="packages-built">#}
diff --git a/lib/toaster/toastergui/views.py b/lib/toaster/toastergui/views.py
index 061e6436c..26cf5ca26 100644
--- a/lib/toaster/toastergui/views.py
+++ b/lib/toaster/toastergui/views.py
@@ -10,6 +10,7 @@ import ast
 import re
 import subprocess
 import sys
+import zlib
 
 import bb.cooker
 from bb.ui import toasterui
@@ -20,6 +21,7 @@ from django.db import IntegrityError
 from django.shortcuts import render, redirect, get_object_or_404, HttpResponseRedirect
 from django.utils.http import urlencode
 from orm.models import Build, Target, Task, Layer, Layer_Version, Recipe
+from orm.models import RecipeVariable
 from orm.models import LogMessage, Variable, Package_Dependency, Package
 from orm.models import Task_Dependency, Package_File
 from orm.models import Target_Installed_Package, Target_File
@@ -619,10 +621,13 @@ def recipe(request, build_id, recipe_id, active_tab="1"):
     layer  = Layer.objects.get(pk=layer_version.layer_id)
     tasks_list  = Task.objects.filter(recipe_id = recipe_id, build_id = build_id).exclude(order__isnull=True).exclude(task_name__endswith='_setscene').exclude(outcome=Task.OUTCOME_NA)
     package_count = Package.objects.filter(recipe_id = recipe_id).filter(build_id = build_id).filter(size__gte=0).count()
+    recipe_variable_count = RecipeVariable.objects.filter(
+        build_id=build_id, recipe_id=recipe_id).values_list(
+            'variable_count', flat=True).first() or 0
 
-    if active_tab != '1' and active_tab != '3' and active_tab != '4' :
+    if active_tab not in ('1', '3', '4', '5'):
         active_tab = '1'
-    tab_states = {'1': '', '3': '', '4': ''}
+    tab_states = {'1': '', '3': '', '4': '', '5': ''}
     tab_states[active_tab] = 'active'
 
     context = {
@@ -632,8 +637,63 @@ def recipe(request, build_id, recipe_id, active_tab="1"):
             'layer'   : layer,
             'tasks'   : tasks_list,
             'package_count' : package_count,
+            'recipe_variable_count' : recipe_variable_count,
             'tab_states' : tab_states,
     }
+
+    if active_tab == '5':
+        (requested_pagesize, requested_orderby) = _get_parameters_values(
+            request, 100, 'variable_name:+')
+        pagesize = requested_pagesize \
+            if str(requested_pagesize) in ('10', '25', '50', '100', '150') \
+            else 100
+        orderby = requested_orderby \
+            if requested_orderby in ('variable_name:+', 'variable_name:-') \
+            else 'variable_name:+'
+        mandatory_parameters = {
+            'count': pagesize,
+            'page': 1,
+            'orderby': orderby,
+        }
+        if _verify_parameters(request.GET, mandatory_parameters):
+            return _redirect_parameters(
+                'recipe', request.GET, mandatory_parameters,
+                build_id=build_id, recipe_id=recipe_id, active_tab='5')
+
+        search_term = request.GET.get('search', '')[:256]
+        search_term = ' '.join(search_term.split()[:16])
+        snapshot = RecipeVariable.objects.filter(
+            build_id=build_id, recipe_id=recipe_id).first()
+        variables = []
+        if snapshot:
+            values = json.loads(zlib.decompress(snapshot.variables).decode('utf-8'))
+            variables = [
+                {'variable_name': name, 'variable_value': value}
+                for name, value in values.items()
+                if not search_term or search_term.lower() in name.lower()
+                or search_term.lower() in value.lower()
+            ]
+        variables.sort(
+            key=lambda variable: variable['variable_name'],
+            reverse=orderby.endswith(':-'))
+        context['variable_count'] = len(variables)
+        context['objects'] = _build_page_range(
+            Paginator(variables, pagesize), request.GET.get('page', 1))
+        context['object_count'] = context['variable_count']
+        context['validated_pagesize'] = pagesize
+        context['search_term'] = search_term
+        context['search_orderby'] = 'variable_name:+'
+        context['tablecols'] = [
+            {
+                'name': 'Variable',
+                'orderfield': _get_toggle_order(request, 'variable_name'),
+                'ordericon': _get_toggle_order_icon(request, 'variable_name'),
+                'orderkey': 'variable_name',
+            },
+            {'name': 'Value'},
+        ]
+        _set_parameters_values(pagesize, orderby, request)
+
     return toaster_render(request, template, context)
 
 def recipe_packages(request, build_id, recipe_id):
@@ -651,6 +711,9 @@ def recipe_packages(request, build_id, recipe_id):
     recipe_object = Recipe.objects.get(pk=recipe_id)
     queryset = Package.objects.filter(recipe_id = recipe_id).filter(build_id = build_id).filter(size__gte=0)
     package_count = queryset.count()
+    variable_count = RecipeVariable.objects.filter(
+        build_id=build_id, recipe_id=recipe_id).values_list(
+            'variable_count', flat=True).first() or 0
     queryset = _get_queryset(Package, queryset, filter_string, search_term, ordering_string, 'name')
 
     packages = _build_page_range(Paginator(queryset, pagesize),request.GET.get('page', 1))
@@ -660,6 +723,7 @@ def recipe_packages(request, build_id, recipe_id):
             'recipe'  : recipe_object,
             'objects'  : packages,
             'object_count' : package_count,
+            'variable_count' : variable_count,
             'tablecols':[
                 {
                     'name':'Package',
-- 
2.55.0



^ permalink raw reply related	[flat|nested] 4+ messages in thread

* Re: [bitbake-devel] [PATCH] toaster: Add recipe variables view
  2026-08-12 15:04 [PATCH] toaster: Add recipe variables view Paolo Wattebled
@ 2026-08-12 20:34 ` Richard Purdie
  2026-08-13 15:31   ` Paolo Wattebled
  0 siblings, 1 reply; 4+ messages in thread
From: Richard Purdie @ 2026-08-12 20:34 UTC (permalink / raw)
  To: paolo.wattebled, bitbake-devel

On Wed, 2026-08-12 at 11:04 -0400, Paolo Wattebled via lists.openembedded.org wrote:
> Toaster records global build variables but does not expose the final
> values associated with individual recipes.
> 
> Collect variables defined or modified by recipes and their bbappends,
> resolve active overrides and expansions, and store one compressed JSON
> snapshot in the database for each build and recipe. Keep the variable
> count separately to avoid decompressing snapshots in recipe-list
> queries.
> 
> Expose the values through a searchable and paginated Variables tab and
> show their counts in the built-recipes table and recipe package view.
> Preserve compatibility with older dependency payloads and invalidate
> stale BitBake caches after extending the cached recipe information.
> 
> Tests cover variable collection, cache transport, database persistence,
> backward compatibility, searching, pagination, escaping, and counts.
> 
> AI-Generated: Uses GitHub Copilot and OpenCode with GPT-5.6 Sol
> Signed-off-by: Paolo Wattebled <paolo.wattebled@savoirfairelinux.com>
> ---
>  bin/bitbake-selftest                          |   1 +
>  lib/bb/cache.py                               |   2 +-
>  lib/bb/cache_extra.py                         |  66 +++++++-
>  lib/bb/cooker.py                              |   6 +-
>  lib/bb/tests/cache_extra.py                   | 141 ++++++++++++++++
>  lib/bb/ui/buildinfohelper.py                  |  21 ++-
>  .../orm/migrations/0022_recipevariable.py     |  27 +++
>  lib/toaster/orm/models.py                     |  10 ++
>  lib/toaster/tests/db/test_db.py               | 101 ++++++++++++
>  lib/toaster/tests/views/test_views.py         | 155 +++++++++++++++++-
>  lib/toaster/toastergui/buildtables.py         |  11 +-
>  .../templates/detail_pagination_bottom.html   |   4 +-
>  .../templates/detail_search_header.html       |   2 +-
>  lib/toaster/toastergui/templates/recipe.html  |  32 ++++
>  .../toastergui/templates/recipe_packages.html |   7 +
>  lib/toaster/toastergui/views.py               |  68 +++++++-
>  16 files changed, 642 insertions(+), 12 deletions(-)
>  create mode 100644 lib/bb/tests/cache_extra.py
>  create mode 100644 lib/toaster/orm/migrations/0022_recipevariable.py

At this point you're basically dumping out the entire datastore for
every possible recipe. Due to the size of the data, I suspect it is
just as expensive to reparse as it is to read the stored compressed
data and it isn't really a cache any more. The presence of that data
will slow the rest of the system down.

Have you looked into the performance (in speed, memory and disk space)
implications of this?

Also, do you know people actively using toaster?

Cheers,

Richard






^ permalink raw reply	[flat|nested] 4+ messages in thread

* Re: [bitbake-devel] [PATCH] toaster: Add recipe variables view
  2026-08-12 20:34 ` [bitbake-devel] " Richard Purdie
@ 2026-08-13 15:31   ` Paolo Wattebled
  2026-08-13 15:58     ` Richard Purdie
  0 siblings, 1 reply; 4+ messages in thread
From: Paolo Wattebled @ 2026-08-13 15:31 UTC (permalink / raw)
  To: Richard Purdie; +Cc: bitbake-devel

Hi Richard,

Thanks for raising this. I ran a quick comparison with and without the change.
Toaster startup and recipe parsing were effectively unchanged (2m46.554s vs
2m46.835s). A cached imx-image-core build took 4m23.648s versus 4m07.387s,
about 16 seconds slower. After vacuuming, both SQLite databases rounded to 74
MiB, including 598 per-recipe variable snapshots. I understand that some of
this data is stored twice. However, I only store variables changed by recipes
or bbappends, and I compress them before saving them. In my test, this did not
noticeably increase the database size.

Our intended use of Toaster is as the backend for an MCP server for AI agents.
Rather than having agents repeatedly parse and search recipe files or invoke
bitbake-getvar, the server would expose resolved, build-specific metadata
through a stable API. The MCP layer is still under development, but per-recipe
variable values are one of its planned endpoints. We chose Toaster because it
already models builds, recipes, packages and variables; extending it avoids
creating a separate metadata store. It will run headlessly in this use case,
with the MCP server translating between Toaster’s API and the agent.

Regards,
Paolo Wattebled


----- Original Message -----
From: "Richard Purdie" <richard.purdie@linuxfoundation.org>
To: "Paolo Wattebled" <paolo.wattebled@savoirfairelinux.com>, bitbake-devel@lists.openembedded.org
Sent: Wednesday, August 12, 2026 4:34:24 PM
Subject: Re: [bitbake-devel] [PATCH] toaster: Add recipe variables view

On Wed, 2026-08-12 at 11:04 -0400, Paolo Wattebled via lists.openembedded.org wrote:
> Toaster records global build variables but does not expose the final
> values associated with individual recipes.
> 
> Collect variables defined or modified by recipes and their bbappends,
> resolve active overrides and expansions, and store one compressed JSON
> snapshot in the database for each build and recipe. Keep the variable
> count separately to avoid decompressing snapshots in recipe-list
> queries.
> 
> Expose the values through a searchable and paginated Variables tab and
> show their counts in the built-recipes table and recipe package view.
> Preserve compatibility with older dependency payloads and invalidate
> stale BitBake caches after extending the cached recipe information.
> 
> Tests cover variable collection, cache transport, database persistence,
> backward compatibility, searching, pagination, escaping, and counts.
> 
> AI-Generated: Uses GitHub Copilot and OpenCode with GPT-5.6 Sol
> Signed-off-by: Paolo Wattebled <paolo.wattebled@savoirfairelinux.com>
> ---
>  bin/bitbake-selftest                          |   1 +
>  lib/bb/cache.py                               |   2 +-
>  lib/bb/cache_extra.py                         |  66 +++++++-
>  lib/bb/cooker.py                              |   6 +-
>  lib/bb/tests/cache_extra.py                   | 141 ++++++++++++++++
>  lib/bb/ui/buildinfohelper.py                  |  21 ++-
>  .../orm/migrations/0022_recipevariable.py     |  27 +++
>  lib/toaster/orm/models.py                     |  10 ++
>  lib/toaster/tests/db/test_db.py               | 101 ++++++++++++
>  lib/toaster/tests/views/test_views.py         | 155 +++++++++++++++++-
>  lib/toaster/toastergui/buildtables.py         |  11 +-
>  .../templates/detail_pagination_bottom.html   |   4 +-
>  .../templates/detail_search_header.html       |   2 +-
>  lib/toaster/toastergui/templates/recipe.html  |  32 ++++
>  .../toastergui/templates/recipe_packages.html |   7 +
>  lib/toaster/toastergui/views.py               |  68 +++++++-
>  16 files changed, 642 insertions(+), 12 deletions(-)
>  create mode 100644 lib/bb/tests/cache_extra.py
>  create mode 100644 lib/toaster/orm/migrations/0022_recipevariable.py

At this point you're basically dumping out the entire datastore for
every possible recipe. Due to the size of the data, I suspect it is
just as expensive to reparse as it is to read the stored compressed
data and it isn't really a cache any more. The presence of that data
will slow the rest of the system down.

Have you looked into the performance (in speed, memory and disk space)
implications of this?

Also, do you know people actively using toaster?

Cheers,

Richard


^ permalink raw reply	[flat|nested] 4+ messages in thread

* Re: [bitbake-devel] [PATCH] toaster: Add recipe variables view
  2026-08-13 15:31   ` Paolo Wattebled
@ 2026-08-13 15:58     ` Richard Purdie
  0 siblings, 0 replies; 4+ messages in thread
From: Richard Purdie @ 2026-08-13 15:58 UTC (permalink / raw)
  To: Paolo Wattebled; +Cc: bitbake-devel

On Thu, 2026-08-13 at 11:31 -0400, Paolo Wattebled wrote:
> Thanks for raising this. I ran a quick comparison with and without the change.
> Toaster startup and recipe parsing were effectively unchanged (2m46.554s vs
> 2m46.835s). A cached imx-image-core build took 4m23.648s versus 4m07.387s,
> about 16 seconds slower. After vacuuming, both SQLite databases rounded to 74
> MiB, including 598 per-recipe variable snapshots. I understand that some of
> this data is stored twice. However, I only store variables changed by recipes
> or bbappends, and I compress them before saving them. In my test, this did not
> noticeably increase the database size.

This does raise a few more questions. I don't think you're covering all
the cases where variables change in recipes as *every* recipe will have
recipe specific values and 598 seems like a low number.

You appear to be only considering where overrides are used but
overrides aren't the only way variables can be changed as =+ and so on
are possible, or the value can just be overwritten.

I don't mind toaster reading out and storing the data but you may as
well just read the data, there is little point in changing the cache
structures to add/cache it?

It does also raise questions about whether you want the variable
history information too? (or will in future?)

> Our intended use of Toaster is as the backend for an MCP server for AI agents.
> Rather than having agents repeatedly parse and search recipe files or invoke
> bitbake-getvar, the server would expose resolved, build-specific metadata
> through a stable API. The MCP layer is still under development, but per-recipe
> variable values are one of its planned endpoints. We chose Toaster because it
> already models builds, recipes, packages and variables; extending it avoids
> creating a separate metadata store. It will run headlessly in this use case,
> with the MCP server translating between Toaster’s API and the agent.

Thanks for the info, that does sound like an interesting use case for
it. It is reasons like this I've pushed back against deleting toaster!
Knowing a bit about your plans here does help me keep fighting to keep
that code!

I'd also note that your original patch is quite large and really should
make more increamental separte changes given the breadth of what it is
doing.

Cheers,

Richard


^ permalink raw reply	[flat|nested] 4+ messages in thread

end of thread, other threads:[~2026-08-13 15:58 UTC | newest]

Thread overview: 4+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-08-12 15:04 [PATCH] toaster: Add recipe variables view Paolo Wattebled
2026-08-12 20:34 ` [bitbake-devel] " Richard Purdie
2026-08-13 15:31   ` Paolo Wattebled
2026-08-13 15:58     ` Richard Purdie

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.