Open Closed

IHasConcurrencyStamp, AbpDbConcurrencyException - Doesn't work #9047


User avatar
0
starting created

Hi,

please take a look at the code, I get (after some time) "Die von Ihnen übermittelten Daten wurden bereits von einem anderen Benutzer/Kunden geändert. Bitte verwerfen Sie die vorgenommenen Änderungen und versuchen Sie es von vorne" when the frontend sends request for the "UpdateAsync". Please give me the corrected code fragments!

  • Exception message and full stack trace:"Die von Ihnen übermittelten Daten wurden bereits von einem anderen Benutzer/Kunden geändert. Bitte verwerfen Sie die vorgenommenen Änderungen und versuchen Sie es von vorne"

  • Steps to reproduce the issue: When the frontend sends request for the "UpdateAsync"


24 Answer(s)
  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Hi,

    It's normal because concurrency occurred.

    I can see you are trying to catch the AbpDbConcurrencyException, but it may not work because EFcore saves changes after the code block.

    Try this.

    await _meetingRepository.UpdateAsync(meeting, autoSave: true);
    

    Here is the document about Concurrency Check https://abp.io/docs/latest/framework/infrastructure/concurrency-check

    BTW, I do not recommend sharing your project link here, this is not safe. You can send it to the support team via email.

  • User Avatar
    0
    starting created

    Is there a pssebility to deactivate the Concurrency Check entirely?:

    That I dont get:
    "The data you have submitted has already been changed by another user. Discard your changes and try again."

    I chagned the classes, but I still get it (after time): "
    [Serializable]
    public class MeetingDto : EntityDto<Guid>//, IHasConcurrencyStamp
    {
    public string Title { get; set; }
    public DateTime UpdatedAt { get; set; }

    public DateTime ScheduledTime { get; set; }
    public List<ParticipantDto> Participants { get; set; }
    public List<MoodTypeDto> AllMoodTypes { get; set; }
    //public List<MoodVoteDto> UserMoodVotes { get; set; }
    
    public List<MessageDto> UserMessages { get; set; }
    //public string ConcurrencyStamp { get; set; }  // muss vom Client mitgesendet werden
    

    }

    [Serializable]
    public class ParticipantDto : EntityDto<Guid>
    {
    public Guid UserId { get; set; }
    public Guid MeetingId { get; set; }

    public UserModeDto eUserMode { get; set; }
    
    public List<MoodVoteDto> MoodVotes { get; set; }
    public List<MessageDto> Messages { get; set; }
    public DateTime LastHeartbeat { get; set; }
    public string UserAgent { get; set; }
    public bool IsInactive { get; set; }
    

    }

    [Serializable]
    public class MessageDto : CreationAuditedEntityDto<Guid>
    {
    public Guid ParticipantId { get; set; }
    public string CurrentMessage { get; set; }
    public DateTime CreatedAt { get; set; }
    }

    [Serializable]
    public class MoodVoteDto : EntityDto<Guid>
    {
    public bool Selected { get; set; } // Wird im FrontEnd gebraucht
    public Guid ParticipantId { get; set; }

    public Guid MoodTypeId { get; set; }
    public DateTime VoteTime { get; set; }
    

    }

    [Serializable]
    public class MoodTypeDto : EntityDto<Guid>
    {
    public bool Selected { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public string Color { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }

    public bool Delete { get; set; } = false;
    

    }

    [Serializable]
    public class MeetingStatusDto : EntityDto<Guid>
    {
    public Guid MeetingId { get; set; }
    public int TotalParticipants { get; set; }
    public List<MoodSummaryDto> MoodSummary { get; set; }
    public List<MessageDto> Messages { get; set; }
    }

    [Serializable]
    public class MoodSummaryDto : EntityDto<Guid>
    {
    public Guid MoodTypeId { get; set; }
    public int VoteCount { get; set; }
    }

    [Serializable]
    public class UserAccessDto : EntityDto<Guid>
    {
    public Guid ParticipantId { get; set; }
    public string UserAgent { get; set; }
    public UserModeDto eUserMode { get; set; }
    public string LastSeen { get; set; }
    public string IsActive { get; set; }

    }" and "
    public class Meeting :BasicAggregateRoot<Guid>//, IHasConcurrencyStamp
    {
    //public override string ConcurrencyStamp { get; set; } = null;
    public string Title { get; set; }
    public DateTime ScheduledTime { get; set; }
    public DateTime UpdatedAt { get; set; }

    public List<Participant> Participants { get; set; }
    
    public Meeting(Guid id, string title, DateTime scheduledTime)
        : base(id)
    {
        Title = title;
        ScheduledTime = scheduledTime;
        Participants = new List<Participant>();
    }
    

    }

    public class Participant : Entity<Guid>
    {
    public Guid UserId { get; set; }
    public Guid MeetingId { get; set; }
    public UserMode eUserMode { get; set; }
    public Meeting Meeting { get; set; }
    public List<MoodVote> MoodVotes { get; set; }
    public List<Message> Messages { get; set; }
    public DateTime LastHeartbeat { get; set; }
    public string UserAgent { get; set; }
    public bool IsInactive { get; set; }
    }

    public class Message : Entity<Guid>
    {

    public Guid ParticipantId { get; set; }
    public Participant Participant { get; set; }
    public string CurrentMessage { get; set; }
    public DateTime CreatedAt { get; set; }
    

    }

    public class MoodVote : Entity<Guid>
    {
    public Guid ParticipantId { get; set; }
    public Participant Participant { get; set; }
    public Guid MoodTypeId { get; set; }
    public MoodType MoodType { get; set; }
    public DateTime VoteTime { get; set; }
    }

    public class MoodType : Entity<Guid>
    {
    [NotMapped]
    public bool Selected { get; set; } // Wird im FrontEnd gebraucht

    public string Name { get; set; }
    public string Description { get; set; }
    public string Color { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }
    

    }

    public enum UserMode
    {
    User = 0,
    Admin = 1,
    Verwalter = 2
    }"

  • User Avatar
    0
    starting created

    with that: "
    public async Task<MeetingDto> UpdateAsync(Guid meetingId, Guid userId, MeetingDto input, UserModeDto userMode)
    {
    #region Allgemein: Validierung
    // Lade das Meeting mit zugehörigen Details (Participants) aus dem Repository
    var queryableMeeting = await _meetingRepository.WithDetailsAsync(m => m.Participants);
    var meetingQuery = queryableMeeting.Where(x => x.Id == meetingId);
    var meeting = await AsyncExecuter.FirstOrDefaultAsync(meetingQuery);

        // Überprüfe, ob das Meeting existiert
        if (meeting == null)
        {
            throw new BusinessException($"Meeting mit Id {meetingId} nicht gefunden.");
        }
    
        // Prüfe, ob der anfragende Benutzer Teilnehmer des Meetings ist
        var participant = meeting.Participants.FirstOrDefault(p => p.UserId == userId);
        if (participant == null)
        {
            throw new BusinessException("Sie haben keinen Zugriff auf dieses Meeting.");
        }
    
        #endregion
    
        // Optimistic Concurrency: Setze den ConcurrencyStamp des geladenen Meeting-Entities 
        // auf den vom Frontend gelieferten Wert. So erkennt das EF/ABP-Framework, 
        // falls das Meeting zwischenzeitlich von jemand anderem geändert wurde.
        //meeting.ConcurrencyStamp = input.ConcurrencyStamp;
    
        // Alle folgenden DB-Änderungen laufen in einer Transaktion (ABP UnitOfWork),
        // um Konsistenz sicherzustellen – entweder werden alle Änderungen gespeichert oder keine.
        try
        {
            #region Globale Überschreibung der AllMoodTypes (nur Admin)
            if (userMode == UserModeDto.Admin)
            {
                // 1. Lösche alle MoodTypes, die vom Administrator zum Entfernen markiert wurden
                foreach (var moodTypeDto in input.AllMoodTypes.Where(m => m.Delete))
                {
                    // Löschen des MoodType (falls bereits von anderem Admin gelöscht, wirft dies eine Exception)
                    await _moodTypeRepository.DeleteAsync(moodTypeDto.Id);
                }
    
                // 2. Lade die verbleibenden existierenden MoodTypes aus der DB (nach evtl. Löschungen)
                var existingMoodTypes = await _moodTypeRepository.GetListAsync();
    
                // 3. Füge neue MoodTypes hinzu oder aktualisiere bestehende anhand der Admin-Eingaben
                foreach (var moodTypeDto in input.AllMoodTypes)
                {
                    if (moodTypeDto.Delete)
                    {
                        // Überspringe MoodTypes, die bereits gelöscht wurden
                        continue;
                    }
    
                    var moodTypeEntity = existingMoodTypes.FirstOrDefault(mt => mt.Id == moodTypeDto.Id);
                    if (moodTypeEntity == null)
                    {
                        // MoodType existiert noch nicht -> neu anlegen
                        var newMoodType = new MoodType
                        {
                            Name = moodTypeDto.Name,
                            Description = moodTypeDto.Description,
                            Color = moodTypeDto.Color,
                            CreatedAt = DateTime.UtcNow
                            // Id (Key) wird bei Bedarf vom ORM/DB vergeben (z.B. Identity oder Guid über GuidGenerator)
                        };
                        await _moodTypeRepository.InsertAsync(newMoodType);
                    }
                    else
                    {
                        // MoodType existiert bereits -> aktualisieren
                        // Setze auch hier den ConcurrencyStamp, um parallele Änderungen an MoodTypes zu erkennen
                        //moodTypeEntity.ConcurrencyStamp = moodTypeDto.ConcurrencyStamp;
                        moodTypeEntity.Name = moodTypeDto.Name;
                        moodTypeEntity.Description = moodTypeDto.Description;
                        moodTypeEntity.Color = moodTypeDto.Color;
                        moodTypeEntity.UpdatedAt = DateTime.UtcNow;
                        await _moodTypeRepository.UpdateAsync(moodTypeEntity);
                    }
                }
            }
            #endregion
    
            #region Vorhandene Moods überschreiben bzw. hinzufügen (MoodVotes)
    
            if (input.AllMoodTypes != null && input.AllMoodTypes.Count > 0)
            {
                // Entferne alle bisherigen MoodVotes des Teilnehmers, um sie durch die neuen zu ersetzen
                await _moodVoteRepository.DeleteAsync(mv => mv.ParticipantId == participant.Id);
                // Lege für jeden im Frontend ausgewählten MoodType einen neuen MoodVote an
                foreach (var moodTypeDto in input.AllMoodTypes)
                {
                    if (moodTypeDto.Selected)
                    {
                        var newMoodVote = new MoodVote
                        {
                            ParticipantId = participant.Id,
                            MoodTypeId = moodTypeDto.Id,
                            VoteTime = DateTime.UtcNow
                        };
                        await _moodVoteRepository.InsertAsync(newMoodVote);
                    }
                }
            }
            #endregion
    
            #region Hinzufügen neuer Nachrichten (UserMessages) für den Teilnehmer
            // Speichere alle neuen Nachrichten, die vom Teilnehmer im Frontend hinzugefügt wurden
            foreach (var messageDto in (input.UserMessages ?? new List<MessageDto>()))
            {
                var newMessage = new Message
                {
                    ParticipantId = participant.Id,
                    CurrentMessage = messageDto.CurrentMessage,
                    CreatedAt = DateTime.UtcNow
                };
                await _messageRepository.InsertAsync(newMessage);
            }
            #endregion
    
            #region Sonstige Eigenschaften aktualisieren
            // Aktualisiere die Meeting-Eigenschaften
            meeting.ScheduledTime = input.ScheduledTime;
            meeting.UpdatedAt = DateTime.UtcNow;
            #endregion
    
            // Speichere die Änderungen am Meeting (ConcurrencyStamp wird vom Framework geprüft)
            var updatedMeeting = await _meetingRepository.UpdateAsync(meeting);
    
            #region DTO vorbereiten (Ergebnis für das Frontend)
            // Mappe das aktualisierte Meeting-Entity zurück auf das MeetingDto
            var meetingDto = ObjectMapper.Map<Meeting, MeetingDto>(updatedMeeting);
    
            #region AllMoodTypes für das Ausgabe-DTO
            // Hole die aktuelle Liste aller MoodTypes aus der Datenbank 
            var moodTypes = await _moodTypeRepository.GetListAsync();
    
            // Markiere im MoodType-Listening diejenigen als 'Selected', die der Teilnehmer jetzt ausgewählt hat
            if (participant.MoodVotes != null && participant.MoodVotes.Any())
            {
                // Falls die MoodVotes des Teilnehmers bereits im Kontext verfügbar sind, nutze sie
                foreach (var vote in participant.MoodVotes)
                {
                    var moodType = moodTypes.FirstOrDefault(m => m.Id == vote.MoodTypeId);
                    if (moodType != null)
                    {
                        moodType.Selected = true;
                    }
                }
            }
            else
            {
                // Falls die MoodVotes nicht im Kontext geladen sind, verwende die aktuelle Auswahl aus dem Input
                foreach (var moodTypeDto in input.AllMoodTypes)
                {
                    var moodType = moodTypes.FirstOrDefault(m => m.Id == moodTypeDto.Id);
                    if (moodType != null)
                    {
                        moodType.Selected = moodTypeDto.Selected;
                    }
                }
            }
    
            // Wandle die MoodType-Entitäten in DTOs um (immer eine Liste bereitstellen, auch wenn leer)
            meetingDto.AllMoodTypes = moodTypes.Any()
                ? ObjectMapper.Map<List<MoodType>, List<MoodTypeDto>>(moodTypes)
                : new List<MoodTypeDto>();
            #endregion
    
            #region UserMessages für das Ausgabe-DTO
            // Hole alle Nachrichten für den Teilnehmer und mappe sie auf DTOs 
            var messages = await _messageRepository.GetListAsync(m => m.ParticipantId == participant.Id);
            meetingDto.UserMessages = messages.Any()
                ? ObjectMapper.Map<List<Message>, List<MessageDto>>(messages)
                : new List<MessageDto>();
            #endregion
    
            #endregion
    
            // Gebe das aktualisierte Meeting als DTO zurück
            return meetingDto;
        }
        catch (AbpDbConcurrencyException)
        {
            // Falls zwischen Laden und Speichern eine parallele Änderung entdeckt wurde (optimistische Sperre),
            // wird eine verständliche Ausnahme geworfen, die z.B. im Frontend angezeigt werden kann.
            throw new BusinessException("Das Meeting wurde in der Zwischenzeit von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.");
        }
    }"
    
  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    You can remove IHasConcurrencyStamp from your entity.

  • User Avatar
    0
    starting created

    its not there anymore (commented out)

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Did you create a new migrations file and apply it?

  • User Avatar
    0
    starting created

    Yes, thats the model {D70E126E-8EB6-4DAF-8874-2F142FC89694}.png

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Hi,

    I think that's enough, you can give it a try

  • User Avatar
    0
    starting created

    no, its not working, i still get that error , not that often but it will appear (espiacially when change the data a lot over different clients){AFF302AE-9EAB-468A-BD73-F9934555A084}.png

  • User Avatar
    0
    starting created

    It must been thrown by the framework directly because " catch (AbpDbConcurrencyException)
    {
    // Falls zwischen Laden und Speichern eine parallele Änderung entdeckt wurde (optimistische Sperre),
    // wird eine verständliche Ausnahme geworfen, die z.B. im Frontend angezeigt werden kann.
    throw new BusinessException("Das Meeting wurde in der Zwischenzeit von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.");
    }" in my method is not beeing throwns instead the english is shown

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Hi,

    Change the EF Core log level to debug and share the logs, thanks.

    image.png

  • User Avatar
    0
    starting created

    2025-03-31 10:53:39.938 +02:00 [INF] Executed action xxx.Services.VibeScan.HeartbeatAppService.CheckParticipantStatusAsync (VibeScan.Application) in 145.3677ms
    2025-03-31 10:53:39.938 +02:00 [INF] Executed endpoint 'xxx.Services.VibeScan.HeartbeatAppService.CheckParticipantStatusAsync (VibeScan.Application)'
    2025-03-31 10:53:39.939 +02:00 [INF] Executed action xxx.Services.VibeScan.HeartbeatAppService.CheckParticipantStatusAsync (VibeScan.Application) in 151.6886ms
    2025-03-31 10:53:39.939 +02:00 [INF] Executed endpoint 'xxx.Services.VibeScan.HeartbeatAppService.CheckParticipantStatusAsync (VibeScan.Application)'
    2025-03-31 10:53:39.950 +02:00 [INF] Executed action xxx.Services.VibeScan.HeartbeatAppService.CheckParticipantStatusAsync (VibeScan.Application) in 160.7774ms
    2025-03-31 10:53:39.950 +02:00 [INF] Executed endpoint 'xxx.Services.VibeScan.HeartbeatAppService.CheckParticipantStatusAsync (VibeScan.Application)'
    2025-03-31 10:53:39.950 +02:00 [INF] Executed action xxx.Services.VibeScan.HeartbeatAppService.CheckParticipantStatusAsync (VibeScan.Application) in 162.9852ms
    2025-03-31 10:53:39.950 +02:00 [INF] Executed endpoint 'xxx.Services.VibeScan.HeartbeatAppService.CheckParticipantStatusAsync (VibeScan.Application)'
    2025-03-31 10:53:39.950 +02:00 [INF] Request finished HTTP/2 POST https://localhost:44398/api/app/heartbeat/check-messsages-to-delete - 204 null null 151.3811ms
    2025-03-31 10:53:39.951 +02:00 [ERR] ---------- RemoteServiceErrorInfo ----------
    {
    "code": null,
    "message": "The data you have submitted has already been changed by another user. Discard your changes and try again.",
    "details": null,
    "data": null,
    "validationErrors": null
    }

    2025-03-31 10:53:39.953 +02:00 [INF] Executed action xxx.Services.VibeScan.HeartbeatAppService.CheckParticipantStatusAsync (VibeScan.Application) in 104.8075ms
    2025-03-31 10:53:39.953 +02:00 [INF] Executed endpoint 'xxx.Services.VibeScan.HeartbeatAppService.CheckParticipantStatusAsync (VibeScan.Application)'
    2025-03-31 10:53:39.953 +02:00 [ERR] The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
    Volo.Abp.Data.AbpDbConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
    ---> Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
    at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ThrowAggregateUpdateConcurrencyExceptionAsync(RelationalDataReader reader, Int32 commandIndex, Int32 expectedRowsAffected, Int32 rowsAffected, CancellationToken cancellationToken)
    at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeResultSetWithRowsAffectedOnlyAsync(Int32 commandIndex, RelationalDataReader reader, CancellationToken cancellationToken)
    at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeAsync(RelationalDataReader reader, CancellationToken cancellationToken)
    at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
    at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
    at Microsoft.EntityFrameworkCore.SqlServer.Update.Internal.SqlServerModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
    at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
    at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList1 entries, CancellationToken cancellationToken)
    at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList1 entriesToSave, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func4 operation, Func4 verifySucceeded, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken) at Volo.Abp.EntityFrameworkCore.AbpDbContext1.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
    --- End of inner exception stack trace ---
    at Volo.Abp.EntityFrameworkCore.AbpDbContext`1.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
    at Volo.Abp.Uow.UnitOfWork.SaveChangesAsync(CancellationToken cancellationToken)
    at Volo.Abp.AspNetCore.Mvc.Uow.AbpUowActionFilter.SaveChangesAsync(ActionExecutingContext context, IUnitOfWorkManager unitOfWorkManager, CancellationToken cancellationToken)
    at Volo.Abp.AspNetCore.Mvc.Uow.AbpUowActionFilter.SaveChangesAsync(ActionExecutingContext context, IUnitOfWorkManager unitOfWorkManager, CancellationToken cancellationToken)
    at Volo.Abp.AspNetCore.Mvc.Uow.AbpUowActionFilter.OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.g__Awaited|10_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
    at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)
    at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
    at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.g__Awaited|13_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
    at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Awaited|26_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
    2025-03-31 10:53:39.959 +02:00 [INF] Request starting HTTP/2 OPTIONS https://localhost:44398/api/app/heartbeat/receive-heartbeat?userId=dec36c6c-e025-4acb-b87a-8ebe474898c8&meetingId=72ca9539-da2e-42f5-b04f-8dc029cf9e29&userAgent=Mozilla/5.0%20(Windows%20NT%2010.0;%20Win64;%20x64)%20AppleWebKit/537.36%20(KHTML,%20like%20Gecko)%20Chrome/134.0.0.0%20Safari/537.36&userMode=0 - null null
    2025-03-31 10:53:39.960 +02:00 [INF] CORS policy execution successful.
    2025-03-31 10:53:39.960 +02:00 [INF] Request finished HTTP/2 OPTIONS https://localhost:44398/api/app/heartbeat/receive-heartbeat?userId=dec36c6c-e025-4acb-b87a-8ebe474898c8&meetingId=72ca9539-da2e-42f5-b04f-8dc029cf9e29&userAgent=Mozilla/5.0%20(Windows%20NT%2010.0;%20Win64;%20x64)%20AppleWebKit/537.36%20(KHTML,%20like%20Gecko)%20Chrome/134.0.0.0%20Safari/537.36&userMode=0 - 204 null null 0.8963ms
    2025-03-31 10:53:39.963 +02:00 [INF] Request starting HTTP/2 POST https://localhost:44398/api/app/heartbeat/receive-heartbeat?userId=dec36c6c-e025-4acb-b87a-8ebe474898c8&meetingId=72ca9539-da2e-42f5-b04f-8dc029cf9e29&userAgent=Mozilla/5.0%20(Windows%20NT%2010.0;%20Win64;%20x64)%20AppleWebKit/537.36%20(KHTML,%20like%20Gecko)%20Chrome/134.0.0.0%20Safari/537.36&userMode=0 - null 0
    2025-03-31 10:53:39.963 +02:00 [INF] CORS policy execution successful.

  • User Avatar
    0
    starting created
    /// <summary>
    /// Führt die Statusprüfung nach erfolgreichem Heartbeat (aus dem Frontend) aus
    /// Überprüft den Status des Benutzers und aktualisiert die Stimmungstypen.
    /// Der Server kann den Status des Benutzers überwachen und bei Inaktivität den Benutzer und all seine Einstellungen löschen.
    /// Der Heartbeat wird alle xxx ms gesendet.
    /// </summary>
    /// <param name="userId"></param>
    /// <param name="meetingId"></param>
    /// <returns></returns>
    [DisableAuditing]
    public async Task ReceiveHeartbeatAsync(Guid userId, Guid meetingId, string userAgent, UserModeDto userMode)
    {
        var participant = await _participantRepository.FirstOrDefaultAsync(p => p.UserId == userId && p.MeetingId == meetingId);
        if (participant != null)
        {
            participant.LastHeartbeat = DateTime.UtcNow;
            participant.UserAgent = userAgent;
            participant.eUserMode = (UserMode)userMode;
            if (participant.IsInactive)
            {
                participant.IsInactive = false;
            }
            // Änderung explizit speichern, um Verbindung schnell freizugeben
            await _participantRepository.UpdateAsync(participant);
        }
        else
        {
            var newParticipant = new Participant
            {
                LastHeartbeat = DateTime.UtcNow,
                eUserMode = (UserMode)userMode,
                MeetingId = meetingId,
                UserId = userId,
                UserAgent = userAgent,
                IsInactive = false // Neuer Teilnehmer ist aktiv
            };
    
            await _participantRepository.InsertAsync(newParticipant, autoSave: true);
        }
    }
    
  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    The log level is not debug and could you share the full logs? thanks.

    My email is shiwei.liang@volosoft.com

  • User Avatar
    0
    starting created

    ok, sent the mail

  • User Avatar
    0
    starting created

    I send you the standard-log: "** public async static Task<int> Main(string[] args)

     {
         Log.Logger = new LoggerConfiguration()
             .WriteTo.Async(c => c.File("Logs/logs.txt"))
             .WriteTo.Async(c => c.Console())
             .CreateBootstrapLogger();
    
         try
         {
             Log.Information("Starting VibeScan.HttpApi.Host.");
             var builder = WebApplication.CreateBuilder(args);
             builder.Host
                 .AddAppSettingsSecretsJson()
                 .UseAutofac()
                 .UseSerilog((context, services, loggerConfiguration) =>
                 {
                     loggerConfiguration
                     #if DEBUG
                         .MinimumLevel.Debug()
                     #else
                         .MinimumLevel.Information()
                     #endif
                         .MinimumLevel.Override("Microsoft", LogEventLevel.Information)
                         .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
                         .Enrich.FromLogContext()
                         .WriteTo.Async(c => c.File("Logs/logs.txt"))
                         .WriteTo.Async(c => c.Console())
                         .WriteTo.Async(c => c.AbpStudio(services));
                 });
             await builder.AddApplicationAsync();
             var app = builder.Build();
             await app.InitializeApplicationAsync();
             await app.RunAsync();
             return 0;
         }
         catch (Exception ex)
         {
             if (ex is HostAbortedException)
             {
                 throw;
             }
    
             Log.Fatal(ex, "Host terminated unexpectedly!");
             return 1;
         }"**
    
  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    This log level is not debug, I don't see any debug level information in the log

    image.png

    Could you try setting it to debug?

    See https://abp.io/support/questions/8622/How-to-enable-Debug-logs-for-troubleshoot-problems

    BWT, If possible, please also share a reproducible example project so I can investigate it in detail. thanks.

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Hi,

    This is actually due to EF Core's state management.

    EF Core tracks entities and throws an exception when they are concurrently modified. You can catch the error and ignore the exception, just like ABP Chat module did.

    image.png

    But if data accuracy and order are important, you can use lock to ensure that resources are not concurrently modified or deleted.

  • User Avatar
    0
    starting created

    The AbpDbConcurrencyException shouldnt be thrown, it is coming from your framework.

    Please give me the correct code, without throwing the exceptions (AbpDbConcurrencyException + EF Exception)

    The following solution is not enough:

    ` public async Task UpdateAsync(Guid meetingId, Guid userId, MeetingDto input, UserModeDto userMode)
    {
    #region Allgemein: Validierung
    // Lade das Meeting mit zugehörigen Details (Participants) aus dem Repository
    var queryableMeeting = await _meetingRepository.WithDetailsAsync(m => m.Participants);
    var meetingQuery = queryableMeeting.Where(x => x.Id == meetingId);
    var meeting = await AsyncExecuter.FirstOrDefaultAsync(meetingQuery);

     // Überprüfe, ob das Meeting existiert
     if (meeting == null)
     {
         throw new BusinessException($"Meeting mit Id {meetingId} nicht gefunden.");
     }
    
     // Prüfe, ob der anfragende Benutzer Teilnehmer des Meetings ist
     var participant = meeting.Participants.FirstOrDefault(p => p.UserId == userId);
     if (participant == null)
     {
         throw new BusinessException("Sie haben keinen Zugriff auf dieses Meeting.");
     }
    
     #endregion
    
     // Optimistic Concurrency: Setze den ConcurrencyStamp des geladenen Meeting-Entities 
     // auf den vom Frontend gelieferten Wert. So erkennt das EF/ABP-Framework, 
     // falls das Meeting zwischenzeitlich von jemand anderem geändert wurde.
     //meeting.ConcurrencyStamp = input.ConcurrencyStamp;
    
     // Alle folgenden DB-Änderungen laufen in einer Transaktion (ABP UnitOfWork),
     // um Konsistenz sicherzustellen – entweder werden alle Änderungen gespeichert oder keine.
     try
     {
         #region Globale Überschreibung der AllMoodTypes (nur Admin)
         if (userMode == UserModeDto.Admin)
         {
    
             var moodTypeIds = input.AllMoodTypes.Where(m => m.Delete).Select(x => x.Id).ToList();
             var moodVotes = await _moodVoteRepository.GetListAsync(mv => moodTypeIds.Contains(mv.MoodTypeId));
    
             foreach (var moodVote in moodVotes)
             {
                 await _moodVoteRepository.DeleteAsync(moodVote, autoSave: true);
             }
    
             // 1. Lösche alle MoodTypes, die vom Administrator zum Entfernen markiert wurden
             foreach (var moodTypeDto in moodTypeIds)
             {
                 // Löschen des MoodType (falls bereits von anderem Admin gelöscht, wirft dies eine Exception)
                 await _moodTypeRepository.DeleteAsync(moodTypeDto, autoSave: true);
             }
    
             // 2. Lade die verbleibenden existierenden MoodTypes aus der DB (nach evtl. Löschungen)
             var existingMoodTypes = await _moodTypeRepository.GetListAsync();
    
             // 3. Füge neue MoodTypes hinzu oder aktualisiere bestehende anhand der Admin-Eingaben
             foreach (var moodTypeDto in input.AllMoodTypes)
             {
                 if (moodTypeDto.Delete)
                 {
                     // Überspringe MoodTypes, die bereits gelöscht wurden
                     continue;
                 }
    
                 var moodTypeEntity = existingMoodTypes.FirstOrDefault(mt => mt.Id == moodTypeDto.Id);
                 if (moodTypeEntity == null)
                 {
                     // MoodType existiert noch nicht -> neu anlegen
                     var newMoodType = new MoodType
                     {
                         Name = moodTypeDto.Name,
                         Description = moodTypeDto.Description,
                         Color = moodTypeDto.Color,
                         CreatedAt = DateTime.UtcNow
                         // Id (Key) wird bei Bedarf vom ORM/DB vergeben (z.B. Identity oder Guid über GuidGenerator)
                     };
                     await _moodTypeRepository.InsertAsync(newMoodType, autoSave: true);
                 }
                 else
                 {
                     // MoodType existiert bereits -> aktualisieren
                     // Setze auch hier den ConcurrencyStamp, um parallele Änderungen an MoodTypes zu erkennen
                     //moodTypeEntity.ConcurrencyStamp = moodTypeDto.ConcurrencyStamp;
                     moodTypeEntity.Name = moodTypeDto.Name;
                     moodTypeEntity.Description = moodTypeDto.Description;
                     moodTypeEntity.Color = moodTypeDto.Color;
                     moodTypeEntity.UpdatedAt = DateTime.UtcNow;
                     await _moodTypeRepository.UpdateAsync(moodTypeEntity, autoSave: true);
                 }
             }
         }
         #endregion
    
         #region Vorhandene Moods überschreiben bzw. hinzufügen (MoodVotes)
    
         if (input.AllMoodTypes != null && input.AllMoodTypes.Count > 0)
         {
             // Entferne alle bisherigen MoodVotes des Teilnehmers, um sie durch die neuen zu ersetzen
             await _moodVoteRepository.DeleteAsync(mv => mv.ParticipantId == participant.Id);
             // Lege für jeden im Frontend ausgewählten MoodType einen neuen MoodVote an
             foreach (var moodTypeDto in input.AllMoodTypes)
             {
                 if (moodTypeDto.Selected)
                 {
                     var newMoodVote = new MoodVote
                     {
                         ParticipantId = participant.Id,
                         MoodTypeId = moodTypeDto.Id,
                         VoteTime = DateTime.UtcNow
                     };
                     await _moodVoteRepository.InsertAsync(newMoodVote, autoSave: true);
                 }
             }
         }
         #endregion
    
         #region Hinzufügen neuer Nachrichten (UserMessages) für den Teilnehmer
         // Speichere alle neuen Nachrichten, die vom Teilnehmer im Frontend hinzugefügt wurden
         foreach (var messageDto in (input.UserMessages ?? new List()))
         {
             var newMessage = new Message
             {
                 ParticipantId = participant.Id,
                 CurrentMessage = messageDto.CurrentMessage,
                 CreatedAt = DateTime.UtcNow
             };
             await _messageRepository.InsertAsync(newMessage, autoSave: true);
         }
         #endregion
    
         #region Sonstige Eigenschaften aktualisieren
         // Aktualisiere die Meeting-Eigenschaften
         meeting.ScheduledTime = input.ScheduledTime;
         meeting.UpdatedAt = DateTime.UtcNow;
         #endregion
    
         // Speichere die Änderungen am Meeting (ConcurrencyStamp wird vom Framework geprüft)
         var updatedMeeting = await _meetingRepository.UpdateAsync(meeting, autoSave: true);
    
         #region DTO vorbereiten (Ergebnis für das Frontend)
         // Mappe das aktualisierte Meeting-Entity zurück auf das MeetingDto
         var meetingDto = ObjectMapper.Map(updatedMeeting);
    
         #region AllMoodTypes für das Ausgabe-DTO
         // Hole die aktuelle Liste aller MoodTypes aus der Datenbank 
         var moodTypes = await _moodTypeRepository.GetListAsync();
    
         // Markiere im MoodType-Listening diejenigen als 'Selected', die der Teilnehmer jetzt ausgewählt hat
         if (participant.MoodVotes != null && participant.MoodVotes.Any())
         {
             // Falls die MoodVotes des Teilnehmers bereits im Kontext verfügbar sind, nutze sie
             foreach (var vote in participant.MoodVotes)
             {
                 var moodType = moodTypes.FirstOrDefault(m => m.Id == vote.MoodTypeId);
                 if (moodType != null)
                 {
                     moodType.Selected = true;
                 }
             }
         }
         else
         {
             // Falls die MoodVotes nicht im Kontext geladen sind, verwende die aktuelle Auswahl aus dem Input
             foreach (var moodTypeDto in input.AllMoodTypes)
             {
                 var moodType = moodTypes.FirstOrDefault(m => m.Id == moodTypeDto.Id);
                 if (moodType != null)
                 {
                     moodType.Selected = moodTypeDto.Selected;
                 }
             }
         }
    
         // Wandle die MoodType-Entitäten in DTOs um (immer eine Liste bereitstellen, auch wenn leer)
         meetingDto.AllMoodTypes = moodTypes.Any()
             ? ObjectMapper.Map, List>(moodTypes)
             : new List();
         #endregion
    
         #region UserMessages für das Ausgabe-DTO
         // Hole alle Nachrichten für den Teilnehmer und mappe sie auf DTOs 
         var messages = await _messageRepository.GetListAsync(m => m.ParticipantId == participant.Id);
         meetingDto.UserMessages = messages.Any()
             ? ObjectMapper.Map, List>(messages)
             : new List();
         #endregion
    
         #endregion
    
         // Gebe das aktualisierte Meeting als DTO zurück
         return meetingDto;
     }
     catch (AbpDbConcurrencyException)
     {
         // Falls zwischen Laden und Speichern eine parallele Änderung entdeckt wurde (optimistische Sperre),
         // wird eine verständliche Ausnahme geworfen, die z.B. im Frontend angezeigt werden kann.
         //throw new BusinessException("Das Meeting wurde in der Zwischenzeit von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.");
    
         var queryableMeeting1 = await _meetingRepository.WithDetailsAsync(m => m.Participants);
         var meetingQuery1 = queryableMeeting1.Where(x => x.Id == meetingId);
         var meeting1 = await AsyncExecuter.FirstOrDefaultAsync(meetingQuery1);
    
         return ObjectMapper.Map(meeting1);
     }
     catch (Exception ex)
     {
         Logger.LogError(ex, "Ein unerwarteter Fehler ist aufgetreten.");
    
         var queryableMeeting1 = await _meetingRepository.WithDetailsAsync(m => m.Participants);
         var meetingQuery1 = queryableMeeting1.Where(x => x.Id == meetingId);
         var meeting1 = await AsyncExecuter.FirstOrDefaultAsync(meetingQuery1);
    
         return ObjectMapper.Map(meeting1);
     }
    

    }

    and

    `[DisableAuditing]
    public async Task ReceiveHeartbeatAsync(Guid userId, Guid meetingId, string userAgent, UserModeDto userMode)
    {
    try
    {
    var participant = await _participantRepository.FirstOrDefaultAsync(p => p.UserId == userId && p.MeetingId == meetingId);
    if (participant != null)
    {
    participant.LastHeartbeat = DateTime.UtcNow;
    participant.UserAgent = userAgent;
    participant.eUserMode = (UserMode)userMode;
    if (participant.IsInactive)
    {
    participant.IsInactive = false;
    }
    // Änderung explizit speichern, um Verbindung schnell freizugeben
    await _participantRepository.UpdateAsync(participant);
    }
    else
    {
    var newParticipant = new Participant
    {
    LastHeartbeat = DateTime.UtcNow,
    eUserMode = (UserMode)userMode,
    MeetingId = meetingId,
    UserId = userId,
    UserAgent = userAgent,
    IsInactive = false // Neuer Teilnehmer ist aktiv
    };

              await _participantRepository.InsertAsync(newParticipant, autoSave: true);
          }
      }
      catch (AbpDbConcurrencyException ex)
      {
          // Fail silently
          Logger.LogWarning(ex, "Ein Konkurrenzfehler ist aufgetreten, wurde jedoch behandelt.");
      }
      catch (Exception ex)
      { 
          Logger.LogError(ex, "Ein unerwarteter Fehler ist aufgetreten.");
      }
    

    }
    `

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Hi,

    This error does not come from ABP but EF core. ABP just catches and re-throw it.

    image.png

    https://github.com/abpframework/abp/blob/dev/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs#L255

    Like I've already explained,
    https://abp.io/support/questions/9047/IHasConcurrencyStamp-AbpDbConcurrencyException---Doesn%27t-work#answer-3a1902be-7ff3-3481-c5c7-9cd0685025bc

    You can reproduce it using only a simple .NET Core application without ABP.

    using ConcurrencyDemo.Data;
    using ConcurrencyDemo.Models;
    using Microsoft.EntityFrameworkCore;
    using Microsoft.Extensions.DependencyInjection;
    
    var services = new ServiceCollection();
    
    services.AddDbContext<AppDbContext>(options =>
        options.UseSqlite("Data Source=ConcurrencyDemo.db"));
    
    var serviceProvider = services.BuildServiceProvider();
    var dbContext = serviceProvider.GetRequiredService<AppDbContext>();
    
    // make sure the database is created
    dbContext.Database.EnsureCreated();
    
    // create a product
    var product = new Product { Id =1 , Name = "Test Product", Price = 100 };
    dbContext.Products.Add(product);
    await dbContext.SaveChangesAsync();
    
    Console.WriteLine("Initial product created: " + product.Name);
    
    // create two tasks to simulate concurrent operations
    var deleteTask = Task.Run(async () =>
    {
        
        using var scope = serviceProvider.CreateScope();
        var deleteContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        
        var productToDelete = await deleteContext.Products.FindAsync(1);
        await Task.Delay(100);
        if (productToDelete != null)
        {
            deleteContext.Products.Remove(productToDelete);
            await deleteContext.SaveChangesAsync();
            Console.WriteLine("delete operation completed");
        }
    });
    
    var updateTask = Task.Run(async () =>
    {
        using var scope = serviceProvider.CreateScope();
        var updateContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        
        var productToUpdate = await updateContext.Products.FindAsync(1);
        if (productToUpdate != null)
        {
            productToUpdate.Price = 200;
            
            // wait for a while to simulate a delay
            await Task.Delay(1000);
            
            try
            {
                await updateContext.SaveChangesAsync();
                Console.WriteLine("update operation completed");
            }
            catch (DbUpdateConcurrencyException e)
            {
                Console.WriteLine(e.Message);
            }
        }
    });
    
    // wait for both tasks to complete
    await Task.WhenAll(deleteTask, updateTask);
    
    Console.WriteLine("All operations completed. Press any key to exit.");
    
    

    image.png

  • User Avatar
    0
    starting created

    Hi,

    I have observed that the DbUpdateConcurrencyException occurs only when the following configurations are applied in the sample code below:

    The RowVersion property is defined in the entity:

    csharp

    public byte[] RowVersion { get; set; }

    The RowVersion property is configured as a concurrency token in the OnModelCreating method:

    csharp
    modelBuilder.Entity<Product>() .Property(p => p.RowVersion) .IsRowVersion();

    In the context of using ABP framework classes, I have none of them include a RowVersion property or implement the IHasConcurrencyStamp interface. Therefore, encountering an AbpDbConcurrencyException is unexpected in this scenario.

    My requirement is to update records without considering concurrent modifications by other users.

    Could you please adjust my code to achieve this behavior?

    Thank you.

    "
    namespace SQL
    {
    public class Product
    {
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }

        [Timestamp]
        public byte[] RowVersion { get; set; }
    }
    
    public class AppDbContext : DbContext
    {
        public DbSet<Product> Products { get; set; }
    
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=ProductsDb;Trusted_Connection=True;");
        }
    
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Product>()
                .Property(p => p.RowVersion)
                .IsRowVersion();
        }
    }
    
    public class Program
    {
        public static void Main()
        {
            using var context = new AppDbContext();
            context.Database.EnsureDeleted();
            context.Database.EnsureCreated();
    
            var product = new Product { Name = "TestProduct", Price = 9.99m };
            context.Products.Add(product);
            context.SaveChanges();
    
            //var deleteTask = Task.Run(() => DeleteProduct(product.Id));
            //var updateTask = Task.Run(() => UpdateProductPrice(product.Id, 5.00m));
    
            //Task.WaitAll(deleteTask, updateTask);
            //Console.WriteLine("Nebenläufigkeitsdemo abgeschlossen. Drücken Sie eine beliebige Taste zum Beenden.");
    
            for (int i = 0; i < 100; i++)
            {
                Task.Run(() => UpdateProductPrice(product.Id, i*5.00m));
            }
    
            Console.WriteLine();
            Console.WriteLine("# Finish");
            Console.ReadKey();
        }
    
        private static void DeleteProduct(int productId)
        {
            using var context = new AppDbContext();
            var product = context.Products.Find(productId);
            if (product != null)
            {
                context.Products.Remove(product);
                try
                {
                    context.SaveChanges();
                    Console.WriteLine("Produkt gelöscht.");
                }
                catch (DbUpdateConcurrencyException)
                {
                    Console.WriteLine("Nebenläufigkeitskonflikt beim Löschen des Produkts.");
                }
            }
        }
    
        private static void UpdateProductPrice(int productId, decimal increment)
        {
            //Thread.Sleep(100); // Verzögerung, um Überschneidung zu simulieren
            using var context = new AppDbContext();
            var product = context.Products.Find(productId);
            if (product != null)
            {
                product.Price += increment;
                try
                {
                    context.SaveChanges();
                    Console.WriteLine("Produktpreis aktualisiert.");
                }
                catch (DbUpdateConcurrencyException)
                {
                    Console.WriteLine("Nebenläufigkeitskonflikt bei der Aktualisierung des Produktpreises.");
                }
            }
        }
    }
    

    }"

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Hi,

    Not actually.

    Like I've been explaining, this has nothing to do with ABP.

    PixPin_2025-04-07_17-31-23.gif

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    In the context of using ABP framework classes, I have none of them include a RowVersion property or implement the IHasConcurrencyStamp interface. Therefore, encountering an AbpDbConcurrencyException is unexpected in this scenario.
    My requirement is to update records without considering concurrent modifications by other users.

    This error does not come from ABP but EF core. ABP just catches and re-throw it.

    See https://github.com/abpframework/abp/blob/dev/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs#L255

    Could you please adjust my code to achieve this behavior?

    You need to capture the exception and handle it manually. (Ignore it or anything you want.)

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer
Boost Your Development
ABP Live Training
Packages
See Trainings
Mastering ABP Framework Book
Do you need assistance from an ABP expert?
Schedule a Meeting
Mastering ABP Framework Book
The Official Guide
Mastering
ABP Framework
Learn More
Mastering ABP Framework Book
Made with ❤️ on ABP v9.3.0-preview. Updated on April 11, 2025, 10:10