Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,30 @@ Use it only with a new database or when replacing all existing data is intention
[KitchenPC Samples repository](https://github.com/KitchenPC/Samples) for a PostgreSQL initializer
and a small sample dataset.

Optional DBContext Capabilities
====

`DBContext` initializes its autocomplete index, ingredient-text parser, and recipe-modeler graph
by default. Applications that do not use every feature can select only the in-memory capabilities
they need while retaining ordinary database-backed operations:

```csharp
var context = DBContext.Configure
.Adapter(/* database adapter configuration */)
.Capabilities(DBContextCapabilities.IngredientParsing)
.Identity(() => AuthIdentity.Anonymous)
.Create();
```

The available flags are `IngredientAutocomplete`, `IngredientParsing`, and `RecipeModeler`.
`DBContextCapabilities.All` is the default for backward compatibility. Calling an API whose
capability was not enabled throws `ContextCapabilityNotEnabledException`. Recipe aggregation uses
the in-memory graph when the modeler is enabled and falls back to loading recipes from the database
when it is disabled.

See [DBContext capability profiles](docs/context-capabilities.md) for capability requirements and
sample-data startup and memory measurements.

Packages and Releases
====

Expand Down
67 changes: 67 additions & 0 deletions docs/context-capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# DBContext capability profiles

`DBContext` has three independently configurable in-memory capabilities:

| Capability | In-memory data | Required by |
| --- | --- | --- |
| `IngredientAutocomplete` | Ingredient-name substring index | `AutocompleteIngredient` |
| `IngredientParsing` | Ingredient, unit, form, prep-note, anomaly, and numeric grammar indexes | `ParseIngredient` and `ParseIngredientUsage` |
| `RecipeModeler` | Ratings, recipes, ingredients, tags, and suggestion graph | Recipe modeling APIs |

Database-backed recipe search, recipe details, ingredients, menus, queues, and shopping lists do
not require these flags. Recipe aggregation uses the modeler graph when available and loads the
requested recipes from the database when the modeler is disabled.

All capabilities are enabled by default for compatibility. Applications can select a smaller
profile during configuration:

```csharp
var context = DBContext.Configure
.Adapter(/* database adapter configuration */)
.Capabilities(DBContextCapabilities.IngredientParsing)
.Identity(() => AuthIdentity.Anonymous)
.Create();
```

Calling an API whose capability was not enabled throws
`ContextCapabilityNotEnabledException`, whose `Capability` property identifies the missing flag.

## Sample-data measurements

The following measurements compare the previous implementation with each capability profile. They
were collected on Linux with .NET 10 and PostgreSQL 17 using the KitchenPC sample snapshot (2,707
ingredients and 30 recipes). Each value is the median of three fresh processes after forced garbage
collection. Working set includes the runtime, NHibernate, and PostgreSQL client infrastructure.

| Profile | Startup | Managed memory | Working set |
| --- | ---: | ---: | ---: |
| Previous implementation | 1,572 ms | 132.9 MiB | 263.1 MiB |
| `All` | 1,567 ms | 133.0 MiB | 262.5 MiB |
| Parsing + modeler | 1,072 ms | 71.5 MiB | 185.7 MiB |
| Parsing only | 993 ms | 71.4 MiB | 180.3 MiB |
| Autocomplete only | 1,049 ms | 66.6 MiB | 183.4 MiB |
| Modeler only | 663 ms | 5.2 MiB | 114.0 MiB |
| Database only | 532 ms | 5.0 MiB | 111.8 MiB |

The parsing-only profile intended for the public sample website reduced startup by approximately
37%, managed memory by 46%, and process working set by 31% compared with the previous all-feature
initialization on this dataset. Production-sized recipe data will make the modeler profile more
expensive than this small snapshot suggests. The private KitchenPC website should be measured
separately with production-scale data when it adopts the parsing-plus-modeler profile.

An end-to-end PostgreSQL check also used the parsing-only profile to search and load a recipe,
aggregate its six ingredients through the database fallback, parse `12 eggs`, add the recipe to the
default shopping list, reload the persisted items, and remove them. The test used a freshly
provisioned database and removed it afterward.

## Legacy NLP database views

The Core and DB runtime loaders do not query the legacy `FormSynonymsForNLP`,
`UnitSynonymsForNLP`, `PrepNotesForNLP`, or `AnomaliesForNLP` views. The private Website repository
still defines those views and grants access to the `Website`, `IngredientCzar`, and `Indexer` roles
in its database installation script, so they should not be removed without separately auditing
those external consumers.

The similarly named `shoppingingredientsfornlp` object remains part of the Core persistence model:
the default schema creates it as a table containing the weight, volume, and unit form pairings used
by NLP. It is not removed or renamed by the capability work.
139 changes: 108 additions & 31 deletions src/Core/Context/DBContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ private AsUser(
ModelerProxy modeler,
Parser parser,
IDBAdapter adapter,
AuthIdentity identity
AuthIdentity identity,
DBContextCapabilities capabilities
)
{
this.ingParser = ingParser;
Expand All @@ -39,10 +40,18 @@ AuthIdentity identity
this.Adapter = adapter;
this.Identity = identity;
this.GetIdentity = () => identity;
this.Capabilities = capabilities;
}

public static AsUser Clone(DBContext context, AuthIdentity identity) =>
new AsUser(context.ingParser, context.modeler, context.parser, context.Adapter, identity);
new AsUser(
context.ingParser,
context.modeler,
context.parser,
context.Adapter,
identity,
context.Capabilities
);
}

private static readonly ReaderWriterLockSlim InitLock = new();
Expand All @@ -52,6 +61,9 @@ public static AsUser Clone(DBContext context, AuthIdentity identity) =>
protected ModelerProxy modeler;
protected Parser parser;

/// <summary>Gets the optional in-memory capabilities configured for this context.</summary>
public DBContextCapabilities Capabilities { get; internal set; } = DBContextCapabilities.All;

/// <summary>Gets or sets the IDBAdapter used to directly talk with the database.</summary>
public IDBAdapter Adapter { get; set; }

Expand Down Expand Up @@ -79,6 +91,7 @@ public virtual Parser Parser
{
get
{
EnsureCapabilityEnabled(DBContextCapabilities.IngredientParsing);
using (InitLock.ReadLock())
{
return parser;
Expand All @@ -90,6 +103,7 @@ public virtual ModelerProxy ModelerProxy
{
get
{
EnsureCapabilityEnabled(DBContextCapabilities.RecipeModeler);
using (InitLock.ReadLock())
{
return modeler;
Expand All @@ -110,7 +124,14 @@ public virtual ModelerProxy ModelerProxy
public virtual QueueAction Queue => new(this);

/// <summary>Provides the ability to fluently work with the recipe modeler.</summary>
public virtual ModelerAction Modeler => new(this);
public virtual ModelerAction Modeler
{
get
{
EnsureCapabilityEnabled(DBContextCapabilities.RecipeModeler);
return new(this);
}
}

/// <summary>
/// Initializes the context and loads necessary data into memory through the configured database adapter.
Expand All @@ -128,41 +149,47 @@ public virtual void Initialize()
// Initialize NHibernate session
Adapter.Initialize(this);

new Thread(
delegate()
using (InitLock.WriteLock())
{
if (HasCapability(DBContextCapabilities.IngredientAutocomplete))
{
using (InitLock.WriteLock())
{
// Initialize ingredient parser
ingParser = new IngredientParser();
var ingredientIndex = Adapter.LoadIngredientsForIndex();
ingParser.CreateIndex(ingredientIndex);

// Initialize modeler
modeler = new ModelerProxy(this);
modeler.LoadSnapshot();

// Initialize natural language parsing
IngredientSynonyms.InitIndex(Adapter.IngredientLoader);
UnitSynonyms.InitIndex(Adapter.UnitLoader);
FormSynonyms.InitIndex(Adapter.FormLoader);
PrepNotes.InitIndex(Adapter.PrepLoader);
Anomalies.InitIndex(Adapter.AnomalyLoader);
NumericVocab.InitIndex();

parser = new Parser();
LoadTemplates();
}
ingParser = new IngredientParser();
var ingredientIndex = Adapter.LoadIngredientsForIndex();
ingParser.CreateIndex(ingredientIndex);
}
).Start();

Thread.Sleep(500); // Provides time for initialize thread to start and acquire InitLock
if (HasCapability(DBContextCapabilities.RecipeModeler))
{
modeler = new ModelerProxy(this);
modeler.LoadSnapshot();
}

if (HasCapability(DBContextCapabilities.IngredientParsing))
{
IngredientSynonyms.InitIndex(Adapter.IngredientLoader);
UnitSynonyms.InitIndex(Adapter.UnitLoader);
FormSynonyms.InitIndex(Adapter.FormLoader);
PrepNotes.InitIndex(Adapter.PrepLoader);
Anomalies.InitIndex(Adapter.AnomalyLoader);
NumericVocab.InitIndex();

parser = new Parser();
LoadTemplates();
}
}
}

/// <summary>
/// Returns an object able to load modeling information. This will be called automatically when the modeler is initialized.
/// </summary>
public virtual IModelerLoader ModelerLoader => new DBModelerLoader(Adapter);
public virtual IModelerLoader ModelerLoader
{
get
{
EnsureCapabilityEnabled(DBContextCapabilities.RecipeModeler);
return new DBModelerLoader(Adapter);
}
}

/// <summary>
/// Takes part of an ingredient name and returns possible matches, useful for autocomplete UIs.
Expand All @@ -171,6 +198,7 @@ public virtual void Initialize()
/// <returns>An enumeration of IngredientNode objects describing possible matches and their IDs.</returns>
public virtual IEnumerable<IngredientNode> AutocompleteIngredient(string query)
{
EnsureCapabilityEnabled(DBContextCapabilities.IngredientAutocomplete);
using (InitLock.ReadLock())
{
return ingParser.MatchIngredient(query);
Expand Down Expand Up @@ -386,6 +414,21 @@ public virtual IList<IngredientAggregation> AggregateIngredients(params Ingredie
/// <returns>A list of IngredientAggregation objects, one per unique ingredient in the set of recipes</returns>
public virtual IList<IngredientAggregation> AggregateRecipes(params Guid[] recipeIds)
{
if (recipeIds == null)
throw new ArgumentNullException(nameof(recipeIds));
if (recipeIds.Length == 0)
return Array.Empty<IngredientAggregation>();

if (!HasCapability(DBContextCapabilities.RecipeModeler))
{
var recipes = Adapter.ReadRecipes(Identity, recipeIds, ReadRecipeOptions.None);
return AggregateRecipeUsages(
recipes
.SelectMany(recipe => recipe.Ingredients)
.Where(usage => usage.Ingredient.Id != ShoppingList.GUID_WATER)
);
}

using (InitLock.ReadLock())
{
var ings = new Dictionary<Guid, IngredientAggregation>(); //List of all ingredients and total usage
Expand All @@ -400,7 +443,9 @@ public virtual IList<IngredientAggregation> AggregateRecipes(params Guid[] recip
foreach (var usage in rNode.Ingredients)
{
var ingId = usage.Ingredient.IngredientId;
var ingName = ingParser.GetIngredientById(ingId);
var ingName = usage.Ingredient.DisplayName;
if (String.IsNullOrWhiteSpace(ingName))
ingName = Adapter.ReadIngredient(ingId)?.Name;
var ing = new Ingredient(ingId, ingName);
ing.ConversionType = usage.Ingredient.ConvType;

Expand All @@ -427,6 +472,38 @@ public virtual IList<IngredientAggregation> AggregateRecipes(params Guid[] recip
}
}

private IList<IngredientAggregation> AggregateRecipeUsages(
IEnumerable<IngredientUsage> usages
) =>
usages
.GroupBy(usage => usage.Ingredient.Id)
.Select(group =>
{
var aggregation = new IngredientAggregation(group.First().Ingredient);
foreach (var usage in group)
{
if (usage.Amount == null)
{
aggregation.Amount = null;
break;
}

aggregation.AddUsage(usage);
}

return aggregation;
})
.ToArray();

private bool HasCapability(DBContextCapabilities capability) =>
(Capabilities & capability) == capability;

private void EnsureCapabilityEnabled(DBContextCapabilities capability)
{
if (!HasCapability(capability))
throw new ContextCapabilityNotEnabledException(capability);
}

/// <summary>
/// Returns the specified set of menus owned by the current user.
/// </summary>
Expand Down
13 changes: 13 additions & 0 deletions src/Core/Context/DBContextBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ public DBContextBuilder Identity(Func<AuthIdentity> getIdentity)
return this;
}

/// <summary>
/// Selects the optional in-memory indexes initialized by this context. All capabilities are
/// enabled by default for backward compatibility.
/// </summary>
public DBContextBuilder Capabilities(DBContextCapabilities capabilities)
{
if ((capabilities & ~DBContextCapabilities.All) != 0)
throw new ArgumentOutOfRangeException(nameof(capabilities));

context.Capabilities = capabilities;
return this;
}

public DBContext Create()
{
return context;
Expand Down
27 changes: 27 additions & 0 deletions src/Core/Context/DBContextCapabilities.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System;

namespace KitchenPC.Core.Context;

/// <summary>
/// Controls which optional in-memory indexes a <see cref="DBContext"/> initializes.
/// Database-backed recipe, ingredient, menu, queue, and shopping-list operations remain available
/// regardless of the selected capabilities.
/// </summary>
[Flags]
public enum DBContextCapabilities
{
/// <summary>Initialize only the configured database adapter.</summary>
None = 0,

/// <summary>Build the substring index used by ingredient autocomplete.</summary>
IngredientAutocomplete = 1,

/// <summary>Build the grammar and synonym indexes used to parse ingredient text.</summary>
IngredientParsing = 2,

/// <summary>Load the in-memory recipe graph used by the recipe modeler.</summary>
RecipeModeler = 4,

/// <summary>Initialize every optional capability. This is the default for compatibility.</summary>
All = IngredientAutocomplete | IngredientParsing | RecipeModeler,
}
3 changes: 2 additions & 1 deletion src/Core/Context/StaticModelerLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ select IngredientBinding.Create(
i.Value.UnitWeight,
f.Value.UnitType,
f.Value.FormAmount,
f.Value.FormUnit
f.Value.FormUnit,
i.Value.DisplayName
)
);

Expand Down
Loading