Lot C : backfill des colonnes de stockage (C2) et quota autoritaire (C3)
C2 — POST /api/Resource/backfill-storage, SuperAdmin, dryRun à true par
défaut : la migration se joue sur une base vide, ce backfill sur des lignes
de production. StoragePath par ResourceStorage.PathFor, SizeBytes par HEAD.
La méthode annoncée au plan — « SizeBytes par listing du bucket Firebase » —
était inapplicable : le serveur n'avait aucun client de stockage. Le sondage
passe donc par HEAD sur l'URL publique, comme le fait déjà la migration, et
le sondeur est extrait plutôt que recopié (Helpers/ResourceSizeProbe,
consommé par MigrationController et par le backfill). Même raisonnement que
pour ResourceStorage : deux copies auraient divergé sur ce qui compte, le
sort réservé aux échecs.
L'extraction a bouché un trou que personne ne cherchait. L'original ne notait
l'échec que dans son catch, or un HEAD sur un blob absent ne lève pas : il
répond 404, sans Content-Length. Ces ressources arrivaient à 0 octet sans
figurer dans le rapport — invisibles au quota et invisibles au diagnostic,
exactement ce que le commentaire d'origine voulait empêcher.
Le « 37 lignes sur 45 » du plan n'étant pas vérifiable, le backfill rend son
propre inventaire : Orphans (aucune URL, blob peut-être jamais téléversé) et
Unsized (URL présente, bucket muet) restent séparés, ce sont deux causes
distinctes.
C3 — pré-vol du quota sur les deux chemins de création, suppression du blob
à Delete, angle mort d'Update tranché.
Deux défauts trouvés en câblant, qui n'étaient documentés nulle part :
- Le pré-vol existait déjà à moitié. Upload (multipart) contrôlait et
renvoyait 413, Create (JSON) ne contrôlait rien — or c'est le chemin
qu'emprunte manager-app, qui crée la ligne puis téléverse.
- Les deux lectures du quota divergeaient. Upload lisait le quota du plan,
GetQuota celui de l'instance avec le plan en repli. Une instance à quota
surchargé — le mécanisme même de l'add-on — affichait un chiffre à l'écran
et se faisait bloquer sur un autre. Helpers/StorageQuota devient la seule
source de vérité pour les deux.
Delete supprime le blob AVANT la ligne et renvoie 502 en conservant la ligne
si le bucket échoue. manager-app faisait l'inverse en avalant l'échec dans un
print : la ligne disparaissait, le blob restait, et n'ayant plus de ligne il
devenait invisible au quota tout en restant facturé. Une ressource encore
listée se rattrape ; un blob que plus aucune ligne ne désigne, non.
L'angle mort laissé ouvert par C1 était une fausse crainte : PathFor ne
construit qu'un pictures/{instanceId}/{resourceId}, le type n'entre pas dans
le chemin, il décide seulement s'il y en a un. Recalculer ne peut donc pas
pointer ailleurs, et Update rejoue Apply.
Aucun secret nouveau : FirebaseAdmin était déjà référencé pour les
notifications push et Startup charge déjà un service account, donc
Google.Cloud.Storage.V1 réutilise le même GoogleCredential. Seule s'ajoute la
clé Firebase:StorageBucket, vide par défaut — à renseigner en prod (I9),
sans quoi Delete ne supprime rien et ne prétend pas le contraire.
dotnet build vert, dotnet test 163/163 (148 au départ, +7 pour C2, +8 pour C3).
Contient aussi le correctif d'indexation préparé en parallèle : un job
Hangfire par section dans BackfillInstanceAsync au lieu d'une boucle, et un
backoff sur 429/503 dans GoogleEmbeddingService.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
f9059ed198
commit
269b3f6703
@ -14,7 +14,10 @@ namespace ManagerService.Tests.Controllers
|
||||
{
|
||||
public class ResourceControllerTests
|
||||
{
|
||||
private static ResourceController BuildController(MyInfoMateDbContext db)
|
||||
private static ResourceController BuildController(
|
||||
MyInfoMateDbContext db,
|
||||
FakeHttpClientFactory httpClientFactory = null,
|
||||
FakeBlobService blobService = null)
|
||||
{
|
||||
var cfg = FakeMongoConfig.Create();
|
||||
var controller = new ResourceController(
|
||||
@ -22,7 +25,9 @@ namespace ManagerService.Tests.Controllers
|
||||
new ResourceDatabaseService(cfg),
|
||||
new SectionDatabaseService(cfg),
|
||||
new ConfigurationDatabaseService(cfg),
|
||||
db);
|
||||
db,
|
||||
httpClientFactory ?? new FakeHttpClientFactory(),
|
||||
blobService ?? new FakeBlobService());
|
||||
FakeUser.SetUser(controller, FakeUser.Create("Manager.contenteditor", "inst-test"));
|
||||
return controller;
|
||||
}
|
||||
@ -165,7 +170,7 @@ namespace ManagerService.Tests.Controllers
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
|
||||
var result = BuildController(db).Delete("unknown");
|
||||
var result = BuildController(db).Delete("unknown").GetAwaiter().GetResult();
|
||||
|
||||
Assert.IsType<NotFoundObjectResult>(result);
|
||||
}
|
||||
@ -177,11 +182,347 @@ namespace ManagerService.Tests.Controllers
|
||||
db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image });
|
||||
db.SaveChanges();
|
||||
|
||||
var result = BuildController(db).Delete("r1");
|
||||
var result = BuildController(db).Delete("r1").GetAwaiter().GetResult();
|
||||
|
||||
var obj = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(202, obj.StatusCode);
|
||||
Assert.Equal(0, db.Resources.Count());
|
||||
}
|
||||
|
||||
// ── QUOTA & BLOB (C3) ────────────────────────────────────────────────
|
||||
|
||||
private static void SeedPlan(MyInfoMateDbContext db, long planQuota, long instanceQuota = 0)
|
||||
{
|
||||
db.SubscriptionPlans.Add(new SubscriptionPlan
|
||||
{
|
||||
Id = "plan-test", Name = "Test", StorageQuotaBytes = planQuota
|
||||
});
|
||||
db.Instances.Add(new Instance
|
||||
{
|
||||
Id = "inst-test", Name = "Test", SubscriptionPlanId = "plan-test",
|
||||
StorageQuotaBytes = instanceQuota
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_OverQuota_Returns413()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
SeedPlan(db, planQuota: 1000);
|
||||
db.Resources.Add(new Resource
|
||||
{
|
||||
Id = "r0", InstanceId = "inst-test", Label = "Déjà là",
|
||||
Type = ResourceType.Image, SizeBytes = 900
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var result = BuildController(db).Create(new ResourceDTO
|
||||
{
|
||||
instanceId = "inst-test", label = "Trop gros",
|
||||
type = ResourceType.Image, sizeBytes = 200
|
||||
});
|
||||
|
||||
Assert.Equal(413, result.StatusCode);
|
||||
Assert.Equal(1, db.Resources.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_UrlType_IgnoresQuota()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
SeedPlan(db, planQuota: 1000);
|
||||
db.Resources.Add(new Resource
|
||||
{
|
||||
Id = "r0", InstanceId = "inst-test", Label = "Déjà là",
|
||||
Type = ResourceType.Image, SizeBytes = 1000
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
// Un lien externe n'occupe rien dans le bucket : il ne doit pas être refusé
|
||||
// pour un quota de stockage, même sur une instance pleine.
|
||||
var result = BuildController(db).Create(new ResourceDTO
|
||||
{
|
||||
instanceId = "inst-test", label = "Lien",
|
||||
type = ResourceType.ImageUrl, url = "https://ailleurs/img.png"
|
||||
});
|
||||
|
||||
Assert.Equal(200, result.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// La divergence que StorageQuota ferme : la surcharge portée par l'instance
|
||||
/// l'emporte sur le plan. Avant, le pré-vol lisait le plan et bloquait à 1 000
|
||||
/// alors que l'écran de quota annonçait 10 000.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Create_InstanceOverridesPlanQuota()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
SeedPlan(db, planQuota: 1000, instanceQuota: 10000);
|
||||
db.Resources.Add(new Resource
|
||||
{
|
||||
Id = "r0", InstanceId = "inst-test", Label = "Déjà là",
|
||||
Type = ResourceType.Image, SizeBytes = 900
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var result = BuildController(db).Create(new ResourceDTO
|
||||
{
|
||||
instanceId = "inst-test", label = "Dans le quota surchargé",
|
||||
type = ResourceType.Image, sizeBytes = 200
|
||||
});
|
||||
|
||||
Assert.Equal(200, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_RemovesBlobBeforeRow()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
db.Resources.Add(new Resource
|
||||
{
|
||||
Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image,
|
||||
StoragePath = "pictures/inst-test/r1"
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var blob = new FakeBlobService();
|
||||
var result = BuildController(db, blobService: blob).Delete("r1").GetAwaiter().GetResult();
|
||||
|
||||
Assert.Equal(202, result.StatusCode);
|
||||
Assert.Equal(new[] { "pictures/inst-test/r1" }, blob.DeletedPaths);
|
||||
Assert.Equal(0, db.Resources.Count());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Le cœur du choix : un échec de bucket conserve la ligne. Une ligne encore
|
||||
/// listée se rattrape ; un blob dont plus aucune ligne ne porte le chemin est
|
||||
/// facturé sans que rien ne le désigne.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Delete_BlobFailure_KeepsRowAndReturns502()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
db.Resources.Add(new Resource
|
||||
{
|
||||
Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image,
|
||||
StoragePath = "pictures/inst-test/r1"
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var blob = new FakeBlobService(BlobDeleteOutcome.Failed);
|
||||
var result = BuildController(db, blobService: blob).Delete("r1").GetAwaiter().GetResult();
|
||||
|
||||
Assert.Equal(502, result.StatusCode);
|
||||
Assert.Equal(1, db.Resources.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_MissingStoragePath_IsRebuiltFromTheDeterministicPath()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
db.Resources.Add(new Resource
|
||||
{
|
||||
Id = "r1", InstanceId = "inst-test", Label = "Avant le backfill", Type = ResourceType.Image
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var blob = new FakeBlobService();
|
||||
BuildController(db, blobService: blob).Delete("r1").GetAwaiter().GetResult();
|
||||
|
||||
Assert.Equal(new[] { "pictures/inst-test/r1" }, blob.DeletedPaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_TypeBecomesFile_FillsStoragePath()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
db.Resources.Add(new Resource
|
||||
{
|
||||
Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.ImageUrl,
|
||||
Url = "https://ailleurs/img.png"
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
BuildController(db).Update(new ResourceDTO
|
||||
{
|
||||
id = "r1", type = ResourceType.Image, sizeBytes = 256
|
||||
});
|
||||
|
||||
var resource = db.Resources.Single();
|
||||
Assert.Equal("pictures/inst-test/r1", resource.StoragePath);
|
||||
Assert.Equal(256, resource.SizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_TypeBecomesUrl_ClearsStorageColumns()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
db.Resources.Add(new Resource
|
||||
{
|
||||
Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image,
|
||||
StoragePath = "pictures/inst-test/r1", SizeBytes = 4096
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
BuildController(db).Update(new ResourceDTO { id = "r1", type = ResourceType.ImageUrl });
|
||||
|
||||
var resource = db.Resources.Single();
|
||||
Assert.Null(resource.StoragePath);
|
||||
Assert.Equal(0, resource.SizeBytes);
|
||||
}
|
||||
|
||||
// ── BACKFILL STORAGE (C2) ────────────────────────────────────────────
|
||||
|
||||
private static ResourceBackfillReportDTO RunBackfill(
|
||||
ResourceController controller, bool dryRun = false, string instanceId = null)
|
||||
{
|
||||
var result = controller.BackfillStorage(dryRun, instanceId).GetAwaiter().GetResult();
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
return Assert.IsType<ResourceBackfillReportDTO>(ok.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backfill_FillsStoragePathAndSizeBytes()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
db.Resources.Add(new Resource
|
||||
{
|
||||
Id = "r1", InstanceId = "inst-test", Label = "A",
|
||||
Type = ResourceType.Image, Url = "https://bucket/r1"
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var http = new FakeHttpClientFactory(new Dictionary<string, long> { ["https://bucket/r1"] = 2048 });
|
||||
var report = RunBackfill(BuildController(db, http));
|
||||
|
||||
var resource = db.Resources.Single();
|
||||
Assert.Equal("pictures/inst-test/r1", resource.StoragePath);
|
||||
Assert.Equal(2048, resource.SizeBytes);
|
||||
Assert.Equal(1, report.StoragePathFilled);
|
||||
Assert.Equal(1, report.SizeBytesFilled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Le point du lien L10 : un backfill qui écrirait quand même laisserait le quota
|
||||
/// de C3 porter sur des zéros. Une ligne non sondable doit ressortir nommée.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Backfill_UnreachableBlob_IsReportedNotCountedAsZero()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
db.Resources.Add(new Resource
|
||||
{
|
||||
Id = "r1", InstanceId = "inst-test", Label = "Absente",
|
||||
Type = ResourceType.Image, Url = "https://bucket/disparue"
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var report = RunBackfill(BuildController(db, new FakeHttpClientFactory()));
|
||||
|
||||
Assert.Equal(0, report.SizeBytesFilled);
|
||||
Assert.Single(report.Unsized);
|
||||
Assert.Contains("r1", report.Unsized[0]);
|
||||
// Le chemin, lui, est déterministe : il se calcule même sans blob joignable.
|
||||
Assert.Equal("pictures/inst-test/r1", db.Resources.Single().StoragePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backfill_FileTypeWithoutUrl_IsListedAsOrphan()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
db.Resources.Add(new Resource
|
||||
{
|
||||
Id = "r1", InstanceId = "inst-test", Label = "Sans URL", Type = ResourceType.PDF
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var report = RunBackfill(BuildController(db));
|
||||
|
||||
Assert.Single(report.Orphans);
|
||||
Assert.Empty(report.Unsized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backfill_UrlType_KeepsNoPathAndNoSize()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
db.Resources.Add(new Resource
|
||||
{
|
||||
Id = "r1", InstanceId = "inst-test", Label = "Lien", Type = ResourceType.ImageUrl,
|
||||
Url = "https://ailleurs/img.png", StoragePath = "pictures/inst-test/r1", SizeBytes = 999
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var report = RunBackfill(BuildController(db));
|
||||
|
||||
var resource = db.Resources.Single();
|
||||
Assert.Null(resource.StoragePath);
|
||||
Assert.Equal(0, resource.SizeBytes);
|
||||
Assert.Equal(1, report.UrlTypesNormalised);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backfill_AlreadyCompleteRow_IsLeftAlone()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
db.Resources.Add(new Resource
|
||||
{
|
||||
Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image,
|
||||
Url = "https://bucket/r1", StoragePath = "pictures/inst-test/r1", SizeBytes = 512
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var http = new FakeHttpClientFactory(new Dictionary<string, long> { ["https://bucket/r1"] = 999999 });
|
||||
var report = RunBackfill(BuildController(db, http));
|
||||
|
||||
// La taille en base a été écrite par la création, qui connaissait le fichier :
|
||||
// la resonder n'apporterait rien et écraserait une valeur plus sûre.
|
||||
Assert.Equal(512, db.Resources.Single().SizeBytes);
|
||||
Assert.Equal(1, report.AlreadyComplete);
|
||||
Assert.Equal(0, report.SizeBytesFilled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backfill_DryRun_WritesNothing()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
db.Resources.Add(new Resource
|
||||
{
|
||||
Id = "r1", InstanceId = "inst-test", Label = "A",
|
||||
Type = ResourceType.Image, Url = "https://bucket/r1"
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var http = new FakeHttpClientFactory(new Dictionary<string, long> { ["https://bucket/r1"] = 2048 });
|
||||
var report = RunBackfill(BuildController(db, http), dryRun: true);
|
||||
|
||||
Assert.True(report.DryRun);
|
||||
Assert.Equal(1, report.StoragePathFilled);
|
||||
|
||||
// Sans vider le ChangeTracker, l'entité modifiée en mémoire répondrait la
|
||||
// nouvelle valeur alors que rien n'a été enregistré : l'assertion serait creuse.
|
||||
db.ChangeTracker.Clear();
|
||||
var reread = db.Resources.Single();
|
||||
Assert.Null(reread.StoragePath);
|
||||
Assert.Equal(0, reread.SizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backfill_ScopedToInstance_IgnoresOthers()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
db.Resources.AddRange(
|
||||
new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image },
|
||||
new Resource { Id = "r2", InstanceId = "other-inst", Label = "B", Type = ResourceType.Image }
|
||||
);
|
||||
db.SaveChanges();
|
||||
|
||||
var report = RunBackfill(BuildController(db), instanceId: "inst-test");
|
||||
|
||||
Assert.Equal(1, report.Examined);
|
||||
Assert.Null(db.Resources.Single(r => r.Id == "r2").StoragePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
ManagerService.Tests/Infrastructure/FakeBlobService.cs
Normal file
31
ManagerService.Tests/Infrastructure/FakeBlobService.cs
Normal file
@ -0,0 +1,31 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ManagerService.Services;
|
||||
|
||||
namespace ManagerService.Tests.Infrastructure
|
||||
{
|
||||
/// <summary>
|
||||
/// Bucket en mémoire. Enregistre les chemins qu'on lui demande de supprimer, ce qui
|
||||
/// permet de vérifier non seulement que la suppression a eu lieu, mais qu'elle a porté
|
||||
/// sur le bon emplacement — le chemin étant reconstruit quand StoragePath est nul.
|
||||
/// </summary>
|
||||
public class FakeBlobService : IResourceBlobService
|
||||
{
|
||||
private readonly BlobDeleteOutcome _outcome;
|
||||
|
||||
public FakeBlobService(BlobDeleteOutcome outcome = BlobDeleteOutcome.Deleted)
|
||||
{
|
||||
_outcome = outcome;
|
||||
}
|
||||
|
||||
public List<string> DeletedPaths { get; } = new();
|
||||
|
||||
public bool IsConfigured => _outcome != BlobDeleteOutcome.NotConfigured;
|
||||
|
||||
public Task<BlobDeleteOutcome> DeleteAsync(string storagePath)
|
||||
{
|
||||
DeletedPaths.Add(storagePath);
|
||||
return Task.FromResult(_outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
69
ManagerService.Tests/Infrastructure/FakeHttpClientFactory.cs
Normal file
69
ManagerService.Tests/Infrastructure/FakeHttpClientFactory.cs
Normal file
@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ManagerService.Tests.Infrastructure
|
||||
{
|
||||
/// <summary>
|
||||
/// Fabrique d'HttpClient sans réseau, pour les chemins qui sondent le poids d'un blob
|
||||
/// par HEAD (<c>ResourceSizeProbe</c>). Les tailles sont données par URL ; une URL
|
||||
/// absente de la table répond sans <c>Content-Length</c>, ce qui est exactement le cas
|
||||
/// « blob introuvable » que le backfill doit inventorier au lieu de le compter pour zéro.
|
||||
/// </summary>
|
||||
public class FakeHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly IDictionary<string, long> _sizesByUrl;
|
||||
private readonly ISet<string> _throwingUrls;
|
||||
|
||||
public FakeHttpClientFactory(IDictionary<string, long> sizesByUrl = null, ISet<string> throwingUrls = null)
|
||||
{
|
||||
_sizesByUrl = sizesByUrl ?? new Dictionary<string, long>();
|
||||
_throwingUrls = throwingUrls ?? new HashSet<string>();
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name = "") =>
|
||||
new HttpClient(new StubHandler(_sizesByUrl, _throwingUrls));
|
||||
|
||||
private class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly IDictionary<string, long> _sizesByUrl;
|
||||
private readonly ISet<string> _throwingUrls;
|
||||
|
||||
public StubHandler(IDictionary<string, long> sizesByUrl, ISet<string> throwingUrls)
|
||||
{
|
||||
_sizesByUrl = sizesByUrl;
|
||||
_throwingUrls = throwingUrls;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = request.RequestUri!.ToString();
|
||||
|
||||
if (_throwingUrls.Contains(url))
|
||||
throw new HttpRequestException("hôte injoignable");
|
||||
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||
if (_sizesByUrl.TryGetValue(url, out var size))
|
||||
{
|
||||
// Content-Length ne se pose que sur un contenu : un HEAD réel renvoie
|
||||
// l'en-tête sans le corps, ce que StringContent + la longueur imite
|
||||
// suffisamment pour la sonde, qui ne lit que l'en-tête.
|
||||
response.Content = new ByteArrayContent(Array.Empty<byte>());
|
||||
response.Content.Headers.ContentLength = size;
|
||||
}
|
||||
else
|
||||
{
|
||||
response.StatusCode = HttpStatusCode.NotFound;
|
||||
response.Content = new ByteArrayContent(Array.Empty<byte>());
|
||||
response.Content.Headers.ContentLength = null;
|
||||
}
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -383,7 +383,9 @@ namespace ManagerService.Controllers
|
||||
var plan = _myInfoMateDbContext.SubscriptionPlans.FirstOrDefault(p => p.Id == instance.SubscriptionPlanId);
|
||||
if (plan != null)
|
||||
{
|
||||
if (storageQuota == 0) storageQuota = plan.StorageQuotaBytes;
|
||||
// Même résolveur que le pré-vol de ResourceController : le chiffre
|
||||
// affiché ici et celui qui bloque un téléversement doivent être le même.
|
||||
storageQuota = StorageQuota.Resolve(storageQuota, plan.StorageQuotaBytes);
|
||||
if (aiQuota == 0) aiQuota = plan.AiTokensPerMonth;
|
||||
}
|
||||
}
|
||||
|
||||
@ -226,36 +226,18 @@ namespace ManagerService.Controllers
|
||||
: _instanceSvc.GetAll().Select(i => i.Id).ToList();
|
||||
var source = instanceIds.SelectMany(id => _resourceSvc.GetAll(id)).ToList();
|
||||
|
||||
// Fetch sizes en parallèle par batches de 30
|
||||
// Sondage des tailles par HEAD, extrait dans ResourceSizeProbe : le backfill
|
||||
// des lignes existantes (C2) en a besoin à l'identique, et deux copies
|
||||
// divergeraient sur ce qui compte — le sort réservé aux échecs.
|
||||
// L'échec était avalé sans laisser de trace : la ressource arrivait à
|
||||
// 0 octet et le quota la comptait pour rien, sans que personne puisse
|
||||
// savoir lesquelles. La sonde les signale, y compris le cas ajouté à
|
||||
// l'extraction : une réponse sans Content-Length, qu'aucun catch n'attrapait.
|
||||
var httpClient = _httpClientFactory.CreateClient();
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
var sizemap = new System.Collections.Concurrent.ConcurrentDictionary<string, long>();
|
||||
var unsized = new System.Collections.Concurrent.ConcurrentDictionary<string, bool>();
|
||||
var urlSource = source.Where(r => !string.IsNullOrEmpty(r.Url)).ToList();
|
||||
|
||||
const int batchSize = 30;
|
||||
for (int i = 0; i < urlSource.Count; i += batchSize)
|
||||
{
|
||||
var batch = urlSource.Skip(i).Take(batchSize);
|
||||
await Task.WhenAll(batch.Select(async r =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Head, r.Url);
|
||||
var resp = await httpClient.SendAsync(req);
|
||||
if (resp.Content.Headers.ContentLength.HasValue)
|
||||
sizemap[r.Id] = resp.Content.Headers.ContentLength.Value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// L'échec était avalé sans laisser de trace : la ressource
|
||||
// arrivait à 0 octet et le quota de stockage la comptait pour
|
||||
// rien, sans que personne puisse savoir lesquelles. On le note.
|
||||
unsized[r.Id] = true;
|
||||
}
|
||||
}));
|
||||
}
|
||||
var probe = await ResourceSizeProbe.ProbeAsync(
|
||||
httpClient, source.Select(r => (r.Id, r.Url)));
|
||||
|
||||
foreach (var old in source)
|
||||
{
|
||||
@ -281,12 +263,12 @@ namespace ManagerService.Controllers
|
||||
// l'était à la main. Les deux passent par le même calculateur que la
|
||||
// création et le backfill (lien L5) — sinon les trois divergent sur
|
||||
// les types URL, qui n'ont ni blob ni poids à compter dans le quota.
|
||||
ResourceStorage.Apply(entity, sizemap.TryGetValue(old.Id, out var size) ? size : 0);
|
||||
ResourceStorage.Apply(entity, probe.TryGetSize(old.Id, out var size) ? size : 0);
|
||||
|
||||
if (!dryRun)
|
||||
_db.Resources.Add(entity);
|
||||
|
||||
if (unsized.ContainsKey(old.Id) && ResourceStorage.HasBlob(entity.Type))
|
||||
if (probe.IsUnsized(old.Id) && ResourceStorage.HasBlob(entity.Type))
|
||||
report.Errors.Add($"Resource {old.Id} ({old.Label}) : taille inconnue (HEAD en échec), SizeBytes à 0 — le quota de stockage la comptera pour rien");
|
||||
|
||||
report.Migrated.Resources++;
|
||||
|
||||
@ -3,6 +3,8 @@ using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Manager.DTOs;
|
||||
using Manager.Services;
|
||||
using ManagerService.Data;
|
||||
@ -30,18 +32,22 @@ namespace ManagerService.Controllers
|
||||
private SectionDatabaseService _sectionService;
|
||||
private ConfigurationDatabaseService _configurationService;
|
||||
private readonly ILogger<ResourceController> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IResourceBlobService _blobService;
|
||||
IHexIdGeneratorService idService = new HexIdGeneratorService();
|
||||
|
||||
private static int MaxWidth = 1024;
|
||||
private static int MaxHeight = 1024;
|
||||
|
||||
public ResourceController(ILogger<ResourceController> logger, ResourceDatabaseService resourceService, SectionDatabaseService sectionService, ConfigurationDatabaseService configurationService, MyInfoMateDbContext myInfoMateDbContext)
|
||||
public ResourceController(ILogger<ResourceController> logger, ResourceDatabaseService resourceService, SectionDatabaseService sectionService, ConfigurationDatabaseService configurationService, MyInfoMateDbContext myInfoMateDbContext, IHttpClientFactory httpClientFactory, IResourceBlobService blobService)
|
||||
{
|
||||
_logger = logger;
|
||||
_resourceService = resourceService;
|
||||
_sectionService = sectionService;
|
||||
_configurationService = configurationService;
|
||||
_myInfoMateDbContext = myInfoMateDbContext;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_blobService = blobService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -234,19 +240,9 @@ namespace ManagerService.Controllers
|
||||
var resourceType = (ResourceType)Enum.Parse(typeof(ResourceType), type);
|
||||
List<Resource> resources = new List<Resource>();
|
||||
|
||||
var instance = _myInfoMateDbContext.Instances
|
||||
.Include(i => i.SubscriptionPlan)
|
||||
.FirstOrDefault(i => i.Id == instanceId);
|
||||
var storageQuota = instance?.SubscriptionPlan?.StorageQuotaBytes ?? 0;
|
||||
if (storageQuota > 0)
|
||||
{
|
||||
var storageUsed = _myInfoMateDbContext.Resources
|
||||
.Where(r => r.InstanceId == instanceId)
|
||||
.Sum(r => (long?)r.SizeBytes) ?? 0;
|
||||
var incomingBytes = Request.Form.Files.Sum(f => f.Length);
|
||||
if (storageUsed + incomingBytes > storageQuota)
|
||||
return new ObjectResult("Quota de stockage dépassé") { StatusCode = 413 };
|
||||
}
|
||||
var incomingBytes = Request.Form.Files.Sum(f => f.Length);
|
||||
if (ExceedsStorageQuota(instanceId, incomingBytes))
|
||||
return new ObjectResult("Quota de stockage dépassé") { StatusCode = 413 };
|
||||
|
||||
foreach (var file in Request.Form.Files)
|
||||
{
|
||||
@ -323,7 +319,15 @@ namespace ManagerService.Controllers
|
||||
if (newResource == null)
|
||||
throw new ArgumentNullException("Resource param is null");
|
||||
|
||||
// Todo add some verification ?
|
||||
// Le pré-vol manquait sur ce chemin : manager-app crée la ligne par JSON
|
||||
// en annonçant sizeBytes, PUIS téléverse le blob. Sans contrôle ici, tout
|
||||
// dépassement passait par la porte de service — seul le chemin multipart
|
||||
// était gardé.
|
||||
if (ResourceStorage.HasBlob(newResource.type)
|
||||
&& ExceedsStorageQuota(newResource.instanceId, newResource.sizeBytes))
|
||||
return new ObjectResult("Quota de stockage dépassé") { StatusCode = 413 };
|
||||
|
||||
// Todo add some verification ?
|
||||
Resource resource = new Resource();
|
||||
resource.InstanceId = newResource.instanceId;
|
||||
resource.Label = newResource.label;
|
||||
@ -387,6 +391,19 @@ namespace ManagerService.Controllers
|
||||
if (updatedResource.sizeBytes > 0)
|
||||
resource.SizeBytes = updatedResource.sizeBytes;
|
||||
|
||||
// Angle mort laissé ouvert par C1, tranché ici : un Update peut faire passer
|
||||
// Type d'un type URL à un type fichier, laissant StoragePath nul sur une ligne
|
||||
// qui a désormais un blob. La crainte d'alors — « recalculer pointerait vers un
|
||||
// objet qui n'a pas bougé » — ne tient pas : PathFor ne construit qu'un
|
||||
// pictures/{instanceId}/{resourceId}, le type n'entre pas dans le chemin, il
|
||||
// décide seulement s'il y en a un. Recalculer est donc sans risque.
|
||||
ResourceStorage.Apply(resource, resource.SizeBytes);
|
||||
if (!ResourceStorage.HasBlob(resource.Type))
|
||||
{
|
||||
resource.StoragePath = null;
|
||||
resource.SizeBytes = 0;
|
||||
}
|
||||
|
||||
_myInfoMateDbContext.SaveChanges();
|
||||
|
||||
//OldResource resourceModified = _resourceService.Update(updatedResource.id, resource);
|
||||
@ -416,8 +433,9 @@ namespace ManagerService.Controllers
|
||||
[ProducesResponseType(typeof(string), 400)]
|
||||
[ProducesResponseType(typeof(string), 404)]
|
||||
[ProducesResponseType(typeof(string), 500)]
|
||||
[ProducesResponseType(typeof(string), 502)]
|
||||
[HttpDelete("{id}")]
|
||||
public ObjectResult Delete(string id)
|
||||
public async Task<ObjectResult> Delete(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
@ -429,6 +447,17 @@ namespace ManagerService.Controllers
|
||||
if (resource == null)
|
||||
throw new KeyNotFoundException("Resource does not exist");
|
||||
|
||||
// Le blob part AVANT la ligne. manager-app faisait l'inverse et avalait
|
||||
// l'échec : la ligne disparaissait, le blob restait, et n'ayant plus de
|
||||
// ligne il devenait invisible au quota tout en restant facturé. Ici un
|
||||
// échec interrompt : mieux vaut une ressource encore listée qu'un orphelin
|
||||
// que plus rien ne désigne.
|
||||
var storagePath = resource.StoragePath
|
||||
?? ResourceStorage.PathFor(resource.Type, resource.InstanceId, resource.Id);
|
||||
var outcome = await _blobService.DeleteAsync(storagePath);
|
||||
if (outcome == BlobDeleteOutcome.Failed)
|
||||
return new ObjectResult("Le fichier n'a pas pu être supprimé du stockage — la ressource est conservée") { StatusCode = 502 };
|
||||
|
||||
|
||||
List<Configuration> configurations = _myInfoMateDbContext.Configurations.Where(c => c.InstanceId == resource.InstanceId).ToList();
|
||||
foreach (var configuration in configurations)
|
||||
@ -704,6 +733,127 @@ namespace ManagerService.Controllers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renseigne StoragePath et SizeBytes sur les ressources déjà en base (C2).
|
||||
///
|
||||
/// Ces colonnes sont nées après le contenu : les lignes créées avant portent un
|
||||
/// StoragePath nul, et un SizeBytes nul pour celles créées avant que le chemin
|
||||
/// multipart ne l'écrive. Un quota calculé là-dessus laisserait tout passer,
|
||||
/// d'où l'ordre imposé — ce backfill précède le contrôle autoritaire (lien L10).
|
||||
///
|
||||
/// dryRun est à true par défaut, à l'inverse de la migration : celle-ci se joue
|
||||
/// sur une base vide, celui-ci sur des lignes de production.
|
||||
/// </summary>
|
||||
[Authorize(Policy = ManagerService.Service.Security.Policies.SuperAdmin)]
|
||||
[ProducesResponseType(typeof(ResourceBackfillReportDTO), 200)]
|
||||
[ProducesResponseType(typeof(string), 500)]
|
||||
[HttpPost("backfill-storage")]
|
||||
public async Task<ObjectResult> BackfillStorage(
|
||||
[FromQuery] bool dryRun = true,
|
||||
[FromQuery] string instanceId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var report = new ResourceBackfillReportDTO { DryRun = dryRun };
|
||||
|
||||
var query = _myInfoMateDbContext.Resources.AsQueryable();
|
||||
if (!string.IsNullOrEmpty(instanceId))
|
||||
query = query.Where(r => r.InstanceId == instanceId);
|
||||
|
||||
var resources = await query.ToListAsync();
|
||||
report.Examined = resources.Count;
|
||||
|
||||
// Un type URL ne doit porter ni chemin ni poids : il pointe hors du bucket.
|
||||
// Le cas ne devrait pas exister, mais une ligne mal renseignée gonflerait
|
||||
// le quota d'octets que personne ne stocke — on la remet dans le contrat
|
||||
// de ResourceStorage plutôt que de la laisser fausser le calcul de C3.
|
||||
foreach (var resource in resources.Where(r => !ResourceStorage.HasBlob(r.Type)))
|
||||
{
|
||||
if (resource.StoragePath == null && resource.SizeBytes == 0) continue;
|
||||
resource.StoragePath = null;
|
||||
resource.SizeBytes = 0;
|
||||
report.UrlTypesNormalised++;
|
||||
}
|
||||
|
||||
var blobs = resources.Where(r => ResourceStorage.HasBlob(r.Type)).ToList();
|
||||
|
||||
// Seules les tailles inconnues sont sondées : une valeur déjà en base a été
|
||||
// écrite par la création, qui connaissait le fichier réel. La resonder
|
||||
// coûterait un HEAD par ressource pour un résultat identique.
|
||||
var toProbe = blobs.Where(r => r.SizeBytes == 0).ToList();
|
||||
|
||||
var httpClient = _httpClientFactory.CreateClient();
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||
var probe = await ResourceSizeProbe.ProbeAsync(
|
||||
httpClient, toProbe.Select(r => (r.Id, r.Url)));
|
||||
|
||||
foreach (var resource in blobs)
|
||||
{
|
||||
var hadPath = !string.IsNullOrEmpty(resource.StoragePath);
|
||||
var hadSize = resource.SizeBytes > 0;
|
||||
|
||||
// Le chemin est déterministe, la taille ne l'est pas : on repasse par
|
||||
// le calculateur commun en lui redonnant la taille connue quand elle
|
||||
// l'est, plutôt que d'écrire les colonnes à la main ici (lien L5).
|
||||
var size = hadSize
|
||||
? resource.SizeBytes
|
||||
: (probe.TryGetSize(resource.Id, out var probed) ? probed : 0);
|
||||
|
||||
ResourceStorage.Apply(resource, size);
|
||||
|
||||
if (!hadPath) report.StoragePathFilled++;
|
||||
if (!hadSize && size > 0) report.SizeBytesFilled++;
|
||||
if (hadPath && hadSize) report.AlreadyComplete++;
|
||||
|
||||
if (!hadSize && size == 0)
|
||||
{
|
||||
// Deux causes distinctes, à ne pas confondre dans un même total :
|
||||
// une ligne sans URL n'est pas sondable du tout (le blob n'a
|
||||
// peut-être jamais été téléversé), une ligne sondée sans réponse
|
||||
// exploitable pointe vers un objet absent ou un bucket muet.
|
||||
if (string.IsNullOrEmpty(resource.Url))
|
||||
report.Orphans.Add($"{resource.Id} ({resource.Label}) — type {resource.Type}, aucune URL : blob jamais téléversé ?");
|
||||
else
|
||||
report.Unsized.Add($"{resource.Id} ({resource.Label}) — HEAD sans Content-Length sur {resource.Url}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun)
|
||||
await _myInfoMateDbContext.SaveChangesAsync();
|
||||
|
||||
return new OkObjectResult(report);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ObjectResult(ex.Message) { StatusCode = 500 };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pré-vol du quota, partagé par les deux chemins de création. Passe par
|
||||
/// <see cref="StorageQuota"/> pour que le chiffre opposé au client soit celui
|
||||
/// que son écran de quota lui annonce.
|
||||
/// </summary>
|
||||
private bool ExceedsStorageQuota(string instanceId, long incomingBytes)
|
||||
{
|
||||
if (string.IsNullOrEmpty(instanceId)) return false;
|
||||
|
||||
var instance = _myInfoMateDbContext.Instances
|
||||
.Include(i => i.SubscriptionPlan)
|
||||
.FirstOrDefault(i => i.Id == instanceId);
|
||||
if (instance == null) return false;
|
||||
|
||||
var quota = StorageQuota.Resolve(
|
||||
instance.StorageQuotaBytes, instance.SubscriptionPlan?.StorageQuotaBytes ?? 0);
|
||||
if (quota <= 0) return false;
|
||||
|
||||
var used = _myInfoMateDbContext.Resources
|
||||
.Where(r => r.InstanceId == instanceId)
|
||||
.Sum(r => (long?)r.SizeBytes) ?? 0;
|
||||
|
||||
return StorageQuota.Exceeds(used, incomingBytes, quota);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Le filigrane est un réglage d'instance depuis le 2026-08-11. Auparavant
|
||||
/// un `instanceId == "633ee379…"` en dur ne l'activait que pour Fort Saint-Héribert.
|
||||
@ -714,4 +864,21 @@ namespace ManagerService.Controllers
|
||||
.Select(i => i.IsImageWatermark)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Le backfill ne se contente pas de compter : les deux listes sont l'inventaire
|
||||
/// des lignes qu'il n'a pas pu réparer. C'est la seule sortie qui dise combien
|
||||
/// d'octets le quota de C3 ne verra jamais.
|
||||
/// </summary>
|
||||
public class ResourceBackfillReportDTO
|
||||
{
|
||||
public bool DryRun { get; set; }
|
||||
public int Examined { get; set; }
|
||||
public int StoragePathFilled { get; set; }
|
||||
public int SizeBytesFilled { get; set; }
|
||||
public int AlreadyComplete { get; set; }
|
||||
public int UrlTypesNormalised { get; set; }
|
||||
public List<string> Orphans { get; set; } = new();
|
||||
public List<string> Unsized { get; set; } = new();
|
||||
}
|
||||
}
|
||||
|
||||
86
ManagerService/Helpers/ResourceSizeProbe.cs
Normal file
86
ManagerService/Helpers/ResourceSizeProbe.cs
Normal file
@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ManagerService.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Sonde le poids d'un blob par requête HEAD sur son URL publique, par lots.
|
||||
///
|
||||
/// Deux appelants : <c>MigrationController</c> (écart e de la bascule) et le backfill
|
||||
/// des lignes existantes. Comme <see cref="ResourceStorage"/>, c'est un calculateur
|
||||
/// unique plutôt que deux implémentations qui divergeraient — ici sur la taille de lot,
|
||||
/// le délai d'attente et surtout le sort réservé aux échecs.
|
||||
///
|
||||
/// ⚠️ Le serveur n'a aucun client de bucket : ni Firebase Admin ni Google.Cloud.Storage
|
||||
/// ne sont référencés dans le projet, les blobs étant téléversés depuis manager-app
|
||||
/// directement. L'URL publique est donc le seul moyen de connaître une taille.
|
||||
/// </summary>
|
||||
public static class ResourceSizeProbe
|
||||
{
|
||||
/// <summary>Trente requêtes en vol à la fois, valeur héritée de la migration.</summary>
|
||||
public const int BatchSize = 30;
|
||||
|
||||
public sealed class Result
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, long> _sizes = new();
|
||||
private readonly ConcurrentDictionary<string, bool> _unsized = new();
|
||||
|
||||
public bool TryGetSize(string id, out long size) => _sizes.TryGetValue(id, out size);
|
||||
|
||||
/// <summary>
|
||||
/// Vrai si la sonde n'a pas su donner de taille : HEAD en échec, ou réponse
|
||||
/// sans <c>Content-Length</c>. Les deux cas laissent la ressource à 0 octet,
|
||||
/// donc invisible au quota — l'appelant doit le signaler, pas l'avaler.
|
||||
/// </summary>
|
||||
public bool IsUnsized(string id) => _unsized.ContainsKey(id);
|
||||
|
||||
public int SizedCount => _sizes.Count;
|
||||
public int UnsizedCount => _unsized.Count;
|
||||
|
||||
internal void Record(string id, long size) => _sizes[id] = size;
|
||||
internal void RecordUnsized(string id) => _unsized[id] = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Le <paramref name="client"/> est fourni par l'appelant, qui reste maître du
|
||||
/// délai d'attente : la migration accepte 10 s par ressource, un backfill lancé
|
||||
/// à la main peut vouloir plus.
|
||||
/// </summary>
|
||||
public static async Task<Result> ProbeAsync(HttpClient client, IEnumerable<(string Id, string Url)> targets)
|
||||
{
|
||||
var result = new Result();
|
||||
var list = targets.Where(t => !string.IsNullOrEmpty(t.Url)).ToList();
|
||||
|
||||
for (int i = 0; i < list.Count; i += BatchSize)
|
||||
{
|
||||
var batch = list.Skip(i).Take(BatchSize);
|
||||
await Task.WhenAll(batch.Select(async target =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Head, target.Url);
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
// Un 404 ne lève pas : il répond simplement sans Content-Length.
|
||||
// Le traiter comme un échec est le seul moyen de ne pas confondre
|
||||
// « blob absent » et « blob de 0 octet ».
|
||||
if (response.Content.Headers.ContentLength.HasValue)
|
||||
result.Record(target.Id, response.Content.Headers.ContentLength.Value);
|
||||
else
|
||||
result.RecordUnsized(target.Id);
|
||||
}
|
||||
catch
|
||||
{
|
||||
result.RecordUnsized(target.Id);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
ManagerService/Helpers/StorageQuota.cs
Normal file
29
ManagerService/Helpers/StorageQuota.cs
Normal file
@ -0,0 +1,29 @@
|
||||
namespace ManagerService.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Seule source de vérité pour « quel quota de stockage s'applique à cette instance ».
|
||||
///
|
||||
/// Les deux lectures divergeaient : le pré-vol du téléversement lisait le quota du
|
||||
/// **plan**, l'écran de quota lisait celui de l'**instance** avec le plan en repli.
|
||||
/// Une instance dont le quota a été surchargé — le mécanisme même de l'add-on —
|
||||
/// affichait donc un chiffre et se faisait bloquer sur un autre.
|
||||
/// </summary>
|
||||
public static class StorageQuota
|
||||
{
|
||||
/// <summary>
|
||||
/// La surcharge portée par l'instance l'emporte ; 0 veut dire « pas de surcharge »
|
||||
/// et renvoie au plan. ⚠️ Au niveau du plan, 0 garde son autre sens : illimité.
|
||||
/// L'asymétrie est volontaire et documentée au §1quinquies — ne pas l'harmoniser
|
||||
/// sans repasser dans CheckQuota, AiController et SectionIndexingInterceptor.
|
||||
/// </summary>
|
||||
public static long Resolve(long instanceQuotaBytes, long planQuotaBytes) =>
|
||||
instanceQuotaBytes != 0 ? instanceQuotaBytes : planQuotaBytes;
|
||||
|
||||
/// <summary>
|
||||
/// Un quota résolu à 0 est illimité : rien ne dépasse. C'est le cas d'Enterprise,
|
||||
/// et c'est aussi ce qui protège une instance sans plan d'être bloquée à zéro octet.
|
||||
/// </summary>
|
||||
public static bool Exceeds(long usedBytes, long incomingBytes, long quotaBytes) =>
|
||||
quotaBytes > 0 && usedBytes + incomingBytes > quotaBytes;
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Google.Cloud.Storage.V1" Version="4.15.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="2.1.30" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
@ -26,6 +27,8 @@ namespace ManagerService.Services
|
||||
/// </summary>
|
||||
private const int BatchSize = 50;
|
||||
|
||||
private const int MaxThrottleRetries = 3;
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly string _apiKey;
|
||||
|
||||
@ -74,7 +77,24 @@ namespace ManagerService.Services
|
||||
Dimensions = Dimensions
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync(Endpoint, payload, cancellationToken);
|
||||
HttpResponseMessage response;
|
||||
|
||||
// Une limite de débit est passagère : la remonter ferait échouer le job Hangfire,
|
||||
// qui retenterait la section entière — donc ré-embedderait ses morceaux déjà faits.
|
||||
// Retry-After d'abord, exponentiel s'il est absent.
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
response = await client.PostAsJsonAsync(Endpoint, payload, cancellationToken);
|
||||
|
||||
var throttled = response.StatusCode == HttpStatusCode.TooManyRequests
|
||||
|| response.StatusCode == HttpStatusCode.ServiceUnavailable;
|
||||
|
||||
if (!throttled || attempt >= MaxThrottleRetries)
|
||||
break;
|
||||
|
||||
var wait = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(Math.Pow(2, attempt));
|
||||
await Task.Delay(wait, cancellationToken);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
|
||||
@ -12,10 +12,10 @@ namespace ManagerService.Services
|
||||
Task IngestSectionAsync(string sectionId);
|
||||
|
||||
/// <summary>
|
||||
/// Réindexe toutes les sections d'une instance et rend le nombre de morceaux produits.
|
||||
/// Sert au rattrapage après un passage à un plan avec IA : sans lui, le client paie un
|
||||
/// guide qui ne connaît rien de son contenu, puisque rien n'a été indexé tant qu'il
|
||||
/// était sur un plan sans IA.
|
||||
/// Met en file un job d'indexation par section de l'instance, et rend le nombre de
|
||||
/// sections mises en file. Sert au rattrapage après un passage à un plan avec IA :
|
||||
/// sans lui, le client paie un guide qui ne connaît rien de son contenu, puisque rien
|
||||
/// n'a été indexé tant qu'il était sur un plan sans IA.
|
||||
/// </summary>
|
||||
Task<int> BackfillInstanceAsync(string instanceId);
|
||||
}
|
||||
|
||||
@ -32,20 +32,20 @@ namespace ManagerService.Services
|
||||
private readonly IVectorStoreService _vectorStore;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<IngestionService> _logger;
|
||||
private readonly IBackgroundJobClient _jobs;
|
||||
|
||||
public IngestionService(MyInfoMateDbContext db, IVectorStoreService vectorStore,
|
||||
IConfiguration configuration, ILogger<IngestionService> logger)
|
||||
IConfiguration configuration, ILogger<IngestionService> logger,
|
||||
IBackgroundJobClient jobs)
|
||||
{
|
||||
_db = db;
|
||||
_vectorStore = vectorStore;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_jobs = jobs;
|
||||
}
|
||||
|
||||
public Task IngestSectionAsync(string sectionId) => IngestSectionCountingAsync(sectionId);
|
||||
|
||||
/// <summary>Même travail, en rendant le nombre de morceaux — ce dont le backfill a besoin.</summary>
|
||||
private async Task<int> IngestSectionCountingAsync(string sectionId)
|
||||
public async Task IngestSectionAsync(string sectionId)
|
||||
{
|
||||
var stub = await _db.Sections.AsNoTracking().FirstOrDefaultAsync(s => s.Id == sectionId);
|
||||
|
||||
@ -53,7 +53,7 @@ namespace ManagerService.Services
|
||||
if (stub == null)
|
||||
{
|
||||
await _vectorStore.DeleteAsync(sectionId, ContentSourceType.Section);
|
||||
return 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-vérifié ici et pas seulement à l'enqueue : le plan a pu changer entre les deux,
|
||||
@ -68,13 +68,13 @@ namespace ManagerService.Services
|
||||
// de paiement réglé le lendemain ne doit pas imposer de tout ré-embedder.
|
||||
// Voir DOCS/v2/rag-indexing-trigger-decision.md.
|
||||
if (!hasAi)
|
||||
return 0;
|
||||
return;
|
||||
|
||||
// Une section désactivée doit disparaître des réponses du guide, pas seulement de l'app.
|
||||
if (!stub.IsActive)
|
||||
{
|
||||
await _vectorStore.DeleteAsync(sectionId, ContentSourceType.Section);
|
||||
return 0;
|
||||
return;
|
||||
}
|
||||
|
||||
var section = await LoadWithChildrenAsync(stub);
|
||||
@ -84,9 +84,14 @@ namespace ManagerService.Services
|
||||
section.Id, ContentSourceType.Section, chunks);
|
||||
|
||||
_logger.LogInformation("Section {SectionId} indexée : {ChunkCount} morceaux", sectionId, chunks.Count);
|
||||
return chunks.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Un job par section plutôt qu'une boucle : l'unité de reprise devient la section.
|
||||
/// En boucle, un échec d'embedding à la 280ᵉ section faisait retenter le job entier par
|
||||
/// Hangfire, donc ré-embedder les 279 déjà indexées — des appels facturés pour rien, et
|
||||
/// autant de chances de retomber sur la même limite de débit.
|
||||
/// </summary>
|
||||
public async Task<int> BackfillInstanceAsync(string instanceId)
|
||||
{
|
||||
var sectionIds = await _db.Sections
|
||||
@ -94,13 +99,12 @@ namespace ManagerService.Services
|
||||
.Select(s => s.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var chunkCount = 0;
|
||||
foreach (var id in sectionIds)
|
||||
chunkCount += await IngestSectionCountingAsync(id);
|
||||
_jobs.Enqueue<IIngestionService>(s => s.IngestSectionAsync(id));
|
||||
|
||||
_logger.LogInformation("Backfill instance {InstanceId} : {Count} sections, {ChunkCount} morceaux",
|
||||
instanceId, sectionIds.Count, chunkCount);
|
||||
return chunkCount;
|
||||
_logger.LogInformation("Backfill instance {InstanceId} : {Count} sections mises en file",
|
||||
instanceId, sectionIds.Count);
|
||||
return sectionIds.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
98
ManagerService/Services/ResourceBlobService.cs
Normal file
98
ManagerService/Services/ResourceBlobService.cs
Normal file
@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Apis.Auth.OAuth2;
|
||||
using Google.Cloud.Storage.V1;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ManagerService.Services
|
||||
{
|
||||
public enum BlobDeleteOutcome
|
||||
{
|
||||
/// <summary>Objet supprimé.</summary>
|
||||
Deleted,
|
||||
|
||||
/// <summary>
|
||||
/// Rien à supprimer : blob jamais téléversé, ou déjà supprimé. Ce n'est pas un
|
||||
/// échec — c'est même l'état recherché, donc l'appelant peut continuer.
|
||||
/// </summary>
|
||||
AlreadyAbsent,
|
||||
|
||||
/// <summary>
|
||||
/// Le bucket n'est pas configuré. La suppression est alors sautée, jamais
|
||||
/// silencieusement réussie : un environnement mal configuré ne doit pas
|
||||
/// ressembler à un environnement qui nettoie.
|
||||
/// </summary>
|
||||
NotConfigured,
|
||||
|
||||
/// <summary>Le bucket a répondu autre chose. L'appelant ne doit pas supprimer la ligne.</summary>
|
||||
Failed
|
||||
}
|
||||
|
||||
public interface IResourceBlobService
|
||||
{
|
||||
bool IsConfigured { get; }
|
||||
Task<BlobDeleteOutcome> DeleteAsync(string storagePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Suppression d'un blob dans le bucket, côté serveur.
|
||||
///
|
||||
/// Jusqu'ici seul manager-app supprimait, après avoir supprimé la ligne et en
|
||||
/// avalant l'échec dans un print : un échec laissait un orphelin définitif, et
|
||||
/// comme sa ligne n'existait plus, le quota ne le comptait pas — il occupait des
|
||||
/// octets facturés que personne ne voyait.
|
||||
///
|
||||
/// Aucun secret nouveau : le credential est celui que Startup charge déjà pour les
|
||||
/// notifications push (Firebase:CredentialsPath). Seul le nom du bucket s'ajoute.
|
||||
/// </summary>
|
||||
public class ResourceBlobService : IResourceBlobService
|
||||
{
|
||||
private readonly ILogger<ResourceBlobService> _logger;
|
||||
private readonly string _bucket;
|
||||
private readonly Lazy<StorageClient> _client;
|
||||
|
||||
public ResourceBlobService(IConfiguration configuration, ILogger<ResourceBlobService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_bucket = configuration["Firebase:StorageBucket"];
|
||||
var credentialsPath = configuration["Firebase:CredentialsPath"];
|
||||
|
||||
// Lazy : ne pas lire le fichier de credentials au démarrage pour un service
|
||||
// dont la plupart des requêtes n'ont pas besoin, et ne pas faire échouer le
|
||||
// boot d'un environnement de développement qui n'a pas le fichier.
|
||||
_client = new Lazy<StorageClient>(() =>
|
||||
StorageClient.Create(GoogleCredential.FromFile(credentialsPath)));
|
||||
|
||||
IsConfigured = !string.IsNullOrEmpty(_bucket)
|
||||
&& !string.IsNullOrEmpty(credentialsPath)
|
||||
&& System.IO.File.Exists(credentialsPath);
|
||||
}
|
||||
|
||||
public bool IsConfigured { get; }
|
||||
|
||||
public async Task<BlobDeleteOutcome> DeleteAsync(string storagePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(storagePath))
|
||||
return BlobDeleteOutcome.AlreadyAbsent;
|
||||
|
||||
if (!IsConfigured)
|
||||
return BlobDeleteOutcome.NotConfigured;
|
||||
|
||||
try
|
||||
{
|
||||
await _client.Value.DeleteObjectAsync(_bucket, storagePath);
|
||||
return BlobDeleteOutcome.Deleted;
|
||||
}
|
||||
catch (Google.GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return BlobDeleteOutcome.AlreadyAbsent;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Suppression du blob {StoragePath} en échec sur le bucket {Bucket}", storagePath, _bucket);
|
||||
return BlobDeleteOutcome.Failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -203,6 +203,10 @@ namespace ManagerService
|
||||
});
|
||||
}
|
||||
services.AddSingleton<NotificationService>();
|
||||
// Réutilise le credential Firebase ci-dessus ; seul Firebase:StorageBucket
|
||||
// s'ajoute à la configuration. Non configuré, le service ne supprime rien
|
||||
// et le dit — il ne fait jamais semblant d'avoir nettoyé.
|
||||
services.AddSingleton<IResourceBlobService, ResourceBlobService>();
|
||||
services.AddHttpClient();
|
||||
services.AddScoped<AgendaSyncService>();
|
||||
services.AddScoped<WeatherSyncService>();
|
||||
|
||||
@ -42,7 +42,8 @@
|
||||
"SearchTopK": 5
|
||||
},
|
||||
"Firebase": {
|
||||
"CredentialsPath": "firebase-adminsdk.json"
|
||||
"CredentialsPath": "firebase-adminsdk.json",
|
||||
"StorageBucket": ""
|
||||
},
|
||||
"AppUrls": {
|
||||
"ManagerApp": "https://manager.myinfomate.be",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user