| 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 | | } |
| | 10 | {{{#!json |
| | 11 | "ConnectionStrings": { |
| | 12 | "DefaultConnection": "Host=localhost;..." |
| 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: |
| | 16 | СО ОВА: |
| 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 |