╭ changed ─────╮ │src/notify.zig│ ╰──────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────╮ │← previous│ ╰──────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────╮ │330 transact(io, allocator, users_dir, &location.owner_id, Ctx{ │ │331 .notif_id = idOf(&location.repo_id, thread_id), │ │332 .user_id = user_id, │ │314 - .subscribed = subscribed, │ │333 + .mode = mode, │ │334 }) catch |err| { │ │316 - std.log.warn("failed to index thread subscriber: {s}", .{@errorName(err)}); │ │335 + std.log.warn("failed to index thread notify mode: {s}", .{@errorName(err)});│ │336 }; │ │337 } │ │338 │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │320 -// subscribe the users `events` show taking part in their threads, the threads' │ │321 -// authors, the commenters and the assignees, unless they hold a record on the │ │322 -// thread. logs rather than returning a failure. │ │339 +// put the users `events` show taking part in their threads in a tier, the │ │340 +// threads' authors and the assignees as involved and the commenters as │ │341 +// commented. logs rather than returning a failure. │ │342 pub fn subscribeParticipants(io: std.Io, allocator: std.mem.Allocator, server: evt.Host.Server, repo_path: []const u8, events: []const evt.EventWithId) void {│ │343 const admin_repo_path = server.admin_repo_path orelse return; │ │344 const location = evt.parseRepoPath(server.users_dir, repo_path) orelse return; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │355 defer arena.deinit(); │ │356 │ │357 for (events) |event| { │ │339 - const thread_hex: [evt.event_id_size * 2]u8, const email: []const u8 = switch (event.event) { │ │340 - inline .discuss, .issue, .patch => |thread| if (thread != null) .{ event.id, event.author.email } else continue, │ │341 - .comment => |comment_maybe| if (comment_maybe) |comment| .{ comment.thread_id, event.author.email } else continue, │ │342 - .assign => |assign_maybe| if (assign_maybe) |assign| .{ assign.thread_id, assign.email } else continue, │ │358 + const thread_hex: [evt.event_id_size * 2]u8, const email: []const u8, const mode: evt.Notify.Mode = switch (event.event) { │ │359 + inline .discuss, .issue, .patch => |thread| if (thread != null) .{ event.id, event.author.email, .involved } else continue, │ │360 + .comment => |comment_maybe| if (comment_maybe) |comment| .{ comment.thread_id, event.author.email, .commented } else continue,│ │361 + .assign => |assign_maybe| if (assign_maybe) |assign| .{ assign.thread_id, assign.email, .involved } else continue, │ │362 else => continue, │ │363 }; │ │364 const user_id = (try evt.User.readIdByEmail(evt.AdminDB, evt.admin_repo_opts.hash, admin_moment, email)) orelse continue; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │365 const thread_id = try evt.parseEventId(&thread_hex); │ │347 - // a mute or an earlier subscribe stands │ │366 + // a mute stands, and a record only ever moves up to involved │ │367 { │ │368 var user_repo = (try evt.openUserRepo(io, allocator, users_dir, &user_id)) orelse continue;│ │369 defer user_repo.deinit(io, allocator); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │370 if (try evt.userMoment(&user_repo)) |moment| { │ │352 - if (try evt.Notify.readMode(UserDB, hash_kind, moment, &arena, &location.repo_id, &thread_id) != null) continue;│ │371 + if (try evt.Notify.readMode(UserDB, hash_kind, moment, &arena, &location.repo_id, &thread_id)) |existing| { │ │372 + if (mode != .involved or existing == .involved or existing == .muted) continue; │ │373 + } │ │374 } │ │375 } │ │376 + // a full commented tier takes nobody new, so a later comment checks again │ │377 + if (mode == .commented) { │ │378 + var owner_repo = (try evt.openUserRepo(io, allocator, users_dir, &location.owner_id)) orelse return error.NotFound; │ │379 + defer owner_repo.deinit(io, allocator); │ │380 + if (try tierFull(try owner_repo.core.latestMoment(), &idOf(&location.repo_id, &thread_id), .commented)) continue; │ │381 + } │ │382 // the write's own author, which for an assignment is the assigner │ │356 - try subscribe(io, allocator, users_dir, &user_id, location, &thread_id, event.author); │ │383 + try writeMode(io, allocator, users_dir, &user_id, &location, &thread_id, mode, event.author); │ │384 } │ │385 } │ │386 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │360 -// the thread `notif_id` names' subscriber set, or null before its first subscriber. │ │361 -// `moment` is its repo owner's user repo's latest. │ │362 -fn subscriberSet(moment: UserDB.HashMap(.read_only), notif_id: *const [evt.event_id_size]u8) !?UserDB.SortedSet(.read_only) { │ │363 - const sets_cursor = try moment.getCursor(hash.hashInt(hash_kind, subscriber_map_key)) orelse return null; │ │364 - const subscribers_cursor = try (try UserDB.HashMap(.read_only).init(sets_cursor)).getCursor(hash.hashInt(hash_kind, notif_id)) orelse return null; │ │365 - return try UserDB.SortedSet(.read_only).init(subscribers_cursor); │ │387 +// the thread `notif_id` names' set under a tier's `map_key`, or null before its │ │388 +// first user. `moment` is its repo owner's user repo's latest. │ │389 +fn tierSet(moment: UserDB.HashMap(.read_only), notif_id: *const [evt.event_id_size]u8, map_key: []const u8) !?UserDB.SortedSet(.read_only) { │ │390 + const sets_cursor = try moment.getCursor(hash.hashInt(hash_kind, map_key)) orelse return null; │ │391 + const users_cursor = try (try UserDB.HashMap(.read_only).init(sets_cursor)).getCursor(hash.hashInt(hash_kind, notif_id)) orelse return null; │ │392 + return try UserDB.SortedSet(.read_only).init(users_cursor); │ │393 } │ │394 │ │368 -// the users subscribed to the thread `notif_id` names. `moment` is its repo owner's user repo's latest. │ │369 -pub fn readSubscribers(moment: UserDB.HashMap(.read_only), arena: *std.heap.ArenaAllocator, notif_id: *const [evt.event_id_size]u8) ![]const [evt.event_id_size]u8 {│ │395 +// the users in the tiers of the thread `notif_id` names. `moment` is its repo owner's user repo's latest. │ │396 +pub fn readNotified(moment: UserDB.HashMap(.read_only), arena: *std.heap.ArenaAllocator, notif_id: *const [evt.event_id_size]u8) ![]const [evt.event_id_size]u8 { │ │397 var user_ids: std.ArrayList([evt.event_id_size]u8) = .empty; │ │371 - const subscribers = (try subscriberSet(moment, notif_id)) orelse return user_ids.items; │ │372 - var iter = try subscribers.iteratorFromIndex(0); │ │373 - while (try iter.next()) |kv_cursor| { │ │374 - var cursor = kv_cursor; │ │375 - const kv_pair = try cursor.readKeyValuePair(); │ │376 - var user_id: [evt.event_id_size]u8 = undefined; │ │377 - _ = try kv_pair.key_cursor.readBytes(&user_id); │ │378 - try user_ids.append(arena.allocator(), user_id); │ │398 + inline for (comptime std.enums.values(evt.Notify.Mode)) |mode| { │ │399 + if (comptime tierOf(mode)) |tier| { │ │400 + if (try tierSet(moment, notif_id, tier.map_key)) |users| { │ │401 + var iter = try users.iteratorFromIndex(0); │ │402 + while (try iter.next()) |kv_cursor| { │ │403 + var cursor = kv_cursor; │ │404 + const kv_pair = try cursor.readKeyValuePair(); │ │405 + var user_id: [evt.event_id_size]u8 = undefined; │ │406 + _ = try kv_pair.key_cursor.readBytes(&user_id); │ │407 + try user_ids.append(arena.allocator(), user_id); │ │408 + } │ │409 + } │ │410 + } │ │411 } │ │412 return user_ids.items; │ │413 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │414 │ │383 -// whether `user_id` is subscribed to the thread `notif_id` names. `moment` is its repo owner's user repo's latest. │ │384 -pub fn isSubscribed(moment: UserDB.HashMap(.read_only), notif_id: *const [evt.event_id_size]u8, user_id: *const [evt.event_id_size]u8) !bool { │ │385 - const subscribers = (try subscriberSet(moment, notif_id)) orelse return false; │ │386 - return try subscribers.contains(user_id); │ │415 +// whether a tier of the thread `notif_id` names holds `user_id`. `moment` is its repo owner's user repo's latest. │ │416 +pub fn isNotified(moment: UserDB.HashMap(.read_only), notif_id: *const [evt.event_id_size]u8, user_id: *const [evt.event_id_size]u8) !bool { │ │417 + inline for (comptime std.enums.values(evt.Notify.Mode)) |mode| { │ │418 + if (comptime tierOf(mode)) |tier| { │ │419 + if (try tierSet(moment, notif_id, tier.map_key)) |users| { │ │420 + if (try users.contains(user_id)) return true; │ │421 + } │ │422 + } │ │423 + } │ │424 + return false; │ │425 } │ │426 │ │427 +// whether `mode`'s tier on the thread `notif_id` names is at its cap. `moment` is its repo owner's user repo's latest. │ │428 +pub fn tierFull(moment: UserDB.HashMap(.read_only), notif_id: *const [evt.event_id_size]u8, comptime mode: evt.Notify.Mode) !bool { │ │429 + const tier = comptime tierOf(mode) orelse @compileError("a mute has no tier"); │ │430 + const cap = tier.cap orelse return false; │ │431 + const users = (try tierSet(moment, notif_id, tier.map_key)) orelse return false; │ │432 + return try users.count() >= cap; │ │433 +} │ │434 + │ │435 // │ │390 -// delivery worker │ │436 +// delivery │ │437 // │ │392 - │ │393 -// repos with events the worker hasn't looked at, deduped │ │394 -pub const DirtyRepos = struct { │ │395 - allocator: std.mem.Allocator, │ │396 - mutex: std.Io.Mutex = .init, │ │397 - // signaled when the set goes non-empty │ │398 - condition: std.Io.Condition = .init, │ │399 - set: std.AutoArrayHashMapUnmanaged(evt.RepoLocation, void) = .empty, │ │400 - │ │401 - pub fn deinit(self: *DirtyRepos) void { │ │402 - self.set.deinit(self.allocator); │ │403 - } │ │438 │ │405 - pub fn mark(self: *DirtyRepos, io: std.Io, location: evt.RepoLocation) void { │ │406 - self.mutex.lockUncancelable(io); │ │407 - defer self.mutex.unlock(io); │ │408 - self.set.put(self.allocator, location, {}) catch |err| { │ │409 - std.log.warn("failed to queue repo notifications: {s}", .{@errorName(err)}); │ │410 - return; │ │411 - }; │ │412 - self.condition.signal(io); │ │413 - } │ │414 -}; │ │415 - │ │416 -// deliver notifs for each repo marked dirty, until canceled │ │417 -pub fn run( │ │418 - dirty_repos: *DirtyRepos, │ │439 +// deliver the notifs of the comments and assignments the repo transaction at │ │440 +// `moment_index` created, logging rather than returning a failure. │ │441 +// `pusher_id` is the user who pushed it, if a push did. │ │442 +pub fn deliverTransaction( │ │443 io: std.Io, │ │444 allocator: std.mem.Allocator, │ │421 - users_dir: []const u8, │ │422 - admin_repo_path: []const u8, │ │423 -) std.Io.Cancelable!void { │ │424 - while (true) { │ │425 - var batch = blk: { │ │426 - try dirty_repos.mutex.lock(io); │ │427 - defer dirty_repos.mutex.unlock(io); │ │428 - while (dirty_repos.set.count() == 0) try dirty_repos.condition.wait(io, &dirty_repos.mutex); │ │429 - const batch = dirty_repos.set; │ │430 - dirty_repos.set = .empty; │ │431 - break :blk batch; │ │432 - }; │ │433 - defer batch.deinit(dirty_repos.allocator); │ │434 - │ │435 - for (batch.keys()) |location| { │ │436 - notifyRepo(io, allocator, users_dir, admin_repo_path, location) catch |err| { │ │437 - std.log.warn("failed to deliver notifications: {s}", .{@errorName(err)}); │ │438 - }; │ │439 - } │ │440 - } │ │441 -} │ │442 - │ │443 -pub fn notifyRepo(io: std.Io, allocator: std.mem.Allocator, users_dir: []const u8, admin_repo_path: []const u8, location: evt.RepoLocation) !void { │ │444 - const path = try evt.repoPath(allocator, users_dir, &location.owner_id, &location.repo_id); │ │445 - defer allocator.free(path); │ │446 - var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, .{ .path = path, .require_repo_root = true }); │ │447 - defer any_repo.deinit(io, allocator); │ │448 - switch (any_repo) { │ │449 - inline else => |*repo| try notifyRepoEvents(repo.self_repo_opts, io, allocator, users_dir, admin_repo_path, location, repo), │ │450 - } │ │445 + server: evt.Host.Server, │ │446 + comptime repo_opts: rp.RepoOpts(.xit), │ │447 + repo: *rp.Repo(.xit, repo_opts), │ │448 + moment_index: u64, │ │449 + pusher_id: ?*const [evt.event_id_size]u8, │ │450 + progress_ctx_maybe: ?repo_opts.ProgressCtx, │ │451 +) void { │ │452 + const admin_repo_path = server.admin_repo_path orelse return; │ │453 + const location = evt.parseRepoPath(server.users_dir, repo.core.work_path) orelse return; │ │454 + deliverEach(repo_opts, io, allocator, server.users_dir, admin_repo_path, location, repo, moment_index, pusher_id, progress_ctx_maybe) catch |err| {│ │455 + std.log.warn("failed to deliver notifications: {s}", .{@errorName(err)}); │ │456 + }; │ │457 } │ │458 │ │453 -// a round's notifs, coalesced per user and thread │ │459 +// a transaction's notifs, coalesced per user and thread │ │460 const Inboxes = struct { │ │461 arena: *std.heap.ArenaAllocator, │ │462 admin_moment: evt.AdminDB.HashMap(.read_only), │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ │489 } │ │490 }; │ │491 │ │486 -// deliver the comments and assignments created since the repo's cursor, then advance it│ │487 -fn notifyRepoEvents( │ │492 +fn deliverEach( │ │493 comptime repo_opts: rp.RepoOpts(.xit), │ │494 io: std.Io, │ │495 allocator: std.mem.Allocator, │ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │497 admin_repo_path: []const u8, │ │498 location: evt.RepoLocation, │ │499 repo: *rp.Repo(.xit, repo_opts), │ │500 + moment_index: u64, │ │501 + pusher_id: ?*const [evt.event_id_size]u8, │ │502 + progress_ctx_maybe: ?repo_opts.ProgressCtx, │ │503 ) !void { │ │504 const DB = evt.EventDB(repo_opts.hash); │ │497 - const haxy_moment = evt.currentMoment(repo_opts, repo) catch |err| switch (err) { │ │498 - error.NotFound => return, │ │499 - else => |e| return e, │ │500 - }; │ │501 - const index_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.moment_index_key)) orelse return error.CursorNotFound;│ │502 - const current = try index_cursor.readUint(); │ │505 + const haxy_moment = try evt.currentMoment(repo_opts, repo); │ │506 + // the transaction's events, newest first │ │507 + const ids = (try evt.momentEventIds(DB, repo_opts.hash, haxy_moment, moment_index)) orelse return; │ │508 │ │509 var owner_repo = (try evt.openUserRepo(io, allocator, users_dir, &location.owner_id)) orelse return; │ │510 defer owner_repo.deinit(io, allocator); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │506 - │ │507 - // a missing cursor starts from the repo's first transaction, and a rewound history resets it │ │508 - const first = if (try readLastNotified(&owner_repo, &location.repo_id)) |last| blk: { │ │509 - if (current < last) return writeLastNotified(io, &owner_repo, &location.repo_id, current);│ │510 - if (current == last) return; │ │511 - break :blk last + 1; │ │512 - } else 0; │ │511 + const owner_moment = (try evt.userMoment(&owner_repo)) orelse return; │ │512 + // the owner's plain data, which holds the tier index │ │513 + const owner_latest = try owner_repo.core.latestMoment(); │ │514 │ │515 var arena = std.heap.ArenaAllocator.init(allocator); │ │516 defer arena.deinit(); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯