| | 1 | == Напреден апликативен развој |
| | 2 | |
| | 3 | Backend делот од апликацијата е .NET 9 Web API кој до базата пристапува преку |
| | 4 | Entity Framework Core и драјверот Npgsql |
| | 5 | ([https://www.nuget.org/packages/Npgsql.EntityFrameworkCore.PostgreSQL Npgsql.EntityFrameworkCore.PostgreSQL 9.0.4]). |
| | 6 | Базата е зад SSH тунел, па секоја физичка конекција е уште еден канал низ тунелот - |
| | 7 | тоа е причината зошто во оваа фаза и трансакциите и pool-от се разгледуваат заедно. |
| | 8 | |
| | 9 | Оваа фаза опфаќа: |
| | 10 | |
| | 11 | * '''Трансакции''' - кои сценарија пишуваат во повеќе табели и како тие се водат како една целина |
| | 12 | * '''Ниво на изолација''' - што се случува кога два студента запишуваат ист семестар истовремено |
| | 13 | * '''Pooling''' - како се менаџираат конекциите и колку од нив навистина се отвораат |
| | 14 | |
| | 15 | == 1. Трансакции |
| | 16 | |
| | 17 | === Како EF Core води трансакција |
| | 18 | |
| | 19 | EF Core не бара анотација како {{{@Transactional}}}. Секој повик на |
| | 20 | {{{SaveChangesAsync()}}} сам по себе е една трансакција - сите промени натрупани во |
| | 21 | контекстот се испраќаат во еден BEGIN/COMMIT. Затоа операција која пишува во повеќе |
| | 22 | табели '''еднаш''' не бара ништо дополнително. |
| | 23 | |
| | 24 | Експлицитна трансакција е потребна кога операцијата мора да повика |
| | 25 | {{{SaveChangesAsync()}}} '''повеќе пати''', најчесто затоа што вториот запис го |
| | 26 | употребува идентификаторот доделен при првиот. Тогаш двата повика се затвораат во |
| | 27 | {{{BeginTransactionAsync()}}} ... {{{CommitAsync()}}}, за да не остане запис од |
| | 28 | првиот чекор ако вториот падне. |
| | 29 | |
| | 30 | Во апликацијата тоа се три сценарија: регистрација на студент, запишување на |
| | 31 | семестар и бришење на предмет. |
| | 32 | |
| | 33 | === 1.1 Регистрација на студент (UC002) |
| | 34 | |
| | 35 | Корисникот, неговите контакт податоци и средношколските податоци се три табели. |
| | 36 | Contact и high_school имаат надворешен клуч кон users, па идентификаторот на |
| | 37 | корисникот мора прво да постои - оттука и двата повика на {{{SaveChangesAsync}}}. |
| | 38 | |
| | 39 | {{{#!csharp |
| | 40 | public async Task<bool> RegisterAsync(RegisterDto registerDto) |
| | 41 | { |
| | 42 | if (await _userRepository.UserExistsAsync(registerDto.Email)) |
| | 43 | throw new InvalidOperationException("User with this email already exists"); |
| | 44 | |
| | 45 | // Use a transaction to ensure all entities are saved together |
| | 46 | using var transaction = await _context.Database.BeginTransactionAsync(); |
| | 47 | try |
| | 48 | { |
| | 49 | // quota and enrollment_year are attributes of Users in the ER |
| | 50 | // model, so they are set here rather than on a separate entity. |
| | 51 | var user = new User |
| | 52 | { |
| | 53 | Name = registerDto.Name, |
| | 54 | Surname = registerDto.Surname, |
| | 55 | Index = registerDto.Index, |
| | 56 | Email = registerDto.Email, |
| | 57 | PasswordHash = BCrypt.Net.BCrypt.HashPassword(registerDto.Password), |
| | 58 | Bday = DateTime.SpecifyKind(registerDto.Bday, DateTimeKind.Unspecified), |
| | 59 | CreatedAt = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified), |
| | 60 | Role = (Models.UserRole)registerDto.Role, |
| | 61 | EMBG = registerDto.EMBG, |
| | 62 | Quota = (Models.Quota)registerDto.quotaType, |
| | 63 | EnrollmentYear = registerDto.enrollmentYear |
| | 64 | }; |
| | 65 | |
| | 66 | _context.User.Add(user); |
| | 67 | await _context.SaveChangesAsync(); // <- тука user.Id добива вредност |
| | 68 | |
| | 69 | int userId = user.Id; |
| | 70 | |
| | 71 | var contactInfo = new ContactInfo |
| | 72 | { |
| | 73 | UserId = userId, |
| | 74 | City = registerDto.city, |
| | 75 | Address = registerDto.address, |
| | 76 | Municipality = registerDto.municipality, |
| | 77 | PhoneNumber = registerDto.phoneNumber, |
| | 78 | MicrosoftEmail = registerDto.microsoftEmail |
| | 79 | }; |
| | 80 | |
| | 81 | var highSchool = new HighSchool |
| | 82 | { |
| | 83 | UserId = userId, |
| | 84 | GPA = registerDto.gpa, |
| | 85 | HighSchoolType = (Models.HighSchoolType)registerDto.tip |
| | 86 | }; |
| | 87 | |
| | 88 | _context.ContactInfo.Add(contactInfo); |
| | 89 | _context.HighSchool.Add(highSchool); |
| | 90 | |
| | 91 | // Save all related entities in a single transaction |
| | 92 | await _context.SaveChangesAsync(); |
| | 93 | await transaction.CommitAsync(); |
| | 94 | |
| | 95 | return true; |
| | 96 | } |
| | 97 | catch |
| | 98 | { |
| | 99 | await transaction.RollbackAsync(); |
| | 100 | throw; |
| | 101 | } |
| | 102 | } |
| | 103 | }}} |
| | 104 | |
| | 105 | Без трансакцијата, пад при вториот {{{SaveChangesAsync}}} би оставил корисник без |
| | 106 | контакт и без средношколски податоци - запис кој ниту може да се употреби, ниту |
| | 107 | може да се регистрира повторно, бидејќи е-поштата е UNIQUE. |
| | 108 | |
| | 109 | === 1.2 Запишување на семестар (UC007) |
| | 110 | |
| | 111 | Запишувањето создава еден ред во enrolled_semesters и '''точно пет''' реда во |
| | 112 | semesters_subjects. Редовите за предметите го бараат идентификаторот на |
| | 113 | запишувањето, па повторно се работи за два чекора. |
| | 114 | |
| | 115 | {{{#!csharp |
| | 116 | await using var transaction = await _context.Database.BeginTransactionAsync(); |
| | 117 | try |
| | 118 | { |
| | 119 | var enrolment = new EnrolledSemesters |
| | 120 | { |
| | 121 | UserId = studentId, |
| | 122 | SemesterId = request.SemesterId, |
| | 123 | MajorId = request.MajorId, |
| | 124 | // enrolled_semesters.quota is NOT NULL; fall back to the |
| | 125 | // state quota when the student record has none. |
| | 126 | QuotaType = student.Quota ?? Models.Quota.drzavna, |
| | 127 | CratedAt = now, |
| | 128 | LastChange = now, |
| | 129 | Verified = null |
| | 130 | }; |
| | 131 | |
| | 132 | _context.EnrolledSemesters.Add(enrolment); |
| | 133 | await _context.SaveChangesAsync(); |
| | 134 | |
| | 135 | foreach (var subjectId in subjectIds) |
| | 136 | { |
| | 137 | _context.SemesterSubjects.Add(new SemesterSubject |
| | 138 | { |
| | 139 | EnrolledSemesterId = enrolment.Id, |
| | 140 | SubjectId = subjectId, |
| | 141 | ProfessorId = professorBySubject[subjectId], |
| | 142 | Signature = false |
| | 143 | }); |
| | 144 | } |
| | 145 | |
| | 146 | await _context.SaveChangesAsync(); |
| | 147 | await transaction.CommitAsync(); |
| | 148 | |
| | 149 | return new EnrollSemesterResultDto |
| | 150 | { |
| | 151 | Ok = true, |
| | 152 | Message = $"Enrolled with {subjectIds.Count} subjects.", |
| | 153 | EnrolledSemesterId = enrolment.Id |
| | 154 | }; |
| | 155 | } |
| | 156 | }}} |
| | 157 | |
| | 158 | Овде трансакцијата не е само заштита од полузапишан семестар. Ограничувањето за |
| | 159 | големина на запишувањето од Фаза 7 е '''одложен''' тригер: |
| | 160 | |
| | 161 | {{{#!sql |
| | 162 | CREATE CONSTRAINT TRIGGER trg_enrolment_size |
| | 163 | AFTER INSERT OR UPDATE OR DELETE ON semesters_subjects |
| | 164 | DEFERRABLE INITIALLY DEFERRED |
| | 165 | FOR EACH ROW EXECUTE FUNCTION check_enrolment_size(); |
| | 166 | }}} |
| | 167 | |
| | 168 | Одложен тригер се проверува при COMMIT, а не по секој ред. Токму затоа петте |
| | 169 | предмети се внесуваат во иста трансакција: ако се внесуваа секој во своја |
| | 170 | трансакција, првиот ред ќе беше видлив во база како запишување со еден предмет, а |
| | 171 | правилото „најмногу 5 предмети и најмногу 30 кредити“ ќе се проверуваше пет пати |
| | 172 | наместо еднаш, на крајот. |
| | 173 | |
| | 174 | === 1.3 Бришење на предмет (UC012) |
| | 175 | |
| | 176 | Предметот е референциран од три врзни табели. Тие мора да се избришат пред него, а |
| | 177 | ако некоја од нив падне, предметот мора да остане недопрен. |
| | 178 | |
| | 179 | {{{#!csharp |
| | 180 | // semesters_subjects references subjects, so a taken subject cannot go. |
| | 181 | var taken = await _context.SemesterSubjects.CountAsync(ss => ss.SubjectId == subjectId); |
| | 182 | if (taken > 0) |
| | 183 | { |
| | 184 | return Fail($"Cannot delete: {taken} enrolment(s) already contain this subject."); |
| | 185 | } |
| | 186 | |
| | 187 | await using var transaction = await _context.Database.BeginTransactionAsync(); |
| | 188 | try |
| | 189 | { |
| | 190 | // The mapping rows must go first; their foreign keys would block the delete. |
| | 191 | var prereqs = await _context.DependencySubjects |
| | 192 | .Where(d => d.SubjectId == subjectId || d.DependencyId == subjectId) |
| | 193 | .ToListAsync(); |
| | 194 | _context.DependencySubjects.RemoveRange(prereqs); |
| | 195 | |
| | 196 | var majors = await _context.MajorSubjects |
| | 197 | .Where(ms => ms.SubjectId == subjectId).ToListAsync(); |
| | 198 | _context.MajorSubjects.RemoveRange(majors); |
| | 199 | |
| | 200 | var teaching = await _context.ProfessorSubjects |
| | 201 | .Where(ps => ps.SubjectId == subjectId).ToListAsync(); |
| | 202 | _context.ProfessorSubjects.RemoveRange(teaching); |
| | 203 | |
| | 204 | _context.Subjects.Remove(subject); |
| | 205 | await _context.SaveChangesAsync(); |
| | 206 | await transaction.CommitAsync(); |
| | 207 | |
| | 208 | return Ok($"Subject '{subject.Name}' deleted."); |
| | 209 | } |
| | 210 | catch |
| | 211 | { |
| | 212 | await transaction.RollbackAsync(); |
| | 213 | throw; |
| | 214 | } |
| | 215 | }}} |
| | 216 | |
| | 217 | === 1.4 Оценување (UC010) - кога трансакција не е потребна |
| | 218 | |
| | 219 | Внесувањето на оценка пишува во една табела, со еден |
| | 220 | {{{SaveChangesAsync}}}, па EF Core сам го обвиткува во трансакција. Додавање на |
| | 221 | експлицитна трансакција овде не би променило ништо, освен уште една размена со |
| | 222 | серверот. |
| | 223 | |
| | 224 | {{{#!csharp |
| | 225 | case GradeAction.Add: |
| | 226 | if (existing is not null) |
| | 227 | { |
| | 228 | return Fail("This subject is already graded. Use edit to change the grade."); |
| | 229 | } |
| | 230 | await _profRepository.AddPassedSubjectAsync(new PassedSubject |
| | 231 | { |
| | 232 | SemesterSubjectId = semesterSubject.Id, |
| | 233 | Grade = (Grade)request.Grade, |
| | 234 | // date_passed is `timestamp without time zone`. |
| | 235 | DatePassed = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified) |
| | 236 | }); |
| | 237 | return Ok($"Grade {request.Grade} added."); |
| | 238 | }}} |
| | 239 | |
| | 240 | Вреди да се забележи дека и овде има повеќе од еден запис во базата, но вториот го |
| | 241 | прави '''базата''', не апликацијата: тригерот trg_grade_rules од Фаза 7 го |
| | 242 | поставува потписот и создава известување за студентот. Бидејќи тие се извршуваат во |
| | 243 | истата трансакција како и внесувањето, оценка без потпис и без известување не може |
| | 244 | да постои. |
| | 245 | |
| | 246 | === 1.5 Ниво на изолација и конкурентни запишувања |
| | 247 | |
| | 248 | Ниту EF Core ниту Npgsql не го менуваат нивото на изолација, па важи |
| | 249 | стандардното ниво на PostgreSQL - '''READ COMMITTED'''. Тоа значи дека секое |
| | 250 | барање во трансакцијата ја гледа состојбата потврдена пред тоа барање, но не ги |
| | 251 | гледа незавршените трансакции на другите. |
| | 252 | |
| | 253 | Тоа е доволно за сите сценарија освен за едно. Пред да запише, апликацијата |
| | 254 | проверува дали студентот веќе го запишал тој семестар: |
| | 255 | |
| | 256 | {{{#!csharp |
| | 257 | // enrolled_semesters has UNIQUE (user_id, semester_id); check first so |
| | 258 | // the student gets a sentence rather than a constraint violation. |
| | 259 | if (await _context.EnrolledSemesters.AnyAsync( |
| | 260 | es => es.UserId == studentId && es.SemesterId == request.SemesterId)) |
| | 261 | { |
| | 262 | return Fail("You are already enrolled in that semester."); |
| | 263 | } |
| | 264 | }}} |
| | 265 | |
| | 266 | Ако студентот два пати кликне на копчето, двете барања ја прават оваа проверка пред |
| | 267 | било кое од нив да потврди, па двете ја поминуваат. Проверката во апликација не |
| | 268 | може да го спречи тоа - таа чита состојба која во меѓувреме се менува. |
| | 269 | |
| | 270 | Ограничувањето во базата може. Проверено со две истовремени трансакции врз шемата |
| | 271 | project: |
| | 272 | |
| | 273 | {{{ |
| | 274 | -- сесија А -- сесија Б (0.5s подоцна) |
| | 275 | BEGIN; BEGIN; |
| | 276 | INSERT INTO enrolled_semesters INSERT INTO enrolled_semesters |
| | 277 | (user_id, quota, major_id, (user_id, quota, major_id, |
| | 278 | semester_id) semester_id) |
| | 279 | VALUES (1, 'drzavna', 1, 3); VALUES (1, 'drzavna', 1, 3); |
| | 280 | SELECT pg_sleep(2); -- чека А да заврши |
| | 281 | COMMIT; ERROR: duplicate key value violates unique |
| | 282 | constraint "enrolled_semesters_user_id_semester_id_key" |
| | 283 | DETAIL: Key (user_id, semester_id)=(1, 3) already exists. |
| | 284 | ROLLBACK |
| | 285 | }}} |
| | 286 | |
| | 287 | Втората трансакција не добива грешка веднаш - таа '''чека''' на редот вметнат од |
| | 288 | првата, бидејќи PostgreSQL мора прво да види дали првата ќе потврди или ќе се |
| | 289 | врати. Дури по COMMIT-от на првата, втората паѓа. |
| | 290 | |
| | 291 | Апликацијата затоа ја фаќа таа грешка и ја претвора во истата реченица која ја враќа |
| | 292 | и проверката погоре, наместо студентот да добие 500: |
| | 293 | |
| | 294 | {{{#!csharp |
| | 295 | catch (DbUpdateException ex) when (IsUniqueViolation(ex)) |
| | 296 | { |
| | 297 | // Two enrolments for the same semester sent at the same time both |
| | 298 | // pass the check above, because each transaction reads the state |
| | 299 | // from before the other one wrote. UNIQUE (user_id, semester_id) |
| | 300 | // is what actually settles it, so the loser gets the same |
| | 301 | // sentence as if the check had caught it. |
| | 302 | await transaction.RollbackAsync(); |
| | 303 | return Fail("You are already enrolled in that semester."); |
| | 304 | } |
| | 305 | catch |
| | 306 | { |
| | 307 | await transaction.RollbackAsync(); |
| | 308 | throw; |
| | 309 | } |
| | 310 | |
| | 311 | /// <summary>23505 is unique_violation.</summary> |
| | 312 | private static bool IsUniqueViolation(DbUpdateException ex) => |
| | 313 | ex.InnerException is PostgresException { SqlState: "23505" }; |
| | 314 | }}} |
| | 315 | |
| | 316 | Значи проверката во апликацијата постои заради пораката, а ограничувањето во базата |
| | 317 | заради точноста. Истиот принцип важи и за правилата од Фаза 7: апликацијата ги |
| | 318 | проверува за да даде разбирлива порака, а базата ги чува за да важат и кога некој |
| | 319 | пишува директно во неа. |
| | 320 | |
| | 321 | == 2. Pooling |
| | 322 | |
| | 323 | === 2.1 Pool на конекции (Npgsql) |
| | 324 | |
| | 325 | Како и кај Spring Boot, конекциите не се отвораат рачно. Npgsql има вграден pool |
| | 326 | кој е вклучен стандардно, па {{{UseNpgsql(connectionString)}}} е доволно. |
| | 327 | |
| | 328 | Стандардните вредности на Npgsql 9.0.4 (отчитани од |
| | 329 | {{{NpgsqlConnectionStringBuilder}}}): |
| | 330 | |
| | 331 | {{{ |
| | 332 | Pooling = True |
| | 333 | Minimum Pool Size = 0 |
| | 334 | Maximum Pool Size = 100 |
| | 335 | Connection Idle Lifetime = 300 (секунди) |
| | 336 | Connection Pruning Interval = 10 (секунди) |
| | 337 | Timeout = 15 (секунди, чекање на слободна конекција) |
| | 338 | Command Timeout = 30 (секунди) |
| | 339 | Multiplexing = False |
| | 340 | }}} |
| | 341 | |
| | 342 | Стандардните 100 конекции се премногу за оваа поставеност: серверот е споделен меѓу |
| | 343 | сите проекти, а секоја конекција е уште еден канал низ SSH тунелот. Затоа pool-от е |
| | 344 | ограничен во самиот connection string: |
| | 345 | |
| | 346 | {{{#!json |
| | 347 | "ConnectionStrings": { |
| | 348 | "DefaultConnection": "Host=localhost;Port=9999;Database=db_202526z_va_prj_know2026;Username=db_202526z_va_prj_iknow2026_owner;Password=***;Search Path=project;Include Error Detail=true;Maximum Pool Size=10;Minimum Pool Size=1;Connection Idle Lifetime=300;Timeout=15" |
| | 349 | } |
| | 350 | }}} |
| | 351 | |
| | 352 | * '''Maximum Pool Size=10''' - горна граница на отворени конекции, исто како default-от на HikariCP |
| | 353 | * '''Minimum Pool Size=1''' - една конекција останува отворена, за тунелот да не мора да отвора нов канал за секое прво барање |
| | 354 | * '''Connection Idle Lifetime=300''' - неупотребените конекции над минимумот се затвораат по 5 минути |
| | 355 | * '''Timeout=15''' - ако сите 10 се зафатени, барањето чека најмногу 15 секунди пред да падне |
| | 356 | |
| | 357 | === 2.2 Pool на DbContext (EF Core) |
| | 358 | |
| | 359 | Покрај конекциите, EF Core може да ги рециклира и самите DbContext објекти. Тоа е |
| | 360 | одделен pool: {{{AddDbContextPool}}} ги чува инстанците на контекстот (со нивните |
| | 361 | поставки и мапирања), додека Npgsql ги чува физичките конекции под нив. |
| | 362 | |
| | 363 | {{{#!csharp |
| | 364 | // AddDbContextPool reuses the DbContext instances themselves; the connections |
| | 365 | // underneath them are pooled separately by Npgsql, sized in the connection |
| | 366 | // string. Both matter here because every physical connection is one more |
| | 367 | // channel through the SSH tunnel. |
| | 368 | builder.Services.AddDbContextPool<AppDbContext>(options => |
| | 369 | options.UseNpgsql( |
| | 370 | builder.Configuration.GetConnectionString("DefaultConnection"), |
| | 371 | npgsql => |
| | 372 | { |
| | 373 | npgsql.MapEnum<UserRole>("user_role", AppDbContext.ProjectSchema, PgEnumLabels.UserRole); |
| | 374 | npgsql.MapEnum<HighSchoolType>("hs_type", AppDbContext.ProjectSchema, PgEnumLabels.Lowercase); |
| | 375 | npgsql.MapEnum<Quota>("quota_type", AppDbContext.ProjectSchema, PgEnumLabels.Lowercase); |
| | 376 | npgsql.MapEnum<sType>("semester_type", AppDbContext.ProjectSchema, PgEnumLabels.Lowercase); |
| | 377 | npgsql.MapEnum<Grade>("grade_type", AppDbContext.ProjectSchema, PgEnumLabels.Grade); |
| | 378 | })); |
| | 379 | }}} |
| | 380 | |
| | 381 | Ова е применливо бидејќи AppDbContext има само еден конструктор, оној кој прима |
| | 382 | {{{DbContextOptions}}}. Контекст кој би примал сопствени зависности (на пример |
| | 383 | тековниот корисник) не смее да се рециклира, бидејќи состојбата од едно барање би |
| | 384 | протекла во следното. |
| | 385 | |
| | 386 | === 2.3 Мерење на pool-от |
| | 387 | |
| | 388 | Мерено со апликацијата пуштена врз локална база со истата шема, со броење на |
| | 389 | {{{pg_stat_activity}}}: |
| | 390 | |
| | 391 | {{{#!sql |
| | 392 | SELECT count(*) FROM pg_stat_activity WHERE datname = 'iknow'; |
| | 393 | }}} |
| | 394 | |
| | 395 | {{{ |
| | 396 | состојба отворени конекции |
| | 397 | -------------------------------------------------------------------- |
| | 398 | по стартување и едно барање 1 |
| | 399 | за време на 50 истовремени барања 10 |
| | 400 | по завршување на барањата 10 |
| | 401 | }}} |
| | 402 | |
| | 403 | Првиот ред е Minimum Pool Size=1 - една конекција останува отворена. Вториот ред |
| | 404 | покажува дека pool-от расте со оптоварувањето, но застанува точно на Maximum Pool |
| | 405 | Size=10; останатите барања чекаат слободна конекција наместо да отворат нова. |
| | 406 | Третиот ред покажува дека отворените конекции не се затвораат веднаш - тие остануваат |
| | 407 | во pool-от и се затвораат дури по Connection Idle Lifetime, бидејќи повторното |
| | 408 | отворање низ тунел е поскапо од држењето отворена конекција. |
| | 409 | |
| | 410 | Истото може да се провери и од страната на тунелот - секоја нова конекција се гледа |
| | 411 | како нов канал во логовите на тунел скриптата: |
| | 412 | |
| | 413 | {{{ |
| | 414 | debug1: Connection to port 9999 forwarding to localhost port 5432 requested. |
| | 415 | debug1: channel 2: new direct-tcpip [direct-tcpip] (inactive timeout: 0) |
| | 416 | }}} |
| | 417 | |
| | 418 | === 2.4 Проверка дека апликацијата е поврзана |
| | 419 | |
| | 420 | Контролерот DbHealth го враќа одговорот кој покажува дека тунелот, конекцијата и |
| | 421 | мапирањето на ентитетите работат: |
| | 422 | |
| | 423 | {{{#!json |
| | 424 | { |
| | 425 | "connected": true, |
| | 426 | "server": "PostgreSQL 18.4 ... on x86_64-pc-linux-gnu, 64-bit", |
| | 427 | "counts": { |
| | 428 | "users": 8, |
| | 429 | "subjects": 20, |
| | 430 | "enrolledSemesters": 4, |
| | 431 | "semesterSubjects": 6, |
| | 432 | "passedSubjects": 4 |
| | 433 | } |
| | 434 | } |
| | 435 | }}} |
| | 436 | |
| | 437 | == Историјат |
| | 438 | |
| | 439 | '''Верзија 1''' - Прва верзија: трансакции во трите сценарија кои пишуваат во |
| | 440 | повеќе табели, обработка на конкурентни запишувања преку ограничувањето |
| | 441 | UNIQUE (user_id, semester_id), и конфигурација и мерење на pool-от на конекции и |
| | 442 | на DbContext. |
| | 443 | |
| | 444 | == Статус |
| | 445 | |
| | 446 | ''' [[span(style=color: #FF8000, Во тек )]] ''' |