LostBeard / SpawnDev.BlazorJS

Full Blazor WebAssembly and Javascript Interop with multithreading via WebWorkers
https://blazorjs.spawndev.com
MIT License
78 stars 6 forks source link

cannot resolve service.. which has another service injected into it #35

Closed waleed-alharthi closed 1 month ago

waleed-alharthi commented 1 month ago

Describe the bug Getting the following error when calling a service method using a web worker.

Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
      Unhandled exception rendering component: Cannot resolve scoped service 'RihalDotnetTemplateWasm.Client.Data.UnitOfWork' from root provider.
System.Exception: Cannot resolve scoped service 'RihalDotnetTemplateWasm.Client.Data.UnitOfWork' from root provider.
   at SpawnDev.BlazorJS.WebWorkers.ServiceCallDispatcher.Call(MethodInfo methodInfo, Object[] args) in D:\users\tj\Projects\SpawnDev.BlazorJS\SpawnDev.BlazorJS\SpawnDev.BlazorJS.WebWorkers\ServiceCallDispatcher.cs:line 372
   at SpawnDev.BlazorJS.Reflection.InterfaceCallDispatcher`1.<InvokeTaskVoid>d__10[[RihalDotnetTemplateWasm.Client.Services.ISyncService, RihalDotnetTemplateWasm.Client, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]].MoveNext() in D:\users\tj\Projects\SpawnDev.BlazorJS\SpawnDev.BlazorJS\SpawnDev.BlazorJS\Reflection\InterfaceCallDispatcher.cs:line 42
   at RihalDotnetTemplateWasm.Client.Data.DynamicRepository.GetToListAsync(Type entityType, CancellationToken cancellationToken, Boolean doSync) in C:\Users\alhar\source\repos\rihal-dotnet-template-wasm\RihalDotnetTemplateWasm\Client\Data\DynamicRepository.cs:line 36
   at RihalDotnetTemplateWasm.Client.Features.Models.ModelsTable.OnParametersSetAsync() in C:\Users\alhar\source\repos\rihal-dotnet-template-wasm\RihalDotnetTemplateWasm\Client\Features\Models\ModelsTable.razor:line 72
   at Microsoft.AspNetCore.Components.ComponentBase.CallStateHasChangedOnAsyncCompletion(Task task)
   at Microsoft.AspNetCore.Components.ComponentBase.RunInitAndSetParametersAsync()
   at Microsoft.AspNetCore.Components.RenderTree.Renderer.GetErrorHandledTask(Task taskToHandle, ComponentState owningComponentState)

To Reproduce Steps to reproduce the behaviour:

  1. Created a service with an interface to use in a web worker.
  2. Injected a service (not using an interface) into the service in step 1.
  3. Called a method from service in step 1 using a web worker.
  4. Maybe all services in play must be using an interface?

Expected behavior The method to run

Screenshots N/A

Desktop (please complete the following information):

Additional context DynamicRepository.cs

public async Task<List<object>> GetToListAsync(Type entityType, CancellationToken cancellationToken = default, bool doSync = true)
{
    if (doSync)
    {
        //_listenerService.TriggerSyncStart(entityType);
        //await _syncService.SyncRemoteWithLocal(entityType, cancellationToken);
        Console.WriteLine("Getting ISyncService from _webWorkerService.TaskPool.GetService<ISyncService>()");
        var webWorker = await _webWorkerService.GetWebWorker();
        Console.WriteLine($"Got WebWorker instance Id {webWorker?.LocalInfo.InstanceId}");
        var service = webWorker.GetService<ISyncService>();
        Console.WriteLine($"Got ISyncService from _webWorkerService.TaskPool.GetService<ISyncService>() instance Id {_webWorkerService.InstanceId}");
        await service.SyncRemoteWithLocal(entityType, cancellationToken);
        Console.WriteLine("Finished SyncRemoteWithLocal using _webWorkerService.TaskPool.GetService<ISyncService>()");

        //Console.WriteLine("_webWorkerService.WindowTask.Run(() => _syncService.SyncRemoteWithLocal(entityType, cancellationToken, 0));");
        //await _webWorkerService.WindowTask.Run(() => _syncService.SyncRemoteWithLocal(entityType, cancellationToken, 0));
        //Console.WriteLine("Finished SyncRemoteWithLocal using _webWorkerService.WindowTask.Run(() => _syncService.SyncRemoteWithLocal(entityType, cancellationToken, 0));");
    }
    return await _unitOfWork.GetListAsync(entityType,cancellationToken);
}

SyncService.cs

public class SyncService : ISyncService
{
    private readonly GenericHttpClient _httpClient;
    private readonly ISyncListenerService _listenerService;
    private readonly UnitOfWork _unitOfWork;

    private const int pageSize = 30;

    public SyncService(GenericHttpClient httpClient, UnitOfWork unitOfWork, ISyncListenerService listenerService)
    {
        _httpClient = httpClient;
        _unitOfWork = unitOfWork;
        _listenerService = listenerService;

        _listenerService.RegisterListener(this);

        _listenerService.SyncStart += async (sender, entityType) =>
        {
            await SyncRemoteWithLocal(entityType);
        };

        _listenerService.SyncItemById += async (sender, args) =>
        {
            await SyncRemoteWithLocal(args.entityType, args.id);
        };
    }

    public async Task SyncRemoteWithLocal(Type entityType, CancellationToken cancellationToken = default, int pageNumber = 0)
    {
        Console.WriteLine($"SyncService.SyncRemoteWithLocal({entityType.Name}) started");

        _listenerService.TriggerNotifySyncStarted(entityType);

        var localEntitiesSet = _unitOfWork.Get(entityType);
        var localEntities = await localEntitiesSet.Cast<object>().ToListAsync(cancellationToken);

        var remoteCount = await _httpClient.GetCount(entityType, cancellationToken);
        _listenerService.TriggerSyncProgress(entityType, 0, remoteCount / pageSize);

        IEnumerable<object> remoteEntities;
        if (pageNumber == 0)
        {
            //do a sliding window sync
            for (int i = 1; i <= (remoteCount / pageSize)+1; i++)
            {
                _listenerService.TriggerSyncProgress(entityType, i, remoteCount / pageSize);
                remoteEntities = await _httpClient.GetPage(entityType, i, pageSize, cancellationToken);
                await MergeLocalWithRemote(entityType, remoteEntities, localEntities, cancellationToken);

                if (cancellationToken.IsCancellationRequested)
                {
                    break;
                }

                _listenerService.TriggerSyncComplete(entityType);
                //if (i == 1)
                //{
                //    _listenerService.TriggerSyncComplete(entityType); //show first page of sync as complete
                //}
            }
        }
        else if (pageNumber > 0)
        {
            remoteEntities = await _httpClient.GetPage(entityType, pageNumber, pageSize, cancellationToken);
            await MergeLocalWithRemote(entityType, remoteEntities, localEntities, cancellationToken);
        }

        _listenerService.TriggerNotifySyncCompleted(entityType);
        _listenerService.TriggerSyncComplete(entityType);
    }
}

UnitOfWork.cs

public class UnitOfWork : IDisposable
{
    private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
    private readonly IServiceProvider _serviceProvider;
    private AppDbContext _dbContext;

    public UnitOfWork(IDbContextFactory<AppDbContext> dbContextFactory, IServiceProvider serviceProvider)
    {
        _dbContextFactory = dbContextFactory;
        _serviceProvider = serviceProvider;
    }
    //More methods below
}

Program.cs

using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.EntityFrameworkCore;
using MudBlazor;
using MudBlazor.Services;
using RihalDotnetTemplateWasm.Client;
using RihalDotnetTemplateWasm.Client.Data;
using RihalDotnetTemplateWasm.Client.Services;
using System.Text.Json;
using System.Text.Json.Serialization;
using SpawnDev.BlazorJS;
using SpawnDev.BlazorJS.WebWorkers;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

// Configure EF Core logging
builder.Logging.SetMinimumLevel(LogLevel.Warning); // Set default minimum log level to Warning
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Warning); // Filter out EF Core command logs
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Infrastructure", LogLevel.Warning); // Filter out EF Core infrastructure logs

builder.Services.AddBlazorJSRuntime();
builder.Services.AddWebWorkerService(webWorkerService =>
{
    // Optionally configure the WebWorkerService service before it is used
    // Default WebWorkerService.TaskPool settings: PoolSize = 0, MaxPoolSize = 1, AutoGrow = true
    // Below sets TaskPool max size to 2. By default the TaskPool size will grow as needed up to the max pool size.
    // Setting max pool size to -1 will set it to the value of navigator.hardwareConcurrency
    webWorkerService.TaskPool.MaxPoolSize = 2;
    // Below is telling the WebWorkerService TaskPool to set the initial size to 2 if running in a Window scope and 0 otherwise
    // This starts up 2 WebWorkers to handle TaskPool tasks as needed
    // Setting this to -1 will set the initial pool size to max pool size
    //webWorkerService.TaskPool.PoolSize = webWorkerService.GlobalScope == GlobalScope.Window ? 2 : 0;
    webWorkerService.TaskPool.PoolSize = webWorkerService.GlobalScope == GlobalScope.Window ? 2 : 0;
});
//builder.Services.RegisterServiceWorker<SyncServiceWorker>();

builder.Services.AddBesqlDbContextFactory<AppDbContext>(options =>
{
    options.UseSqlite("Data Source=app.db");
    options.UseSnakeCaseNamingConvention();
    options.EnableDetailedErrors();
    options.EnableSensitiveDataLogging(); // This enables logging parameter values
    options.LogTo(Console.WriteLine, LogLevel.Error); // Add logging to console
});
builder.Services.AddScoped<UnitOfWork>();
builder.Services.AddScoped<DynamicRepository>();

// Configure JSON serialization options
builder.Services.AddOptions<JsonSerializerOptions>().Configure(options =>
{
    options.ReferenceHandler = ReferenceHandler.Preserve; // Use ReferenceHandler.Preserve to handle object cycles
});

builder.Services.AddSingleton(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddSingleton<GenericHttpClient>();

//MudBlazor
builder.Services.AddMudServices(config =>
{
    config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomRight;
});
builder.Services.AddSingleton<MudThemeService>();

//Listeners to trigger UI updates from background services
builder.Services.AddSingleton<ISyncListenerService, SyncListenerService>();
builder.Services.AddScoped<ISyncService, SyncService>();

var app = builder.Build();

await using (var scope = app.Services.CreateAsyncScope())
{
    // Create db context
    await using var dbContext = await scope.ServiceProvider
        .GetRequiredService<IDbContextFactory<AppDbContext>>()
        .CreateDbContextAsync();

    // migrate database
    await dbContext.Database.MigrateAsync();
}

//await app.RunAsync();
await app.BlazorJSRunAsync();
waleed-alharthi commented 1 month ago

Update: Added an interface to UnitOfWork, still getting the same error

Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
      Unhandled exception rendering component: Cannot resolve scoped service 'RihalDotnetTemplateWasm.Client.Data.IUnitOfWork' from root provider.
System.Exception: Cannot resolve scoped service 'RihalDotnetTemplateWasm.Client.Data.IUnitOfWork' from root provider.
   at SpawnDev.BlazorJS.WebWorkers.ServiceCallDispatcher.Call(MethodInfo methodInfo, Object[] args) in D:\users\tj\Projects\SpawnDev.BlazorJS\SpawnDev.BlazorJS\SpawnDev.BlazorJS.WebWorkers\ServiceCallDispatcher.cs:line 372
   at SpawnDev.BlazorJS.Reflection.InterfaceCallDispatcher`1.<InvokeTaskVoid>d__10[[RihalDotnetTemplateWasm.Client.Services.ISyncService, RihalDotnetTemplateWasm.Client, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]].MoveNext() in D:\users\tj\Projects\SpawnDev.BlazorJS\SpawnDev.BlazorJS\SpawnDev.BlazorJS\Reflection\InterfaceCallDispatcher.cs:line 42
   at RihalDotnetTemplateWasm.Client.Data.DynamicRepository.GetToListAsync(Type entityType, CancellationToken cancellationToken, Boolean doSync) in C:\Users\alhar\source\repos\rihal-dotnet-template-wasm\RihalDotnetTemplateWasm\Client\Data\DynamicRepository.cs:line 36
   at RihalDotnetTemplateWasm.Client.Features.Models.ModelsTable.OnParametersSetAsync() in C:\Users\alhar\source\repos\rihal-dotnet-template-wasm\RihalDotnetTemplateWasm\Client\Features\Models\ModelsTable.razor:line 72
   at Microsoft.AspNetCore.Components.ComponentBase.CallStateHasChangedOnAsyncCompletion(Task task)
   at Microsoft.AspNetCore.Components.ComponentBase.RunInitAndSetParametersAsync()
   at Microsoft.AspNetCore.Components.RenderTree.Renderer.GetErrorHandledTask(Task taskToHandle, ComponentState owningComponentState)
LostBeard commented 1 month ago

Maybe all services in play must be using an interface?

As far as SpawnDev.BlazorJS.WebWorkers is concerned, you only need to register services with an interface if you wish to use the GetService<TService>() method to obtain an Interface proxy. See AsynCallDispatcher for ways to interact with services without registering interfaces.

It might be a caching issue. Worth trying.

In Chrome:

The exception states that the WebWorker's ServiceProvider does not contain a service registered as IUnitOfWork (and previously UnitOfWork). If your WebWorkers are loading the older, previous version of your app, before you added the IUnitOfWork or UnitOfWork service, that would explain the exact error message you are seeing.

waleed-alharthi commented 1 month ago

I've followed your steps in Edge. Also, while dev tools is open, I often right click the refresh button to do "Empty cache and hard refresh" to clear out stuff which can cause this.

Unfortunately, neither helped.

To make sure my service wasn't causing issues on its own i tried the following (injecting the service and running it without a web worker), which worked fine:

public async Task<List<object>> GetToListAsync(Type entityType, CancellationToken cancellationToken = default, bool doSync = true)
{
    if (doSync)
    {
        await _syncService.SyncRemoteWithLocal(entityType, cancellationToken);
    }
    return await _unitOfWork.GetListAsync(entityType,cancellationToken);
}

Generally speaking, I think I'm injecting and using the WebWorkerService as intended:

public class DynamicRepository
    {
        private readonly IUnitOfWork _unitOfWork;
        private readonly ISyncListenerService _listenerService;
        private readonly WebWorkerService _webWorkerService;
        private readonly ISyncService _syncService;

        public DynamicRepository(IUnitOfWork unitOfWork, ISyncListenerService listenerService, WebWorkerService webWorkerService, ISyncService syncService)
        {
            _unitOfWork = unitOfWork;
            _listenerService = listenerService;
            _listenerService.RegisterListener(this);
            _webWorkerService = webWorkerService;
            _syncService = syncService;
        }

        public async Task<List<object>> GetToListAsync(Type entityType, CancellationToken cancellationToken = default, bool doSync = true)
        {
            if (doSync)
            {
                Console.WriteLine("Getting ISyncService from _webWorkerService.TaskPool.GetService<ISyncService>()");
                var webWorker = await _webWorkerService.GetWebWorker();
                Console.WriteLine($"Got WebWorker instance Id {webWorker?.LocalInfo.InstanceId}");
                var service = webWorker.GetService<ISyncService>();
                Console.WriteLine($"Got ISyncService from _webWorkerService.TaskPool.GetService<ISyncService>() instance Id {_webWorkerService.InstanceId}");
                await service.SyncRemoteWithLocal(entityType, cancellationToken);
                Console.WriteLine("Finished SyncRemoteWithLocal using _webWorkerService.TaskPool.GetService<ISyncService>()");
            }
            return await _unitOfWork.GetListAsync(entityType,cancellationToken);
        }
}

I do get the following lines before the error:

Got WebWorker instance Id 67411a44-4c1f-455a-9f57-2892bfc9d6af
Got ISyncService from _webWorkerService.TaskPool.GetService<ISyncService>() instance Id 67411a44-4c1f-455a-9f57-2892bfc9d6af
LostBeard commented 1 month ago

It is because it is registered as a scoped service. I am not sure exactly why that is causing the issue or if it is resolvable, but looking into it now.

LostBeard commented 1 month ago

One thing you can do is switch the registration scope. Scoped and Singleton are effectively the same thing in Blazor WebAssembly

waleed-alharthi commented 1 month ago

I'm trying to figure out how to do it with Besql, which needs to be a singleton to work with UnitOfWork:

builder.Services.AddBesqlDbContextFactory<AppDbContext>(options =>
{
    options.UseSqlite("Data Source=app.db");
    options.UseSnakeCaseNamingConvention();
    options.EnableDetailedErrors();
    options.EnableSensitiveDataLogging(); // This enables logging parameter values
    options.LogTo(Console.WriteLine, LogLevel.Error); // Add logging to console
});
LostBeard commented 1 month ago

Let me look into it and I will get back to you. Hopefully you won't have to change anything. Brb

waleed-alharthi commented 1 month ago

Thanks for the speedy responses. I'll play around with Besql on my end to see if it can give me more control over registering it.

waleed-alharthi commented 1 month ago

Changing all the services from scoped to singleton (which like you said, are the same thing in wasm anyways) did solve the problem!

Code for reference:

//add besql as singleton
builder.Services.AddSingleton<IBesqlStorage, BrowserCacheBesqlStorage>();
builder.Services.AddDbContextFactory<AppDbContext, BesqlDbContextFactory<AppDbContext>>(options =>
{
    options.UseSqlite("Data Source=app.db");
    options.UseSnakeCaseNamingConvention();
    options.EnableDetailedErrors();
    options.EnableSensitiveDataLogging(); // This enables logging parameter values
    options.LogTo(Console.WriteLine, LogLevel.Error); // Add logging to console
}, ServiceLifetime.Singleton);

No I'm having a new interesting challenge where i sync the data in a web worker.. then read the data from the main thread, no results are returned ๐Ÿ˜‚

when looking at this options.UseSqlite("Data Source=app.db");, does the web worker have its own copy?

Edit: refreshing the page BEFORE using web workers had the same data loss effect, maybe it's a persistence issue from Besql or the way I implemented Besql.

LostBeard commented 1 month ago

My first guess is that this options.UseSqlite("Data Source=app.db"); is telling SQLite to store the database in the MEMFS file system that Blazor WASM uses, which is created at instance startup (part of EMScripten). It is the same place System.IO.File and System.IO.Directory read and write to in Blazor WASM. You could check by doing a File.Exists("app.db") after writing your database.

More info

I tried looking at the site I found that think is related: bitplatform.dev , but I didn't see much documentation.

waleed-alharthi commented 1 month ago

Maybe because of the service worker clearing the cache(s) every time?

service-worker.js

// In development, always fetch from the network and do not enable offline support.
// This is because caching would make development more difficult (changes would not
// be reflected on the first load after each change).
self.addEventListener('fetch', () => { });
LostBeard commented 1 month ago

That line is there because it is the minimum required listener for a service worker. It is a service worker that does nothing. It does not do anything with the cache.

waleed-alharthi commented 1 month ago

I'll create an issue with bitplatform to investigate. Many thanks for your help and patience!

LostBeard commented 1 month ago

Thank you for sponsoring me. ๐Ÿš€

LostBeard commented 1 month ago

I just uploaded version 2.2.100 which supports calling Singleton, Scoped, and Transient services in WebWorkers. Keyed services are not currently supported. Thank you for reporting issues. It really helps. ๐Ÿ‘๐Ÿ‘