Skip to content

Commit c6f568c

Browse files
authored
Merge pull request #21 from Local-Connectivity-Lab/ticket-797
Ticket 797 - Fix locking problem when handling exception during ticket sync
2 parents 036b4ef + 58c29f0 commit c6f568c

File tree

6 files changed

+127
-68
lines changed

6 files changed

+127
-68
lines changed

cog_tickets.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,18 +193,30 @@ async def create_new_ticket(self, ctx: discord.ApplicationContext, title:str):
193193
async def thread(self, ctx: discord.ApplicationContext, ticket_id:int):
194194
ticket = self.redmine.get_ticket(ticket_id)
195195
if ticket:
196+
ticket_link = self.bot.formatter.format_link(ticket)
197+
198+
# check if sync data exists for a different channel
199+
synced = ticket.get_sync_record()
200+
if synced and synced.channel_id != ctx.channel_id:
201+
thread = self.bot.get_channel(synced.channel_id)
202+
if thread:
203+
await ctx.respond(f"Ticket {ticket_link} already synced with {thread.jump_url}")
204+
return # stop processing
205+
else:
206+
log.info(f"Ticket {ticket_id} synced with unknown thread ID {synced.channel_id}. Recovering.")
207+
# delete the sync record
208+
self.redmine.ticket_mgr.remove_sync_record(synced)
209+
# fall thru to create thread and sync
210+
196211
# create the thread...
197212
thread = await self.create_thread(ticket, ctx)
198213

199214
# update the discord flag on tickets, add a note with url of thread; thread.jump_url
200-
# TODO message templates
201215
note = f"Created Discord thread: {thread.name}: {thread.jump_url}"
202216
user = self.redmine.user_mgr.find_discord_user(ctx.user.name)
203-
log.debug(f">>> found {user} for {ctx.user.name}")
204217
self.redmine.enable_discord_sync(ticket.id, user, note)
205218

206219
# ticket-614: add ticket link to thread response
207-
ticket_link = self.bot.formatter.format_link(ticket)
208220
await ctx.respond(f"Created new thread {thread.jump_url} for ticket {ticket_link}")
209221
else:
210222
await ctx.respond(f"ERROR: Unkown ticket ID: {ticket_id}")

docs/netbot-usage.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,12 +153,22 @@ Will create a new ticket with the title "Upgrade the server to the v1.62 LTS", a
153153
### `/ticket thread [ticket-id]` - Create a Discord thread for a ticket
154154

155155
Create a new discord thread from an existing ticket *ticket-id*, using the ticket title as the thread title. The thread is created in the channel it is invoked in, and all notes from the existing ticket are copied into the thread.
156-
157156
```
158157
/ticket thread 787
159158
```
160159
will create a new thread for ticket 787 in the current Discord channel.
161160

161+
If a Discord thread has already been created for that ticket, a note with a link to that thread will be displayed.
162+
163+
If an existing *ticket thread* has been deleted, it can be recreated in any channel with the same command.
164+
165+
To move a *ticket thread* to a new channel:
166+
1. Delete the existing *ticket thread* using the Discord UI. NOTE: The relevant comments are already synced to Redmine.
167+
2. Create the *ticket thread* in the new channel with (you guessed it): `/thread ticket [id]`
168+
169+
`/ticket thread` is designed to be forgiving: If no *ticket thread* exists or syncdata is old, incomplete or invalid, a new valid *ticket thread* will be created and synchroized. Otherwise, an existing *ticket thread* is associated with that ticket ID and a link to it will be displayed.
170+
171+
162172
### `/ticket assign [ticket-id]` - Assign a ticket
163173

164174
Assign ticket *ticket-id* to yourself (the invoking user).

model.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -368,34 +368,38 @@ def __str__(self):
368368
return f"#{self.id:04d} {self.status.name:<11} {self.priority.name:<6} {self.assigned:<20} {self.subject}"
369369

370370

371-
def get_sync_record(self, expected_channel: int = 0) -> synctime.SyncRecord | None:
371+
def get_sync_record(self) -> synctime.SyncRecord | None:
372372
# Parse custom_field into datetime
373373
# lookup field by name
374374
token = self.get_custom_field(SYNC_FIELD_NAME)
375375
if token:
376376
record = synctime.SyncRecord.from_token(self.id, token)
377377
log.debug(f"created sync_rec from token: {record}")
378-
if record:
379-
# check channel
380-
if record.channel_id == 0:
381-
# no valid channel set in sync data, assume lagacy
382-
record.channel_id = expected_channel
383-
# update the record in redmine after adding the channel info
384-
# self.update_sync_record(record) REALLY needed? should be handled when token created
385-
return record
386-
elif record.channel_id != expected_channel:
387-
log.debug(f"channel mismatch: rec={record.channel_id} =/= {expected_channel}, token={token}")
388-
return None
389-
else:
390-
return record
378+
return record
379+
380+
381+
def validate_sync_record(self, expected_channel: int = 0) -> synctime.SyncRecord | None:
382+
# Parse custom_field into datetime
383+
# lookup field by name
384+
record = self.get_sync_record()
385+
if record:
386+
# check channel
387+
if record.channel_id == 0:
388+
# no valid channel set in sync data, assume lagacy
389+
record.channel_id = expected_channel
390+
# update the record in redmine after adding the channel info
391+
# self.update_sync_record(record) REALLY needed? should be handled when token created
392+
return record
393+
elif record.channel_id != expected_channel:
394+
log.debug(f"channel mismatch: rec={record.channel_id} =/= {expected_channel}")
395+
return None
396+
else:
397+
return record
391398
else:
392399
# no token implies not-yet-initialized
393400
record = synctime.SyncRecord(self.id, expected_channel, synctime.epoch_datetime())
394-
# apply the new sync record back to redmine
395-
# self.update_sync_record(record) same REALLY as above ^^^^
396401
log.debug(f"created new sync record, none found: {record}")
397402
return record
398-
return None
399403

400404

401405
def get_notes(self, since:dt.datetime|None=None) -> list[TicketNote]:

netbot.py

Lines changed: 61 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -171,51 +171,52 @@ async def synchronize_ticket(self, ticket, thread:discord.Thread) -> bool:
171171
# get the self lock before checking the lock collection
172172
async with self.lock:
173173
if ticket.id in self.ticket_locks:
174-
log.debug(f"ticket #{ticket.id} locked, skipping")
174+
log.info(f"ticket #{ticket.id} locked, skipping")
175175
return False # locked
176176
else:
177177
# create lock flag
178178
self.ticket_locks[ticket.id] = True
179-
log.debug(f"thread lock set, id: {ticket.id}, thread: {thread}")
180-
181-
# start of the process, will become "last update"
182-
sync_start = synctime.now()
183-
sync_rec = ticket.get_sync_record(expected_channel=thread.id)
184-
185-
if sync_rec:
186-
log.debug(f"sync record: {sync_rec}")
187-
188-
# get the new notes from the redmine ticket
189-
redmine_notes = self.gather_redmine_notes(ticket, sync_rec)
190-
for note in redmine_notes:
191-
# Write the note to the discord thread
192-
dirty_flag = True
193-
await thread.send(self.formatter.format_discord_note(note))
194-
log.debug(f"synced {len(redmine_notes)} notes from #{ticket.id} --> {thread}")
195-
196-
# get the new notes from discord
197-
discord_notes = await self.gather_discord_notes(thread, sync_rec)
198-
for message in discord_notes:
199-
dirty_flag = True
200-
self.append_redmine_note(ticket, message)
201-
202-
log.debug(f"synced {len(discord_notes)} notes from {thread} -> #{ticket.id}")
203-
204-
# update the SYNC timestamp
205-
# only update if something has changed
206-
if dirty_flag:
207-
sync_rec.last_sync = sync_start
208-
self.redmine.update_sync_record(sync_rec)
209-
179+
log.debug(f"LOCK thread - id: {ticket.id}, thread: {thread}")
180+
181+
try:
182+
# start of the process, will become "last update"
183+
sync_start = synctime.now()
184+
sync_rec = ticket.validate_sync_record(expected_channel=thread.id)
185+
186+
if sync_rec:
187+
log.debug(f"sync record: {sync_rec}")
188+
189+
# get the new notes from the redmine ticket
190+
redmine_notes = self.gather_redmine_notes(ticket, sync_rec)
191+
for note in redmine_notes:
192+
# Write the note to the discord thread
193+
dirty_flag = True
194+
await thread.send(self.formatter.format_discord_note(note))
195+
log.debug(f"synced {len(redmine_notes)} notes from #{ticket.id} --> {thread}")
196+
197+
# get the new notes from discord
198+
discord_notes = await self.gather_discord_notes(thread, sync_rec)
199+
for message in discord_notes:
200+
dirty_flag = True
201+
self.append_redmine_note(ticket, message)
202+
203+
log.debug(f"synced {len(discord_notes)} notes from {thread} -> #{ticket.id}")
204+
205+
# update the SYNC timestamp
206+
# only update if something has changed
207+
if dirty_flag:
208+
sync_rec.last_sync = sync_start
209+
self.redmine.update_sync_record(sync_rec)
210+
211+
log.info(f"DONE sync {ticket.id} <-> {thread.name}, took {synctime.age_str(sync_start)}")
212+
return True # processed as expected
213+
else:
214+
log.info(f"empty sync_rec for channel={thread.id}, assuming mismatch and skipping")
215+
return False # not found
216+
finally:
210217
# unset the sync lock
211218
del self.ticket_locks[ticket.id]
212-
213-
log.info(f"DONE sync {ticket.id} <-> {thread.name}, took {synctime.age_str(sync_start)}")
214-
return True # processed as expected
215-
else:
216-
log.debug(f"empty sync_rec for channel={thread.id}, assuming mismatch and skipping")
217-
return False # not found
218-
219+
log.debug(f"UNLOCK thread - id: {ticket.id}, thread: {thread}")
219220

220221
def get_channel_by_name(self, channel_name: str):
221222
for channel in self.get_all_channels():
@@ -245,6 +246,25 @@ async def sync_thread(self, thread:discord.Thread):
245246
ticket = self.redmine.get_ticket(ticket_id, include_journals=True)
246247
if ticket:
247248
completed = await self.synchronize_ticket(ticket, thread)
249+
# note: synchronize_ticket returns True only when successfully completing a sync
250+
# it can fail due to: lock, missing or mismatched sync record, network, remote service.
251+
# all these are ignored due to the lock.... not the best option.
252+
# need to think deeper about the pre-condition and desired outcome:
253+
# - What are the possible states and what causes them?
254+
# - Can all unexpected states be recovered?
255+
# - If not, what are the edge conditions and what data is needed to recover?
256+
# - (the answer is usually: this data conflict with old values. align to new values or old?)
257+
# reasonable outcomes are:
258+
# - temporary failure, will try again: self-resolves due to job-nature (lock, network, http)
259+
# - user input error: flag error in response, expect re-entry with valid input. OPPORTUNITY: "did you mean?"
260+
# - missing sync, reasonable recovery: create new.
261+
# - missmatch sync implies human by-hand changes in thread names/locations or bad code.
262+
# as "bad code" is not a "reasonable" outcome, it should be fixed, so the only outcomes are:
263+
# - troubleshoot if it's a bug or
264+
# - note out-of-sync unexpected results: thread-name or whatever.
265+
# In this situation, "locked" is not unexpected (per se) but channel-mismatch is.
266+
# Implies a "user-error" exception to report back to the user specific conditions.
267+
# NetbotException is general, NetbotUserException
248268
if completed:
249269
return ticket
250270
else:
@@ -272,12 +292,10 @@ async def sync_all_threads(self):
272292
if ticket:
273293
# successful sync
274294
log.debug(f"SYNC complete for ticket #{ticket.id} to {thread.name}")
275-
#else:
276-
#log.debug(f"no ticket found for {thread.name}")
277295
except NetbotException as ex:
278296
# ticket is locked.
279297
# skip gracefully
280-
log.debug(f"Ticket locked, sync in progress: {thread}: {ex}")
298+
log.debug(str(ex))
281299
except Exception:
282300
log.exception(f"Error syncing {thread}")
283301

@@ -290,7 +308,7 @@ async def expiration_notification(self, ticket: Ticket) -> None:
290308
"""
291309

292310
# first, check the syncdata.
293-
sync = ticket.get_sync_record()
311+
sync = ticket.validate_sync_record()
294312
if sync and sync.channel_id > 0:
295313
notification = self.bot.formatter.format_expiration_notification(ticket)
296314
thread: discord.Thread = self.bot.get_channel(sync.channel_id)

test_synctime.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def test_redmine_times(self):
2424
ticket = self.create_test_ticket()
2525

2626
test_channel = 4321
27-
sync_rec = ticket.get_sync_record(expected_channel=test_channel)
27+
sync_rec = ticket.validate_sync_record(expected_channel=test_channel)
2828
self.assertIsNotNone(sync_rec)
2929
self.assertEqual(sync_rec.ticket_id, ticket.id)
3030
self.assertEqual(sync_rec.channel_id, test_channel)
@@ -35,7 +35,7 @@ def test_redmine_times(self):
3535

3636
# refetch ticket
3737
ticket2 = self.redmine.get_ticket(ticket.id)
38-
sync_rec2 = ticket2.get_sync_record(expected_channel=1111) # NOT the test_channel
38+
sync_rec2 = ticket2.validate_sync_record(expected_channel=1111) # NOT the test_channel
3939
log.info(f"ticket2 updated={ticket2.updated_on}, {synctime.age_str(ticket2.updated_on)} ago, channel: {sync_rec.channel_id}")
4040

4141
self.assertIsNone(sync_rec2)

tickets.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010

1111

12-
from model import TO_CC_FIELD_NAME, User, Message, NamedId, Team, Ticket, TicketNote, TicketsResult
12+
from model import TO_CC_FIELD_NAME, User, Message, NamedId, Team, Ticket, TicketNote, TicketsResult, SYNC_FIELD_NAME
1313
from session import RedmineSession, RedmineException
1414
import synctime
1515

@@ -468,12 +468,27 @@ def update_sync_record(self, record:synctime.SyncRecord):
468468
log.debug(f"Updating sync record in redmine: {record}")
469469
fields = {
470470
"custom_fields": [
471-
{ "id": 4, "value": record.token_str() } # cf_4, custom field syncdata, #TODO search for it
471+
{ "id": 4, "value": record.token_str() } # cf_4, custom field syncdata, #TODO see below
472472
]
473473
}
474474
self.update(record.ticket_id, fields)
475475

476476

477+
def remove_sync_record(self, record:synctime.SyncRecord):
478+
field = self.custom_fields[SYNC_FIELD_NAME]
479+
if field:
480+
log.debug(f"Removing sync record in redmine: {record}")
481+
fields = {
482+
"custom_fields": [
483+
{ "id": field.id, "value": "" }
484+
]
485+
}
486+
self.update(record.ticket_id, fields)
487+
log.debug(f"Removed {SYNC_FIELD_NAME} from ticket {record.ticket_id}")
488+
else:
489+
log.error(f"Missing expected custom field: {SYNC_FIELD_NAME}")
490+
491+
477492
def get_updated_field(self, ticket) -> dt.datetime:
478493
return synctime.parse_str(ticket.updated_on)
479494

0 commit comments

Comments
 (0)