source: docs/Ph8.md

Last change on this file was 002cf5f, checked in by Boris Gjorgjievski <boris@…>, 7 days ago

Phase 8: connection pooling and concurrent-enrolment handling

Two enrolments for the same semester sent at once both pass the
"already enrolled" check, because each transaction reads the state from
before the other one wrote. UNIQUE (user_id, semester_id) is what
settles it, so EnrollAsync now catches the 23505 and returns the same
sentence instead of a 500.

Sizes the connection pool in the connection string (max 10, min 1)
rather than taking Npgsql's default of 100 - every physical connection
is one more channel through the SSH tunnel - and switches to
AddDbContextPool, which AppDbContext qualifies for since its only
constructor takes DbContextOptions.

docs/Ph8.md is the wiki page: the three transactional scenarios, the
isolation level and what it does not cover, and the pool settings with
the measured connection counts.

  • Property mode set to 100644
File size: 22.0 KB
Line 
1== Напреден апликативен развој
2
3Backend делот од апликацијата е .NET 9 Web API кој до базата пристапува преку
4Entity 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
19EF 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Корисникот, неговите контакт податоци и средношколските податоци се три табели.
36Contact и high_school имаат надворешен клуч кон users, па идентификаторот на
37корисникот мора прво да постои - оттука и двата повика на {{{SaveChangesAsync}}}.
38
39{{{#!csharp
40public 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 и '''точно пет''' реда во
112semesters_subjects. Редовите за предметите го бараат идентификаторот на
113запишувањето, па повторно се работи за два чекора.
114
115{{{#!csharp
116await using var transaction = await _context.Database.BeginTransactionAsync();
117try
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
162CREATE 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.
181var taken = await _context.SemesterSubjects.CountAsync(ss => ss.SubjectId == subjectId);
182if (taken > 0)
183{
184 return Fail($"Cannot delete: {taken} enrolment(s) already contain this subject.");
185}
186
187await using var transaction = await _context.Database.BeginTransactionAsync();
188try
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}
210catch
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
225case 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.
259if (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Ограничувањето во базата може. Проверено со две истовремени трансакции врз шемата
271project:
272
273{{{
274-- сесија А -- сесија Б (0.5s подоцна)
275BEGIN; BEGIN;
276INSERT INTO enrolled_semesters INSERT INTO enrolled_semesters
277 (user_id, quota, major_id, (user_id, quota, major_id,
278 semester_id) semester_id)
279VALUES (1, 'drzavna', 1, 3); VALUES (1, 'drzavna', 1, 3);
280SELECT pg_sleep(2); -- чека А да заврши
281COMMIT; 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
295catch (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}
305catch
306{
307 await transaction.RollbackAsync();
308 throw;
309}
310
311/// <summary>23505 is unique_violation.</summary>
312private 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{{{
332Pooling = True
333Minimum Pool Size = 0
334Maximum Pool Size = 100
335Connection Idle Lifetime = 300 (секунди)
336Connection Pruning Interval = 10 (секунди)
337Timeout = 15 (секунди, чекање на слободна конекција)
338Command Timeout = 30 (секунди)
339Multiplexing = 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=REDACTED_ROTATE_ME;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.
368builder.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
392SELECT 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
405Size=10; останатите барања чекаат слободна конекција наместо да отворат нова.
406Третиот ред покажува дека отворените конекции не се затвораат веднаш - тие остануваат
407во pool-от и се затвораат дури по Connection Idle Lifetime, бидејќи повторното
408отворање низ тунел е поскапо од држењето отворена конекција.
409
410Истото може да се провери и од страната на тунелот - секоја нова конекција се гледа
411како нов канал во логовите на тунел скриптата:
412
413{{{
414debug1: Connection to port 9999 forwarding to localhost port 5432 requested.
415debug1: 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, Во тек )]] '''
Note: See TracBrowser for help on using the repository browser.